From fa2ceb7579a3f18f11085a52d361321c1f095f67 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:27:24 -0700 Subject: [PATCH 001/258] path: a request sent through a helper is not independent of the route it may reach path said 'independent' for an HTTP client and the service method a route calls, when the request goes out through a helper the graph does not follow. It now names the unresolved/library request()/urlopen()/fetch() send and the nearest route over the other side, and answers 'NOT shown to be independent'. A route decorated with an empty path under a router prefix (@router.post("")) is now an http entry point. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/config-resolution/entry-points.dl | 3 + .../skills/axiomcode/scripts/ax_pages.py | 5 ++ .../skills/axiomcode/scripts/axiomcode-path | 74 ++++++++++++++++++- .../python/value-callee-is-unknown/case.json | 47 ++++++++++++ .../value-callee-is-unknown/src/over_http.py | 31 ++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/value-callee-is-unknown/src/over_http.py diff --git a/graph/python/engine/config-resolution/entry-points.dl b/graph/python/engine/config-resolution/entry-points.dl index 3ba5d41d..99c835ce 100644 --- a/graph/python/engine/config-resolution/entry-points.dl +++ b/graph/python/engine/config-resolution/entry-points.dl @@ -72,6 +72,9 @@ http_route_deco(dp) :- decorator_text("client", dp, _, argc, _), argc != "0", .decl http_route_path(deco:symbol) http_route_path(d) :- annotation_arg("client", _, v, _, _, "false", d, _), strlen(v) > 0, substr(v, 0, 1) = "/". +// The one path that does not begin with "/": the empty one, first and written as a string, which a router +// mounted under a prefix serves at the prefix itself (`APIRouter(prefix="/orders")` + `@router.post("")`). +http_route_path(d) :- annotation_arg("client", _, "", "STRING_LITERAL", "0", "false", d, _). entry_point(m, "http") :- decorator_target("client", _, m, h), decorator_text("client", dp, _, _, h), http_route_deco(dp), http_route_path(h). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 8b202a7a..9ca9898f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -227,6 +227,11 @@ def next_path(text): if 'which is the key it is registered under' in text: return ("next: the start writes the key the other is registered under (named above), so a framework connects " "them and no call does; the `impact … --tests` command printed there follows that hop") + if 'an HTTP hop the graph did not link' in text: + m = re.search(r'sends a request the graph does not follow: `[^`]*` in \S+ at (\S+:\d+)', text) + return ("next: not shown to be independent — " + (f"read {m.group(1)}, " if m else "read the request named above, ") + + "find the path and method it sends, and compare them with the routes named above; a route that serves " + "them is the connection") if 'NOT shown to be independent' in 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 " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index deb1de3e..c72f6bca 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1349,6 +1349,37 @@ def entered_in_process(g, ids): order = {i: n for n, i in enumerate(ids)} return sorted(out.items(), key=lambda x: order.get(x[0], 0)) +# the verbs that name an HTTP send and nothing else, whatever client library spells them +REQUEST_VERBS = ('request', 'urlopen', 'fetch') + +def request_sends(g, ids): + """[(caller, verb, file, line)] — the sends among `ids`' call sites the graph did not follow into client code: + unresolved, or ending in a library. A verb resolved to a method in the graph is that method, and the walk + already went through it.""" + out = []; ids = [i for i in ids if i in g.sym] + vq = ','.join('?' * len(REQUEST_VERBS)); tq = ','.join('?' * len(g.TERMINAL_TIERS)) + for k in range(0, len(ids), 900): + part = ids[k:k + 900]; q = ','.join('?' * len(part)) + out += [(r['c'], r['n'], r['f'], r['ln']) for r in g.q( + f"""SELECT DISTINCT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln FROM call_sites s + WHERE s.caller_id IN ({q}) AND s.callee_name IN ({vq}) + AND (s.id IN (SELECT call_site_id FROM unresolved_sites) + OR s.id IN (SELECT call_site_id FROM call_edges WHERE tier IN ({tq})))""", + *part, *REQUEST_VERBS, *g.TERMINAL_TIERS)] + return sorted(set(out), key=lambda r: (g.site_file(r[2]), r[3] or 0)) + +def routes_over(g, ids): + """the methods among `ids` that serve an HTTP request: route entry points""" + mid = {g.sym[i]['method_id']: i for i in ids if i in g.sym and g.sym[i].get('method_id')} + keys = list(mid); out = [] + if not g.has('entry_points'): return out + for k in range(0, len(keys), 900): + part = keys[k:k + 900]; q = ','.join('?' * len(part)) + out += [mid[r[0]] for r in g.q(f"SELECT DISTINCT method_id FROM entry_points WHERE method_id IN ({q})" + " AND reason IN ('http', 'url')", *part)] + order = {i: n for n, i in enumerate(ids)} + return sorted(set(out), key=lambda i: order.get(i, 0)) + # AN UNMODELLED ENTRY: A SIGNAL THAT SOMETHING OUTSIDE THE CODE CALLS A DECLARATION, WHICH THE ENGINE DID NOT TURN INTO AN # EDGE OR AN ENTRY POINT. A decoration the rules do not know (a listener, a hook, a validator, a registration), or a # base type the graph does not contain (a library class the declaration's owner extends, which the parser could not @@ -1534,12 +1565,49 @@ 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 REQUEST IS A CALL INTO ANOTHER PROCESS, AND A ROUTE IS WHERE IT LANDS. The rule above leaves routes out on + # purpose: an in-process library call cannot enter one. An HTTP request can, and the engine links the two only + # where it can read both ends of the destination; a client whose path goes through a helper stays unlinked, and + # "independent" was printed for a client and the service method its request reaches. The send is named by its + # verb, the handful that mean nothing but a request (`get`, `post`, `send` also mean a dict, a router, a queue). + # The route's own call into the service is often unresolved too (a dependency the framework injects), so the + # walk up from the other side also follows an unresolved call that names a method on the way, as the by-name + # search above does; it is built only when a send was found. + sends = []; rby = None + if not (remote_found or lib_side): + for (xs, lx), (ys, ly) in (((A_, la), (B_, lb)), ((B_, lb), (A_, la))): + own, rest = closure_of(xs, adj) + sites = request_sends(g, own + rest) + if not sites: continue + if rby is None: + rby = collections.defaultdict(set, {k: set(v) for k, v in radj.items()}) + by_name = collections.defaultdict(list) + for i, sy in g.sym.items(): + if sy.get('method_id') and sy.get('name'): by_name[sy['name']].append(i) + for c, n in g.q("SELECT u.caller_id, s.callee_name FROM unresolved_sites u JOIN call_sites s ON s.id = u.call_site_id"): + for m in by_name.get(n, ()): rby[m].add(c) + up = [i for i in ys if i in g.sym]; seen = set(up); fr = list(up) # nearest first: the route named is + while fr: # the one closest to the other side + fr = [y for y in dict.fromkeys(y for x in fr for y in rby.get(x, ())) if y not in seen and y in g.sym] + seen.update(fr); up += fr + routes = routes_over(g, up) + if routes: sends.append((lx, ly, sites, routes)) + for lx, ly, sites, routes in sends: + RESULT['requests'] = RESULT.get('requests', []) + [ + {'side': lx, 'caller': g.disp(c), 'callee': n, 'at': f"{g.site_file(f)}:{ln}"} for c, n, f, ln in sites[:50]] + print(f" {lx} sends a request the graph does not follow: " + + ', '.join(f"`{n}()` in {g.disp(c)} at {g.site_file(f)}:{ln}" for c, n, f, ln in sites[:3]) + + (f" … +{len(sites) - 3}" if len(sites) > 3 else '')) + print(f" {ly} is served over HTTP: " + + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in routes[:3]) + + (f" … +{len(routes) - 3}" if len(routes) > 3 else '') + + " — the request may be what reaches it") # 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'): + if not (remote_found or lib_side or sends) 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: @@ -1562,7 +1630,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 or opaque): + if not (remote_found or keyed or lib_side or sends 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] @@ -1577,6 +1645,8 @@ 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 " — but a request sent above may reach a route over the other (an HTTP hop the graph did not link):" + " NOT shown to be independent" if sends 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 diff --git a/tests/cases/python/value-callee-is-unknown/case.json b/tests/cases/python/value-callee-is-unknown/case.json index 59c77b46..7d63b1c5 100644 --- a/tests/cases/python/value-callee-is-unknown/case.json +++ b/tests/cases/python/value-callee-is-unknown/case.json @@ -206,6 +206,53 @@ "avoid": [ "connection is UNKNOWN, not absent" ] + }, + { + "why": "a client whose request goes out through a helper, and a function reached from a route mounted at the router's prefix (@router.post(\"\")): the hop is HTTP, so path names the send and the route and does not say independent", + "run": [ + "path", + "OrdersClient.create", + "record_order" + ], + "expect_error": true, + "want": [ + "NOT shown to be independent", + "`request()` in OrdersClient._call at src/over_http.py:25", + "is served over HTTP: create_order src/over_http.py:16" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: a client method that sends nothing stays independent of the routed function", + "run": [ + "path", + "OrdersClient.headers", + "record_order" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "sends a request" + ] + }, + { + "why": "control: a function no route reaches stays independent of a client that sends a request", + "run": [ + "path", + "OrdersClient.create", + "audit" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "sends a request" + ] } ] } diff --git a/tests/cases/python/value-callee-is-unknown/src/over_http.py b/tests/cases/python/value-callee-is-unknown/src/over_http.py new file mode 100644 index 00000000..d5318f19 --- /dev/null +++ b/tests/cases/python/value-callee-is-unknown/src/over_http.py @@ -0,0 +1,31 @@ +import httpx +from fastapi import APIRouter + +router = APIRouter(prefix="/orders") + + +def record_order(body): + return dict(body) + + +def audit(body): + return body + + +@router.post("") +def create_order(body: dict): + return record_order(body) + + +class OrdersClient: + def __init__(self, http: httpx.Client): + self._http = http + + def _call(self, method, url, json=None): + return self._http.request(method, url, json=json) + + def create(self, body): + return self._call("POST", "/orders", json=body) + + def headers(self): + return {"Accept": "application/json"} From bd8263e73feff582b133af5d685ee3f53ce57f01 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:59:25 -0700 Subject: [PATCH 002/258] typescript: a class in one program is not a structural implementor of another program's global A client global belongs to one program (#1574 scoped name lookup and merging), but structural satisfaction still paired every same-shaped class with it. A production class with a `map` became an implementor of a test fixture's hand-written `interface Array`, so fixture `xs.map(cb)` dispatched into production code and production `this.items.map(f)` dispatched into fixture classes; callback reach then crossed every program in the repository. A structural pair is dropped when the target is a client global and neither program sees the other's file (same governed-by / under-directory test as program_sees). Root globals keep nested conformers; same-program pairs and modules with no tsconfig are untouched. Golden case 82 (sibling programs) with a control: the fixture's own Bag keeps its dispatch from rows.map. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../src/app/globals.d.ts | 14 +++++++ .../src/app/pipeline.ts | 16 ++++++++ .../src/app/tsconfig.json | 1 + .../src/fixture/globals.d.ts | 16 ++++++++ .../src/fixture/tsconfig.json | 1 + .../src/fixture/use.ts | 16 ++++++++ ...structural-conformer-across-programs.edges | 9 +++++ ...ructural-conformer-across-programs.entries | 7 ++++ ...uctural-conformer-across-programs.envelope | 6 +++ ...tructural-conformer-across-programs.fields | 1 + ...al-conformer-across-programs.fields-oracle | 7 ++++ ...al-conformer-across-programs.known-missing | 5 +++ ...tructural-conformer-across-programs.oracle | 5 +++ ...uctural-conformer-across-programs.type-use | 9 +++++ ...ral-conformer-across-programs.types-oracle | 7 ++++ .../resolution/structural-satisfaction.dl | 39 ++++++++++++++++++- graph/typescript/souffle/decls_all.dl | 5 +++ 17 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/globals.d.ts create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/pipeline.ts create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/tsconfig.json create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/globals.d.ts create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/tsconfig.json create mode 100644 graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/use.ts create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.edges create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.entries create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.envelope create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.fields create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.fields-oracle create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.known-missing create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.oracle create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.type-use create mode 100644 graph/test/typescript/expected/82-structural-conformer-across-programs.types-oracle diff --git a/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/globals.d.ts b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/globals.d.ts new file mode 100644 index 00000000..7bd87348 --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/globals.d.ts @@ -0,0 +1,14 @@ +// The PRODUCTION program's standard library (noLib, as case 21 explains). `Array.map` here +// and `Pipeline.map` below both take one callback. +interface Array { + map(fn: (v: T) => U): U[]; +} +interface Object { toString(): string; } +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/82-structural-conformer-across-programs/src/app/pipeline.ts b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/pipeline.ts new file mode 100644 index 00000000..6c9601e8 --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/pipeline.ts @@ -0,0 +1,16 @@ +// Production code, in its own program. `Pipeline` has a `map`, so by shape it "satisfies" +// the FIXTURE program's hand-written `Array` too — but no value of it can ever reach a +// receiver typed by that `Array`: neither program sees the other's files. So the fixture's +// `rows.map(...)` must not dispatch here, and `this.items.map(project)` must not reach the +// fixture's callback through that dispatch. +export class Pipeline { + constructor(private readonly items: T[]) {} + + map(project: (value: T) => U): Pipeline { + return new Pipeline(this.items.map(project)); + } +} + +export function build(xs: string[]): Pipeline { + return new Pipeline(xs).map((s) => s); +} diff --git a/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/tsconfig.json b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/tsconfig.json new file mode 100644 index 00000000..c901fced --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/app/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "noLib": true, "target": "es2022", "module": "commonjs" } } diff --git a/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/globals.d.ts b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/globals.d.ts new file mode 100644 index 00000000..745c7321 --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/globals.d.ts @@ -0,0 +1,16 @@ +// A TEST FIXTURE's own standard library, in a SIBLING program: these globals belong to +// ./tsconfig.json alone. The parameter name differs from ../app/globals.d.ts on purpose, +// so the two `Array.map` declarations are told apart in the goldens, and this header is +// one line longer so neither `Array` spans the line of the other's `map`. +interface Array { + map(each: (item: T) => U): U[]; +} +interface Object { toString(radix?: number): string; } +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/82-structural-conformer-across-programs/src/fixture/tsconfig.json b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/tsconfig.json new file mode 100644 index 00000000..c901fced --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "noLib": true, "target": "es2022", "module": "commonjs" } } diff --git a/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/use.ts b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/use.ts new file mode 100644 index 00000000..e0b82e39 --- /dev/null +++ b/graph/test/typescript/cases/82-structural-conformer-across-programs/src/fixture/use.ts @@ -0,0 +1,16 @@ +// CONTROL: `Bag` lives in this program, has the fixture `Array`'s one member and is +// constructed, so it stays a structural conformer of this program's `Array`: `rows.map` +// keeps its dispatch to `Bag.map`. Only the other program's `Pipeline` drops out. +export class Row { + key(): string { return "row"; } +} + +export class Bag { + map(each: (item: T) => U): U[] { return []; } +} + +export function keys(rows: Row[]): string[] { + return rows.map((r) => r.key()); +} + +export const bag = new Bag(); diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.edges b/graph/test/typescript/expected/82-structural-conformer-across-programs.edges new file mode 100644 index 00000000..5d1aad27 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.edges @@ -0,0 +1,9 @@ +known_edge CONSTRUCTOR_CALL Pipeline#map((value: T) =) @L10 -> Pipeline#(T[]) +known_edge CONSTRUCTOR_CALL app/pipeline#build(string[]) @L15 -> Pipeline#(T[]) +known_edge CONSTRUCTOR_CALL fixture/use#() @L16 -> Bag#() +known_edge METHOD_CALL app/pipeline#build(string[]) @L15 -> Pipeline#map((value: T) =) +known_edge METHOD_CALL fixture/use#(?) @L13 -> Row#key() +multi_inferred METHOD_CALL Pipeline#map((value: T) =) @L10 -> Array#map((v: T) =) +multi_inferred METHOD_CALL Pipeline#map((value: T) =) @L10 -> Pipeline#map((value: T) =) +multi_inferred METHOD_CALL fixture/use#keys(Row[]) @L13 -> Array#map((item: T) =) +multi_inferred METHOD_CALL fixture/use#keys(Row[]) @L13 -> Bag#map((item: T) =) diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.entries b/graph/test/typescript/expected/82-structural-conformer-across-programs.entries new file mode 100644 index 00000000..b002c567 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.entries @@ -0,0 +1,7 @@ +── entry_point (6) ── + exported_from_entry_module app/pipeline#build pipeline.ts:14 + exported_from_entry_module fixture/use#keys use.ts:12 + unimported_module app/globals# globals.d.ts:1 + unimported_module app/pipeline# pipeline.ts:1 + unimported_module fixture/globals# globals.d.ts:1 + unimported_module fixture/use# use.ts:1 diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.envelope b/graph/test/typescript/expected/82-structural-conformer-across-programs.envelope new file mode 100644 index 00000000..bcd93261 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.envelope @@ -0,0 +1,6 @@ +structural app/globals#Array.map -> app/pipeline#Pipeline.map +structural fixture/globals#Array.map -> fixture/use#Bag.map +value app/globals#@4:14 -> app/pipeline# +value app/pipeline#@9:19 -> app/pipeline# +value fixture/globals#@6:16 -> fixture/use# +value fixture/use#@9:16 -> fixture/use# diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.fields b/graph/test/typescript/expected/82-structural-conformer-across-programs.fields new file mode 100644 index 00000000..a069adc8 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.fields @@ -0,0 +1 @@ +known_edge read Pipeline#map((value: T) =) -> Pipeline#items diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.fields-oracle b/graph/test/typescript/expected/82-structural-conformer-across-programs.fields-oracle new file mode 100644 index 00000000..ca4ac9b8 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.fields-oracle @@ -0,0 +1,7 @@ +82-structural-conformer-across-programs [fields] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.known-missing b/graph/test/typescript/expected/82-structural-conformer-across-programs.known-missing new file mode 100644 index 00000000..0b193f75 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.known-missing @@ -0,0 +1,5 @@ +# The per-case oracle compiles every file under src/ as ONE program (tsc-program.mjs walks +# the directory), so the two programs' `interface Array` merge and the fixture's later +# `map` overload is the one it picks. In the real layout app/ and fixture/ are separate +# programs and `this.items.map` resolves to app/globals.d.ts, which is what the engine says. +Pipeline#map((value: T) =) -> Array#map((item: T) =) diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.oracle b/graph/test/typescript/expected/82-structural-conformer-across-programs.oracle new file mode 100644 index 00000000..f5b1660d --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.oracle @@ -0,0 +1,5 @@ +oracle=7 engine=9 agree=6 missing=1 (known 1, NEW 0) extra=3 + known Pipeline#map((value: T) =) -> Array#map((item: T) =) + extra Pipeline#map((value: T) =) -> Array#map((v: T) =) + extra Pipeline#map((value: T) =) -> Pipeline#map((value: T) =) + extra fixture/use#keys(Row[]) -> Bag#map((item: T) =) diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.type-use b/graph/test/typescript/expected/82-structural-conformer-across-programs.type-use new file mode 100644 index 00000000..b39a992e --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.type-use @@ -0,0 +1,9 @@ +known_edge METHOD_RETURN 0 Pipeline [METHOD] -> Pipeline +known_edge METHOD_RETURN 0 app/pipeline [METHOD] -> Pipeline +known_edge METHOD_TYPE_ARGUMENT 0 fixture/use [EXPRESSION] -> Row +known_edge OBJECT_CREATION_TYPE 0 Pipeline [EXPRESSION] -> Pipeline +known_edge OBJECT_CREATION_TYPE 0 app/pipeline [EXPRESSION] -> Pipeline +known_edge OBJECT_CREATION_TYPE 0 fixture/use [EXPRESSION] -> Bag +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function +known_edge TYPE_ELEMENT 1 fixture/use [METHOD_PARAM] -> Row diff --git a/graph/test/typescript/expected/82-structural-conformer-across-programs.types-oracle b/graph/test/typescript/expected/82-structural-conformer-across-programs.types-oracle new file mode 100644 index 00000000..e1b1ff22 --- /dev/null +++ b/graph/test/typescript/expected/82-structural-conformer-across-programs.types-oracle @@ -0,0 +1,7 @@ +82-structural-conformer-across-programs [types] + precision 1.0000 (6 correct, 0 wrong) + recall 1.0000 (6 of 6 the compiler resolved) + sites 11 resolved 11 (100.0%) + tiers known_edge=11 + contexts METHOD_RETURN=2 METHOD_TYPE_ARGUMENT=1 OBJECT_CREATION_TYPE=3 SUPER_TYPE=4 TYPE_ELEMENT=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index d911f708..2753406b 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -120,7 +120,41 @@ target_has_nominal_implementor(t) :- implementors(t, _). structural_implementor(t, s) :- type_satisfies(s, t), satisfaction_target(t), s != t, - !target_has_nominal_implementor(t). + !target_has_nominal_implementor(t), + !sat_cross_program(s, t). + +// ── sat_cross_program(Source, Target) — a shape match no value can cross (#1574) ── +// A CLIENT GLOBAL belongs to one program (module-graph.dl). A class in ANOTHER program +// that happens to have the global's members — production's `Pipeline.map` against a test +// fixture's hand-written `interface Array { map }` — was counted as its implementor, so +// every fixture `xs.map(cb)` dispatched into production code and production's own +// `this.items.map(f)` dispatched into the fixture's classes: callback reach then spanned +// every program in the repository. A value of the class can reach a receiver typed by +// that global only in code that sees both, so the pair is dropped when NEITHER program +// can see the other's file: the class's module is outside the global's program, and the +// global's file is outside the class's. Governed-by or under-the-directory-of, the same +// test program_sees makes, so a root `types/global.d.ts` keeps every nested package's +// conformers, and a nested package's global keeps a root class its code may import. +// Only pairs of two different governing tsconfigs are asked, so this is a handful of +// prefix tests, not (modules) x (programs). A module with no tsconfig is never cut. +path_key(p) :- module_tsconfig(_, p). +tsconfig_dir(p, d) :- module_tsconfig(_, p), path_dir(p, d). +sat_cross_program_pair(s, t, ms, pt, mt, ps) :- sat_pair_seed(s, t), + client_global_program(t, pt), + type_module("client", ms, s), module_tsconfig(ms, ps), ps != pt, + type_module("client", mt, t). +sat_tree_ask(ms, pt) :- sat_cross_program_pair(_, _, ms, pt, _, _). +sat_tree_ask(mt, ps) :- sat_cross_program_pair(_, _, _, _, mt, ps). +module_in_program_tree(m, p) :- sat_tree_ask(m, p), module_tsconfig(m, p). +module_in_program_tree(m, p) :- sat_tree_ask(m, p), + tsconfig_dir(p, d), + module_file("client", fp, m), + n = strlen(d), + strlen(fp) >= n, + substr(fp, 0, n) = d. +sat_cross_program(s, t) :- sat_cross_program_pair(s, t, ms, pt, mt, ps), + !module_in_program_tree(ms, pt), + !module_in_program_tree(mt, ps). // ── …AND A CONFORMER THE PROGRAM ACTUALLY CONSTRUCTS, EVEN THEN (#428) ───── // The gate above is right about EVIDENCE and wrong about what it does with it. Where a @@ -169,7 +203,8 @@ structural_implementor(t, s) :- type_satisfies(s, t), s != t, target_has_nominal_implementor(t), !implementors(t, s), - type_instantiated(s, _). + type_instantiated(s, _), + !sat_cross_program(s, t). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index b046589a..1a0523d2 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -692,6 +692,11 @@ .decl program_module_count(c0:symbol,c1:number) .decl program_is_wide(c0:symbol) .decl global_decl_wide(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl tsconfig_dir(c0:symbol,c1:symbol) +.decl sat_cross_program_pair(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) +.decl sat_tree_ask(c0:symbol,c1:symbol) +.decl module_in_program_tree(c0:symbol,c1:symbol) +.decl sat_cross_program(c0:symbol,c1:symbol) .decl remote_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl remote_undetermined(c0:symbol,c1:symbol,c2:symbol) .decl remote_unsent(c0:symbol,c1:symbol,c2:symbol) From b115602e7faeb3e4cf03290d90c7ff01c8a4b8f7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:48:22 -0700 Subject: [PATCH 003/258] javascript: a JSX tag bound to a Vue defineComponent renders its setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` where `const JsVue = defineComponent({ setup() {…} })` (or the module's default export) was an ambiguous_unknown row in a .jsx file: the JavaScript rules followed memo/forwardRef/lazy but not a Vue definer. The tag now renders the options object's setup, its render when there is no setup, or argument 0 in the function form, as the TypeScript engine does. The same object handed to any other call stays unresolved. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/jsx.dl | 19 +++++++++++ graph/javascript/souffle/decls_all.dl | 3 ++ .../71-jsx-vue-define-component/src/app.jsx | 34 +++++++++++++++++++ .../71-jsx-vue-define-component/src/def.jsx | 6 ++++ .../expected/71-jsx-vue-define-component.diag | 10 ++++++ .../71-jsx-vue-define-component.edges | 25 ++++++++++++++ .../71-jsx-vue-define-component.oracle | 7 ++++ 7 files changed, 104 insertions(+) create mode 100644 graph/test/javascript/cases/71-jsx-vue-define-component/src/app.jsx create mode 100644 graph/test/javascript/cases/71-jsx-vue-define-component/src/def.jsx create mode 100644 graph/test/javascript/expected/71-jsx-vue-define-component.diag create mode 100644 graph/test/javascript/expected/71-jsx-vue-define-component.edges create mode 100644 graph/test/javascript/expected/71-jsx-vue-define-component.oracle diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl index f3c31375..7359d518 100644 --- a/graph/javascript/engine/call-edge-generation/jsx.dl +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -59,12 +59,21 @@ jsx_loader_name("lazy"). jsx_loader_name("dynamic"). jsx_loader_name("loadable"). jsx_component_loader(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _, _, w, _, _), jsx_loader_name(n). +// A Vue DEFINER — `defineComponent({ setup() {…} })` (Nuxt: `defineNuxtComponent`): the +// tag invokes the options object's `setup` with its props, or its `render` when there is +// no setup; a render function setup returns is reached from setup's body. The function +// form, `defineComponent((props) => () => …)`, is argument 0 itself, as for memo. Named by +// the definer, so the same object handed to any other call stays out. +jsx_vue_definer_name("defineComponent"). +jsx_vue_definer_name("defineNuxtComponent"). +jsx_vue_definer(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _, _, w, _, _), jsx_vue_definer_name(n). // jsx_holds(Expr, Call) — the wrapper or loader call an expression holds, followed the // ways a component reaches a tag: the call itself, a const initialised with it, a named // or default import of an export that holds it, and a re-export of one. jsx_holds(w, w) :- jsx_component_wrapper(w). jsx_holds(w, w) :- jsx_component_loader(w). +jsx_holds(w, w) :- jsx_vue_definer(w). jsx_holds(e, w) :- expr_binding(_, v, e), jsx_var_holds(v, w). jsx_var_holds(v, w) :- var_init(_, _, e, v), !var_binding_form(_, "OBJECT_PATTERN", v), !var_binding_form(_, "ARRAY_PATTERN", v), jsx_holds(e, w). @@ -97,6 +106,16 @@ jsx_call_renders(w, k, i) :- jsx_loader_settles(w, "obj", o), prop_value("obj", jsx_call_renders(w, k, i) :- jsx_loader_settles(w, k, i), jsx_component_kind(k). jsx_component_kind("func"). jsx_component_kind("ctor"). +// A Vue definer: the options object's `setup`, else its `render` — one target, not both. +// "Has a setup" is read off the literal's own keys, not the value layer, which this +// relation feeds. +jsx_call_renders(w, "func", m) :- jsx_vue_definer(w), call_arg(w, 0, a), expr_value(a, "obj", o), + prop_value("obj", o, "setup", "func", m). +jsx_call_renders(w, "func", m) :- jsx_vue_definer(w), call_arg(w, 0, a), expr_value(a, "obj", o), + !jsx_vue_has_setup(o), prop_value("obj", o, "render", "func", m). +jsx_call_renders(w, "func", f) :- jsx_vue_definer(w), call_arg(w, 0, a), expr_value(a, "func", f). +jsx_vue_has_setup(o) :- expr_kind(_, "OBJECT_LITERAL", _, o), expr_child(_, o, "PROPERTY_KEY", _, key), expr_name(_, "setup", key). +jsx_vue_has_setup(o) :- literal_owns_method(o, m), method_decl(_, "setup", _, _, _, _, _, m). // ── jsx_renders(Element, Component) — the callables the element runs ─────── // A function component (a constructor FUNCTION is callable_value's "ctor" half). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index e876f7b2..a126c63c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -55,6 +55,9 @@ .decl jsx_component_wrapper(c0:symbol) .decl jsx_loader_name(c0:symbol) .decl jsx_component_loader(c0:symbol) +.decl jsx_vue_definer_name(c0:symbol) +.decl jsx_vue_definer(c0:symbol) +.decl jsx_vue_has_setup(c0:symbol) .decl jsx_holds(c0:symbol, c1:symbol) .decl jsx_var_holds(c0:symbol, c1:symbol) .decl jsx_import_holds(c0:symbol, c1:symbol) diff --git a/graph/test/javascript/cases/71-jsx-vue-define-component/src/app.jsx b/graph/test/javascript/cases/71-jsx-vue-define-component/src/app.jsx new file mode 100644 index 00000000..f169d640 --- /dev/null +++ b/graph/test/javascript/cases/71-jsx-vue-define-component/src/app.jsx @@ -0,0 +1,34 @@ +import { defineComponent } from "vue"; +import { makeStore } from "store-kit"; +import DefVue from "./def"; + +export function leaf() { return 1; } +export function renderLeaf() { return 2; } +export function fnLeaf() { return 3; } +export function bothLeaf() { return 4; } +export function storeLeaf() { return 5; } + +// A tag bound to a Vue definer renders the options object's setup… +export const SetupVue = defineComponent({ setup() { leaf(); return () => "v"; } }); +// …or its render when there is no setup… +export const RenderVue = defineComponent({ render() { renderLeaf(); return "r"; } }); +// …or, in the function form, argument 0 itself. +export const FnVue = defineComponent((props) => { fnLeaf(); return () => "f"; }); +// Setup and render both: setup is what the tag invokes; render is reached from it. +export const BothVue = defineComponent({ setup() { bothLeaf(); return () => "b"; }, render() { return "x"; } }); + +// CONTROL: the same options object handed to a call that is not a definer. +export const Store = makeStore({ setup() { storeLeaf(); return () => "s"; } }); + +export function App() { + return ( +
+ + + + + + +
+ ); +} diff --git a/graph/test/javascript/cases/71-jsx-vue-define-component/src/def.jsx b/graph/test/javascript/cases/71-jsx-vue-define-component/src/def.jsx new file mode 100644 index 00000000..ed821eba --- /dev/null +++ b/graph/test/javascript/cases/71-jsx-vue-define-component/src/def.jsx @@ -0,0 +1,6 @@ +import { defineComponent } from "vue"; + +export function defLeaf() { return 6; } + +// The module's default export, rendered through a default import. +export default defineComponent({ setup() { defLeaf(); return () => "d"; } }); diff --git a/graph/test/javascript/expected/71-jsx-vue-define-component.diag b/graph/test/javascript/expected/71-jsx-vue-define-component.diag new file mode 100644 index 00000000..31c98cac --- /dev/null +++ b/graph/test/javascript/expected/71-jsx-vue-define-component.diag @@ -0,0 +1,10 @@ +import_cause app.jsx:1:10 vue not_staged +import_cause app.jsx:2:10 store-kit not_staged +import_cause def.jsx:1:10 vue not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved app.jsx:12:25 FUNCTION_CALL defineComponent callee_untyped +unresolved app.jsx:14:26 FUNCTION_CALL defineComponent callee_untyped +unresolved app.jsx:16:22 FUNCTION_CALL defineComponent callee_untyped +unresolved app.jsx:18:24 FUNCTION_CALL defineComponent callee_untyped +unresolved app.jsx:21:22 FUNCTION_CALL makeStore callee_untyped +unresolved def.jsx:6:16 FUNCTION_CALL defineComponent callee_untyped diff --git a/graph/test/javascript/expected/71-jsx-vue-define-component.edges b/graph/test/javascript/expected/71-jsx-vue-define-component.edges new file mode 100644 index 00000000..e26d823f --- /dev/null +++ b/graph/test/javascript/expected/71-jsx-vue-define-component.edges @@ -0,0 +1,25 @@ +app.jsx:12:25 FUNCTION_CALL defineComponent -> ambiguous_unknown - +app.jsx:12:25 FUNCTION_CALL defineComponent -> callback_registered app.jsx:12:43 setup +app.jsx:12:53 FUNCTION_CALL leaf -> known_edge app.jsx:5:1 leaf +app.jsx:14:26 FUNCTION_CALL defineComponent -> ambiguous_unknown - +app.jsx:14:26 FUNCTION_CALL defineComponent -> callback_registered app.jsx:14:44 render +app.jsx:14:55 FUNCTION_CALL renderLeaf -> known_edge app.jsx:6:1 renderLeaf +app.jsx:16:22 FUNCTION_CALL defineComponent -> ambiguous_unknown - +app.jsx:16:22 FUNCTION_CALL defineComponent -> callback_registered app.jsx:16:38 +app.jsx:16:51 FUNCTION_CALL fnLeaf -> known_edge app.jsx:7:1 fnLeaf +app.jsx:18:24 FUNCTION_CALL defineComponent -> ambiguous_unknown - +app.jsx:18:24 FUNCTION_CALL defineComponent -> callback_registered app.jsx:18:42 setup +app.jsx:18:24 FUNCTION_CALL defineComponent -> callback_registered app.jsx:18:85 render +app.jsx:18:52 FUNCTION_CALL bothLeaf -> known_edge app.jsx:8:1 bothLeaf +app.jsx:21:22 FUNCTION_CALL makeStore -> ambiguous_unknown - +app.jsx:21:22 FUNCTION_CALL makeStore -> callback_registered app.jsx:21:34 setup +app.jsx:21:44 FUNCTION_CALL storeLeaf -> known_edge app.jsx:9:1 storeLeaf +app.jsx:26:7 JSX_ELEMENT -> known_edge app.jsx:12:43 setup +app.jsx:27:7 JSX_ELEMENT -> known_edge app.jsx:14:44 render +app.jsx:28:7 JSX_ELEMENT -> known_edge app.jsx:16:38 +app.jsx:29:7 JSX_ELEMENT -> known_edge app.jsx:18:42 setup +app.jsx:30:7 JSX_ELEMENT -> known_edge def.jsx:6:34 setup +app.jsx:31:7 JSX_ELEMENT -> ambiguous_unknown - +def.jsx:6:16 FUNCTION_CALL defineComponent -> ambiguous_unknown - +def.jsx:6:16 FUNCTION_CALL defineComponent -> callback_registered def.jsx:6:34 setup +def.jsx:6:44 FUNCTION_CALL defLeaf -> known_edge def.jsx:3:1 defLeaf diff --git a/graph/test/javascript/expected/71-jsx-vue-define-component.oracle b/graph/test/javascript/expected/71-jsx-vue-define-component.oracle new file mode 100644 index 00000000..2bfd938e --- /dev/null +++ b/graph/test/javascript/expected/71-jsx-vue-define-component.oracle @@ -0,0 +1,7 @@ +app.jsx:12:53 FUNCTION_CALL leaf EXACT app.jsx:5:1 +app.jsx:14:55 FUNCTION_CALL renderLeaf EXACT app.jsx:6:1 +app.jsx:16:51 FUNCTION_CALL fnLeaf EXACT app.jsx:7:1 +app.jsx:18:52 FUNCTION_CALL bothLeaf EXACT app.jsx:8:1 +app.jsx:21:44 FUNCTION_CALL storeLeaf EXACT app.jsx:9:1 +def.jsx:6:44 FUNCTION_CALL defLeaf EXACT def.jsx:3:1 +# defects: 0 From 51732e7b8d8d34df837cc28291da44020d698029 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:00:43 -0700 Subject: [PATCH 004/258] javascript: resolve imports through a vite/webpack config's resolve.alias A Vite or Vue app often declares '@' -> src only in vite.config.js, with no jsconfig.json, so every import through it stayed unresolved. The config is read (never run) for resolve.alias entries whose replacement the syntax fixes (path.resolve/join with __dirname, fileURLToPath(new URL(..., import.meta.url)), a root-relative string); the nearer of it and a tsconfig/jsconfig governs. Case 71 covers object and array alias forms, webpack's exact 'key$', and controls: a bare relative replacement, '@utils' vs '@', and a nearer jsconfig mapping '@/*' elsewhere. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../71-bundler-config-alias/src/legacy/app.js | 12 ++ .../src/legacy/lib/extra.js | 3 + .../src/legacy/lib/index.js | 3 + .../src/legacy/lib/util/trim.js | 3 + .../src/legacy/webpack.config.cjs | 13 ++ .../src/mapped/jsconfig.json | 1 + .../src/mapped/own/date.js | 3 + .../src/mapped/probe.js | 6 + .../71-bundler-config-alias/src/package.json | 1 + .../71-bundler-config-alias/src/shared/fmt.js | 3 + .../src/src/pages/Home.js | 12 ++ .../src/src/utils/date.js | 3 + .../src/vite.config.js | 13 ++ .../expected/71-bundler-config-alias.diag | 21 ++ .../expected/71-bundler-config-alias.edges | 18 ++ .../expected/71-bundler-config-alias.oracle | 8 + .../javascript/bundler-alias-reader.ts | 196 ++++++++++++++++++ .../javascript/javascript-project-analyzer.ts | 81 ++++++-- 18 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/package.json create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js create mode 100644 graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js create mode 100644 graph/test/javascript/expected/71-bundler-config-alias.diag create mode 100644 graph/test/javascript/expected/71-bundler-config-alias.edges create mode 100644 graph/test/javascript/expected/71-bundler-config-alias.oracle create mode 100644 parser/src/parsers/javascript/bundler-alias-reader.ts diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js new file mode 100644 index 00000000..45209bad --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js @@ -0,0 +1,12 @@ +import { boot } from 'Lib'; +import { trim } from 'Util/trim'; +// CONTROL: 'Lib$' is exact, and 'Loose' has no fixed directory: both stay unresolved +import { extra } from 'Lib/extra'; +import { extra as loose } from 'Loose/extra'; + +export function start(s) { + boot(); + extra(); + loose(); + return trim(s); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js new file mode 100644 index 00000000..814444de --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js @@ -0,0 +1,3 @@ +export function extra() { + return 2; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js new file mode 100644 index 00000000..43a40916 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js @@ -0,0 +1,3 @@ +export function boot() { + return 1; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js new file mode 100644 index 00000000..408e0649 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js @@ -0,0 +1,3 @@ +export function trim(s) { + return s.trim(); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs new file mode 100644 index 00000000..53450f19 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = { + resolve: { + alias: { + // exact match only: 'Lib' resolves, 'Lib/extra' does not + Lib$: path.resolve(__dirname, 'lib/index.js'), + Util: path.join(__dirname, 'lib/util'), + // CONTROL: a bare relative replacement is read from the importer, so it fixes nothing + Loose: './lib', + }, + }, +}; diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json new file mode 100644 index 00000000..cffb09dd --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./own/*"] } } } diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js new file mode 100644 index 00000000..c8b5d61b --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js @@ -0,0 +1,3 @@ +export function fmtDate(d) { + return `own ${d}`; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js new file mode 100644 index 00000000..1b8e2c63 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js @@ -0,0 +1,6 @@ +// CONTROL: the nearest jsconfig.json maps '@/*' itself, and that mapping wins over vite.config.js +import { fmtDate } from '@/date'; + +export function probe(d) { + return fmtDate(d); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/package.json b/graph/test/javascript/cases/71-bundler-config-alias/src/package.json new file mode 100644 index 00000000..42903152 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/package.json @@ -0,0 +1 @@ +{ "name": "bundler-alias-fixture", "type": "module" } diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js b/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js new file mode 100644 index 00000000..80f99618 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js @@ -0,0 +1,3 @@ +export function fmtMoney(n) { + return n.toFixed(2); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js b/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js new file mode 100644 index 00000000..b0c1a0ed --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js @@ -0,0 +1,12 @@ +// '@' from vite.config.js resolve.alias: resolved to src/utils/date.js +import { fmtDate } from '@/utils/date'; +// '~shared' through fileURLToPath(new URL(...)): resolved to shared/fmt.js +import { fmtMoney } from '~shared/fmt'; +// CONTROL: '@utils/date' is not '@' or '@/…', so the '@' alias does not apply +import { fmtDate as scoped } from '@utils/date'; + +export function render(order) { + fmtMoney(order.total); + scoped(order.at); + return fmtDate(order.at); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js b/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js new file mode 100644 index 00000000..be60df34 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js @@ -0,0 +1,3 @@ +export function fmtDate(d) { + return String(d); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js b/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js new file mode 100644 index 00000000..14da4645 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import path from 'path'; +import { fileURLToPath, URL } from 'node:url'; + +// the only place '@' and '~shared' are declared: nothing nearer maps them +export default defineConfig(({ mode }) => ({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '~shared': fileURLToPath(new URL('./shared', import.meta.url)), + }, + }, +})); diff --git a/graph/test/javascript/expected/71-bundler-config-alias.diag b/graph/test/javascript/expected/71-bundler-config-alias.diag new file mode 100644 index 00000000..d46236f8 --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.diag @@ -0,0 +1,21 @@ +import_cause legacy/app.js:4:10 Lib/extra not_staged +import_cause legacy/app.js:5:10 Loose/extra not_staged +import_cause legacy/webpack.config.cjs:1:14 path builtin +import_cause src/pages/Home.js:6:10 @utils/date not_staged +import_cause vite.config.js:1:10 vite not_staged +import_cause vite.config.js:2:1 path builtin +import_cause vite.config.js:3:10 node:url builtin +import_cause vite.config.js:3:25 node:url builtin +package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped +unresolved legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped +unresolved legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped +unresolved legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target +unresolved legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target +unresolved shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped +unresolved src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped +unresolved src/utils/date.js:2:10 FUNCTION_CALL String no_target +unresolved vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target +unresolved vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target +unresolved vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped +unresolved vite.config.js:9:12 METHOD_CALL resolve receiver_untyped diff --git a/graph/test/javascript/expected/71-bundler-config-alias.edges b/graph/test/javascript/expected/71-bundler-config-alias.edges new file mode 100644 index 00000000..db2535ff --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.edges @@ -0,0 +1,18 @@ +legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - +legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge legacy/lib/util/trim.js:1:1 trim +legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge legacy/lib/index.js:1:1 boot +legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - +legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - +legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - +legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - +mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge mapped/own/date.js:1:1 fmtDate +shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - +src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - +src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge src/utils/date.js:1:1 fmtDate +src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge shared/fmt.js:1:1 fmtMoney +src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - +vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - +vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - +vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - +vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered vite.config.js:6:29 +vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - diff --git a/graph/test/javascript/expected/71-bundler-config-alias.oracle b/graph/test/javascript/expected/71-bundler-config-alias.oracle new file mode 100644 index 00000000..2d722071 --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.oracle @@ -0,0 +1,8 @@ +legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK +legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK +src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK +vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK +vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK +vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED +vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK +# defects: 0 diff --git a/parser/src/parsers/javascript/bundler-alias-reader.ts b/parser/src/parsers/javascript/bundler-alias-reader.ts new file mode 100644 index 00000000..4ae2c174 --- /dev/null +++ b/parser/src/parsers/javascript/bundler-alias-reader.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +/** + * The `resolve.alias` of a bundler config, as `compilerOptions.paths` would spell it. + * + * Vite and Vue apps usually have no `jsconfig.json`: `@` → `src` is declared only in + * `vite.config.js` (webpack apps do the same in `webpack.config.js`), so every + * `import … from '@/x'` was UNRESOLVED_MISSING and the call behind it "by name" (#1746). + * + * The config is read, never run. Only a replacement the syntax fixes is taken: + * `path.resolve(__dirname, 'src')` / `path.join(…)` / `resolve(…)`, + * `fileURLToPath(new URL('./src', import.meta.url))`, and a root-relative string + * (`'/src'`). A regular-expression `find`, a computed key or a replacement built from + * anything else is skipped, so the import stays unresolved rather than guessed. + */ +export type BundlerPaths = Record; + +export const BUNDLER_CONFIG_NAMES: readonly string[] = [ + 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', + 'vite.config.ts', 'vite.config.mts', 'vite.config.cts', + 'webpack.config.js', 'webpack.config.mjs', 'webpack.config.cjs', 'webpack.config.ts', +]; + +export function readBundlerAliases(configPath: string): BundlerPaths | undefined { + let text: string; + try { + text = fs.readFileSync(configPath, 'utf8'); + } catch { + return undefined; + } + const configDir = path.dirname(configPath); + const source = ts.createSourceFile(configPath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const paths: BundlerPaths = {}; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && propertyName(node.name) === 'alias' + && ts.isObjectLiteralExpression(node.parent) && ts.isPropertyAssignment(node.parent.parent) + && propertyName(node.parent.parent.name) === 'resolve') { + for (const [find, replacement] of aliasEntries(node.initializer, configDir)) { + addAlias(paths, find, replacement); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return Object.keys(paths).length === 0 ? undefined : paths; +} + +/** `{ '@': x }` or `[{ find: '@', replacement: x }]`: each string key with a fixed replacement. */ +function aliasEntries(value: ts.Expression, configDir: string): Array<[string, string]> { + const entries: Array<[string, string]> = []; + const take = (find: string | undefined, replacement: ts.Expression | undefined) => { + const target = replacement === undefined ? undefined : fixedPath(replacement, configDir); + if (find !== undefined && find !== '' && target !== undefined) { + entries.push([find, target]); + } + }; + if (ts.isObjectLiteralExpression(value)) { + for (const property of value.properties) { + if (ts.isPropertyAssignment(property)) { + take(propertyName(property.name), property.initializer); + } + } + } else if (ts.isArrayLiteralExpression(value)) { + for (const element of value.elements) { + if (!ts.isObjectLiteralExpression(element)) { + continue; + } + let find: string | undefined; + let replacement: ts.Expression | undefined; + for (const property of element.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const key = propertyName(property.name); + if (key === 'find' && ts.isStringLiteralLike(property.initializer)) { + find = property.initializer.text; + } else if (key === 'replacement') { + replacement = property.initializer; + } + } + take(find, replacement); + } + } + return entries; +} + +/** + * Webpack's `key$` is an exact match only; any other key matches itself and `key/…`, + * which is the rule of both bundlers for a string `find`. + */ +function addAlias(paths: BundlerPaths, find: string, target: string): void { + if (find.endsWith('$')) { + paths[find.slice(0, -1)] ??= [target]; + return; + } + const key = find.endsWith('/') ? find.slice(0, -1) : find; + paths[key] ??= [target]; + paths[`${key}/*`] ??= [`${target}/*`]; +} + +/** An absolute directory the expression fixes, or undefined when it depends on anything else. */ +function fixedPath(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (ts.isStringLiteralLike(node)) { + // A bare relative string is replaced textually and read from the IMPORTER, so it + // fixes no directory; a leading `/` is the project root in Vite. + return node.text.startsWith('/') ? path.join(configDir, node.text) : undefined; + } + if (ts.isCallExpression(node)) { + const callee = calleeName(node.expression); + // `require.resolve('pkg/x')` names a file inside a package, not a directory here. + const onRequire = ts.isPropertyAccessExpression(node.expression) + && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'require'; + if ((callee === 'resolve' || callee === 'join') && !onRequire) { + const segments: string[] = []; + for (const argument of node.arguments) { + const segment = segmentOf(argument, configDir); + if (segment === undefined) { + return undefined; + } + segments.push(segment); + } + return segments.length === 0 ? undefined : path.resolve(configDir, ...segments); + } + if (callee === 'fileURLToPath' && node.arguments.length === 1) { + return urlRelativeToConfig(node.arguments[0]!, configDir); + } + return undefined; + } + // `new URL('./src', import.meta.url).pathname` + if (ts.isPropertyAccessExpression(node) && node.name.text === 'pathname') { + return urlRelativeToConfig(node.expression, configDir); + } + return undefined; +} + +/** One argument of `path.resolve` / `path.join`: a string, `__dirname`, or `process.cwd()`. */ +function segmentOf(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (ts.isStringLiteralLike(node)) { + return node.text; + } + if (ts.isIdentifier(node) && node.text === '__dirname') { + return configDir; + } + if (ts.isCallExpression(node) && node.arguments.length === 0 && calleeName(node.expression) === 'cwd') { + return configDir; + } + return undefined; +} + +/** `new URL('./src', import.meta.url)`: the first argument read from the config's own directory. */ +function urlRelativeToConfig(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (!ts.isNewExpression(node) || calleeName(node.expression) !== 'URL' || node.arguments?.length !== 2) { + return undefined; + } + const [relative, base] = node.arguments; + if (!ts.isStringLiteralLike(relative!) || !isImportMetaUrl(unwrap(base!))) { + return undefined; + } + return relative.text.startsWith('/') ? undefined : path.resolve(configDir, relative.text); +} + +function isImportMetaUrl(node: ts.Node): boolean { + return ts.isPropertyAccessExpression(node) && node.name.text === 'url' + && ts.isMetaProperty(node.expression) && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword; +} + +function calleeName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) { + return expression.text; + } + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + return undefined; +} + +function propertyName(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { + return name.text; + } + return undefined; +} + +function unwrap(node: ts.Expression): ts.Expression { + let current = node; + while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) { + current = current.expression; + } + return current; +} diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 1b465952..18e3e6ae 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -21,6 +21,7 @@ import { IrCompletenessReport, } from '@/parsers/javascript/extractors/js-ir-completeness'; import { moduleHashFor } from '@/parsers/javascript/extractors/js-module-extractor'; +import { BUNDLER_CONFIG_NAMES, readBundlerAliases } from '@/parsers/javascript/bundler-alias-reader'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { buildOutputDirectoriesNamedBy, @@ -633,36 +634,84 @@ type PathAliases = Partial(); + private readonly byDirectory = new Map(); + private readonly bundlerByDirectory = new Map(); aliasesFor(file: string): PathAliases { - return this.inDirectory(path.dirname(file)); + const directory = path.dirname(file); + const config = this.inDirectory(directory); + const bundler = this.bundlerInDirectory(directory); + // Both lie on one line of ancestors, so the longer directory is the nearer one. + if (bundler.aliases.paths === undefined) { + return config.aliases; + } + if (config.aliases.paths === undefined) { + return { ...config.aliases, ...bundler.aliases }; + } + return bundler.from.length > config.from.length ? bundler.aliases : config.aliases; } - private inDirectory(directory: string): PathAliases { - const cached = this.byDirectory.get(directory); + private bundlerInDirectory(directory: string): GoverningAliases { + return this.nearest(this.bundlerByDirectory, directory, (dir) => { + for (const name of BUNDLER_CONFIG_NAMES) { + const configPath = path.join(dir, name); + const paths = fs.existsSync(configPath) ? readBundlerAliases(configPath) : undefined; + if (paths !== undefined) { + return { paths, pathsBasePath: dir }; + } + } + return undefined; + }); + } + + private inDirectory(directory: string): GoverningAliases { + return this.nearest(this.byDirectory, directory, (dir) => { + for (const name of ['tsconfig.json', 'jsconfig.json']) { + const configPath = path.join(dir, name); + if (fs.existsSync(configPath)) { + return readPathAliases(configPath); + } + } + return undefined; + }); + } + + /** The first directory at or above `directory` where `read` finds a config, cached per directory. */ + private nearest( + cache: Map, + directory: string, + read: (dir: string) => PathAliases | undefined + ): GoverningAliases { + const cached = cache.get(directory); if (cached !== undefined) { return cached; } - let aliases: PathAliases | undefined; - for (const name of ['tsconfig.json', 'jsconfig.json']) { - const configPath = path.join(directory, name); - if (fs.existsSync(configPath)) { - aliases = readPathAliases(configPath); - break; - } - } - if (aliases === undefined) { + const here = read(directory); + let governing: GoverningAliases; + if (here !== undefined) { + governing = { aliases: here, from: directory }; + } else { const parent = path.dirname(directory); - aliases = parent === directory ? {} : this.inDirectory(parent); + governing = parent === directory ? { aliases: {}, from: '' } : this.nearest(cache, parent, read); } - this.byDirectory.set(directory, aliases); - return aliases; + cache.set(directory, governing); + return governing; } } +/** The aliases in force, and the directory of the config they came from ('' for none). */ +interface GoverningAliases { + aliases: PathAliases; + from: string; +} + function readPathAliases(configPath: string): PathAliases { const read = ts.readConfigFile(configPath, ts.sys.readFile); if (read.error !== undefined || read.config === undefined) { From 54d03bee5515e3f1edbf7762ecc7e8c280cefed5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:03:59 -0700 Subject: [PATCH 005/258] release 0.1.9: every package and plugin manifest at 0.1.9 (the parser keeps its own version) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- 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 9b59561d..ebb25063 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.8", + "version": "0.1.9", "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 0b64aeea..15a3a4e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.8", + "version": "0.1.9", "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.8", - "@axiomcode/engine-darwin-x64": "0.1.8", - "@axiomcode/engine-linux-x64": "0.1.8", - "@axiomcode/engine-linux-arm64": "0.1.8", - "@axiomcode/engine-win32-x64": "0.1.8" + "@axiomcode/engine-darwin-arm64": "0.1.9", + "@axiomcode/engine-darwin-x64": "0.1.9", + "@axiomcode/engine-linux-x64": "0.1.9", + "@axiomcode/engine-linux-arm64": "0.1.9", + "@axiomcode/engine-win32-x64": "0.1.9" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index cd9da1b8..1c32d192 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.8", + "version": "0.1.9", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 27eb62ef..72069042 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.8", + "version": "0.1.9", "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 60d16349..3e3941cf 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.8", + "version": "0.1.9", "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 48c07899960d9c4c3aa0319831e91b63046eeb21 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:07 -0700 Subject: [PATCH 006/258] typescript: read strictBindCallApply off the call site's module In a project whose tsconfigs disagree on strictBindCallApply, every call/apply/bind on a callable receiver answered both Function's and CallableFunction's declaration, and a site in a loose program named the strict one. The flag now comes from the tsconfig governing the module the call is written in: a strict program's call answers CallableFunction, a loose program's answers Function. A module no tsconfig claims keeps the project-wide hedge, and a receiver that only reaches Function's member keeps it under any flag. The case oracle compiles each nested tsconfig as its own program, so a case can hold a strict and a loose program side by side. Case 82: oracle 10, engine 16 -> 10, extra 6 -> 0. Controls: cases 21, 22, 79 and the own-bind sites unchanged; 8 real apps (uniform regime) give identical edges before and after. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../82-mixed-bind-call-apply/src/factory.ts | 28 +++ .../82-mixed-bind-call-apply/src/globals.d.ts | 66 ++++++++ .../src/loose/loose-site.ts | 20 +++ .../src/loose/tsconfig.json | 8 + .../src/strict-site.ts | 19 +++ .../src/tsconfig.json | 7 + .../expected/82-mixed-bind-call-apply.edges | 14 ++ .../expected/82-mixed-bind-call-apply.entries | 8 + .../82-mixed-bind-call-apply.envelope | 4 + .../expected/82-mixed-bind-call-apply.fields | 0 .../82-mixed-bind-call-apply.fields-oracle | 7 + .../expected/82-mixed-bind-call-apply.oracle | 1 + .../82-mixed-bind-call-apply.type-use | 7 + .../82-mixed-bind-call-apply.types-oracle | 7 + .../typescript/ground-truth/tsc-program.mjs | 56 +++--- .../test/typescript/tools/tsc_oracle_case.mjs | 159 ++++++++++-------- .../engine/expression-resolution/overload.dl | 27 +++ .../typescript/engine/projections/modules.dl | 11 ++ graph/typescript/souffle/decls_all.dl | 2 + 19 files changed, 361 insertions(+), 90 deletions(-) create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.edges create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.entries create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.envelope create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.fields create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.oracle create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.type-use create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts new file mode 100644 index 00000000..0b9ab952 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts @@ -0,0 +1,28 @@ +// ============================================================================ +// CASE 82 — `call` / `apply` / `bind` in a project whose tsconfigs DISAGREE +// ============================================================================ +// Cases 21 and 22 each compile under ONE setting of `strictBindCallApply`. This one +// has two programs: `src/tsconfig.json` (strict) governs this file and `strict-site.ts`, +// and `src/loose/tsconfig.json` (strictBindCallApply: false) governs `loose/`. The +// same call written in each answers with a different declaration: +// +// strict-site.ts -> CallableFunction#bind / #call / #apply +// loose/loose-site.ts -> Function#bind / #call / #apply +// +// The flag is read off the module the CALL is written in, never off the module that +// declares `bind` (the library) and never as one answer for the whole project. +// +// The receivers are TYPED through a method's return, the shape that made the regime +// decide at all: `factory.get(k).bind(r)`. + +export type Handler = (req: string) => string; + +export class HandlerFactory { + get(key: string): Handler { + return (req: string) => key + req; + } +} + +export function plain(value: string): string { + return value; +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts new file mode 100644 index 00000000..850c0f89 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts @@ -0,0 +1,66 @@ +// ============================================================================ +// The case's OWN standard library — the minimum the checker needs under `noLib`. +// ============================================================================ +// WHY noLib. This mechanism's targets are declared in the REAL `lib.es5.d.ts`, and a +// case never has it: the case oracle only records a target inside the case's own files, +// and the engine is handed only `--library `. So with the real library +// neither side can name the target and the case measures nothing. Declaring `Function` +// and `CallableFunction` HERE puts them on both sides of the comparison. +// +// This is not a mock of the behaviour. The checker resolves `CallableFunction` by NAME +// from whatever is in scope and then applies `strictBindCallApply` itself, so these +// declarations exercise the real decision. Verified: the same two declarations answer +// CallableFunction under `strict: true` and Function under +// `strict: false, strictBindCallApply: false`. +// +// Everything other than Function/CallableFunction is here only because `noLib` removes +// it and the checker requires it. Keep this file minimal: an interface added here is a +// declaration the engine can resolve to, and an accidental one is an accidental answer. + +interface Object { toString(): string; } +interface Boolean {} +interface Number {} +interface String { padStart(width: number): string; padEnd(width: number): string; trim(): string; } +interface Array { length: number; } +interface ReadonlyArray { length: number; } +interface IArguments {} +interface RegExp {} + +// ── the two declarations the flag chooses between ─────────────────────────── +// Mirrors lib.es5.d.ts: `Function` declares all three, and `CallableFunction extends +// Function` REDECLARES the same three with precise generic signatures. Both are in +// scope at every site; only the flag decides. +// NO explicit `this` parameter on these. lib.es5.d.ts declares them with one, but the +// case oracle's edge label includes a `this` parameter in the signature while the engine +// (correctly) does not treat it as a parameter at all — so `this: Function` renders as +// `Function#apply(Function,any,any)` on one side and `Function#apply(any,any)` on the +// other, and every site scores as a disagreement the engine did not actually make. +interface Function { + apply(thisArg: any, argArray?: any): any; + call(thisArg: any, ...argArray: any[]): any; + bind(thisArg: any, ...argArray: any[]): any; + readonly name: string; + toString(): string; +} + +interface CallableFunction extends Function { + // Simply typed on purpose. lib.es5.d.ts gives these precise generic signatures + // (`this: (this: T, ...args: A) => R`), and this fixture does not need them: what it + // tests is that TWO declarations of the same member names exist and that + // `strictBindCallApply` picks between them, which the checker decides from the flag + // and not from how well a signature fits. A function-typed parameter also renders + // badly in the case oracle's edge labels — the label splits on the commas inside + // `(this: T, ...args: A) => R` and emits `apply(args: A) =,T,T)`, which can never + // match the engine's label, so every site scored as a disagreement. + // + // Arguments are optional and returns are `any` so every call form in the case source + // typechecks. The two declarations stay distinguishable without help: an edge label + // carries the OWNER, so `CallableFunction#call` and `Function#call` never collide, and + // that owner is exactly what the flag decides. + call(thisArg: any, arg0?: any, arg1?: any): any; + apply(thisArg: any, args?: any): any; + bind(thisArg: any, arg0?: any): any; +} + + +interface NewableFunction extends Function {} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts new file mode 100644 index 00000000..ab5f80d3 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts @@ -0,0 +1,20 @@ +// Governed by src/loose/tsconfig.json (strictBindCallApply: false) -> Function's +// declarations, although the project's other program is strict. +import { HandlerFactory, plain } from '../factory'; + +export function looseSites(factory: HandlerFactory, r: object): string { + const bound = factory.get('a').bind(r); + factory.get('b').call(r, 'x'); + plain.apply(null, ['y']); + return bound('z'); +} + +// CONTROL — a type that declares its own `bind` keeps it under either regime. +interface OwnBindLoose { + (x: number): number; + bind(label: string): string; +} + +export function ownBindLoose(ob: OwnBindLoose): string { + return ob.bind('own'); +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json new file mode 100644 index 00000000..33a61be1 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": false, + "strictBindCallApply": false, + "noLib": true + }, + "include": ["**/*", "../globals.d.ts"] +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts new file mode 100644 index 00000000..6776b405 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts @@ -0,0 +1,19 @@ +// Governed by src/tsconfig.json (strict) -> CallableFunction's declarations. +import { HandlerFactory, plain } from './factory'; + +export function strictSites(factory: HandlerFactory, r: object): string { + const bound = factory.get('a').bind(r); + factory.get('b').call(r, 'x'); + plain.apply(null, ['y']); + return bound('z'); +} + +// CONTROL — a type that declares its own `bind` keeps it under either regime. +interface OwnBind { + (x: number): number; + bind(label: string): string; +} + +export function ownBindStrict(ob: OwnBind): string { + return ob.bind('own'); +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json new file mode 100644 index 00000000..f901b84b --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true, + "noLib": true + }, + "exclude": ["loose"] +} diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.edges b/graph/test/typescript/expected/82-mixed-bind-call-apply.edges new file mode 100644 index 00000000..2c0fac84 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.edges @@ -0,0 +1,14 @@ +ambiguous_unknown FUNCTION_CALL loose/loose-site#looseSites(HandlerFactory,object) @L9 -> - +ambiguous_unknown FUNCTION_CALL strict-site#strictSites(HandlerFactory,object) @L8 -> - +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L6 -> Function#bind(any,any[]) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L6 -> HandlerFactory#get(string) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L7 -> Function#call(any,any[]) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L7 -> HandlerFactory#get(string) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L8 -> Function#apply(any,any) +known_edge METHOD_CALL loose/loose-site#ownBindLoose(OwnBindLoose) @L19 -> OwnBindLoose#bind(string) +known_edge METHOD_CALL strict-site#ownBindStrict(OwnBind) @L18 -> OwnBind#bind(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L5 -> CallableFunction#bind(any,any) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L5 -> HandlerFactory#get(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L6 -> CallableFunction#call(any,any,any) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L6 -> HandlerFactory#get(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L7 -> CallableFunction#apply(any,any) diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.entries b/graph/test/typescript/expected/82-mixed-bind-call-apply.entries new file mode 100644 index 00000000..ff9be6b1 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.entries @@ -0,0 +1,8 @@ +── entry_point (7) ── + exported_from_entry_module loose/loose-site#looseSites loose-site.ts:5 + exported_from_entry_module loose/loose-site#ownBindLoose loose-site.ts:18 + exported_from_entry_module strict-site#ownBindStrict strict-site.ts:17 + exported_from_entry_module strict-site#strictSites strict-site.ts:4 + unimported_module globals# globals.d.ts:1 + unimported_module loose/loose-site# loose-site.ts:1 + unimported_module strict-site# strict-site.ts:1 diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope b/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope new file mode 100644 index 00000000..65b5ab00 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope @@ -0,0 +1,4 @@ +nominal globals#Function.apply -> globals#CallableFunction.apply +nominal globals#Function.bind -> globals#CallableFunction.bind +nominal globals#Function.call -> globals#CallableFunction.call +value factory#@18:23 -> factory#HandlerFactory. diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.fields b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle new file mode 100644 index 00000000..7431a689 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle @@ -0,0 +1,7 @@ +82-mixed-bind-call-apply [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/82-mixed-bind-call-apply.oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.oracle new file mode 100644 index 00000000..f31a2b29 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.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/82-mixed-bind-call-apply.type-use b/graph/test/typescript/expected/82-mixed-bind-call-apply.type-use new file mode 100644 index 00000000..6eaa852a --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.type-use @@ -0,0 +1,7 @@ +known_edge METHOD_PARAM 0 loose/loose-site [METHOD_PARAM] -> HandlerFactory +known_edge METHOD_PARAM 0 loose/loose-site [METHOD_PARAM] -> OwnBindLoose +known_edge METHOD_PARAM 0 strict-site [METHOD_PARAM] -> HandlerFactory +known_edge METHOD_PARAM 0 strict-site [METHOD_PARAM] -> OwnBind +known_edge METHOD_RETURN 0 HandlerFactory [METHOD] -> Handler +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle new file mode 100644 index 00000000..255041ba --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle @@ -0,0 +1,7 @@ +82-mixed-bind-call-apply [types] + 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 + contexts METHOD_PARAM=4 METHOD_RETURN=1 SUPER_TYPE=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index bcf4b561..ef034111 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -7,7 +7,7 @@ * them is meaningless. Copying ninety lines of labelling is how they would drift apart. * The existing per-case goldens prove the lift changed nothing. * - * loadProgram(srcDir, libDir, toolName) + * loadProgram(srcDir, libDir, toolName[, programDir]) * -> { ts, program, checker, own, reachable, root, libRoot, * moduleName, simple, labelOf, callerOf, diagnostics } * @@ -18,7 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadTypeScript } from './load-typescript.mjs'; -export function loadProgram(srcDir, libDir, toolName) { +export function loadProgram(srcDir, libDir, toolName, programDir) { const root = path.resolve(srcDir); // Through the shared loader like the rest of the stack: a bare `import ts from // 'typescript'` resolves to whatever is nearest and dies on a property access if that @@ -70,29 +70,39 @@ export function loadProgram(srcDir, libDir, toolName) { // The PARSER already reads the case's tsconfig (it must, to emit the resolved flag at // ts_module c27), so honouring it here is what makes the two sides describe the same // program. - const caseConfig = path.join(root, 'tsconfig.json'); - if (fs.existsSync(caseConfig)) { - const read = ts.readConfigFile(caseConfig, ts.sys.readFile); - if (read.error) { - console.error(`case tsconfig is unreadable: ${caseConfig}`); - process.exit(1); - } - const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root); - if (parsed.errors.length) { - console.error(`case tsconfig is invalid: ${caseConfig}`); - for (const e of parsed.errors) { - console.error(` ${ts.flattenDiagnosticMessageText(e.messageText, ' ')}`); + // A NESTED tsconfig (`programDir`) is a second program in the same case, as the parser + // treats it: its options are merged over the case root's, so one case can hold a strict + // program and a loose one side by side (#416). The caller picks, per file, the program + // whose directory governs it. + const configDirs = [root]; + if (programDir !== undefined && path.resolve(programDir) !== root) { + configDirs.push(path.resolve(programDir)); + } + for (const configDir of configDirs) { + const caseConfig = path.join(configDir, 'tsconfig.json'); + if (fs.existsSync(caseConfig)) { + const read = ts.readConfigFile(caseConfig, ts.sys.readFile); + if (read.error) { + console.error(`case tsconfig is unreadable: ${caseConfig}`); + process.exit(1); + } + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, configDir); + if (parsed.errors.length) { + console.error(`case tsconfig is invalid: ${caseConfig}`); + for (const e of parsed.errors) { + console.error(` ${ts.flattenDiagnosticMessageText(e.messageText, ' ')}`); + } + process.exit(1); } - process.exit(1); + // Merged, not replaced: a case states the ONE option it is about and inherits the + // rest, so a case tsconfig cannot silently drop `lib` and change every other answer. + Object.assign(options, parsed.options); + // `noLib` and the default `lib` list contradict each other, and the default is ours, + // not the case's — so a case asking for noLib gets it rather than getting both. + if (options.noLib) delete options.lib; + // `files`/`include` are ignored on purpose — the file set is the directory walk above, + // which is what the parser is handed too. } - // Merged, not replaced: a case states the ONE option it is about and inherits the - // rest, so a case tsconfig cannot silently drop `lib` and change every other answer. - Object.assign(options, parsed.options); - // `noLib` and the default `lib` list contradict each other, and the default is ours, - // not the case's — so a case asking for noLib gets it rather than getting both. - if (options.noLib) delete options.lib; - // `files`/`include` are ignored on purpose — the file set is the directory walk above, - // which is what the parser is handed too. } const program = ts.createProgram(files, options); diff --git a/graph/test/typescript/tools/tsc_oracle_case.mjs b/graph/test/typescript/tools/tsc_oracle_case.mjs index 681e8f48..21b0783a 100644 --- a/graph/test/typescript/tools/tsc_oracle_case.mjs +++ b/graph/test/typescript/tools/tsc_oracle_case.mjs @@ -18,83 +18,108 @@ * * usage: node tsc_oracle_case.mjs [lib-dir] */ +import fs from 'node:fs'; import path from 'node:path'; import { loadProgram } from '../ground-truth/tsc-program.mjs'; -const { - ts, program, checker, own, reachable, labelOf, callerOf, diagnostics, - implicitCtorOwner, labelOfImplicitCtor, -} = loadProgram(process.argv[2], process.argv[3], 'tsc_oracle_case'); +// ONE PROGRAM PER tsconfig. A tsconfig.json below the case root is a second program, +// exactly as the parser treats it, and a file is answered by the program whose directory +// is nearest above it — so a case can compile one directory strict and another loose +// (#416). A case with only the root tsconfig loads one program, as before. +const caseRoot = path.resolve(process.argv[2] ?? '.'); +const programDirs = [caseRoot]; +(function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const sub = path.join(d, e.name); + if (fs.existsSync(path.join(sub, 'tsconfig.json'))) programDirs.push(sub); + walk(sub); + } +})(caseRoot); +const programs = programDirs.map((dir) => ({ + dir, ...loadProgram(process.argv[2], process.argv[3], 'tsc_oracle_case', dir), +})); +const governing = (file) => programs + .filter((p) => file === p.dir || file.startsWith(p.dir + path.sep)) + .reduce((a, b) => (b.dir.length > a.dir.length ? b : a)); const pairs = new Set(); -for (const sf of program.getSourceFiles()) { - if (!own.has(path.resolve(sf.fileName))) continue; - const visit = (node) => { - // A DECORATOR APPLICATION IS A CALL, and this side did not think so. The project - // oracle enumerates `ts.isDecorator` and calls it DECORATOR_CALL; this list omitted - // it, so the per-case suite was blind to decorators entirely — green on them whatever - // the engine did, while a project run counted 191 absences on a decorator-driven - // codebase. No case had ever used a decorator, so nothing caught the disagreement. - // Whichever side is right, both must say it, and the compiler settles it: it resolves - // the application, because a decorator is a function invoked with (target, key, - // descriptor). #233. - if (ts.isCallExpression(node) || ts.isNewExpression(node) - || ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node) - || ts.isTaggedTemplateExpression(node) || ts.isDecorator(node)) { - let sig; - try { sig = checker.getResolvedSignature(node); } catch { sig = undefined; } - let decl = sig?.declaration; - // AN IMPLICIT CONSTRUCTOR HAS NO DECLARATION. `new Bag()` on a class that - // declares no constructor anywhere in its chain resolves to a signature whose - // `declaration` is undefined, so the site was silently unscored — and the - // engine's answer for it, whatever it was, went unchecked. The parser now - // synthesises that constructor on the ROOT class of the `extends` chain (the - // one that extends nothing; a subclass runs its base's), so the compiler side - // names the same declaration: the root class, labelled as its ``. #583. - let target; - if (decl === undefined && ts.isNewExpression(node)) { - const cls = implicitCtorOwner(node); - target = cls === undefined ? undefined : labelOfImplicitCtor(cls); - } else { - target = labelOf(decl); +for (const { dir, ts, program, checker, own, labelOf, callerOf, + implicitCtorOwner, labelOfImplicitCtor } of programs) { + for (const sf of program.getSourceFiles()) { + const file = path.resolve(sf.fileName); + if (!own.has(file) || governing(file).dir !== dir) continue; + const visit = (node) => { + // A DECORATOR APPLICATION IS A CALL, and this side did not think so. The project + // oracle enumerates `ts.isDecorator` and calls it DECORATOR_CALL; this list omitted + // it, so the per-case suite was blind to decorators entirely — green on them whatever + // the engine did, while a project run counted 191 absences on a decorator-driven + // codebase. No case had ever used a decorator, so nothing caught the disagreement. + // Whichever side is right, both must say it, and the compiler settles it: it resolves + // the application, because a decorator is a function invoked with (target, key, + // descriptor). #233. + if (ts.isCallExpression(node) || ts.isNewExpression(node) + || ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node) + || ts.isTaggedTemplateExpression(node) || ts.isDecorator(node)) { + let sig; + try { sig = checker.getResolvedSignature(node); } catch { sig = undefined; } + let decl = sig?.declaration; + // AN IMPLICIT CONSTRUCTOR HAS NO DECLARATION. `new Bag()` on a class that + // declares no constructor anywhere in its chain resolves to a signature whose + // `declaration` is undefined, so the site was silently unscored — and the + // engine's answer for it, whatever it was, went unchecked. The parser now + // synthesises that constructor on the ROOT class of the `extends` chain (the + // one that extends nothing; a subclass runs its base's), so the compiler side + // names the same declaration: the root class, labelled as its ``. #583. + let target; + if (decl === undefined && ts.isNewExpression(node)) { + const cls = implicitCtorOwner(node); + target = cls === undefined ? undefined : labelOfImplicitCtor(cls); + } else { + target = labelOf(decl); + } + if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); } - if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); - } - // AN ACCESSOR IS INVOKED BY THE ACCESS. `c.req.url` runs `get url()` and `c.res = r` - // runs `set res(v)`, and the compiler knows which declaration each is: the symbol at - // the property name carries the get and set declarations. A read names the getter; an - // assignment target names the setter; a compound assignment or an update (`x.n += 1`, - // `x.n++`) reads then writes and names both. The engine emits these as PROPERTY_READ / - // PROPERTY_WRITE edges (#703), and without this they would be unscored extras: the - // one shape whose ground truth is the compiler's and was never asked of it. - if (ts.isPropertyAccessExpression(node)) { - let sym; - try { sym = checker.getSymbolAtLocation(node.name); } catch { sym = undefined; } - if (sym && (sym.flags & ts.SymbolFlags.Alias)) sym = checker.getAliasedSymbol(sym); - if (sym && (sym.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor))) { - const parent = node.parent; - const isLeft = ts.isBinaryExpression(parent) && parent.left === node - && parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment - && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment; - const plain = isLeft && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - const update = (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) - && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken); - const reads = !plain; - const writes = isLeft || update; - for (const d of sym.declarations ?? []) { - if ((reads && ts.isGetAccessorDeclaration(d)) || (writes && ts.isSetAccessorDeclaration(d))) { - const target = labelOf(d); - if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); + // AN ACCESSOR IS INVOKED BY THE ACCESS. `c.req.url` runs `get url()` and `c.res = r` + // runs `set res(v)`, and the compiler knows which declaration each is: the symbol at + // the property name carries the get and set declarations. A read names the getter; an + // assignment target names the setter; a compound assignment or an update (`x.n += 1`, + // `x.n++`) reads then writes and names both. The engine emits these as PROPERTY_READ / + // PROPERTY_WRITE edges (#703), and without this they would be unscored extras: the + // one shape whose ground truth is the compiler's and was never asked of it. + if (ts.isPropertyAccessExpression(node)) { + let sym; + try { sym = checker.getSymbolAtLocation(node.name); } catch { sym = undefined; } + if (sym && (sym.flags & ts.SymbolFlags.Alias)) sym = checker.getAliasedSymbol(sym); + if (sym && (sym.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor))) { + const parent = node.parent; + const isLeft = ts.isBinaryExpression(parent) && parent.left === node + && parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment + && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + const plain = isLeft && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + const update = (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken); + const reads = !plain; + const writes = isLeft || update; + for (const d of sym.declarations ?? []) { + if ((reads && ts.isGetAccessorDeclaration(d)) || (writes && ts.isSetAccessorDeclaration(d))) { + const target = labelOf(d); + if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); + } } } } - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sf, visit); + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } } -const diags = diagnostics(); +// A diagnostic counts only in a file its own program governs: the root program also +// compiles the nested program's files under the root's options, and those are not the +// options the author wrote for them. +const diags = programs.flatMap((p) => p.diagnostics().filter((d) => !d.file + || governing(path.resolve(d.file.fileName)).dir === p.dir)); if (diags.length > 0) { // A case that does not typecheck has an unreliable oracle: the checker still // answers, but it answers about a program the author did not mean to write. @@ -102,7 +127,7 @@ if (diags.length > 0) { // case does not typecheck -- threw a ReferenceError instead of printing anything, and // the author saw a stack trace from the oracle rather than the compiler's message. // The branch only runs when a fixture fails to compile, which is why it survived. - const caseRoot = path.resolve(process.argv[2] ?? '.'); + const { ts } = programs[0]; for (const d of diags.slice(0, 8)) { // A diagnostic about the PROGRAM rather than a file -- a bad compiler option, a // missing lib -- carries no `d.file` either. diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index 948d5b68..f5d28a76 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -91,10 +91,33 @@ call_has_spread(ce) :- call_site("client", _, _, _, _, ce, cs), // target, so its count is unknown in the same sense a spread's is: arity stands down. call_has_spread(ce) :- bare_decorator_call(ce). +// ── sbca_site_rejects(CallExpr, Callee) — the other family of call/apply/bind ── +// In a MIXED project member-lookup.dl seeds BOTH `Function` and `CallableFunction` for +// a callable receiver, because the project has no single answer. A call site does: its +// module's governing tsconfig decides (module_sbca_governed), so here the family that +// module's flag rules out is dropped. Only where the other family's same-named member +// is ALSO a candidate at the site, so a receiver that reaches `Function.bind` alone +// (an interface extending Function with no call signature) keeps it under any flag. +sbca_site_rejects(ce, m) :- expr_call_candidate(ce, m), + call_site("client", _, _, _, _, ce, cs), + call_module(cs, mod), + module_sbca_governed(mod, "true"), + plain_function_member(n, m), + callable_function_member(n, other), + expr_call_candidate(ce, other). +sbca_site_rejects(ce, m) :- expr_call_candidate(ce, m), + call_site("client", _, _, _, _, ce, cs), + call_module(cs, mod), + module_sbca_governed(mod, "false"), + callable_function_member(n, m), + plain_function_member(n, other), + expr_call_candidate(ce, other). + // ── arity_match(CallExpr, Callee) ─────────────────────────────────────────── // Guarded by the candidate set, so the numeric comparison is never a cross product // of all N-argument calls against all N-parameter methods. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_expr_argc(ce, n), !call_has_spread(ce), method_min_arity(m, lo), @@ -102,6 +125,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), method_max_arity(m, hi), n <= hi. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_has_spread(ce). // AN IMPLEMENTATION'S OWN MAXIMUM IS NOT A BOUND ON THE CALL. // `const assert: (left, right) => R = x => x as any` is legal TypeScript: @@ -120,6 +144,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), // passes fewer arguments than a parameter the callee cannot default is still not // a call to it. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_expr_argc(ce, n), !call_has_spread(ce), method_min_arity(m, lo), @@ -129,6 +154,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), // kept rather than dropped: prune-only means the absence of evidence never removes // the real callee. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), !method_arity_raw(m, _, _, _). // ============================================================================ @@ -993,6 +1019,7 @@ expr_resolves_to_method(ce, m) :- arity_match(ce, m), // engine's error, not the program's. call_has_arity_match(ce) :- arity_match(ce, _). expr_resolves_to_method(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), !call_has_arity_match(ce). // ── arity_rejected(CallExpr, Callee, ArgCount, Min, Max) ──────────────────── diff --git a/graph/typescript/engine/projections/modules.dl b/graph/typescript/engine/projections/modules.dl index f3c1e237..dc2a1429 100644 --- a/graph/typescript/engine/projections/modules.dl +++ b/graph/typescript/engine/projections/modules.dl @@ -165,3 +165,14 @@ sbca_value(v) :- ts_module(_, _, _, _, _, _, _, _, _, _, _, _, _, _, tsconfig, _ sbca_regime("STRICT") :- sbca_value("true"), !sbca_value("false"). sbca_regime("LOOSE") :- sbca_value("false"), !sbca_value("true"). sbca_regime("MIXED") :- sbca_value("true"), sbca_value("false"). + +// ── module_sbca_governed(ModuleHash, Flag) — the flag a CALL SITE is compiled under ── +// MIXED above is the right answer for the project and the wrong one for a call: each +// file is compiled under the tsconfig that governs it, so a call in a strict program +// answers with CallableFunction and a call in a loose one with Function, in the same +// repository. overload.dl reads this per call site to settle what MIXED hedged. +// The same vote guard as sbca_value: a module no tsconfig claims carries the resolver's +// default, not a measurement, and keeps the project's hedge. +module_sbca_governed(h, v) :- ts_module(_, _, _, _, _, _, _, _, _, _, _, _, _, _, tsconfig, _, _, _, _, _, _, _, _, _, _, _, h, v), + v != "", + tsconfig != "". diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index b046589a..d8e05988 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -176,6 +176,8 @@ .decl module_strict_bind_call_apply(c0:symbol,c1:symbol,c2:symbol) .decl sbca_value(c0:symbol) .decl sbca_regime(c0:symbol) +.decl module_sbca_governed(c0:symbol,c1:symbol) +.decl sbca_site_rejects(c0:symbol,c1:symbol) .decl expr_spread(c0:symbol,c1:symbol) .decl expr_static_type(c0:symbol,c1:symbol,c2:symbol) .decl expr_type(c0:symbol,c1:symbol,c2:symbol) From 0dbf91dbc5ece91505a1581f6adf3c8847933115 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:40:20 -0700 Subject: [PATCH 007/258] freshness: watch a file whose extension case the parser ignores (Main.JS, Up.VUE, Calc.CS) The JavaScript parser matches an extension whatever its case and the C# parser reads *.CS, but the file table matched exactly, so such a file was parsed yet never watched: an edit to it left the graph 'up to date'. The table now folds case for exactly the extensions those parsers fold; TypeScript, Python and Java stay case-sensitive, as their parsers are (the control in tests/freshness.py). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/ax_fresh.py | 15 +++++++++++-- tests/freshness.py | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 808b4a3c..4993e7cc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -64,6 +64,17 @@ 'python': ('pyproject.toml', 'setup.cfg', 'setup.py'), 'csharp': ('global.json', 'Directory.Build.props'), } +# EXTENSION CASE (#1771). The JavaScript parser matches an extension whatever its case (jsExtensionOf lower-cases the +# name, so Main.JS and Up.VUE are read) and the C# parser reads *.CS (discoverCsFiles); the others match it exactly. +# Matched exactly here, such a file was parsed but left out of the table: an edit to it was invisible and `index` said +# "graph up to date". These are the extensions each parser folds; every other one stays case-sensitive, as its parser is. +FOLD = {'javascript': frozenset(EXT['javascript']), 'csharp': frozenset({'.cs'})} + +def has_ext(lang, name, exts): + """True when the parser of `lang` takes file `name` as ending in one of `exts`""" + if name.endswith(exts): return True + fold = FOLD.get(lang) + return bool(fold) and name.lower().endswith(tuple(e for e in exts if e in fold)) # TOOL OUTPUT NOBODY PARSES: what the non-source scan of `impact` (axiomcode-impact) leaves out. NOT what the file table # prunes: that is SKIP below, per language. PRUNE_ALL = {'.git', '.hg', '.svn', '.axiomcode', 'node_modules', 'bower_components', 'dist', 'build', 'out', @@ -194,7 +205,7 @@ def watched(root, lang): for f in files: for l, exts, names in spec: if l in off: continue - if f.endswith(exts) or f in names or (l == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \ + if has_ext(l, f, exts) or f in names or (l == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \ or (l == 'python' and python_script(os.path.join(d, f), f)): yield os.path.join(d, f); break @@ -1118,7 +1129,7 @@ def main(argv): n = {l: 0 for l in SOURCE} for l in SOURCE: for p in watched(repo, l): - if p.endswith(SOURCE[l]) or (l == 'python' and python_script(p)): n[l] += 1 + if has_ext(l, os.path.basename(p), SOURCE[l]) or (l == 'python' and python_script(p)): n[l] += 1 print(n['java'], n['typescript'], n['python'], n['javascript'], n['csharp']); return 0 if cmd == 'pyscripts': # how many extensionless python scripts are under repo: added to the build's .py count when it picks a language diff --git a/tests/freshness.py b/tests/freshness.py index 97ffa425..47242267 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -131,6 +131,27 @@ def prune_checks(): write(only, 'src/App.vue', '\n') n = {l: sum(1 for p in ax_fresh.watched(only, l) if p.endswith(ax_fresh.SOURCE[l])) for l in ('javascript', 'typescript')} check("prune: a repository of .vue files alone counts no JavaScript or TypeScript source", n == {'javascript': 0, 'typescript': 0}, n) + # EXTENSION CASE (#1771): the JavaScript parser reads Main.JS and Up.VUE and the C# parser reads Calc.CS, so an edit + # to one makes the graph stale and they count as source. The control: the TypeScript, Python and Java parsers match + # an extension exactly, so Main.TS, calc.PY and Calc.JAVA stay unwatched and uncounted + upper = os.path.join(work, 'upper') + for f in ('src/lib.js', 'src/Main.JS', 'src/views/Up.VUE', 'src/Old.Mjs', 'src/Main.TS', 'src/calc.PY', + 'src/Calc.JAVA', 'src/Calc.CS'): + write(upper, f, 'x\n') + want = {'javascript': {'src/lib.js', 'src/Main.JS', 'src/views/Up.VUE', 'src/Old.Mjs'}, 'typescript': {'src/lib.js'}, + 'python': set(), 'java': set(), 'csharp': {'src/Calc.CS'}} + for lang, files in want.items(): + got = {os.path.relpath(p, upper) for p in ax_fresh.watched(upper, lang)} + check(f"prune: {lang} watches an upper-case extension exactly when its parser reads one", got == files, sorted(got)) + table = dict(lang='javascript', lang_auto=True, src='', files=ax_fresh.snapshot(upper, 'javascript', upper)) + write(upper, 'src/Main.JS', 'third();\n'); write(upper, 'src/views/Up.VUE', 'third();\n'); write(upper, 'src/Main.TS', 'y\n') + c = ax_fresh.changes(upper, table) + check("prune: javascript: an edit to Main.JS or Up.VUE makes the graph stale; one to Main.TS does not", + c == (['src/Main.JS', 'src/views/Up.VUE'], [], []), c) + n = {l: sum(1 for p in ax_fresh.watched(upper, l) if ax_fresh.has_ext(l, os.path.basename(p), ax_fresh.SOURCE[l])) + for l in ax_fresh.SOURCE} + check("prune: Main.JS and Calc.CS count as source; Main.TS, calc.PY, Calc.JAVA and Up.VUE do not", + n == {'java': 0, 'typescript': 0, 'python': 0, 'javascript': 3, 'csharp': 1}, n) finally: shutil.rmtree(work, ignore_errors=True) From f27290da321f0b4306fe8e0de292772154729fa3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:49:10 -0700 Subject: [PATCH 008/258] impact: walk the import closure back from the reached modules, not over the whole project (#1033) imports_file was the transitive closure of every file's imports, built by joining the relation to itself on every query whatever was asked. Only import_hop reads it, and only for the files of modules the change reaches. It is now seeded from those files and walked back linearly, which derives the same pairs. On a 3.6k-file TypeScript project the closure was 1.2M rows and 27 s of the solve; impact goes from 61-85 s to 4-17 s over seven targets, with identical JSON answers. A new case covers a chain of imports through a cycle, with a control chain that reaches nothing. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/dl/impact.dl | 13 ++++++++++--- .../runs-at-import-through-a-chain/case.json | 10 ++++++++++ .../src/__tests__/page.spec.ts | 8 ++++++++ .../src/__tests__/sum.spec.ts | 8 ++++++++ .../runs-at-import-through-a-chain/src/escape.ts | 9 +++++++++ .../runs-at-import-through-a-chain/src/maps.ts | 6 ++++++ .../runs-at-import-through-a-chain/src/page.ts | 6 ++++++ .../runs-at-import-through-a-chain/src/plain.ts | 4 ++++ .../runs-at-import-through-a-chain/src/render.ts | 9 +++++++++ .../runs-at-import-through-a-chain/src/sum.ts | 5 +++++ .../runs-at-import-through-a-chain/src/tags.ts | 4 ++++ 11 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/case.json create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/page.spec.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/sum.spec.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/escape.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/maps.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/page.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/plain.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/render.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/sum.ts create mode 100644 tests/cases/typescript/runs-at-import-through-a-chain/src/tags.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index be140955..151f4997 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -919,11 +919,18 @@ parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d // (`imports()` in axiomcode-impact, per language). Only a target that resolves to a file IN THIS REPOSITORY is // kept: a stdlib or third-party import names no file here and is dropped rather than guessed. .decl imports_fact(f:symbol, g:symbol) .input imports_fact +// ASKED FOR, NOT PRECOMPUTED (#1033). `import_hop` only ever reads the files that import a module the change +// reaches, so the closure is walked backwards from those files and nowhere else. The whole-project closure this +// replaces was the same for every question and quadratic to build (imports_file joined to itself): on a +// 3.6k-file TypeScript graph it was 1.2M rows and 27 s of a 38 s solve, the flat floor under every `impact`. +.decl import_goal(g:symbol) +import_goal(g) :- up_running(_, mod, _), kind(mod, "module"), decl_file(mod, g). .decl imports_file(f:symbol, g:symbol) -imports_file(f, g) :- imports_fact(f, g). +imports_file(f, g) :- import_goal(g), imports_fact(f, g). // and transitively: a test file imports the module that imports the settings object. The chain is what makes -// the rule worth anything — the file that breaks is almost never the file the test names. -imports_file(f, h) :- imports_file(f, g), imports_file(g, h), f != h. +// the rule worth anything — the file that breaks is almost never the file the test names. Linear in the chain: +// any path of imports from f to g with f != g yields the pair, the same set the self-join derived. +imports_file(f, g) :- imports_fact(f, h), imports_file(h, g), f != g. .output fw_edge // ── tests: a test's own body reaches the change, or a fixture its framework runs before it does ─────────────── diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/case.json b/tests/cases/typescript/runs-at-import-through-a-chain/case.json new file mode 100644 index 00000000..92dde0cd --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/case.json @@ -0,0 +1,10 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a module-level call breaks every file that imports its module, however many imports away: page.spec reaches the table only through page -> escape -> render -> tags, and escape and render import each other", + "run": ["impact", "makeMap", "--tests"], + "want": ["page.spec.ts", "at import"], + "avoid": ["sum.spec.ts"]}, + {"why": "control: a chain of imports that never reaches a module doing work at import propagates nothing", + "run": ["impact", "double", "--tests"], + "want": ["sum.spec.ts"], + "avoid": ["page.spec.ts"]}]} diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/page.spec.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/page.spec.ts new file mode 100644 index 00000000..2dc97fd3 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/page.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { page } from '../page' + +describe('page', () => { + it('wraps', () => { + expect(page('x')).toBe('x') + }) +}) diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/sum.spec.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/sum.spec.ts new file mode 100644 index 00000000..0c05ab21 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/__tests__/sum.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { sum } from '../sum' + +describe('sum', () => { + it('adds', () => { + expect(sum(1, 2)).toBe(4) + }) +}) diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/escape.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/escape.ts new file mode 100644 index 00000000..0a61ce59 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/escape.ts @@ -0,0 +1,9 @@ +import { open } from './render' + +export function escape(s: string): string { + return s.replace(/ { + const out: Record = {} + for (const k of list.split(',')) out[k] = true + return out +} diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/page.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/page.ts new file mode 100644 index 00000000..6bea8b6a --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/page.ts @@ -0,0 +1,6 @@ +import { wrap } from './escape' + +// two imports away from the table module, and calls nothing in it +export function page(s: string): string { + return wrap(s) +} diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/plain.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/plain.ts new file mode 100644 index 00000000..a1ba9813 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/plain.ts @@ -0,0 +1,4 @@ +// imports nothing that reaches the table module +export function double(n: number): number { + return n * 2 +} diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/render.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/render.ts new file mode 100644 index 00000000..17781fc3 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/render.ts @@ -0,0 +1,9 @@ +import { isVoidTag } from './tags' +import { escape } from './escape' + +// imports the table module, and is imported by `escape`, which it imports: a cycle +export function open(tag: string): string { + return '<' + escape(tag) + '>' +} + +export const voids = Object.keys(isVoidTag) diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/sum.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/sum.ts new file mode 100644 index 00000000..eeec5197 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/sum.ts @@ -0,0 +1,5 @@ +import { double } from './plain' + +export function sum(a: number, b: number): number { + return double(a) + b +} diff --git a/tests/cases/typescript/runs-at-import-through-a-chain/src/tags.ts b/tests/cases/typescript/runs-at-import-through-a-chain/src/tags.ts new file mode 100644 index 00000000..3d466c18 --- /dev/null +++ b/tests/cases/typescript/runs-at-import-through-a-chain/src/tags.ts @@ -0,0 +1,4 @@ +import { makeMap } from './maps' + +// the module body runs this while being imported +export const isVoidTag = makeMap('br,hr,img') From 4760cecdf9380dfdfc06f2278749fb764daad145 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:53:26 -0700 Subject: [PATCH 009/258] typescript: a class property initialised with an arrow is a method named for its field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handler = () => {…}` was indexed as Svc. beside a field Svc.handler, so this.handler(), which the engine resolves to the arrow, never reached Svc.handler (the caller showed only as [in scope]). The field's initializer is the arrow's expression row, whose anonymousDeclarationHash is the method: name it for the field, as const foo = () => … already is. The bound field or variable row is dropped by its own line, so a property written over several lines is one member. A property whose initializer is a call wrapping an arrow stays a field. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-index | 34 +++++++++++++------ .../typescript/class-property-arrow/case.json | 23 +++++++++++++ .../class-property-arrow/src/main.ts | 6 ++++ .../class-property-arrow/src/svc.ts | 22 ++++++++++++ 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 tests/cases/typescript/class-property-arrow/case.json create mode 100644 tests/cases/typescript/class-property-arrow/src/main.ts create mode 100644 tests/cases/typescript/class-property-arrow/src/svc.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 962c0b4c..9130f20d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -20,8 +20,9 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections csv.field_size_limit(10**9) -INDEX_VERSION = '5' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; - # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding); the query +INDEX_VERSION = '6' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; + # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; + # v6: TypeScript class-property arrows named after their field); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -98,8 +99,13 @@ A = { kind=lambda r: 'const' if r.get('isConst') == 'true' else 'variable', name='name', owner=None, filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-fields.csv', id='tsFieldUniqueHash', kind=lambda r: 'field', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine')], - # `const foo = () => …` : the method is named ; its name is the variable's - boundNames=dict(file='all-typescript-variables.csv', name='name', method='boundFunctionLinkHash'), + # `const foo = () => …` : the method is named ; its name is the variable's. A class property `handler = () => …` + # is the same shape through the field: its initializer is the ARROW_FUNCTION row whose anonymousDeclarationHash is the + # method, so `this.handler()` (resolved to that method) is a caller of `Svc.handler`, not of `Svc.`. + # `handler = debounce(() => …)` is not: its initializer is the CALL, which declares nothing + boundNames=[dict(file='all-typescript-variables.csv', name='name', method='boundFunctionLinkHash'), + dict(file='all-typescript-fields.csv', name='name', + via=dict(key='initializerExpressionLinkHash', file='all-typescript-expressions.csv', id='tsExpressionUniqueHash', method='anonymousDeclarationHash'))], skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -224,19 +230,25 @@ c.executemany("INSERT OR IGNORE INTO paths VALUES (?,?)", ((p, rel(p)) for p in # ── names a function or class takes from what it is bound to ────────────────────────────────────── bound = {} -if A['boundNames']: - b = A['boundNames'] - if b.get('via'): # variable -> expression -> method +# method -> (file, line, name) of the variable or field that names it, where that row says its own place: `run:\n T =\n (…) => …` +# starts the field lines above its arrow, and without this the field stayed a second `Holder.run`, a field beside the method +bound_src = {} +def bind(m_, r, name): + if m_ in bound: return + bound[m_] = name + if r.get('filePath') and (r.get('startLine') or '').isdigit(): bound_src[m_] = (rel(r['filePath']), int(r['startLine']), name) +for b in ([A['boundNames']] if isinstance(A['boundNames'], dict) else A['boundNames'] or []): + if b.get('via'): # variable (or field) -> expression -> method v = b['via']; expr2m = {} for r in rows(v['file']): if r.get(v['method']) and r.get(v['id']): expr2m[r[v['id']]] = r[v['method']] for r in rows(b['file']): if b.get('only') and not b['only'](r): continue m_ = expr2m.get(r.get(v['key'], '')) - if m_ and r.get(b['name']): bound.setdefault(m_, r[b['name']]) + if m_ and r.get(b['name']): bind(m_, r, r[b['name']]) else: for r in rows(b['file']): - if r.get(b['method']): bound.setdefault(r[b['method']], r[b['name']]) + if r.get(b['method']): bind(r[b['method']], r, r[b['name']]) # a function that is the value of a property takes the property's name: `recv.key = function …` (an ASSIGNMENT whose # target is a non-computed property access) and `{ key: () => … }` (an OBJECT_LITERAL's PROPERTY_VALUE, keyed by the # PROPERTY_KEY sibling at the same childIndex). The owner is the receiver as written (`helpers`, `Foo` for @@ -363,7 +375,9 @@ def enum_constant_of(fp, ln): return None for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type_id, owner_qualified_name, file_path, start_line, end_line FROM methods WHERE provenance='client'"): name = m['name'] or ckey.get(cmeth.get(m['id'], ''), '') - if name.startswith('<') and m['id'] in bound: name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) + if name.startswith('<') and m['id'] in bound: + name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) + if m['id'] in bound_src: bound_at.add(bound_src[m['id']]) od = tdisplay(m['owner_type_id']) if m['owner_type_id'] else bound_owner.get(m['id']) if m['kind'] == 'ENUM_CONSTANT_METHOD' and od: k_ = enum_constant_of(rel(m['file_path']), m['start_line'] or 0) diff --git a/tests/cases/typescript/class-property-arrow/case.json b/tests/cases/typescript/class-property-arrow/case.json new file mode 100644 index 00000000..ef1ce35b --- /dev/null +++ b/tests/cases/typescript/class-property-arrow/case.json @@ -0,0 +1,23 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a class property initialised with an arrow is a method named for its field: the engine resolves this.handler() to that arrow, so the caller is resolved, not a same-scope guess, and the member reads as Svc.handler, never Svc.", + "run": ["impact", "Svc.handler"], + "want": ["Svc.run", "[resolved]"], + "avoid": ["Svc. src/svc.ts:6", "nothing named", "[in scope] Svc.run"]}, + {"why": "a property written over several lines (run:\n T =\n () => ...) starts its field lines above its arrow: it is still one member, the method, not a field beside a method of the same name", + "run": ["impact", "Svc.wide"], + "want": ["change: Svc.wide [method]", "Svc.go"], + "avoid": ["more than one kind", "field Svc.wide"]}, + {"why": "the arrow's own callee lists it by its field's name", + "run": ["impact", "audit"], + "want": ["Svc.handler", "standalone"], + "avoid": ["Svc. src/svc.ts:6"]}, + {"why": "control: a property whose initializer is a CALL wrapping the arrow (debounce(() => ...)) stays a field; the wrapped arrow does not take its name", + "run": ["impact", "Svc.throttled"], + "want": ["change: field Svc.throttled"], + "avoid": ["nothing named"]}, + {"why": "control: a module const bound to an arrow is still the function standalone", + "run": ["impact", "standalone"], + "want": ["main"], + "avoid": ["", "nothing named"]} + ]} diff --git a/tests/cases/typescript/class-property-arrow/src/main.ts b/tests/cases/typescript/class-property-arrow/src/main.ts new file mode 100644 index 00000000..4d89933f --- /dev/null +++ b/tests/cases/typescript/class-property-arrow/src/main.ts @@ -0,0 +1,6 @@ +import { Svc, standalone } from './svc'; + +export function main(): number { + standalone(); + return new Svc().run(); +} diff --git a/tests/cases/typescript/class-property-arrow/src/svc.ts b/tests/cases/typescript/class-property-arrow/src/svc.ts new file mode 100644 index 00000000..70e5d269 --- /dev/null +++ b/tests/cases/typescript/class-property-arrow/src/svc.ts @@ -0,0 +1,22 @@ +declare function debounce any>(fn: T): T; + +export function audit(): number { return 1; } + +export class Svc { + handler = () => { return audit(); }; + throttled = debounce(() => audit()); + label = 'svc'; + + run(): number { return this.handler(); } + later(): number { return this.throttled(); } + name(): string { return this.label; } + + wide: + Fn = + () => audit(); + go(): number { return this.wide(); } +} + +export type Fn = () => number; + +export const standalone = () => audit(); From 504e6205e434954e1dd8755747cbbe7546828ffb Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:51:06 -0700 Subject: [PATCH 010/258] typescript: --closed-world on narrows the dispatch fan to constructible types, and records it type_instantiated was computed and never read, so a TypeScript multi_inferred fan was every override the hierarchy admits (CHA), while Python's is narrowed to what the program builds. Narrowing is only sound when every construction is visible, so it is opt-in: - --closed-world on (env AXIOM_DISPATCH_CLOSED_WORLD) admits an implementor only if a constructible type conforms to it: a typed `new`, or the class used as a value anywhere, in its own module (TYPE) or imported into another (IMPORT_BINDING, the container `useClass: X` shape); - each edge the premise removes is a dispatch_assumes_closed_world row (assumption-dispatch-closed-world.csv), and run.dispatch_closed_world says which mode ran; - the default fan and every golden are unchanged. Case 83 pins the default fan; closed-world-dispatch-test.sh solves it with the flag on: Unused, Orphan and TypeOnly drop (3 assumption rows), Built, Registered, Base (inherited by a constructed Leaf) and Provided (imported, handed over as useClass) stay. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- bin/axiomcode | 4 +- graph/bundle/SCHEMA.md | 1 + graph/bundle/schema.ts | 1 + graph/csharp/souffle/decls_all.dl | 1 + graph/csharp/souffle/gen_decls_all.py | 3 + graph/java/souffle/decls_all.dl | 1 + graph/javascript/souffle/decls_all.dl | 1 + graph/pipeline/run-souffle.sh | 23 +++++- graph/python/souffle/decls_all.dl | 1 + .../83-closed-world-dispatch/src/handlers.ts | 55 ++++++++++++++ .../83-closed-world-dispatch/src/impl.ts | 12 +++ .../83-closed-world-dispatch/src/module.ts | 7 ++ .../expected/83-closed-world-dispatch.edges | 16 ++++ .../expected/83-closed-world-dispatch.entries | 2 + .../83-closed-world-dispatch.envelope | 8 ++ .../expected/83-closed-world-dispatch.fields | 0 .../83-closed-world-dispatch.fields-oracle | 7 ++ .../83-closed-world-dispatch.known-missing | 7 ++ .../expected/83-closed-world-dispatch.oracle | 9 +++ .../83-closed-world-dispatch.type-use | 18 +++++ .../83-closed-world-dispatch.types-oracle | 7 ++ graph/test/typescript/run-tests.sh | 8 ++ .../tools/closed-world-dispatch-test.sh | 75 +++++++++++++++++++ .../callee-resolution.dl | 55 ++++++++++++++ graph/typescript/souffle/decls_all.dl | 6 ++ graph/typescript/souffle/export_manifest.tsv | 1 + 26 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.edges create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.entries create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.envelope create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.fields create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.known-missing create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.oracle create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.type-use create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.types-oracle create mode 100644 graph/test/typescript/tools/closed-world-dispatch-test.sh diff --git a/bin/axiomcode b/bin/axiomcode index 3e1d32c8..01c25295 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -25,6 +25,8 @@ # # Engine options: # --dispatch-cap --lib-depth --jdk-depth --taint +# --closed-world narrow the dispatch fan to constructed types (TypeScript), recording +# each dropped edge as an assumption row # # Commands: # parser [--version V] [--exclude-tests] @@ -195,7 +197,7 @@ case "$cmd" in --language) lang="$2"; shift 2;; --src) src="$2"; shift 2;; --out) out="$2"; shift 2;; --library) libs="$2"; shift 2;; --progress) progress="$2"; shift 2;; --version|--slug) version="$2"; shift 2;; --exclude-tests) popts+=(--exclude-tests); shift;; - --intermediate|--dispatch-cap|--lib-depth|--jdk-depth|--taint) rest+=("$1" "$2"); shift 2;; + --intermediate|--dispatch-cap|--lib-depth|--jdk-depth|--taint|--closed-world) rest+=("$1" "$2"); shift 2;; --debug) rest+=(--debug); shift;; --*) die "unknown option $1 (try --help)";; *) pos+=("$1"); shift;; esac; done diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 625aac8d..ff013e58 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -215,6 +215,7 @@ What produced this bundle: one key/value row per fact about the run (language, e | `client_ir` | all | Path of the client IR directory the engine read. | | `library_roots` | all | Comma-separated library IR roots staged as the type oracle; empty for a client-only run. | | `dispatch_cap` | all | Fan-width cap on virtual dispatch in effect; `off` when uncapped. | +| `dispatch_closed_world` | all | `on` when the dispatch fan was narrowed to types the analysed code constructs (a closed-world premise; each dropped edge is an assumption row); `off` otherwise. Read by TypeScript. | | `jdk_depth` | all | Platform-library hop cap (engine-ii). | | `lib_depth` | all | External-library hop cap; `uncapped` when unset. | | `engine_ii` | all | `on` when the library-frontier forward chain (engine-ii) was included; `off` for a client-only solve. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 87b31156..23e6b0ad 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -384,6 +384,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'run', column: 'key', value: 'client_ir', languages: 'all', meaning: 'Path of the client IR directory the engine read.' }, { table: 'run', column: 'key', value: 'library_roots', languages: 'all', meaning: 'Comma-separated library IR roots staged as the type oracle; empty for a client-only run.' }, { table: 'run', column: 'key', value: 'dispatch_cap', languages: 'all', meaning: 'Fan-width cap on virtual dispatch in effect; `off` when uncapped.' }, + { table: 'run', column: 'key', value: 'dispatch_closed_world', languages: 'all', meaning: '`on` when the dispatch fan was narrowed to types the analysed code constructs (a closed-world premise; each dropped edge is an assumption row); `off` otherwise. Read by TypeScript.' }, { table: 'run', column: 'key', value: 'jdk_depth', languages: 'all', meaning: 'Platform-library hop cap (engine-ii).' }, { table: 'run', column: 'key', value: 'lib_depth', languages: 'all', meaning: 'External-library hop cap; `uncapped` when unset.' }, { table: 'run', column: 'key', value: 'engine_ii', languages: 'all', meaning: '`on` when the library-frontier forward chain (engine-ii) was included; `off` for a client-only solve.' }, diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 4372d0de..25be747d 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -18,6 +18,7 @@ // ============================================================================ .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl runtime_observed_edge(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/csharp/souffle/gen_decls_all.py b/graph/csharp/souffle/gen_decls_all.py index 4ccb7452..7a8f6cc0 100644 --- a/graph/csharp/souffle/gen_decls_all.py +++ b/graph/csharp/souffle/gen_decls_all.py @@ -69,6 +69,9 @@ # --dispatch-cap K: refuse a virtual-dispatch fan wider than K. Recall-risky, so # opt-in; empty means uncapped, which is the answer that cannot be wrong. "dispatch_cap": ("symbol",), + # --closed-world on: narrow the dispatch fan to constructed types (#473). Staged + # for every language by the shared executor; no C# rule reads it yet. + "dispatch_closed_world": ("symbol",), # --lib-depth: how far a client->lib chain is expanded. Empty means uncapped. "lib_max_depth": ("number",), # The shared executor ALWAYS stages these two, for every language, and emits one diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 625f6581..0298ce49 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -463,6 +463,7 @@ .decl lib_dispatch_width_low(c0:symbol,c1:number) .decl lib_wide_dispatch(c0:symbol) .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl field_final(c0:symbol) .decl field_init_new_type(c0:symbol,c1:symbol) .decl field_multi_new(c0:symbol) diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index a126c63c..cae221d9 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -380,6 +380,7 @@ .decl literal_contains_method_nested(c0:symbol, c1:symbol) // ── executor knobs (written by run-souffle.sh for every language; unread here) ── .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl taint_gating(c0:symbol) diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 4d47f08c..0ec31971 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -45,7 +45,10 @@ DISPATCH_CAP="${DISPATCH_CAP:-20}" # fan-width cap on virtual dispatch. DEFAUL LANG_ARG="" # which rule set under graph// to run. Default java. TAINT="" # --taint on → gate lib→lib GROW on client-seeded data flow (dataflow/taint.dl). Also # settable via env AXIOM_TAINT_GATING=on. Empty = ungated (default behavior). -MODE="run" # run | print-engine-id | emit-program — the last two need no IR and no souffle +CLOSED_WORLD="" # --closed-world on → narrow the dispatch fan to types the program constructs (RTA), + # and record every edge that drops as an assumption row. Env AXIOM_DISPATCH_CLOSED_WORLD=on. + # Empty = the fan is every declared override (default). See #473. +MODE="run" # run | print-engine-id | emit-program — the last two need no IR and no souffle EMIT="" while [ $# -gt 0 ]; do case "$1" in --client-ir) CLIENT="$2"; shift 2;; --library) LIB="$2"; shift 2;; @@ -54,6 +57,7 @@ while [ $# -gt 0 ]; do case "$1" in --dispatch-cap) DISPATCH_CAP="$2"; shift 2;; --lib-depth) LIB_DEPTH="$2"; shift 2;; --taint) TAINT="$2"; shift 2;; + --closed-world) CLOSED_WORLD="$2"; shift 2;; --language) LANG_ARG="$2"; shift 2;; --print-engine-id) MODE="print-engine-id"; shift;; --emit-program) MODE="emit-program"; EMIT="$2"; shift 2;; @@ -195,7 +199,7 @@ write_program(){ map_rels_su "$TPL/client-ir.map" || return 1 map_rels_su "$TPL/lib.map" sig || return 1 for r in $LIB_BODY; do _SU+=("$r"); done - _SU+=(jdk_max_depth lib_max_depth taint_gating dispatch_cap) + _SU+=(jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world) sort_unique_su PROGRAM_INPUTS=(${_SU[@]+"${_SU[@]}"}) # rfc4180=true: the IR is CSV, not TSV. The parser quotes any field containing a @@ -247,7 +251,7 @@ write_program(){ # the expected one, each exactly once, with nothing extra. Exit status only: no pipe to lose. verify_program(){ awk -v cmap="$TPL/client-ir.map" -v lmap="$TPL/lib.map" -v man="$DL/export_manifest.tsv" \ - -v libsig=" $LIB_SIG " -v extra="$LIB_BODY jdk_max_depth lib_max_depth taint_gating dispatch_cap" ' + -v libsig=" $LIB_SIG " -v extra="$LIB_BODY jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world" ' function want(line) { if (!(line in need)) { need[line] = 1; n++ } } function inp(r) { want(".input " r "(IO=file, filename=\"" r ".facts\", delimiter=\"\\t\", rfc4180=true)") } function maprels(path, sigonly, l, rc, r, k) { @@ -504,6 +508,17 @@ echo "▶ taint gating = $( [ -s "$FACTS/taint_gating.facts" ] && echo 'on (opt- CAP_EFF="${AXIOM_DISPATCH_CAP:-$DISPATCH_CAP}" case "$CAP_EFF" in off|none|no|0|"") CAP_EFF="";; esac [ -n "$CAP_EFF" ] && printf '%s\n' "$CAP_EFF" > "$FACTS/dispatch_cap.facts" + +# dispatch_closed_world — OPT-IN RTA narrowing of the dispatch fan (#473). The file always exists +# so its .input directive is generated; EMPTY = the fan is every declared override (the default, +# and what every golden pins). "on" admits only overrides a constructed or escaping type can +# reach, and exports each dropped edge as an assumption row: the narrowing is sound only when +# every construction is in the analysed code, so the bundle says it was made. Only front ends +# that read the relation change; the others ignore it. +: > "$FACTS/dispatch_closed_world.facts" +CW_EFF="${AXIOM_DISPATCH_CLOSED_WORLD:-$CLOSED_WORLD}" +case "$CW_EFF" in on|yes|1|true) CW_EFF="on"; printf 'on\n' > "$FACTS/dispatch_closed_world.facts";; *) CW_EFF="off";; esac +echo "▶ dispatch closed world = $CW_EFF" echo "▶ dispatch cap = $( [ -s "$FACTS/dispatch_cap.facts" ] && echo "$(cat "$FACTS/dispatch_cap.facts") (default; --dispatch-cap off for unbounded reachability)" || echo 'OFF — uncapped/sound (sink & taint traversal)' )" # engine-ii (lib-frontier forward-chain) is GATED — default OFF so the build is CLIENT-ONLY @@ -803,7 +818,7 @@ BUNDLE_FLAGS=(); [ "$DEBUG_BUNDLE" = "1" ] && BUNDLE_FLAGS+=(--debug) "${BUNDLE[@]}" --language "$LANG_ARG" --src "$SRC" --client-ir "$CLIENT" --raw "$RAW" --out "$OUT" \ --library "$LIB" --lib-facts "$LIBDIR" "${BUNDLE_FLAGS[@]}" \ --meta "engine_commit=$ENGINE_COMMIT" \ - --meta "dispatch_cap=${CAP_EFF:-off}" --meta "jdk_depth=$JDK_DEPTH" --meta "lib_depth=${LIB_DEPTH:-uncapped}" \ + --meta "dispatch_cap=${CAP_EFF:-off}" --meta "dispatch_closed_world=$CW_EFF" --meta "jdk_depth=$JDK_DEPTH" --meta "lib_depth=${LIB_DEPTH:-uncapped}" \ --meta "engine_ii=$ENGINE_II_MODE" --meta "solve_iterations=$iter" --meta "solve_seconds=$((SOLVE_EPOCH-START_EPOCH))" \ ${EXTRA_META[@]+"${EXTRA_META[@]}"} diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 64b71d8f..09a7d62c 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -1,4 +1,5 @@ .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl taint_gating(c0:symbol) diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts new file mode 100644 index 00000000..864290cb --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts @@ -0,0 +1,55 @@ +// A call through an interface-typed receiver fans to every declared implementor by +// default. `--closed-world on` narrows that fan to what the program can build (#473). +// Each class below is one shape of "can the program build it": + +export interface Handler { + handle(x: string): string; +} + +// constructed with `new`: stays in the narrowed fan +export class Built implements Handler { + handle(x: string): string { return "b" + x; } +} + +// never written in a `new`, but handed to a registry as a class VALUE that constructs it: +// stays, because a class that escapes as a value can be built where the parser cannot see +export class Registered implements Handler { + handle(x: string): string { return "r" + x; } +} + +// named only as a TYPE: nothing can build it, so the narrowed fan drops it +export class Unused implements Handler { + handle(x: string): string { return "u" + x; } +} + +// never constructed itself, but a constructed subclass inherits its body: stays +export abstract class Base implements Handler { + handle(x: string): string { return "base" + x; } +} +export class Leaf extends Base {} + +// a subclass nobody constructs, overriding: dropped +export class Orphan extends Base { + handle(x: string): string { return "o" + x; } +} + +type HandlerClass = new () => Handler; +const registry: HandlerClass[] = []; +export function register(c: HandlerClass): void { registry.push(c); } +register(Registered); + +export function dispatch(h: Handler, x: string): string { + return h.handle(x); +} + +export function main(): void { + const built = new Built(); + const leaf = new Leaf(); + let later: Unused | undefined; + let other: Orphan | undefined; + dispatch(built, "1"); + dispatch(leaf, "2"); + for (const C of registry) dispatch(new C(), "3"); + void later; + void other; +} diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts new file mode 100644 index 00000000..eaedd82a --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts @@ -0,0 +1,12 @@ +import { Handler } from "./handlers"; + +// declared in one module, handed to a container as a class VALUE from another +// (`useClass: Provided` in module.ts): stays in the narrowed fan +export class Provided implements Handler { + handle(x: string): string { return "p" + x; } +} + +// imported by module.ts too, but only ever named there as a TYPE: dropped +export class TypeOnly implements Handler { + handle(x: string): string { return "t" + x; } +} diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts new file mode 100644 index 00000000..fda3351a --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts @@ -0,0 +1,7 @@ +import { Provided, TypeOnly } from "./impl"; + +// The container shape: the class crosses a module boundary as an imported value and is +// constructed by code the analysis does not see. +export const providers = [{ provide: "handler", useClass: Provided }]; + +export let pending: TypeOnly | undefined; diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.edges b/graph/test/typescript/expected/83-closed-world-dispatch.edges new file mode 100644 index 00000000..3e35361f --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.edges @@ -0,0 +1,16 @@ +ambiguous_unknown CONSTRUCTOR_CALL handlers#main() @L52 -> - +ambiguous_unknown METHOD_CALL handlers#register(HandlerClass) @L38 -> - +known_edge CONSTRUCTOR_CALL handlers#main() @L46 -> Built#() +known_edge CONSTRUCTOR_CALL handlers#main() @L47 -> Base#() +known_edge FUNCTION_CALL handlers#() @L39 -> handlers#register(HandlerClass) +known_edge FUNCTION_CALL handlers#main() @L50 -> handlers#dispatch(Handler,string) +known_edge FUNCTION_CALL handlers#main() @L51 -> handlers#dispatch(Handler,string) +known_edge FUNCTION_CALL handlers#main() @L52 -> handlers#dispatch(Handler,string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Base#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Built#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Handler#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Orphan#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Provided#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Registered#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> TypeOnly#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Unused#handle(string) diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.entries b/graph/test/typescript/expected/83-closed-world-dispatch.entries new file mode 100644 index 00000000..37921bcc --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.entries @@ -0,0 +1,2 @@ +── entry_point (1) ── + unimported_module module# module.ts:1 diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.envelope b/graph/test/typescript/expected/83-closed-world-dispatch.envelope new file mode 100644 index 00000000..d8c90f4f --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.envelope @@ -0,0 +1,8 @@ +nominal handlers#Base.handle -> handlers#Orphan.handle +nominal handlers#Handler.handle -> handlers#Base.handle +nominal handlers#Handler.handle -> handlers#Built.handle +nominal handlers#Handler.handle -> handlers#Orphan.handle +nominal handlers#Handler.handle -> handlers#Registered.handle +nominal handlers#Handler.handle -> handlers#Unused.handle +nominal handlers#Handler.handle -> impl#Provided.handle +nominal handlers#Handler.handle -> impl#TypeOnly.handle diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields b/graph/test/typescript/expected/83-closed-world-dispatch.fields new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle new file mode 100644 index 00000000..3355e740 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle @@ -0,0 +1,7 @@ +83-closed-world-dispatch [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.known-missing b/graph/test/typescript/expected/83-closed-world-dispatch.known-missing new file mode 100644 index 00000000..15bb2695 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.known-missing @@ -0,0 +1,7 @@ +# Accepted gaps for 83-closed-world-dispatch — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# `new C()` where C is typed by a construct-signature alias: the compiler names the +# alias's signature, the engine leaves the site ambiguous_unknown. Unrelated to the +# closed-world fan this case is about. +handlers#main() -> handlers#() diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.oracle b/graph/test/typescript/expected/83-closed-world-dispatch.oracle new file mode 100644 index 00000000..7f00d858 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.oracle @@ -0,0 +1,9 @@ +oracle=6 engine=12 agree=5 missing=1 (known 1, NEW 0) extra=7 + known handlers#main() -> handlers#() + extra handlers#dispatch(Handler,string) -> Base#handle(string) + extra handlers#dispatch(Handler,string) -> Built#handle(string) + extra handlers#dispatch(Handler,string) -> Orphan#handle(string) + extra handlers#dispatch(Handler,string) -> Provided#handle(string) + extra handlers#dispatch(Handler,string) -> Registered#handle(string) + extra handlers#dispatch(Handler,string) -> TypeOnly#handle(string) + extra handlers#dispatch(Handler,string) -> Unused#handle(string) diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.type-use b/graph/test/typescript/expected/83-closed-world-dispatch.type-use new file mode 100644 index 00000000..b398f762 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.type-use @@ -0,0 +1,18 @@ +ambiguous_unknown OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> - +known_edge IMPLEMENTS_INTERFACE 0 Base [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Built [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Provided [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Registered [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 TypeOnly [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Unused [HERITAGE] -> Handler +known_edge METHOD_PARAM 0 handlers [METHOD_PARAM] -> Handler +known_edge METHOD_PARAM 0 handlers [METHOD_PARAM] -> HandlerClass +known_edge METHOD_RETURN 1 HandlerClass [TYPE] -> Handler +known_edge OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> Built +known_edge OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> Leaf +known_edge SUPER_TYPE 0 Leaf [HERITAGE] -> Base +known_edge SUPER_TYPE 0 Orphan [HERITAGE] -> Base +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> HandlerClass +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> Orphan +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> Unused +known_edge TYPE_ELEMENT 1 module [VARIABLE] -> TypeOnly diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle new file mode 100644 index 00000000..9f55c300 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle @@ -0,0 +1,7 @@ +83-closed-world-dispatch [types] + precision 1.0000 (16 correct, 0 wrong) + recall 1.0000 (16 of 16 the compiler resolved) + sites 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + contexts IMPLEMENTS_INTERFACE=6 METHOD_PARAM=2 METHOD_RETURN=1 OBJECT_CREATION_TYPE=3 SUPER_TYPE=2 TYPE_ELEMENT=4 + not scored: 1 rows whose target is not a client declaration diff --git a/graph/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh index ae67e28e..145cb86b 100755 --- a/graph/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -247,6 +247,14 @@ if ! bash "$HERE/tools/envelope-merge-test.sh"; then exit 1 fi PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +# ── --closed-world on narrows the dispatch fan and records it (#473) ───────── +# The goldens pin the default (CHA) fan; this solves case 82 with the flag on and checks +# that exactly the never-constructed implementors drop, each with an assumption row, and +# that a class built through `new`, through a registry, or through a subclass stays. +if ! bash "$HERE/tools/closed-world-dispatch-test.sh"; then + echo "aborting: --closed-world does not narrow the fan the way it claims" + exit 1 +fi WORK="$HERE/.work" # shellcheck source=../tools/case-pool.sh . "$ROOT/graph/test/tools/case-pool.sh" diff --git a/graph/test/typescript/tools/closed-world-dispatch-test.sh b/graph/test/typescript/tools/closed-world-dispatch-test.sh new file mode 100644 index 00000000..c7070d15 --- /dev/null +++ b/graph/test/typescript/tools/closed-world-dispatch-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# `--closed-world on` NARROWS THE DISPATCH FAN, AND SAYS SO (#473). +# +# By default an interface-typed call fans to every declared implementor (CHA). With the +# flag the fan is narrowed to the implementors a value the program can build could run +# (RTA), and every edge the premise removed is exported as a +# dispatch_assumes_closed_world row. The default is pinned by the ordinary golden of case +# 83-closed-world-dispatch; this solves the same case with the flag on and checks: +# +# dropped Unused (named only as a type), Orphan (a subclass nobody constructs), +# TypeOnly (imported into another module, but named there only as a type) +# KEPT Built (a `new`), Registered (handed to a registry as a class VALUE — the +# DI/factory shape), Base (never constructed, but its body runs on a Leaf), +# Provided (imported into another module and handed over as `useClass:` — +# the container shape, where the value is an import binding, not the class) +# and the assumption file names exactly the three dropped targets, while the flag-off +# run writes none. +# The four kept targets are the controls: each is a way a class is built without the +# engine seeing a typed `new` of it, and losing any one would be a wrong narrowing. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +CASE="$HERE/../cases/83-closed-world-dispatch" + +fail=0; checks=0 +ok(){ checks=$((checks+1)); return 0; } +bad(){ checks=$((checks+1)); printf ' FAIL %s\n' "$1"; fail=1; } + +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +mkdir -p "$W/ir" "$W/empty" +if ! node "$PARSER" "$CASE/src" cw false "$W/ir" >"$W/parse.log" 2>&1; then + echo " FAIL parse — see the log below"; sed 's/^/ /' "$W/parse.log" | tail -20 + echo "closed-world-dispatch: FAILED"; exit 1 +fi +for mode in off on; do + if ! bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language typescript --closed-world "$mode" \ + --client-ir "$W/ir" --library "$W/empty" --intermediate "$W/$mode/int" --output "$W/$mode/out" \ + >"$W/$mode.log" 2>&1; then + echo " FAIL solve ($mode)"; tail -20 "$W/$mode.log" | sed 's/^/ /' + echo "closed-world-dispatch: FAILED"; exit 1 + fi + python3 "$HERE/normalize_edges.py" "$W/ir" "$W/$mode/out/raw" > "$W/$mode.edges" +done + +site='handlers#dispatch(Handler,string) @L42 -> ' +has(){ grep -qF "multi_inferred METHOD_CALL $site$2" "$W/$1.edges"; } + +kept='Built#handle(string) Registered#handle(string) Base#handle(string) Provided#handle(string)' +dropped='Unused#handle(string) Orphan#handle(string) TypeOnly#handle(string)' +for t in $kept $dropped; do + has off "$t" && ok || bad "flag off: the CHA fan lost $t — the default must not move" +done +for t in $kept; do + has on "$t" && ok || bad "flag on: $t was narrowed away, but the program can build a value that runs it" +done +for t in $dropped; do + has on "$t" && bad "flag on: $t is still in the fan, though nothing constructs it" || ok +done + +gone="$(comm -23 <(sort "$W/off.edges") <(sort "$W/on.edges") | wc -l | tr -d ' ')" +added="$(comm -13 <(sort "$W/off.edges") <(sort "$W/on.edges") | wc -l | tr -d ' ')" +[ "$gone" = 3 ] && [ "$added" = 0 ] && ok || bad "flag on moved $gone edge(s) out and $added in; expected exactly the 3 dropped fan edges" + +aoff="$W/off/out/raw/assumption-dispatch-closed-world.csv" +aon="$W/on/out/raw/assumption-dispatch-closed-world.csv" +[ -f "$aoff" ] && [ ! -s "$aoff" ] && ok || bad "flag off wrote assumption rows (or no file): the default makes no closed-world step" +n="$(wc -l < "$aon" 2>/dev/null | tr -d ' ')" +[ "${n:-0}" = 3 ] && ok || bad "flag on wrote ${n:-no} assumption row(s); expected one per dropped edge (3)" +grep -q "dispatch closed world = on" "$W/on.log" && ok || bad "the solve log does not report the closed-world mode" + +if [ "$fail" != 0 ]; then echo "closed-world-dispatch: FAILED ($checks checks)"; exit 1; fi +echo "closed-world-dispatch: ok ($checks checks)" diff --git a/graph/typescript/engine/expression-resolution/callee-resolution.dl b/graph/typescript/engine/expression-resolution/callee-resolution.dl index db0a0bd1..6fe5e680 100644 --- a/graph/typescript/engine/expression-resolution/callee-resolution.dl +++ b/graph/typescript/engine/expression-resolution/callee-resolution.dl @@ -799,6 +799,7 @@ expr_call_dispatch(ce, m) :- call_site("client", _, cn, rk, recv, ce, _), !call_super_receiver(ce), !dispatch_too_wide(rt, cn), implementors(rt, sub), + dispatch_admits(sub), declared_method_direct(sub, cn, "false", m). // ── THE STRUCTURAL FAN ────────────────────────────────────────────────────── @@ -843,6 +844,7 @@ call_super_receiver(ce) :- call_site("client", _, _, "SUPER", _, ce, _). dispatch_width(rt, cn, n) :- implementor_member(rt, cn, _), n = count : { implementor_member(rt, cn, _) }. implementor_member(rt, cn, m) :- implementors(rt, sub), + dispatch_admits(sub), declared_method_direct(sub, cn, "false", m). implementor_member(rt, cn, m) :- structural_implementor(rt, sub), declared_method_direct(sub, cn, "false", m). @@ -851,6 +853,59 @@ dispatch_too_wide(rt, cn) :- dispatch_width(rt, cn, n), k = to_number(c), n > k. +// ── the closed-world narrowing (#473) — OPT-IN ────────────────────────────── +// By default the nominal fan is every declared override the hierarchy admits (CHA), +// bounded only by the cap. With `--closed-world on` it is narrowed to the overrides a +// value the program can build could run (RTA), which is how Python's self-dispatch is +// already bounded. The two are different claims under one tier, so the default is +// unchanged and the narrowed run says it was narrowed: `run.dispatch_closed_world`, and +// one dispatch_assumes_closed_world row per edge the premise removed. +// +// NARROWING IS SOUND ONLY IF EVERY CONSTRUCTION IS VISIBLE, and TypeScript has two +// common ways to build a class without a `new` the parser can type: a framework or +// container instantiates a class it was HANDED (`providers: [OrderService]`, +// `register(Handler)`), and a factory calls `new C()` on a class it received as a +// value. Both hand the class around as a VALUE, so a class referenced as a value +// anywhere counts as constructible, alongside every typed `new`. What is left out is a +// class that is only ever named as a TYPE, or not named at all — the case RTA exists for. +// +// A SUBTYPE IS LIVE IF A CONSTRUCTIBLE TYPE CONFORMS TO IT, not only if it is itself +// constructed: `class Mid { run() {} }` extended by a constructed `Leaf` that does not +// redeclare `run` executes Mid.run, and dropping Mid would lose an edge a run takes. The +// conformance closure (not the member-inheritance one) is used so an interface that +// only a constructed class implements stays in the fan exactly as it is today. +closed_world_dispatch() :- dispatch_closed_world("on"). +type_constructible(t) :- type_instantiated(t, _). +type_constructible(t) :- expr_referenced("client", "TYPE", t, _). +// ...and from ANOTHER module the same value is an IMPORT_BINDING, not a TYPE: a Nest +// module's `useClass: FileRelationalRepository` names the class it imported. Followed +// through import_binds directly, not expr_static_type, which sits inside the call +// resolution this narrowing feeds (dispatch_too_wide negates it: a stratification cycle). +type_constructible(t) :- expr_referenced("client", "IMPORT_BINDING", ih, _), + import_binds(ih, _, "TYPE", t). +type_live(t) :- type_constructible(t). +type_live(anc) :- type_constructible(t), type_subtype_star(t, anc). +type_live(sib) :- type_live(t), scope_sibling(t, sib). +dispatch_admits(sub) :- implementors(_, sub), !closed_world_dispatch(). +dispatch_admits(sub) :- implementors(_, sub), closed_world_dispatch(), type_live(sub). + +// ── dispatch_assumes_closed_world(CallExprHash, DroppedMethodHash) ────────── +// THE ASSUMPTION IS A ROW, NOT A FOOTNOTE — the counterpart of Python's +// self_dispatch_assumes_closed_world. Each row is an edge the CHA fan had and the +// narrowed fan does not, so a consumer can count what the premise cost and restore it +// for a library whose subclasses live in someone else's code. Empty unless the flag is on. +dispatch_assumes_closed_world(ce, m) :- closed_world_dispatch(), + call_site("client", _, cn, rk, recv, ce, _), + receiver_kind_is_value(rk), + cn != "", + expr_type(recv, _, rt), + !call_exact_receiver(ce), + !call_super_receiver(ce), + !dispatch_too_wide(rt, cn), + implementors(rt, sub), + !type_live(sub), + declared_method_direct(sub, cn, "false", m). + // ── the merged candidate set ──────────────────────────────────────────────── expr_call_candidate(ce, m) :- expr_call_dispatch(ce, m). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 22d38679..36d89cb3 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -97,6 +97,7 @@ .decl declared_method_direct(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl demanded_qualified_name(c0:symbol) .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl dispatch_too_wide(c0:symbol,c1:symbol) .decl dispatch_width(c0:symbol,c1:symbol,c2:number) .decl dynamic_import_site(c0:symbol) @@ -703,3 +704,8 @@ .decl remote_undetermined(c0:symbol,c1:symbol,c2:symbol) .decl remote_unsent(c0:symbol,c1:symbol,c2:symbol) .decl remote_unserved(c0:symbol,c1:symbol,c2:symbol) +.decl closed_world_dispatch() +.decl dispatch_admits(c0:symbol) +.decl dispatch_assumes_closed_world(c0:symbol,c1:symbol) +.decl type_constructible(c0:symbol) +.decl type_live(c0:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index 23b265df..f319cddf 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -54,3 +54,4 @@ remote_edge remote-edge.csv remote_unserved remote-unserved.csv remote_unsent remote-unsent.csv remote_undetermined remote-undetermined.csv +dispatch_assumes_closed_world assumption-dispatch-closed-world.csv From 9bdddb8a3600f3a457009dab4c658384c00be00b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:16:17 -0700 Subject: [PATCH 011/258] javascript: handlers handed to a component through React context or redux connect props are registered useContext(Ctx) / use(Ctx) / now evaluate to the value of every of the same createContext call (followed through consts and imports, never by name), plus its default. connect(mapState, mapDispatch)(C) is a component wrapper and hands C (props param or this.props) what the map functions return, or the object form of mapDispatch. useMemo/useCallback pass their value on. Controls: another context's same-named key, an unconnected component, a plain non-curried connect(), and a separately connected subclass stay unchanged. Closes #1751 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../engine/call-edge-generation/jsx.dl | 67 ++++++++++++++++++- graph/javascript/souffle/decls_all.dl | 7 ++ .../72-jsx-context-connect-props/src/Auth.jsx | 34 ++++++++++ .../72-jsx-context-connect-props/src/Menu.jsx | 7 ++ .../src/Plain.jsx | 9 +++ .../src/Profile.jsx | 25 +++++++ .../src/Settings.jsx | 12 ++++ .../72-jsx-context-connect-props/src/db.js | 1 + .../72-jsx-context-connect-props/src/main.jsx | 16 +++++ .../72-jsx-context-connect-props.diag | 26 +++++++ .../72-jsx-context-connect-props.edges | 45 +++++++++++++ .../72-jsx-context-connect-props.oracle | 3 + 12 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js create mode 100644 graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx create mode 100644 graph/test/javascript/expected/72-jsx-context-connect-props.diag create mode 100644 graph/test/javascript/expected/72-jsx-context-connect-props.edges create mode 100644 graph/test/javascript/expected/72-jsx-context-connect-props.oracle diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl index 7359d518..30860312 100644 --- a/graph/javascript/engine/call-edge-generation/jsx.dl +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -26,7 +26,10 @@ // A tag bound to a component WRAPPER (`memo(C)`, `forwardRef(fn)`, `observer(C)`, // `withRouter(C)`) or a LOADER (`lazy(() => import("./x"))`) renders what the wrapper // was handed or what the loader settles to — see "wrapped and lazy components" below. -// Not modelled: `connect(…)(C)` and other curried wrappers. +// Redux's curried `connect(…)(C)` is such a wrapper, and it also hands C the props its +// map functions return; React context hands `useContext(Ctx)` the provider's `value` +// — see "props that come from outside the element" below. Not modelled: other curried +// wrappers. // ============================================================================ // ── the element and what its tag holds ────────────────────────────────────── @@ -67,6 +70,16 @@ jsx_component_loader(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, jsx_vue_definer_name("defineComponent"). jsx_vue_definer_name("defineNuxtComponent"). jsx_vue_definer(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _, _, w, _, _), jsx_vue_definer_name(n). +// Redux `connect(mapState, mapDispatch)(C)`: the OUTER call is the wrapper, its argument +// 0 the component. Keyed on the callee being a call of `connect`, so a plain +// `connect(db)` or a `connect(…)` whose result is not called again stays out. +jsx_connect_call(w, inner) :- expr_kind(_, "CALL", _, w), expr_child(_, w, "CALLEE", _, inner), + expr_kind(_, "CALL", _, inner), call_site("client", _, "connect", _, _, _, inner, _, _). +jsx_component_wrapper(w) :- jsx_connect_call(w, _). +// React `createContext(…)`: not a wrapper, but it reaches `` and +// `useContext(Ctx)` the ways a component reaches a tag, so jsx_holds follows it too. +// No jsx_call_renders rule reads it: holding a context renders nothing. +jsx_context_create(c) :- expr_kind(_, "CALL", _, c), call_site("client", _, "createContext", _, _, _, c, _, _). // jsx_holds(Expr, Call) — the wrapper or loader call an expression holds, followed the // ways a component reaches a tag: the call itself, a const initialised with it, a named @@ -74,6 +87,7 @@ jsx_vue_definer(w) :- expr_kind(_, "CALL", _, w), call_site("client", _, n, _, _ jsx_holds(w, w) :- jsx_component_wrapper(w). jsx_holds(w, w) :- jsx_component_loader(w). jsx_holds(w, w) :- jsx_vue_definer(w). +jsx_holds(c, c) :- jsx_context_create(c). jsx_holds(e, w) :- expr_binding(_, v, e), jsx_var_holds(v, w). jsx_var_holds(v, w) :- var_init(_, _, e, v), !var_binding_form(_, "OBJECT_PATTERN", v), !var_binding_form(_, "ARRAY_PATTERN", v), jsx_holds(e, w). @@ -147,6 +161,57 @@ param_value(p, "obj", je) :- jsx_tag_value(je, "ctor", t), type_construct_target param_decl(_, _, "0", m, p), !param_is_rest(_, p). prop_value("inst", t, "props", "obj", je) :- jsx_tag_value(je, "ctor", t), type_decl("client", _, _, _, _, t). +// ── props that come from outside the element ──────────────────────────────── +// Two library conventions hand a component values no JSX attribute names, so a +// handler reached through them had no use: `onClick={props.onClickLogout}` and +// `const { logout } = useContext(Ctx); … onClick={logout}` registered nothing. +// +// connect(mapState, mapDispatch)(C): C's props also hold what each map function +// returns, and the object form of mapDispatch (`connect(null, { onSave })`) itself. +// Every returned object is merged into one props value, as the library does. +jsx_connect_props(w, k, i) :- jsx_connect_call(w, inner), call_arg(inner, pos, a), pos <= 1, + expr_value(a, "func", f), return_value(f, k, i), k = "obj". +jsx_connect_props(w, "obj", o) :- jsx_connect_call(w, inner), call_arg(inner, 1, a), expr_value(a, "obj", o). +param_value(p, k, i) :- jsx_connect_props(w, k, i), jsx_call_renders(w, kc, ic), kc != "ctor", callable_value(kc, ic, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + param_decl(_, _, "0", m, p), !param_is_rest(_, p). +// A class component reads them as `this.props` in its OWN methods. Not a prop_value of +// the instance: that is inherited (value-flow.dl), and a subclass connected on its own +// (`class Favorites extends Profile`, each with its connect) would read the parent's +// handlers too. +jsx_connect_class_props(t, k, i) :- jsx_connect_props(w, k, i), jsx_call_renders(w, "ctor", t), type_decl("client", _, _, _, _, t). +expr_value(e, k, i) :- expr_kind(_, "PROPERTY_ACCESS", _, e), expr_name(_, "props", e), + expr_child(_, e, "ACCESS_TARGET", _, r), expr_kind(_, "THIS", _, r), expr_owner(_, m, _, r), + this_value(m, "inst", t), jsx_connect_class_props(t, k, i). + +// React context: `useContext(Ctx)` (and React 19's `use(Ctx)`) evaluates to the `value` +// of every `` (React 19: ``) of the same +// createContext call, and to that call's default argument. "Same" is the call the +// argument and the tag hold, followed through consts, imports and re-exports — never +// the name — so two contexts in one app keep their values apart. +jsx_context_tag(je, c) :- jsx_element_tag(je, tag), expr_kind(_, "PROPERTY_ACCESS", _, tag), expr_name(_, "Provider", tag), + expr_child(_, tag, "ACCESS_TARGET", _, r), jsx_holds(r, c), jsx_context_create(c). +jsx_context_tag(je, c) :- jsx_element_tag(je, tag), jsx_holds(tag, c), jsx_context_create(c). +jsx_context_value(c, k, i) :- jsx_context_tag(je, c), prop_value("obj", je, "value", k, i). +jsx_context_value(c, k, i) :- jsx_context_create(c), call_arg(c, 0, a), expr_value(a, k, i). +// The provider's value is usually memoised — `useMemo(() => ({ user, logout }), [user])` +// with its handlers in `useCallback(fn, deps)` — and React's library is not staged, so +// both calls had no value. useMemo evaluates to what its argument 0 returns, +// useCallback to argument 0 itself. +expr_value(u, k, i) :- expr_kind(_, "CALL", _, u), call_site("client", _, "useMemo", _, _, _, u, _, _), + call_arg(u, 0, a), expr_value(a, "func", f), return_value(f, k, i). +expr_value(u, "func", f) :- expr_kind(_, "CALL", _, u), call_site("client", _, "useCallback", _, _, _, u, _, _), + call_arg(u, 0, a), expr_value(a, "func", f). +jsx_context_reader_name("useContext"). +jsx_context_reader_name("use"). +expr_value(u, k, i) :- expr_kind(_, "CALL", _, u), call_site("client", _, n, _, _, _, u, _, _), jsx_context_reader_name(n), + call_arg(u, 0, a), jsx_holds(a, c), jsx_context_create(c), jsx_context_value(c, k, i). +// `{(value) => …}` — the render function receives it. +param_value(p, k, i) :- jsx_element_tag(je, tag), expr_kind(_, "PROPERTY_ACCESS", _, tag), expr_name(_, "Consumer", tag), + expr_child(_, tag, "ACCESS_TARGET", _, r), jsx_holds(r, c), jsx_context_create(c), + prop_value("obj", je, "children", "func", m), method_decl("client", _, _, _, _, _, _, m), + param_decl(_, _, "0", m, p), !param_is_rest(_, p), jsx_context_value(c, k, i). + // ── the rows ──────────────────────────────────────────────────────────────── // The caller is the callable whose body holds the markup; top-level markup // (`root.render()`) belongs to the module initializer, as a top-level call does. diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index a126c63c..81f190e3 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -65,6 +65,13 @@ .decl jsx_call_renders(c0:symbol, c1:symbol, c2:symbol) .decl jsx_loader_settles(c0:symbol, c1:symbol, c2:symbol) .decl jsx_component_kind(c0:symbol) +.decl jsx_connect_call(c0:symbol, c1:symbol) +.decl jsx_connect_props(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_connect_class_props(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_context_create(c0:symbol) +.decl jsx_context_tag(c0:symbol, c1:symbol) +.decl jsx_context_value(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_context_reader_name(c0:symbol) // ── call-edge-generation/calls.dl ── .decl method_prov(c0:symbol, c1:symbol) diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx new file mode 100644 index 00000000..17944bd8 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Auth.jsx @@ -0,0 +1,34 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; + +export const AuthCtx = createContext(null); +export const ThemeCtx = createContext({ toggle: () => 'default-toggle' }); + +export function AuthProvider({ children }) { + const logout = () => 'out'; + return {children}; +} + +export function ThemeProvider({ children }) { + const toggle = useCallback(() => 'dark', []); + return ({ toggle }), [toggle])}>{children}; +} + +// context in the same file, destructured +export function LogoutButton() { + const { logout } = useContext(AuthCtx); + return ; +} + +// a hook that returns the context value +export const useAuth = () => useContext(AuthCtx); + +// the render-prop consumer +export function LogoutLink() { + return {(auth) => out}; +} + +// CONTROL: another context — its `logout` is not AuthCtx's, only toggle (and the default) are here +export function ThemeButton() { + const { toggle, logout } = useContext(ThemeCtx); + return ; +} diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx new file mode 100644 index 00000000..2400ce16 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Menu.jsx @@ -0,0 +1,7 @@ +import { useAuth } from './Auth'; + +// through an imported hook, member read +export default function Menu() { + const auth = useAuth(); + return
  • out
  • ; +} diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx new file mode 100644 index 00000000..7e47a960 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Plain.jsx @@ -0,0 +1,9 @@ +import { connect } from './db'; + +// CONTROL: not wrapped by connect(...)(C) — its props come from nobody the graph sees +export function Plain(props) { + return ; +} + +// CONTROL: a one-shot connect(...) that is not curried is not a component wrapper +export const conn = connect({ onClickLogout: () => 'db' }); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx new file mode 100644 index 00000000..e3d85313 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Profile.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +function saveProfile() { return { type: 'SAVE' }; } + +// a class component reads this.props; mapDispatch in its object form +class Profile extends React.Component { + render() { + const { onSave } = this.props; + return
    ; + } +} + +export default connect(null, { onSave: saveProfile })(Profile); + +function openFavorites() { return { type: 'OPEN' }; } + +// CONTROL: a subclass connected on its own reads its own props, not Profile's onSave +class Favorites extends Profile { + footer() { + return

    more

    ; + } +} + +export const ConnectedFavorites = connect(null, { onOpen: openFavorites })(Favorites); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx new file mode 100644 index 00000000..28923604 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/Settings.jsx @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; + +function Settings(props) { + return ; +} + +const mapStateToProps = (state) => ({ user: state.user }); +const mapDispatchToProps = (dispatch) => ({ + onClickLogout: () => dispatch({ type: 'LOGOUT' }), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Settings); diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js b/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js new file mode 100644 index 00000000..ac639bfe --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/db.js @@ -0,0 +1 @@ +export function connect(opts) { return opts; } diff --git a/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx b/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx new file mode 100644 index 00000000..941a5678 --- /dev/null +++ b/graph/test/javascript/cases/72-jsx-context-connect-props/src/main.jsx @@ -0,0 +1,16 @@ +import { AuthProvider, ThemeProvider, LogoutButton, LogoutLink, ThemeButton } from './Auth'; +import Menu from './Menu'; +import ConnectedSettings from './Settings'; +import ConnectedProfile from './Profile'; +import { Plain } from './Plain'; + +export function App() { + return ( + + + + + + + ); +} diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.diag b/graph/test/javascript/expected/72-jsx-context-connect-props.diag new file mode 100644 index 00000000..f3db0ab8 --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.diag @@ -0,0 +1,26 @@ +import_cause Auth.jsx:1:10 react not_staged +import_cause Auth.jsx:1:25 react not_staged +import_cause Auth.jsx:1:38 react not_staged +import_cause Auth.jsx:1:50 react not_staged +import_cause Profile.jsx:1:1 react not_staged +import_cause Profile.jsx:2:10 react-redux not_staged +import_cause Settings.jsx:1:10 react-redux not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved Auth.jsx:12:18 FUNCTION_CALL useCallback callee_untyped +unresolved Auth.jsx:13:27 FUNCTION_CALL useMemo callee_untyped +unresolved Auth.jsx:18:22 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:23:30 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:32:30 FUNCTION_CALL useContext callee_untyped +unresolved Auth.jsx:3:24 FUNCTION_CALL createContext callee_untyped +unresolved Auth.jsx:4:25 FUNCTION_CALL createContext callee_untyped +unresolved Profile.jsx:14:16 FUNCTION_CALL callee_untyped +unresolved Profile.jsx:14:16 FUNCTION_CALL connect callee_untyped +unresolved Profile.jsx:25:35 FUNCTION_CALL callee_untyped +unresolved Profile.jsx:25:35 FUNCTION_CALL connect callee_untyped +unresolved Settings.jsx:12:16 FUNCTION_CALL callee_untyped +unresolved Settings.jsx:12:16 FUNCTION_CALL connect callee_untyped +unresolved Settings.jsx:9:24 FUNCTION_CALL dispatch callee_untyped +value_callee Profile.jsx:14:16 connect(null, { onSave: saveProfile }) expression +value_callee Profile.jsx:25:35 connect(null, { onOpen: openFavorites }) expression +value_callee Settings.jsx:12:16 connect(mapStateToProps, mapDispatchToProps) expression +value_callee Settings.jsx:9:24 dispatch parameter diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.edges b/graph/test/javascript/expected/72-jsx-context-connect-props.edges new file mode 100644 index 00000000..a9b882a8 --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.edges @@ -0,0 +1,45 @@ +Auth.jsx:12:18 FUNCTION_CALL useCallback -> ambiguous_unknown - +Auth.jsx:12:18 FUNCTION_CALL useCallback -> callback_registered Auth.jsx:12:30 +Auth.jsx:13:10 JSX_ELEMENT ({ toggle }), [toggle])}>{chi -> ambiguous_unknown - +Auth.jsx:13:27 FUNCTION_CALL useMemo -> ambiguous_unknown - +Auth.jsx:13:27 FUNCTION_CALL useMemo -> callback_registered Auth.jsx:13:35 +Auth.jsx:18:22 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:19:18 JSX_ATTRIBUTE onClick={logout} -> callback_registered Auth.jsx:7:18 +Auth.jsx:23:30 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:27:10 JSX_ELEMENT {(auth) => out ambiguous_unknown - +Auth.jsx:27:42 JSX_ATTRIBUTE onClick={auth.logout} -> callback_registered Auth.jsx:7:18 +Auth.jsx:32:30 FUNCTION_CALL useContext -> ambiguous_unknown - +Auth.jsx:33:18 JSX_ATTRIBUTE onClick={toggle} -> callback_registered Auth.jsx:12:30 +Auth.jsx:33:18 JSX_ATTRIBUTE onClick={toggle} -> callback_registered Auth.jsx:4:49 +Auth.jsx:3:24 FUNCTION_CALL createContext -> ambiguous_unknown - +Auth.jsx:4:25 FUNCTION_CALL createContext -> ambiguous_unknown - +Auth.jsx:4:25 FUNCTION_CALL createContext -> callback_registered Auth.jsx:4:49 +Auth.jsx:8:10 JSX_ELEMENT {children} ambiguous_unknown - +Menu.jsx:5:16 FUNCTION_CALL useAuth -> known_edge Auth.jsx:23:24 +Menu.jsx:6:14 JSX_ATTRIBUTE onClick={auth.logout} -> callback_registered Auth.jsx:7:18 +Plain.jsx:9:21 FUNCTION_CALL connect -> known_edge db.js:1:1 connect +Profile.jsx:10:18 JSX_ATTRIBUTE onSubmit={onSave} -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:10:44 JSX_ATTRIBUTE onClick={this.props.onSave} -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:14:16 FUNCTION_CALL connect -> ambiguous_unknown - +Profile.jsx:14:16 FUNCTION_CALL connect -> callback_registered Profile.jsx:4:1 saveProfile +Profile.jsx:14:16 FUNCTION_CALL connect(null, { onSave: saveProfile }) -> ambiguous_unknown - +Profile.jsx:21:15 JSX_ATTRIBUTE onClick={this.props.onOpen} -> callback_registered Profile.jsx:16:1 openFavorites +Profile.jsx:25:35 FUNCTION_CALL connect -> ambiguous_unknown - +Profile.jsx:25:35 FUNCTION_CALL connect -> callback_registered Profile.jsx:16:1 openFavorites +Profile.jsx:25:35 FUNCTION_CALL connect(null, { onOpen: openFavorites }) -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect -> callback_registered Settings.jsx:7:25 +Settings.jsx:12:16 FUNCTION_CALL connect -> callback_registered Settings.jsx:8:28 +Settings.jsx:12:16 FUNCTION_CALL connect(mapStateToProps, mapDispatchToProps) -> ambiguous_unknown - +Settings.jsx:12:16 FUNCTION_CALL connect(mapStateToProps, mapDispatchToProps) -> callback_registered Settings.jsx:3:1 Settings +Settings.jsx:4:18 JSX_ATTRIBUTE onClick={props.onClickLogout} -> callback_registered Settings.jsx:9:18 +Settings.jsx:9:24 FUNCTION_CALL dispatch -> ambiguous_unknown - +main.jsx:10:7 JSX_ELEMENT \n known_edge Auth.jsx:11:1 ThemeProvider +main.jsx:11:25 JSX_ELEMENT -> known_edge Auth.jsx:26:1 LogoutLink +main.jsx:11:39 JSX_ELEMENT -> known_edge Auth.jsx:31:1 ThemeButton +main.jsx:11:54 JSX_ELEMENT -> known_edge Menu.jsx:4:1 Menu +main.jsx:11:9 JSX_ELEMENT -> known_edge Auth.jsx:17:1 LogoutButton +main.jsx:12:30 JSX_ELEMENT -> known_edge Profile.jsx:8:3 render +main.jsx:12:50 JSX_ELEMENT -> known_edge Plain.jsx:4:1 Plain +main.jsx:12:9 JSX_ELEMENT -> known_edge Settings.jsx:3:1 Settings +main.jsx:9:5 JSX_ELEMENT \n \n known_edge Auth.jsx:6:1 AuthProvider diff --git a/graph/test/javascript/expected/72-jsx-context-connect-props.oracle b/graph/test/javascript/expected/72-jsx-context-connect-props.oracle new file mode 100644 index 00000000..b3a2dc1f --- /dev/null +++ b/graph/test/javascript/expected/72-jsx-context-connect-props.oracle @@ -0,0 +1,3 @@ +Menu.jsx:5:16 FUNCTION_CALL useAuth EXACT Auth.jsx:23:24 +Plain.jsx:9:21 FUNCTION_CALL connect EXACT db.js:1:1 +# defects: 0 From a7a7106b67b8910754e9f015e20decd9dc429767 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:26:08 -0700 Subject: [PATCH 012/258] impact file:line: a class field's initializer line names the callable declared in it A field initializer that declares its own method (static cfg = make({ run() {} }), api = client({ fetch() {} })) now answers that method; the node that runs the initializers (/) and the initializer's own arrow stay part of the field. A const/field answered by file:line carries the same '(at file:line)' header as every other file:line answer. Closes #1752 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/axiomcode-impact | 12 +++++++++--- .../skills/axiomcode/scripts/axiomcode-path | 8 +++++++- .../field-initializer-is-a-sibling/case.json | 6 +++++- .../cases/javascript/wrapped-handler-route/case.json | 4 ++-- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 09746099..1c52fef6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -488,10 +488,16 @@ class Impact: 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 + # — unless that function IS the const (`const h = async (req, res) => …` is named `h`), which stays the method. + # A CLASS field is the other way round: G.decl_at_line has already taken it when only its own initializer node + # or lambda is written there, so what is left on its line is a callable of its own that its initializer + # declares (`static cfg = make({ run() {…} })`), and that callable is what the line names (#1752) 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 + at = [i for i in self.methods(s, soft=True) if self.g.sym[i].get('line') == out[0][2][0]['line']] + if not {self.g.sym[i]['name'] for i in at} & {f['name'] for f in out[0][2]}: + if at and out[0][2][0].get('kind') == 'field': out = out[1:] + # the same header as every other file:line answer: the line says WHICH declaration of the name + else: return [(k, f"{lbl} (at {s})", p) for k, lbl, p in 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index c72f6bca..98036d05 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -69,6 +69,10 @@ BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIG # 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 = {''} +# what a front end synthesises ON a field's line that is the field's own, never a callable written there (G.decl_at_line): +# the node that runs a class's field initializers, and the unnamed function an initializer holds (`cb = wrap(() => …)`), +# which JavaScript and TypeScript name `` / `` where Python and C# say `` +FIELD_OWNED_NAMES = {'', '', '', ''} # a reference the parser binds to a VALUE (refs.entity_kind, as each front end spells it): the name there is a variable, # a parameter, a member or a function, never a type, so it is not evidence of a type the code uses without declaring VALUE_BINDINGS = {'VARIABLE', 'LOCAL_VARIABLE', 'GLOBAL_VARIABLE', 'FREE_VARIABLE', 'NONLOCAL_VARIABLE', 'PARAMETER', @@ -257,7 +261,9 @@ class G: 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 + # the initializer's own lambda, or the node that runs the class's field initializers, is the field's + if (fields and (self.is_lambda(x['id']) or s.get('name') in FIELD_OWNED_NAMES) + 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]) 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 17ac8a0d..b7c75098 100644 --- a/tests/cases/javascript/field-initializer-is-a-sibling/case.json +++ b/tests/cases/javascript/field-initializer-is-a-sibling/case.json @@ -9,5 +9,9 @@ {"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": "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", "change: field ObjLit.cfg"]}, + {"why": "an instance field's initializer that declares a method names that method, as a static one does", "run": ["impact", "src/app.js:47"], "want": ["change: fetch (at src/app.js:47)"], "avoid": ["change: field InstObj.api"]}, + {"why": "control: a static field whose initializer only calls is the field, not the node that runs the initializers", "run": ["impact", "src/app.js:6"], "want": ["change: field Registry.count (at src/app.js:6)"], "avoid": [""]}, + {"why": "control: an instance field whose initializer only calls is the field", "run": ["impact", "src/app.js:12"], "want": ["change: field Panel.size (at src/app.js:12)"], "avoid": [""]}, + {"why": "control: an arrow in a field's initializer is part of the field", "run": ["impact", "src/app.js:25"], "want": ["change: field Host.cb (at src/app.js:25)"], "avoid": ["change: "]}, {"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/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json index e4c85a01..6b329131 100644 --- a/tests/cases/javascript/wrapped-handler-route/case.json +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -10,7 +10,7 @@ "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"], + "want": ["const listAdmins (at src/routes/other.js:2) [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"], @@ -38,7 +38,7 @@ "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]"], + "want": ["const listReports (at src/controllers/report.controller.js:2) [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"], From 709f920b66401172b6137591ca36b8d0ed0c4106 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:31:00 -0700 Subject: [PATCH 013/258] javascript: a .vue template tag links to the component it renders A in a JavaScript .vue component was a FUNCTION_CALL of Child, ambiguous_unknown whenever Child was a single-file component. The tag is now a JSX_ELEMENT, so the JSX rules apply: a function or definer component renders as before, and an imported .vue component with no rendering default export ( + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue new file mode 100644 index 00000000..4649dc06 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue @@ -0,0 +1,4 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue new file mode 100644 index 00000000..64fda779 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue @@ -0,0 +1,8 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue new file mode 100644 index 00000000..0b8279d6 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue @@ -0,0 +1,6 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue new file mode 100644 index 00000000..01e94047 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue @@ -0,0 +1,5 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js b/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js new file mode 100644 index 00000000..91986299 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js @@ -0,0 +1 @@ +export function Badge() { return "b"; } diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js b/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js new file mode 100644 index 00000000..7e5362e8 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js @@ -0,0 +1 @@ +export default { items: [] }; diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue b/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue new file mode 100644 index 00000000..16cdfe43 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue @@ -0,0 +1,24 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue b/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue new file mode 100644 index 00000000..d9c302fc --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue @@ -0,0 +1,12 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js b/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js new file mode 100644 index 00000000..798b97d7 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js @@ -0,0 +1,4 @@ +import { fileURLToPath, URL } from "node:url"; +export default { + resolve: { alias: { "@": fileURLToPath(new URL("./components", import.meta.url)) } }, +}; diff --git a/graph/test/javascript/expected/69-vue-sfc.edges b/graph/test/javascript/expected/69-vue-sfc.edges index d7efd225..47623a7d 100644 --- a/graph/test/javascript/expected/69-vue-sfc.edges +++ b/graph/test/javascript/expected/69-vue-sfc.edges @@ -1,5 +1,5 @@ Comp.vue:10:34 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper -Comp.vue:10:6 FUNCTION_CALL Child -> known_edge child.js:1:1 Child -Comp.vue:11:6 FUNCTION_CALL MyBadge -> known_edge child.js:5:1 MyBadge +Comp.vue:10:6 JSX_ELEMENT Child(/**/) -> known_edge child.js:1:1 Child +Comp.vue:11:6 JSX_ELEMENT MyBadge(/**/) -> known_edge child.js:5:1 MyBadge Comp.vue:12:21 FUNCTION_CALL onSave -> known_edge Comp.vue:4:1 onSave Comp.vue:12:40 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper diff --git a/graph/test/javascript/expected/72-vue-component-tag.diag b/graph/test/javascript/expected/72-vue-component-tag.diag new file mode 100644 index 00000000..ef755ad8 --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.diag @@ -0,0 +1,12 @@ +import_cause components/Defined.vue:2:10 vue not_staged +import_cause views/Parent.vue:8:10 vue not_staged +import_cause views/Shell.vue:3:1 @/Gone.vue not_staged +import_cause vite.config.js:1:10 node:url builtin +import_cause vite.config.js:1:25 node:url builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved components/Defined.vue:3:16 FUNCTION_CALL defineComponent callee_untyped +unresolved components/MyWidget.vue:2:1 FUNCTION_CALL defineProps callee_untyped +unresolved views/Parent.vue:11:26 FUNCTION_CALL String no_target +unresolved views/Parent.vue:9:15 FUNCTION_CALL defineComponent callee_untyped +unresolved vite.config.js:3:28 FUNCTION_CALL fileURLToPath no_target +unresolved vite.config.js:3:42 CONSTRUCTOR_CALL URL no_target diff --git a/graph/test/javascript/expected/72-vue-component-tag.edges b/graph/test/javascript/expected/72-vue-component-tag.edges new file mode 100644 index 00000000..91c94a20 --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.edges @@ -0,0 +1,26 @@ +components/Aliased.vue:4:19 FUNCTION_CALL aliasedLocal -> known_edge components/Aliased.vue:2:1 aliasedLocal +components/Child.vue:4:18 FUNCTION_CALL childLocal -> known_edge components/Child.vue:2:1 childLocal +components/Defined.vue:3:16 FUNCTION_CALL defineComponent -> ambiguous_unknown - +components/Defined.vue:3:16 FUNCTION_CALL defineComponent -> callback_registered components/Defined.vue:4:3 setup +components/Defined.vue:4:20 FUNCTION_CALL definedSetup -> known_edge components/Defined.vue:6:1 definedSetup +components/Legacy.vue:4:78 FUNCTION_CALL legacyInit -> known_edge components/Legacy.vue:3:1 legacyInit +components/Legacy.vue:6:15 JSX_ELEMENT Child(/**/) -> known_edge components/Child.vue:1:1 +components/MyWidget.vue:2:1 FUNCTION_CALL defineProps -> ambiguous_unknown - +components/MyWidget.vue:5:21 FUNCTION_CALL widgetLabel -> known_edge components/MyWidget.vue:3:1 widgetLabel +views/Parent.vue:11:26 FUNCTION_CALL String -> ambient_terminal - +views/Parent.vue:15:6 JSX_ELEMENT Child(/**/) -> known_edge components/Child.vue:1:1 +views/Parent.vue:16:6 JSX_ELEMENT MyWidget(/**/) -> known_edge components/MyWidget.vue:1:1 +views/Parent.vue:17:6 JSX_ELEMENT Legacy(/**/) -> known_edge components/Legacy.vue:1:1 +views/Parent.vue:18:6 JSX_ELEMENT Defined(/**/) -> known_edge components/Defined.vue:4:3 setup +views/Parent.vue:19:6 JSX_ELEMENT Local(/**/) -> known_edge views/Parent.vue:9:33 setup +views/Parent.vue:20:6 JSX_ELEMENT Badge(/**/) -> known_edge components/badge.js:1:1 Badge +views/Parent.vue:21:6 JSX_ELEMENT Store(/**/) -> ambiguous_unknown - +views/Parent.vue:22:15 FUNCTION_CALL fmt -> known_edge views/Parent.vue:11:1 fmt +views/Parent.vue:9:15 FUNCTION_CALL defineComponent -> ambiguous_unknown - +views/Parent.vue:9:15 FUNCTION_CALL defineComponent -> callback_registered views/Parent.vue:9:33 setup +views/Parent.vue:9:50 FUNCTION_CALL localSetup -> known_edge views/Parent.vue:10:1 localSetup +views/Shell.vue:10:6 JSX_ELEMENT Badge(/**/) -> known_edge components/badge.js:1:1 Badge +views/Shell.vue:8:6 JSX_ELEMENT Aliased(/**/) -> known_edge components/Aliased.vue:1:1 +views/Shell.vue:9:6 JSX_ELEMENT Gone(/**/) -> ambiguous_unknown - +vite.config.js:3:28 FUNCTION_CALL fileURLToPath -> ambient_terminal - +vite.config.js:3:42 CONSTRUCTOR_CALL URL -> ambient_terminal - diff --git a/graph/test/javascript/expected/72-vue-component-tag.oracle b/graph/test/javascript/expected/72-vue-component-tag.oracle new file mode 100644 index 00000000..8675b52d --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.oracle @@ -0,0 +1 @@ +# defects: 0 diff --git a/parser/src/parsers/javascript/extractors/js-expression-extractor.ts b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts index 0429800d..5ff2128c 100644 --- a/parser/src/parsers/javascript/extractors/js-expression-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts @@ -33,6 +33,7 @@ import { rangeOf, bindingPathOf, } from '@/utils/javascript'; +import { isVueTemplateTag } from '@/utils/vue-sfc'; /** * `js_expression` and `js_call_site` — the spine. @@ -310,6 +311,10 @@ export class JsExpressionExtractor { child(node.right, JsEdgeRole.OPERAND, 1); return; } + if (isVueTemplateTag(node)) { + this.emitJsxChildren(node, row, next, ownerMethodHash); + return; + } if (ts.isCallExpression(node)) { this.emitCallChildren(node, row, next, rootContext, ownerMethodHash); return; @@ -1239,6 +1244,9 @@ function expressionKindOf(node: ts.Expression): JsExpressionKind | undefined { if (isModuleEdgeCall(node)) { return JsExpressionKind.MODULE_EDGE_CALL; } + if (isVueTemplateTag(node)) { + return JsExpressionKind.JSX_ELEMENT; + } if (ts.isCallExpression(node)) { return JsExpressionKind.CALL; } @@ -1323,7 +1331,7 @@ function expressionKindOf(node: ts.Expression): JsExpressionKind | undefined { } function isCallLike(node: ts.Expression): boolean { - return ts.isCallExpression(node) || ts.isNewExpression(node) + return (ts.isCallExpression(node) && !isVueTemplateTag(node)) || ts.isNewExpression(node) || ts.isTaggedTemplateExpression(node); } @@ -1575,9 +1583,15 @@ function propertyKeyText(name: ts.PropertyName): string { return ''; } +/** + * A JSX element, or a `.vue` template tag: `` is written into the + * component's virtual script as the marked call `Child(/**\/)`, and it is + * the same render a JSX tag is — a JSX_ELEMENT whose tag is the callee, not a + * call of `Child`. + */ function isJsxNode(node: ts.Node): boolean { return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) - || ts.isJsxFragment(node); + || ts.isJsxFragment(node) || isVueTemplateTag(node); } /** @@ -1603,6 +1617,9 @@ function isValidIdentifierText(text: string): boolean { } function jsxTagReference(node: ts.Node): ts.Expression | undefined { + if (isVueTemplateTag(node)) { + return (node as ts.CallExpression).expression; + } const tagName = ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index b3fcc452..de97820e 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -1213,7 +1213,7 @@ class JsModuleEdgeExtractor { // tsc resolves no `.vue` import itself (its extensions are fixed), so a // component in this program is looked up by path. const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) - ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath); + ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath, this.options.compilerOptions); if (resolved === undefined) { return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; } diff --git a/parser/src/utils/vue-sfc.ts b/parser/src/utils/vue-sfc.ts index 1d153ce5..35d53304 100644 --- a/parser/src/utils/vue-sfc.ts +++ b/parser/src/utils/vue-sfc.ts @@ -167,16 +167,55 @@ export function scriptTextOf( } /** - * The file a `.vue` specifier names, when it is relative. tsc resolves no `.vue` - * import itself (its extensions are fixed), so without this every - * `import Comp from './Comp.vue'` would read as unresolved. + * The file a `.vue` specifier names, when it is relative or goes through a path + * alias. tsc resolves no `.vue` import itself (its extensions are fixed), so + * without this every `import Comp from './Comp.vue'` would read as unresolved. + * + * `import Comp from '@/components/Comp.vue'` — the usual spelling in a Vite app — + * is mapped through `options.paths` the way tsc maps any other aliased import: the + * pattern with the longest prefix before its `*`, each substitution in order, the + * first that exists. */ -export function resolveVueSpecifier(specifier: string, fromFile: string): string | undefined { - if (!isVueFile(specifier) || !(specifier.startsWith('./') || specifier.startsWith('../'))) { +export function resolveVueSpecifier( + specifier: string, + fromFile: string, + options?: ts.CompilerOptions +): string | undefined { + if (!isVueFile(specifier)) { + return undefined; + } + if (specifier.startsWith('./') || specifier.startsWith('../')) { + const file = path.normalize(path.resolve(path.dirname(fromFile), specifier)); + return fs.existsSync(file) ? file : undefined; + } + const base = (options?.pathsBasePath as string | undefined) ?? options?.baseUrl; + if (options?.paths === undefined || base === undefined) { return undefined; } - const file = path.normalize(path.resolve(path.dirname(fromFile), specifier)); - return fs.existsSync(file) ? file : undefined; + let best: { prefix: string; star: string; targets: readonly string[] } | undefined; + for (const [pattern, targets] of Object.entries(options.paths)) { + const star = pattern.indexOf('*'); + if (star < 0) { + if (pattern === specifier) { + best = { prefix: pattern, star: '', targets }; + break; + } + continue; + } + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (specifier.length >= prefix.length + suffix.length && specifier.startsWith(prefix) + && specifier.endsWith(suffix) && (best === undefined || prefix.length > best.prefix.length)) { + best = { prefix, star: specifier.slice(prefix.length, specifier.length - suffix.length), targets }; + } + } + for (const target of best?.targets ?? []) { + const file = path.normalize(path.resolve(base, target.replace('*', best!.star))); + if (isVueFile(file) && fs.existsSync(file)) { + return file; + } + } + return undefined; } // ── the file's top-level blocks ──────────────────────────────────────────── From 20cf71deaecb63a983ba62c342a250333a0d2116 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:31:59 -0700 Subject: [PATCH 014/258] typescript: a function assigned to an object-literal key or to obj.x is named for the property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ all: (p) => … }`, `{ get: function … }` and `helpers.titleCase = function …` were indexed as `` / ``, so `impact Articles.all` answered "not declared here" although the call edge was resolved. The index's naming pass now reads TypeScript's expression rows in the shape it already reads for JavaScript: the function takes the property's name and is owned by the variable the literal initialises (`Articles.all`, `Articles.tags.list`, also under `as const`) or by the receiver (`helpers.titleCase`); none for `this`. Computed keys stay unnamed, a named function expression keeps its own name and owner, and an arrow passed as an argument stays anonymous. The owner an assignment names now wins over the class the function is written in (`o.f` for `const o = { f: () => … }` inside a method), and is attached only to a function that took the property's name. A real React app: call edges 5404 -> 5404, placeholder methods 777 -> 623, index time unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-index | 72 +++++++++++++++++-- .../member-assigned-function-names/case.json | 48 +++++++++++++ .../member-assigned-function-names/src/api.ts | 38 ++++++++++ .../member-assigned-function-names/src/use.ts | 11 +++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/cases/typescript/member-assigned-function-names/case.json create mode 100644 tests/cases/typescript/member-assigned-function-names/src/api.ts create mode 100644 tests/cases/typescript/member-assigned-function-names/src/use.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 962c0b4c..1d585abe 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -100,6 +100,10 @@ A = { dict(file='all-typescript-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine')], # `const foo = () => …` : the method is named ; its name is the variable's boundNames=dict(file='all-typescript-variables.csv', name='name', method='boundFunctionLinkHash'), + # `obj.x = function …`, `{ all: (p) => … }`: named for the property, as JavaScript's are (#1585). The rows are + # read through `ts_member_rows`, which gives them JavaScript's expression shape + memberNames=dict(file='all-typescript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', + vars='all-typescript-variables.csv', shape='typescript'), skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -242,12 +246,68 @@ if A['boundNames']: # PROPERTY_KEY sibling at the same childIndex). The owner is the receiver as written (`helpers`, `Foo` for # `Foo.prototype`), none for `exports` / `module.exports` / `this`; for a literal, the variable it initialises or the # key chain of the literal it is nested in. A named function expression keeps its own name: only placeholders move. +TS_WRAPPERS = {'AS_EXPRESSION', 'SATISFIES_EXPRESSION', 'TYPE_ASSERTION', 'NON_NULL_EXPRESSION'} +TS_ROLES = {'OBJECT_PROPERTY_KEY': 'PROPERTY_KEY', 'OBJECT_PROPERTY_VALUE': 'PROPERTY_VALUE', + 'LEFT_OPERAND': 'ASSIGNMENT_TARGET', 'RIGHT_OPERAND': 'ASSIGNMENT_VALUE'} +def ts_member_rows(file): + """TypeScript expression rows in the shape the naming pass reads (JavaScript's), and a map from a cast's id to the + expression it wraps. An arrow or function expression is a FUNCTION_EXPRESSION whose introducesDeclarationLinkHash is + its method; a literal's key and value are siblings at one `position`, the key's text in literalValue (a computed key + has no key row, so it names nothing); a property access has its name in a PROPERTY_NAME child and gets `text` as + written when its receiver is a dotted name. `x as T`, `x satisfies T`, `x` and `x!` are seen through, so + `export const Api = { … } as const` is owned by `Api`.""" + src = {r['tsExpressionUniqueHash']: r for r in rows(file) if r.get('tsExpressionUniqueHash')} + kids = collections.defaultdict(list) + for r in src.values(): kids[r.get('parentExpressionHash', '')].append(r) + def inner(i, depth=0): + r = src.get(i) + while r is not None and r.get('kind') in TS_WRAPPERS and depth < 8: + k = kids.get(r['tsExpressionUniqueHash'], ()) + r = k[0] if len(k) == 1 else None; depth += 1 + return r + def dotted(r, depth=0): + r = inner(r['tsExpressionUniqueHash']) if r else None + if r is None or depth > 8: return None + if r.get('kind') == 'THIS_REFERENCE': return 'this' + if r.get('kind') == 'IDENTIFIER_REFERENCE': return r.get('literalValue') or None + if r.get('kind') == 'PROPERTY_ACCESS': + ks = kids.get(r['tsExpressionUniqueHash'], ()) + recv = next((k for k in ks if k.get('edgeRole') == 'RECEIVER'), None) + name = next((k.get('literalValue') for k in ks if k.get('edgeRole') == 'PROPERTY_NAME'), None) + o = dotted(recv, depth + 1) + return f"{o}.{name}" if o and name else None + def up(r): # the parent and role, seen through the casts around r + p = src.get(r.get('parentExpressionHash', '')); role = r.get('edgeRole'); pos = r.get('position') + while p is not None and p.get('kind') in TS_WRAPPERS: + role = p.get('edgeRole'); pos = p.get('position'); p = src.get(p.get('parentExpressionHash', '')) + return p, role, pos + out = {} + for i, r in src.items(): + kind = r.get('kind') + if kind in TS_WRAPPERS: continue + p, role, pos = up(r) + o = dict(jsExpressionUniqueHash=i, parentExpressionLinkHash=p['tsExpressionUniqueHash'] if p else '', + edgeRole=TS_ROLES.get(role, role), childIndex=pos, operatorString=r.get('operatorString', ''), + expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT'}.get(kind, kind), + introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', '')) + if role == 'OBJECT_PROPERTY_KEY' and (kind == 'IDENTIFIER_REFERENCE' + or (kind == 'LITERAL' and r.get('literalType') in ('STRING', 'NUMBER'))): + o['name'] = r.get('literalValue', '') + elif kind == 'PROPERTY_ACCESS': + ks = kids.get(i, ()) + o['name'] = next((k.get('literalValue', '') for k in ks if k.get('edgeRole') == 'PROPERTY_NAME'), '') + o['text'] = dotted(r) or '' + out[i] = o + return out.values(), {i: inner(i)['tsExpressionUniqueHash'] for i, r in src.items() if r.get('kind') in TS_WRAPPERS and inner(i)} bound_owner = {}; cls_named = {} if A.get('memberNames'): mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) - for r in rows(mn['file']): + ts_shape = mn.get('shape') == 'typescript' + mrows, unwrap = ts_member_rows(mn['file']) if ts_shape else (rows(mn['file']), {}) + for r in mrows: ex[r[mn['id']]] = r; kids[r.get('parentExpressionLinkHash', '')].append(r) - lit_var = {r['initializerExpressionLinkHash']: r['name'] for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} + lit_var = {unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash']): r['name'] + for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} DOTTED = re.compile(r'^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$') def key_of(parent, role, idx): for k in kids.get(parent, ()): @@ -363,8 +423,12 @@ def enum_constant_of(fp, ln): return None for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type_id, owner_qualified_name, file_path, start_line, end_line FROM methods WHERE provenance='client'"): name = m['name'] or ckey.get(cmeth.get(m['id'], ''), '') - if name.startswith('<') and m['id'] in bound: name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) - od = tdisplay(m['owner_type_id']) if m['owner_type_id'] else bound_owner.get(m['id']) + renamed = name.startswith('<') and m['id'] in bound + if renamed: name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) + # the object a function was assigned into names it before the class it is written in: `const o = { f: () => … }` + # inside a method of C is `o.f`, not `C.f` (a TypeScript arrow's owner type is the class around it). Only for a + # function that took the property's name: `helpers.alias = function realName …` stays `realName` + od = (renamed and bound_owner.get(m['id'])) or (tdisplay(m['owner_type_id']) if m['owner_type_id'] else None) if m['kind'] == 'ENUM_CONSTANT_METHOD' and od: k_ = enum_constant_of(rel(m['file_path']), m['start_line'] or 0) if k_: od = f"{od}.{k_}" diff --git a/tests/cases/typescript/member-assigned-function-names/case.json b/tests/cases/typescript/member-assigned-function-names/case.json new file mode 100644 index 00000000..25d2dad5 --- /dev/null +++ b/tests/cases/typescript/member-assigned-function-names/case.json @@ -0,0 +1,48 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "an arrow that is the value of an object-literal key is named for the key and owned by the variable the literal initialises: `impact Articles.all` answered `not declared here` because the arrow was `` (#1585)", + "run": ["impact", "Articles.all"], + "want": ["change: Articles.all [method]", "[resolved] run src/use.ts:4"], + "avoid": ["not declared here", "nothing named"] + }, + { + "why": "a function expression under a key is named the same way", + "run": ["impact", "Articles.get"], + "want": ["change: Articles.get [method]", "[resolved] run src/use.ts:5"], + "avoid": ["not declared here", ""] + }, + { + "why": "a literal nested under a key is owned by the key chain", + "run": ["impact", "Articles.tags.list"], + "want": ["change: Articles.tags.list [method]", "[resolved] run src/use.ts:7"], + "avoid": ["not declared here", ""] + }, + { + "why": "obj.x = function is named x and owned by obj", + "run": ["impact", "titleCase"], + "want": ["change: helpers.titleCase [method]"], + "avoid": ["not declared here", "as written at"] + }, + { + "why": "a literal under `as const` is still owned by the variable it initialises", + "run": ["impact", "Routes.home"], + "want": ["change: Routes.home [method]", "run src/use.ts:3"], + "avoid": ["not declared here", "nothing named"] + }, + { + "why": "CONTROL: a named function expression keeps its own name, not the property's", + "run": ["impact", "realName"], + "want": ["change: realName [method]"], + "avoid": ["alias"] + }, + { + "why": "CONTROL: an arrow passed as an argument has no property to take a name from and stays a placeholder; method shorthand keeps its name", + "run": ["impact", "src/api.ts:29", "del"], + "want": ["", "del"], + "avoid": ["Articles.del", "map"] + } + ] +} diff --git a/tests/cases/typescript/member-assigned-function-names/src/api.ts b/tests/cases/typescript/member-assigned-function-names/src/api.ts new file mode 100644 index 00000000..452fbd32 --- /dev/null +++ b/tests/cases/typescript/member-assigned-function-names/src/api.ts @@ -0,0 +1,38 @@ +// an object literal key bound to an arrow or a function expression +export const Articles = { + all: (page: number) => request('/articles', page), + get: function (slug: string) { + return request('/articles/' + slug, 0); + }, + // CONTROL: method shorthand already keeps its name + del(slug: string) { + return request('/articles/' + slug, -1); + }, + // a literal nested under a key: owned by the key chain + tags: { + list: () => request('/tags', 0), + }, +}; + +// obj.x = function +export const helpers: any = {}; +helpers.titleCase = function (s: string) { + return s.toUpperCase(); +}; + +// CONTROL: a named function expression keeps its OWN name, not the key's +helpers.alias = function realName(s: string) { + return s; +}; + +// CONTROL: an arrow passed as an argument has no name to take +[1, 2].map((n) => request('/n', n)); + +function request(url: string, arg: number) { + return url + arg; +} + +// a literal under `as const` +export const Routes = { + home: () => request('/', 0), +} as const; diff --git a/tests/cases/typescript/member-assigned-function-names/src/use.ts b/tests/cases/typescript/member-assigned-function-names/src/use.ts new file mode 100644 index 00000000..6cee2637 --- /dev/null +++ b/tests/cases/typescript/member-assigned-function-names/src/use.ts @@ -0,0 +1,11 @@ +import { Articles, helpers, Routes } from './api'; + +export function run() { + Articles.all(1); + Articles.get('x'); + Articles.del('x'); + Articles.tags.list(); + helpers.titleCase('a'); + helpers.alias('b'); + Routes.home(); +} From b7e37972a062cfc9983b871f893bffb11132d7cd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:08:50 -0700 Subject: [PATCH 015/258] tests: assigned-interface-members and object-literal-and-expression-callees name the arrows for their property Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- tests/cases/typescript/assigned-interface-members/case.json | 2 +- .../typescript/object-literal-and-expression-callees/case.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cases/typescript/assigned-interface-members/case.json b/tests/cases/typescript/assigned-interface-members/case.json index bd1beebf..8f2c0adb 100644 --- a/tests/cases/typescript/assigned-interface-members/case.json +++ b/tests/cases/typescript/assigned-interface-members/case.json @@ -11,7 +11,7 @@ { "why": "a function assigned to an interface's method member through a property chain implements it: the walk from a call to the member reaches both assigned arrows, one of them through `run = parse`", "run": ["path", "parse", "*"], - "want": [" src/schema.ts:15", " src/schema.ts:19"], + "want": ["inst._i.parse src/schema.ts:15", "inst._i.run src/schema.ts:19"], "avoid": [] }, { diff --git a/tests/cases/typescript/object-literal-and-expression-callees/case.json b/tests/cases/typescript/object-literal-and-expression-callees/case.json index fce1cc33..ac6f6f01 100644 --- a/tests/cases/typescript/object-literal-and-expression-callees/case.json +++ b/tests/cases/typescript/object-literal-and-expression-callees/case.json @@ -17,7 +17,7 @@ { "why": "a literal bound to a const declared as the interface implements it too", "run": ["path", "usePool", "src/pool.ts:16"], - "want": ["[dispatch] src/pool.ts:16"], + "want": ["[dispatch] fixed.run src/pool.ts:16"], "avoid": ["no chain of resolved calls"] }, { From aebc68e3f83b97be1ae3b8cff58b208d2001d3ad Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 29 Sep 2026 01:53:24 -0700 Subject: [PATCH 016/258] freshness: per-language engine key, capped background rebuilds, owner-checked compile lock No issue: found in the background-refresh logs of one machine (2026-09-28/29). What was wrong - A graph's "built by" key hashed every file under the engine's bin/, dist/, graph/ and parser/dist, plus the plugin's query rules and IMPACT_VERSION. So any change to an installed build marked every graph on the machine stale: another language's rules, another language's parser, or only a new version number in package.json. 59% of 896 background rebuilds had no file edit behind them. - Nothing bounded rebuilds across checkouts. After one install switch about 40 checkouts rebuilt at once, the load reached 221, and a Python build that takes 58-160 s alone took about 1,000 s. - run-souffle.sh took over an engine compile lock older than 30 min. A Python compile can take 27 min on a loaded machine, so two multi-GB compiles of one engine could run at once. The change - ax_fresh.py records the engine hash over the files that shape the graph's own languages (engine_langs): that language's rules and parser, plus the shared pipeline, bundle and launcher. A path named for another language (graph/java/, parser/dist/parsers/java/, java-detector.js) is left out; TypeScript and JavaScript count each other. package.json is hashed without its version. Anything unclassified counts as shared, so a mistake costs a rebuild, never a stale graph read as current. - axiomcode-index (it writes the symbols tables) is keyed on its own hash. The query rules (dl/*.dl) no longer mark a graph stale: they are compiled and cached by content and never write the graph. - IMPACT_VERSION no longer rebuilds the graph. The impact export is already keyed on it; the refresher now writes the export again for every graph of the repository (rewarm), in seconds, and records the new version. - A table written before this change is compared the way it was recorded (whole engine, rules, IMPACT_VERSION), so installing this rebuilds nothing that was current. - Background rebuilds take one of AXIOMCODE_REFRESH_MAX (default 2) slots, machine-wide: flock'd files in the user cache, released by the OS when the worker exits. A worker that finds none free waits, queued, never dropped, and logs that it was queued. 0 turns the cap off. An explicit index is not capped. - The compile lock moved to graph/pipeline/compile-lock.sh. Its owner writes its PID; a waiter takes it over only when that process is gone. A lock with no PID (an older run's) still expires after 30 min. Tests (tests/freshness.py, 80 of 80), each with a near-miss control - a java rule change or java parser change leaves python and csharp graphs current and makes java and python+java graphs stale; a python rule or parser change the reverse; a version-only bump leaves all current; a shared pipeline or bundle change makes all stale; control: unchanged engine leaves all current - IMPACT_VERSION or query-rule changes leave the graph current; an axiomcode-index change makes it stale; a pre-change table from the same engine stays current, and is still stale for a java-only change - with a cap of 2, a third refresh waits while two run and then runs (3 of 3 end fresh); control: with a cap of 3 none waits - a live lock three hours old is not taken over; a dead owner's is, at once; control: a young lock with no PID is waited for - the new checks were run against the old code: the per-language, cap and lock checks fail there Suites run on the rebased tree - tests/freshness.py: 80 of 80 - tests/run.py --lang python: 206 of 207; --lang java: 192 of 192; --lang csharp: 57 of 62, 1 pending. The failures (python 1, csharp 4) are the same checks, failing the same way, on the unchanged tip. - tests/refresh.py --lang python, java, csharp: all pass, including the new IMPACT_VERSION check. Under load the MCP-timer check failed once each for python and java, as a timing race (the start-up refresh found the edit first); a rerun passed. On the unchanged tip, java failed a different timing check. - tests/fastpath.py python/java/csharp 4 of 4 each; hook_languages.py 7 of 7; enrich_lines.py 45 of 45; no_exec.py 7 of 7; publish_order.py and graph_verb.py pass. Measurement (before vs after) The 48 graph tables on this machine built by the installed engine and plugin (19 python, 17 java, 10 csharp, 2 mixed) were judged against three copies of that engine, each with one change: java-only rule edit: before 48 of 48 stale, after 18 (the java ones) python-only rule edit: before 48 of 48 stale, after 20 (the python ones) version-only bump: before 48 of 48 stale, after 0 unchanged (control): 0 before, 0 after "After" re-records each table as this change would, from the same engine. Smoke, end to end: three dev projects (a 650-file Django app, a small Spring app, a small ASP.NET Core app) indexed by this code from a copy of the engine, then judged against a copy with one java rule edited: `index`'s up-to-date check found 1 of 3 stale (the java one). The python and csharp refresh workers ran and started no rebuild (about 1 s each, state fresh). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- graph/pipeline/compile-lock.sh | 35 +++ graph/pipeline/run-souffle.sh | 21 +- .../skills/axiomcode/scripts/ax_fresh.py | 214 +++++++++++++++--- tests/freshness.py | 205 +++++++++++++++-- tests/refresh.py | 31 ++- 5 files changed, 438 insertions(+), 68 deletions(-) create mode 100644 graph/pipeline/compile-lock.sh diff --git a/graph/pipeline/compile-lock.sh b/graph/pipeline/compile-lock.sh new file mode 100644 index 00000000..451f4735 --- /dev/null +++ b/graph/pipeline/compile-lock.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# compile-lock.sh — ONE COMPILE PER ENGINE ID, sourced by run-souffle.sh. +# Concurrent runs that miss the engine cache together (a suite's concurrent cases, several +# agents on one machine) each compiled the same engine: a multi-GB c++ per run, enough of them +# at once to exhaust memory. The first takes the lock (a directory, created atomically) and +# compiles; the others wait, then reuse its binary. +# +# A LOCK IS DEAD WHEN ITS OWNER IS, NOT WHEN IT IS OLD. The owner writes its PID into the lock, +# and a waiter takes the lock over only when that process is gone (killed, out of memory). An +# age limit of 30 min was wrong both ways: a Python compile takes up to 27 min on a loaded +# machine, so a slower one was taken over while it ran and two multi-GB compiles of one engine +# ran at once. A lock with no PID (an older run's, or one caught between its mkdir and its +# write) is still taken over after COMPILE_LOCK_NOPID_MIN minutes (default 30). +# +# compile_lock_take wait until is ours (prints one line when it has to wait) +# compile_lock_drop release it +compile_lock_take() { + local lock="$1" owner waited=0 + until mkdir "$lock" 2>/dev/null; do + owner="$(cat "$lock/pid" 2>/dev/null || true)" + if [ -n "$owner" ]; then + # re-read before removing: another waiter may have taken the dead lock over in between + if ! kill -0 "$owner" 2>/dev/null && [ "$(cat "$lock/pid" 2>/dev/null || true)" = "$owner" ]; then + echo "▶ the run compiling this engine (pid $owner) is gone; taking its lock over" + rm -rf "$lock" 2>/dev/null || true; continue + fi + elif [ -n "$(find "$lock" -maxdepth 0 -mmin +"${COMPILE_LOCK_NOPID_MIN:-30}" 2>/dev/null)" ]; then + rm -rf "$lock" 2>/dev/null || true; continue + fi + [ "$waited" = 1 ] || echo "▶ another run${owner:+ (pid $owner)} is compiling this engine; waiting for it..." + waited=1; sleep "${COMPILE_LOCK_POLL:-3}" + done + echo "$$" > "$lock/pid" +} +compile_lock_drop() { rm -rf "$1" 2>/dev/null || true; } diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 0ec31971..0d58ba94 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -75,6 +75,8 @@ PKG="$(cd "$SRC/.." && pwd)" # the package root: package.json, node_modules, p . "$SRC/pipeline/portable-stat.sh" # shellcheck source=lib-cache-key.sh . "$SRC/pipeline/lib-cache-key.sh" +# shellcheck source=compile-lock.sh +. "$SRC/pipeline/compile-lock.sh" # Rules are PER-LANGUAGE and live under graph//; the executor itself is shared. LANG_ARG="${LANG_ARG:-java}" ENG="$SRC/$LANG_ARG/engine"; ENG2="$SRC/$LANG_ARG/engine-ii"; DL="$SRC/$LANG_ARG/souffle"; TPL="$SRC/$LANG_ARG/templates" @@ -615,18 +617,11 @@ if [ -n "$PACKAGED" ]; then elif [ -x "$BIN" ]; then echo "▶ reusing cached binary" elif command -v souffle >/dev/null 2>&1; then - # ONE COMPILE PER ENGINE ID. Concurrent runs that miss the cache together (a suite's - # concurrent cases, several agents on one machine) each compiled the same engine: a - # multi-GB c++ per run, enough of them at once to exhaust memory. The first takes the - # lock and compiles; the others wait, then reuse its binary. A lock older than 30 min - # is a dead compile's (killed, out of memory) and is taken over. - COMPILE_LOCK="$BIN.lock"; _waited=0 - until mkdir "$COMPILE_LOCK" 2>/dev/null; do - if [ -n "$(find "$COMPILE_LOCK" -maxdepth 0 -mmin +30 2>/dev/null)" ]; then rmdir "$COMPILE_LOCK" 2>/dev/null || true; continue; fi - [ "$_waited" = 1 ] || echo "▶ another run is compiling this engine; waiting for it..." - _waited=1; sleep 3 - done - trap 'rmdir "$COMPILE_LOCK" 2>/dev/null || true' EXIT + # ONE COMPILE PER ENGINE ID, under a lock whose owner must be dead, not merely old, before + # another run takes it over (compile-lock.sh). + COMPILE_LOCK="$BIN.lock" + compile_lock_take "$COMPILE_LOCK" + trap 'compile_lock_drop "$COMPILE_LOCK"' EXIT fi if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then echo "▶ reusing the binary another run compiled" @@ -668,7 +663,7 @@ elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then fi mv -f "$BIN.tmp.$$" "$BIN" fi -if [ -n "${COMPILE_LOCK:-}" ]; then rmdir "$COMPILE_LOCK" 2>/dev/null || true; trap - EXIT +if [ -n "${COMPILE_LOCK:-}" ]; then compile_lock_drop "$COMPILE_LOCK"; trap - EXIT elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 4993e7cc..85a8ce7b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -30,7 +30,8 @@ ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) -is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file +is the quiet window; AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on +the machine, the rest queued; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file waits for a refresh expected to finish within it, AXIOMCODE_FRESH=1 (--fresh) makes it wait for the refresh whatever it takes, up to AXIOMCODE_FRESH_MAX (default 600); AXIOMCODE_BUILD_WAIT (seconds, default 900) is how long a query that finds no graph waits for a build that is running rather than starting @@ -263,10 +264,17 @@ def changes(repo, table=None): # 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 +# engine a hash of what the engine builds THIS graph's languages with (`engine_langs`): their parser, their rules, +# and the shared pipeline, bundle and launcher. Not another language's rules or parser, and not the version +# number in package.json: a graph is rebuilt only for a change that can change it. +# index a hash of axiomcode-index, which writes the symbols every verb reads into the graph +# impact IMPACT_VERSION, the version of the facts `impact` exports from the graph. Recorded, not a rebuild: the +# export is keyed on it and rewritten from the graph as it stands (the refresher does it, `rewarm`) +# PER LANGUAGE, NOT PER INSTALL (measured 2026-09-29): the engine was hashed whole, so any change to an installed build +# (a Java rule, a TypeScript parser fix, a version bump) marked every graph on the machine stale. 59% of 896 background +# rebuilds had no edit behind them; one install switch rebuilt about 40 checkouts at once, a 58-160 s Python build took +# about 1,000 s in that burst, and the machine's load reached 221. The query rules (dl/*.dl) are not part of it either: +# they are compiled and cached by their own content (dl_program.py) and never write 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 @@ -275,6 +283,28 @@ def changes(repo, table=None): 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') +# the languages a path in the engine can belong to. A path component named for a language (graph/java/, parser/dist/ +# parsers/python/) or a file named for one (csharp-detector.js, python-constants.js) belongs to that language; every +# other file is shared and counts for every graph. TypeScript and JavaScript share one front end, so each counts the +# other's files. Shared is the safe side: a file wrongly taken as shared costs a rebuild, one wrongly taken as +# another language's would leave a stale graph looking current. +ENGINE_LANGS = ('java', 'typescript', 'javascript', 'python', 'csharp') +ENGINE_FAMILY = {'typescript': ('typescript', 'javascript'), 'javascript': ('typescript', 'javascript')} + +def engine_lang_list(langs): + """the languages a graph covers, as the file table writes them ('python' or 'java,python'), as a sorted list""" + if isinstance(langs, str): langs = langs.split(',') + return sorted({l.strip() for l in (langs or ()) if l and l.strip()}) + +def _foreign(rel, langs): + """rel (a path in the engine) belongs only to languages other than langs""" + keep = {m for l in langs for m in ENGINE_FAMILY.get(l, (l,))} + others = [l for l in ENGINE_LANGS if l not in keep] + for part in rel.replace(os.sep, '/').split('/'): + stem = part.split('.')[0] + for o in others: + if part == o or stem == o or part.startswith((o + '-', o + '_')): return True + return False 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')) \ @@ -303,37 +333,51 @@ def current_engine(repo): if engine_built(d): return d return None -def _engine_files(d): +def _engine_files(d, langs=None): + """the engine's files that shape a graph of `langs` (every file when langs is None: a table from before the + per-language key, compared the way it was recorded)""" 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) + if f.endswith(ENGINE_SKIP_EXT): continue + p = os.path.join(root, f) + if langs is not None and _foreign(os.path.relpath(p, d), langs): continue + yield p 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""" +def engine_id(d, langs=None): + """(version, content hash, stat signature) of the engine at d, over the files that shape a graph of `langs`. 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""" + langs = None if langs is None else engine_lang_list(langs) stats = [] - for p in _engine_files(d): + for p in _engine_files(d, langs): 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() + sig = hashlib.sha1(json.dumps([langs, stats]).encode()).hexdigest() if langs is not None else 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 + return version, (lambda: _engine_hash(d, sig, stats, langs is not None)), sig + +def _manifest_digest(p): + """a package.json without its version: a release that only renumbers the package builds the same graph""" + try: m = json.load(open(p, encoding='utf-8')) + except (OSError, ValueError): return digest(p) + if isinstance(m, dict): m.pop('version', None) + return hashlib.sha1(json.dumps(m, sort_keys=True).encode()).hexdigest() -def _engine_hash(d, sig, stats): +def _engine_hash(d, sig, stats, scoped=False): 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()) + p = os.path.join(d, rel) + try: h.update((_manifest_digest(p) if scoped and rel in ENGINE_FILES else digest(p)).encode()) except OSError: pass _ENGINE_MEMO[k] = h.hexdigest() return _ENGINE_MEMO[k] @@ -341,7 +385,8 @@ def _engine_hash(d, sig, stats): 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""" + """(rules hash, IMPACT_VERSION) of the plugin this script belongs to. The rules hash (dl/*.dl and axiomcode-index) + is what a table from before the per-language key recorded, and is compared only for such a table""" h = hashlib.sha1() dl = os.path.join(H, 'dl') try: names = sorted(f for f in os.listdir(dl) if f.endswith('.dl')) @@ -354,42 +399,67 @@ def plugin_id(): 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""" +def index_id(): + """a hash of axiomcode-index, the plugin code that writes into the graph (its symbols tables)""" + try: return digest(os.path.join(H, 'axiomcode-index')) + except OSError: return '?' + +def built_by(engine, langs=None): + """what a build with `engine` of a graph of `langs` records in its file table""" rules, impact = plugin_id() - d = dict(rules=rules, impact=impact) + d = dict(rules=rules, impact=impact, index=index_id()) + langs = engine_lang_list(langs) if engine and engine_ok(engine): - version, content, sig = engine_id(engine) + version, content, sig = engine_id(engine, langs or None) d.update(engine=engine, engine_version=version, engine_hash=content(), engine_stat=sig) + if langs: d['engine_langs'] = langs 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""" + "graph built by an older axiomcode ( -> )". Only what shapes this graph's languages counts (see WHAT BUILT + THE GRAPH). 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): + now_e = engine_id(eng, engine_lang_list(t.get('lang')) or None) if eng else None 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})" + # a table from before the per-language key recorded a hash of the whole engine: it is compared the same way, so + # the upgrade itself rebuilds nothing that was current + langs = old.get('engine_langs') + now_e = engine_id(eng, langs) if eng else None 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)}") + diff.append(f"engine {_label(old.get('engine_version', '?'), old['engine_hash'])} -> {_label(now_e[0], h)}" + + (f" ({', '.join(langs)})" if langs else '')) 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}") + if 'index' in old: + ix = index_id() + if old.get('index') != ix: diff.append(f"axiomcode-index {(old.get('index') or 'unrecorded')[:8]} -> {ix[:8]}") + else: + 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 export_behind(t): + """the IMPACT_VERSION the graph's facts were exported with, when it is not this plugin's (a table with the + per-language key only: an older one rebuilds for it), else ''""" + old = (t or {}).get('built_by') + if not isinstance(old, dict) or 'index' not in old: return '' + have = str(old.get('impact')) + return have if have != plugin_id()[1] 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""" @@ -701,6 +771,85 @@ def kick(repo, trigger='an edit'): except OSError: continue return False +# ── HOW MANY BUILD AT ONCE ──────────────────────────────────────────────────────────────────────────────────────── +# ONE MACHINE, MANY CHECKOUTS. Each repository's worker is single-flight, but nothing bounded them together: after one +# install switch about 40 checkouts rebuilt at once, the load reached 221, and a Python build that takes 58-160 s alone +# took about 1,000 s. So a background build takes one of AXIOMCODE_REFRESH_MAX (default 2) slots, machine-wide: lock files +# in the user's cache that the OS releases when the worker exits, however it exits (no stale slot to time out). A worker +# that finds every slot taken WAITS for one, holding its repository's refresh lock, so the rebuild is queued, never +# dropped, and later edits fold into it. 0 turns the cap off. An explicit `axiomcode index` is not capped: the user +# asked for it and is waiting. +def refresh_cap(): + try: return max(0, int(os.environ.get('AXIOMCODE_REFRESH_MAX') or 2)) + except ValueError: return 2 + +def slot_dir(): + return os.environ.get('AXIOMCODE_REFRESH_SLOTS') or os.path.join( + os.environ.get('XDG_CACHE_HOME') or os.path.join(os.path.expanduser('~'), '.cache'), 'axiomcode', 'refresh-slots') + +def take_slot(repo): + """block until one of the machine's background build slots is free and hold it: the fd to close, or None when there + is no cap (AXIOMCODE_REFRESH_MAX=0, or no cache directory to keep the slots in: a build uncapped beats none)""" + n = refresh_cap() + if not n: return None + d = slot_dir() + try: os.makedirs(d, exist_ok=True) + except OSError: return None + import random + t0 = time.time(); said = False + while True: + for i in range(n): + try: fd = os.open(os.path.join(d, f'slot-{i}.lock'), os.O_RDWR | os.O_CREAT, 0o644) + except OSError: return None + if _flock(fd, False): + if os.name != 'nt': + try: os.ftruncate(fd, 0); os.write(fd, f"{os.getpid()} {repo}\n".encode()) + except OSError: pass + if said: print(f"{time.strftime('%H:%M:%S')} refresh: got a build slot after {round(time.time() - t0, 1)}s", flush=True) + return fd + os.close(fd) + if not said: + print(f"{time.strftime('%H:%M:%S')} refresh: {n} background build(s) already running on this machine " + f"(AXIOMCODE_REFRESH_MAX={n}); queued until one ends", flush=True) + write_state(repo, state='queued', queued=time.time()); said = True + time.sleep(0.5 + random.random()) + +def give_slot(fd): + if fd is not None: + try: os.close(fd) + except OSError: pass + +def rewarm(repo, t): + """IMPACT_VERSION moved and nothing that shapes the graph did: the graph stands, and only the facts `impact` exports + from it are written again for the new version (seconds, not a rebuild), every graph of the repository's, under a + build slot; then the file table records the version. Left to the first query instead, a large export can outlast a + hook's timeout and be killed on every try""" + have, now = export_behind(t), plugin_id()[1] + if not have: return + ax = os.path.join(repo, '.axiomcode') + graphs = [None] + [os.path.join(ax, 'lang', l) for l in sorted(os.listdir(os.path.join(ax, 'lang')) if os.path.isdir(os.path.join(ax, 'lang')) else []) + if os.path.isfile(os.path.join(ax, 'lang', l, 'out', 'graph.sqlite'))] + graphs += [g for g in [os.path.join(ax, 'base')] + [os.path.join(ax, 'base', 'lang', l) for l in + (sorted(os.listdir(os.path.join(ax, 'base', 'lang'))) if os.path.isdir(os.path.join(ax, 'base', 'lang')) else [])] + if os.path.isfile(os.path.join(g, 'out', 'graph.sqlite'))] + slot = take_slot(repo) + try: + print(f"{time.strftime('%H:%M:%S')} refresh: IMPACT_VERSION {have} -> {now}; exporting the graph's facts again (no rebuild)", flush=True) + for g in graphs: + env = {k: v for k, v in os.environ.items() if k != 'AXIOMCODE_GRAPH'} + if g: env['AXIOMCODE_GRAPH'] = g + try: subprocess.run([sys.executable, os.path.join(H, 'axiomcode-impact'), '--warm', repo], env=env, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=1800, **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) + except (OSError, subprocess.SubprocessError): pass + finally: + give_slot(slot) + t2 = load_table(repo) # a build that replaced the table meanwhile recorded its own + if t2 and t2.get('built') == t.get('built') and isinstance(t2.get('built_by'), dict): + t2['built_by']['impact'] = now + tmp = table_path(repo) + f'.{os.getpid()}' + try: json.dump(t2, open(tmp, 'w')); os.replace(tmp, table_path(repo)) + except OSError: pass + def worker(repo): lock = os.path.join(repo, '.axiomcode', 'refresh.lock') fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644) @@ -726,6 +875,7 @@ def worker(repo): 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): + if export_behind(t): rewarm(repo, t) 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) @@ -737,14 +887,18 @@ def worker(repo): (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) + slot = take_slot(repo) # queued behind the machine's other background builds 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) 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, - **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) + try: + 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, + **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) + finally: + give_slot(slot) took = round(time.time() - t0, 1) if r.returncode != 0: write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, failed_engine=eng, @@ -1119,7 +1273,7 @@ def main(argv): 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))) + by = {} if os.environ.get('AXIOMCODE_BUILT_BY_UNKNOWN') else dict(built_by=built_by(os.environ.get('AXIOMCODE_ENGINE') or current_engine(repo), lang)) 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)), **by), sys.stdout); return 0 if cmd == 'count': diff --git a/tests/freshness.py b/tests/freshness.py index 47242267..f7f385c9 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -18,9 +18,16 @@ 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 + engine a graph built by another engine or another axiomcode-index 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 + per-lang only what shapes the graph's own languages counts: another language's rules or parser, a version-only bump, + the query rules or IMPACT_VERSION leave it current (IMPACT_VERSION re-exports its facts, no rebuild); its + own rules or parser, or a shared pipeline or bundle file, make it stale. A table from before the + per-language key is compared the way it was recorded + cap at most AXIOMCODE_REFRESH_MAX background rebuilds run at once on the machine: a third waits while two run, + and runs once one ends (queued, never dropped); control: with room for three, none waits + lock the engine compile lock is taken over when its owner process is dead, never while it lives, however old 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 @@ -204,7 +211,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), built_by=ax_fresh.built_by(ax_fresh.current_engine(repo))) + files=ax_fresh.snapshot(repo, 'python', repo), built_by=ax_fresh.built_by(ax_fresh.current_engine(repo), 'python')) 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')) @@ -277,9 +284,14 @@ def wait_checks(): # ── 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')): +def fake_engine(d, rules='rel(1).\n', version='1.0.0', java='jrel(1).\n', parser_java='// java parser\n', parser_py='// python parser\n', + pipeline='# run\n', bundle='// bundle\n'): + for rel, text in (('bin/axiomcode', '#!/bin/sh\n'), ('graph/python/rules.dl', rules), ('package.json', json.dumps(dict(version=version, name='e'))), + ('parser/dist/index.js', '// parser\n'), ('parser/dist/index.js.map', '{}'), ('graph/test/case.dl', 'x.\n'), + ('graph/java/rules.dl', java), ('graph/csharp/rules.dl', 'crel(1).\n'), ('graph/pipeline/run-souffle.sh', pipeline), + ('dist/bundle/write.js', bundle), ('parser/dist/parsers/java/java-parser.js', parser_java), + ('parser/dist/parsers/python/python-parser.js', parser_py), ('parser/dist/language-detectors/java-detector.js', parser_java), + ('parser/dist/constants/python-constants.js', parser_py)): write(d, rel, text) return d @@ -296,9 +308,10 @@ def engine_checks(): 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) + check("engine: the file table records the engine (its version, and a content hash over the python graph's files), " + "axiomcode-index and IMPACT_VERSION", + by.get('engine_version') == '1.0.0' and len(by.get('engine_hash', '')) == 40 and by.get('engine_langs') == ['python'] + and len(by.get('index', '')) == 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)) @@ -308,14 +321,19 @@ def engine_checks(): 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))) + py = sorted(os.path.relpath(p, e2).replace(os.sep, '/') for p in ax_fresh._engine_files(e2, ['python'])) + check("per-lang: a python graph's engine files are python's and the shared ones, never another language's", + 'graph/python/rules.dl' in py and 'parser/dist/parsers/python/python-parser.js' in py and 'parser/dist/constants/python-constants.js' in py + and 'graph/pipeline/run-souffle.sh' in py and 'dist/bundle/write.js' in py and 'parser/dist/index.js' in py + and not any(x.startswith(('graph/java/', 'graph/csharp/', 'parser/dist/parsers/java/')) or 'java-detector' in x for x in py), py) 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] + old, new = by['engine_hash'][:8], ax_fresh.engine_id(e2, ['python'])[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) + 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} (python))", 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) + n.startswith(f"graph refresh: graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new} (python)); 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} (python)); 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() @@ -326,10 +344,28 @@ def engine_checks(): 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)) + check("per-lang: another IMPACT_VERSION leaves the graph current (no rebuild) and asks for its facts to be exported again", + ax_fresh.engine_change(repo) == '' and ax_fresh.status(repo).get('state') == 'fresh' and ax_fresh.export_behind(t) == '1', + (ax_fresh.engine_change(repo), ax_fresh.export_behind(t))) 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)) + check("per-lang: other query rules (dl/*.dl, compiled apart, never written into the graph) leave it current", + ax_fresh.engine_change(repo) == '', ax_fresh.engine_change(repo)) + t = dict(table, built_by=dict(by, index='f' * 40)); json.dump(t, open(tp, 'w')) + check("engine: another axiomcode-index (it writes the graph's symbols) makes it stale", + ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (axiomcode-index ffffffff -> '), ax_fresh.engine_change(repo)) + legacy = {k: v for k, v in by.items() if k not in ('index', 'engine_langs')} + legacy.update(engine_hash=ax_fresh.engine_id(e1)[1](), engine_stat=ax_fresh.engine_id(e1)[2]) + json.dump(dict(table, built_by=legacy), open(tp, 'w')) + check("per-lang: a table from before the per-language key, from the same engine, is current (the upgrade rebuilds nothing)", + ax_fresh.engine_change(repo) == '', ax_fresh.engine_change(repo)) + json.dump(dict(table, built_by=dict(legacy, impact='1')), open(tp, 'w')) + check("per-lang: such a table with another IMPACT_VERSION is stale, as it was recorded to be", + 'IMPACT_VERSION 1 -> ' in ax_fresh.engine_change(repo), ax_fresh.engine_change(repo)) + json.dump(dict(table, built_by=legacy), open(tp, 'w')) + fake_engine(e1, java='jrel(2).\n') + check("per-lang: such a table is stale after a java-only change, as it was (whole-engine hash)", + ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (engine '), ax_fresh.engine_change(repo)) + fake_engine(e1) 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)) @@ -348,6 +384,139 @@ def engine_checks(): shutil.rmtree(work, ignore_errors=True) +# ── per language ────────────────────────────────────────────────────────────────────────────────────────────────── +def per_language_checks(): + """the measured storm (2026-09-29): any change to an installed build marked every graph stale. Now a graph is stale + only for a change to what builds ITS languages. Each language edit has a near miss: the same kind of edit in + another language's tree""" + work = tempfile.mkdtemp(prefix='axiomcode-perlang-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e = fake_engine(os.path.join(work, 'e')); os.environ['AXIOMCODE_ENGINE'] = e + repos = {} + for name, lang in (('python', 'python'), ('java', 'java'), ('csharp', 'csharp'), ('python+java', 'python,java')): + r = os.path.join(work, name.replace('+', '-')); write(r, '.axiomcode/out/graph.sqlite', '') + json.dump(dict(lang=lang, lang_auto=False, src='', src_arg='', library='', built=time.time(), files={}, + built_by=ax_fresh.built_by(e, lang)), open(os.path.join(r, '.axiomcode/out/files.json'), 'w')) + repos[name] = r + stale = lambda: sorted(l for l, r in repos.items() if ax_fresh.engine_change(r)) + check("per-lang: control: the engine that built them, unchanged, leaves every graph current", stale() == [], stale()) + fake_engine(e, java='jrel(2).\n') + check("per-lang: a java rule change makes the java graphs stale and leaves the python and csharp graphs current", + stale() == ['java', 'python+java'], stale()) + fake_engine(e) + check("per-lang: control: put back, every graph is current again", stale() == [], stale()) + fake_engine(e, rules='rel(2).\n') + check("per-lang: a python rule change makes the python graphs stale, not the java or csharp ones", + stale() == ['python', 'python+java'], stale()) + fake_engine(e, parser_java='// java parser 2\n') + check("per-lang: a java parser change (its parser directory and its detector) leaves the python and csharp graphs current", + stale() == ['java', 'python+java'], stale()) + fake_engine(e, parser_py='// python parser 2\n') + check("per-lang: a python parser change makes the python graphs stale", stale() == ['python', 'python+java'], stale()) + fake_engine(e, version='9.9.9') + check("per-lang: a version-only bump (package.json's version) leaves every graph current", stale() == [], stale()) + fake_engine(e, pipeline='# run 2\n') + check("per-lang: a shared pipeline change makes every graph stale", stale() == ['csharp', 'java', 'python', 'python+java'], stale()) + fake_engine(e, bundle='// bundle 2\n') + check("per-lang: a bundle change (it writes every graph) makes every graph stale", stale() == ['csharp', 'java', 'python', 'python+java'], stale()) + finally: + if saved is None: os.environ.pop('AXIOMCODE_ENGINE', None) + else: os.environ['AXIOMCODE_ENGINE'] = saved + shutil.rmtree(work, ignore_errors=True) + + +# ── cap ─────────────────────────────────────────────────────────────────────────────────────────────────────────── +FAKE_BUILD = r'''#!{py} +# stands in for `bash axiomcode-build `: logs when it runs, takes a while, and records the table a build would +import json, os, subprocess, sys, time +repo = sys.argv[2]; log = os.environ['CAP_LOG'] +open(log, 'a').write(f"start {{os.path.basename(repo)}} {{time.time()}}\n") +time.sleep(float(os.environ.get('CAP_BUILD_SECONDS') or 3)) +out = subprocess.run([sys.executable, os.path.join({scripts!r}, 'ax_fresh.py'), 'snapshot', repo, 'python', ''], capture_output=True, text=True).stdout +open(os.path.join(repo, '.axiomcode', 'out', 'files.json'), 'w').write(out) +open(log, 'a').write(f"end {{os.path.basename(repo)}} {{time.time()}}\n") +''' + + +def overlap(log): + """the most builds the log shows running at once, and how many ran to the end""" + ev = sorted((float(t), 1 if k == 'start' else -1) for k, _, t in (l.split() for l in open(log) if l.strip())) + run = most = 0 + for _, d in ev: run += d; most = max(most, run) + return most, sum(1 for l in open(log) if l.startswith('end ')) + + +def cap_checks(): + work = tempfile.mkdtemp(prefix='axiomcode-cap-') + try: + fake = os.path.join(work, 'fake-build'); open(fake, 'w').write(FAKE_BUILD.format(py=sys.executable, scripts=SCRIPTS)); os.chmod(fake, 0o755) + for cap in ('2', '3'): + log = os.path.join(work, f'log-{cap}'); open(log, 'w').close() + repos = [] + for i in range(3): + r = fake_repo(work, f'r{cap}-{i}'); os.remove(os.path.join(r, '.axiomcode/refresh.json')) + open(os.path.join(r, 'shop/api.py'), 'a').write('\ndef audit():\n return 1\n') + repos.append(r) + env = dict(os.environ, AXIOMCODE_BASH=fake, CAP_LOG=log, AXIOMCODE_REFRESH_MAX=cap, AXIOMCODE_REFRESH_DEBOUNCE='0.1', + AXIOMCODE_REFRESH_SLOTS=os.path.join(work, f'slots-{cap}')) + for k in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_GRAPH'): env.pop(k, None) + procs = [subprocess.Popen([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'worker', r], env=env, + stdout=open(os.path.join(r, '.axiomcode/refresh.log'), 'w'), stderr=subprocess.STDOUT) for r in repos] + for p in procs: p.wait(timeout=120) + most, ended = overlap(log) + logs = [open(os.path.join(r, '.axiomcode/refresh.log')).read() for r in repos] + queued = [l for l in logs if 'queued until one ends' in l] + if cap == '2': + check(f"cap: with AXIOMCODE_REFRESH_MAX=2, a third refresh waits while two run (at most {most} at once), then runs " + f"({ended} of 3 finished), and says it was queued", most == 2 and ended == 3 and len(queued) == 1, (open(log).read(), logs)) + check("cap: every queued repository ends fresh (queued, never dropped)", + all(ax_fresh.status(r).get('state') == 'fresh' for r in repos), [ax_fresh.status(r) for r in repos]) + else: + check(f"cap: control: with room for three, all three run at once ({most}) and none is queued", + most == 3 and ended == 3 and not queued, (open(log).read(), logs)) + saved = os.environ.pop('AXIOMCODE_REFRESH_MAX', None) + try: + d = ax_fresh.refresh_cap(); os.environ['AXIOMCODE_REFRESH_MAX'] = '0' + check("cap: the default is 2, and 0 turns it off (no slot taken)", d == 2 and ax_fresh.take_slot(work) is None, d) + finally: + os.environ.pop('AXIOMCODE_REFRESH_MAX', None) + if saved is not None: os.environ['AXIOMCODE_REFRESH_MAX'] = saved + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── compile lock ────────────────────────────────────────────────────────────────────────────────────────────────── +def lock_checks(): + work = tempfile.mkdtemp(prefix='axiomcode-lock-') + helper = os.path.join(ROOT, 'graph', 'pipeline', 'compile-lock.sh') + def take(lock, secs): + """True when compile_lock_take got `lock` within secs; its output""" + p = subprocess.Popen(['bash', '-c', '. "$1"; compile_lock_take "$2"; echo TOOK; compile_lock_drop "$2"', 'x', helper, lock], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=dict(os.environ, COMPILE_LOCK_POLL='0.2')) + try: out = p.communicate(timeout=secs)[0] + except subprocess.TimeoutExpired: p.kill(); out = p.communicate()[0] + return 'TOOK' in out, out + try: + live = subprocess.Popen(['sleep', '60']) + lock = os.path.join(work, 'live.lock'); os.mkdir(lock); open(os.path.join(lock, 'pid'), 'w').write(f'{live.pid}\n') + old = time.time() - 3 * 3600; os.utime(lock, (old, old)) # three hours old: dead by the old 30-min rule + took, out = take(lock, 3) + check("lock: a lock whose owner is alive is not taken over, however old it is", not took and os.path.isdir(lock), out) + live.kill(); live.wait() + took, out = take(lock, 20) + check("lock: once its owner is dead it is taken over at once", took and 'is gone; taking its lock over' in out and not os.path.exists(lock), out) + young = os.path.join(work, 'nopid.lock'); os.mkdir(young) + took, out = take(young, 3) + check("lock: control: a young lock with no pid yet (between its mkdir and its write) is waited for", not took, out) + os.utime(young, (old, old)); took, out = take(young, 20) + check("lock: an old lock with no pid (an older run's) is taken over", took, out) + mine = os.path.join(work, 'mine.lock') + r = subprocess.run(['bash', '-c', '. "$1"; compile_lock_take "$2"; [ "$(cat "$2/pid")" = "$$" ] && echo OWNER', 'x', helper, mine], capture_output=True, text=True) + check("lock: the run that takes the lock writes its own pid into it", 'OWNER' in r.stdout, (r.stdout, r.stderr)) + finally: + shutil.rmtree(work, ignore_errors=True) + + # ── named ───────────────────────────────────────────────────────────────────────────────────────────────────────── NOT_FOUND = "nothing named 'audit' in the graph, and nothing close to it." @@ -419,7 +588,7 @@ def mcp_checks(): if __name__ == '__main__': - prune_checks(); marks_checks(); wait_checks(); engine_checks(); named_checks(); mcp_checks() + prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); cap_checks(); lock_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 39d7c3bf..dd10229d 100644 --- a/tests/refresh.py +++ b/tests/refresh.py @@ -94,7 +94,9 @@ def check(ok, why, detail=''): for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): sh(repo, *cmd) - env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_REFRESH_DEBOUNCE='0.5', AXIOMCODE_FRESH_WAIT='600') + # the machine-wide cap on background builds counts this suite's refreshes apart from the machine's own + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_REFRESH_DEBOUNCE='0.5', AXIOMCODE_FRESH_WAIT='600', + AXIOMCODE_REFRESH_SLOTS=os.path.join(work, 'slots')) quiet = dict(env, AXIOMCODE_NO_REFRESH='1') # the control: a query that neither refreshes nor waits out = os.path.join(repo, '.axiomcode', 'out') tree = lambda: open(os.path.join(out, 'indexed-tree')).read().strip() if os.path.exists(os.path.join(out, 'indexed-tree')) else '' @@ -110,20 +112,21 @@ def check(ok, why, detail=''): 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 + # no file changed, but the table says another engine built the graph (what an 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. The + # IMPACT_VERSION set beside it is not a reason: the graph's facts are exported again, the graph is not rebuilt 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)) + check(by.get('engine_hash') and by.get('engine_langs') == [lang] and by.get('index') and by.get('impact'), + f'{lang}: the file table records the engine (keyed on {lang}), axiomcode-index 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, + and f'({lang})' in q.stderr and 'IMPACT_VERSION' not 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() @@ -137,6 +140,20 @@ def older(): 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) + # ── another IMPACT_VERSION alone ────────────────────────────────────────────────────────────────── + # the graph stands; the refresher exports its facts again for the new version and records it (no rebuild) + t = json.load(open(tp)); want = t['built_by']['impact']; t['built_by']['impact'] = '1'; json.dump(t, open(tp, 'w')) + q = sh(repo, AX, 'impact', helper, '.', env=quiet) + check(q.returncode == 0 and helper in q.stdout and 'older axiomcode' not in q.stderr and 'graph refresh' not in q.stderr, + f'{lang}: another IMPACT_VERSION alone is not a stale graph: a query answers with no note', q.stderr) + n0, built0 = rebuilds(), meta().get('refreshed_at') + stamp = os.path.join(out, 'dl', 'impact', 'stamp'); open(stamp, 'w').write('0:1') # the export an older version left + w = sh(repo, sys.executable, FRESH, 'worker', '.', env=env) + log = open(os.path.join(repo, '.axiomcode', 'refresh.log')).read() if os.path.exists(os.path.join(repo, '.axiomcode', 'refresh.log')) else '' + check(w.returncode == 0 and json.load(open(tp))['built_by']['impact'] == want and rebuilds() == n0 and meta().get('refreshed_at') == built0 + and open(stamp).read().endswith(':' + want) and 'exporting the graph' in w.stdout + log, + f'{lang}: the refresher exports the facts again for it and records it, with no rebuild', w.stdout + w.stderr + log[-400:]) + # ── 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 b5b8f6ebd1f1bbbf0e3aea4295cde0f5719685c7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:50:00 -0700 Subject: [PATCH 017/258] name gate: describe five measured projects by what they are, not by name graph/test/tools/name_gate.py failed on the release branch: five tracked files, none of them in name-debt.txt, named a project the engine is measured on. What was wrong and the change, per file: - plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py (six comment lines) and plugins/axiomcode/skills/axiomcode/scripts/dl_program.py (one docstring line): measurement notes named the project the number came from. Each now describes the project by its shape (a large Java message broker, a large container-wired Java server, a container-free JSON library) and keeps the magnitude. Class names that identified the project are generalised too. - plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact and the matching table comment in graph_sql.py: the comment above the generated-members table named an ORM. The table keys (DeclarativeBase, declarative_base, SQLModel) are unchanged, so the two copies still agree and behaviour is unchanged. - graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py and tests/cases/python/framework-hop-is-a-dependent/app/tasks.py: the fixture imported shared_task from a named package. The rules key on the decorator name `shared_task` (knobs.dl py_task_register_deco), not on the module it is imported from, so the import now reads from a neutral module. The import stays an unresolved external one, the same shape as before, and the line count is unchanged, so no expectation moves. No code path changed; only comments, one docstring and two fixture imports. name_gate.py now passes: ok, 0 new, 0 stale, 0 missing. Suites, on the rebased tree with the gate enabled: tests/run.py --lang python: 166 of 171 checks passed; the 5 failures are all python/lambda-is-named-by-its-place, which also fails on the tip. graph/test/python/run-tests.sh: passed 33, failed 0. No smoke run: the change touches no code path, so there are no corpus numbers. Not yet measured on the full corpus or on held-out projects. --- .../29-framework-edge-consumers/src/app/tasks.py | 2 +- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../skills/axiomcode/scripts/dl_program.py | 2 +- .../skills/axiomcode/scripts/graph_sql.py | 15 ++++++++------- .../framework-hop-is-a-dependent/app/tasks.py | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py index 4adaa970..50f3118a 100644 --- a/graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py @@ -1,7 +1,7 @@ """Two registered tasks (one with bind=True), an unregistered function, and a class whose method is also called `delay`.""" -from celery import shared_task +from taskqueue import shared_task @shared_task diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 1c52fef6..1117a88a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -176,7 +176,7 @@ GENERATED = {'Data': {'get', 'set', 'is', 'ctor'}, 'Getter': {'get', 'is'}, 'Set # breaks each construction site at the argument it passes. TypedDict generates no callable, but its # members are reached by STRING KEY, which is what makes its [text] hits members rather than noise. 'NamedTuple': {'ctor'}, 'TypedDict': {'ctor'}, - # discriminating names only. SQLAlchemy 2.0 states its base outright; 1.x builds one with + # discriminating names only. The ORM's 2.0 API states its base outright; 1.x builds one with # declarative_base(), which the base-alias rule in impact.dl resolves through. Django's # `models.Model` is NOT here: the parser keeps only the last segment, so the key would be # `Model` and would fire on any project's own class of that name. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index 6e9ec862..5082bc81 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -15,7 +15,7 @@ starts, in parallel with the engine, so normally they are done before the graph is published. What this buys is the COMPILE STEP, not query speed: on a 5 MB fact set the binary ran the same program in 1.3 s -against the interpreter's 2.5 s, but on apache/rocketmq (2,265 files, 184k edges) a query took 22.5 s compiled and +against the interpreter's 2.5 s, but on a large Java message broker (184k edges) a query took 22.5 s compiled and 19.4 s interpreted — there, loading the facts dominates and the binary wins nothing. Do not quote a speed-up without saying which graph it was measured on. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 7e19cabe..3d168396 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1081,7 +1081,8 @@ def no_caller_phrase(kind, label): 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. + never exercises — which is why a container-free JSON library (0 rows in ext_bean_def) was clean on it and a large + container-wired Java server (213) was not. bean_call(q,c,m) :- target(q,"method",m,_), owner(m,ot), bean(_,ot,_), calls(c,m,t,_,_), t != "multi_inferred", t != "stub" @@ -1243,7 +1244,7 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None, via=None if fields: # every file a MEMBER of the owner is written in, not just the file of the owner's own type symbol. # `member(t,c,…)` resolves the owner display to one type id, so a type owns every method written with - # that display wherever it lives: keycloak has a jpa RealmAdapter and an infinispan one, and taking the + # that display wherever it lives: a large Java server has two storage adapters sharing one display, and taking the # first type symbol's file left 179 rows worded "a sibling of the same type" where the rules say # "…, using the same field cached". want_files = set() @@ -1706,7 +1707,7 @@ def direct_for_string(q, vals, at, rel): # TypedDict generates no callable, but its members are reached by STRING KEY (`d["zip_code"]`), so # naming it here is what makes the [text] layer's string hits legible as members rather than noise. 'NamedTuple': {'ctor'}, 'TypedDict': {'ctor'}, - # discriminating names only. SQLAlchemy 2.0 states its base outright; 1.x builds one with + # discriminating names only. The ORM's 2.0 API states its base outright; 1.x builds one with # declarative_base(), which the base-alias rule in impact.dl resolves through. Django's # `models.Model` is NOT here: the parser keeps only the last segment, so the key would be # `Model` and would fire on any project's own class of that name. @@ -2460,8 +2461,8 @@ def _declares(q, t): """`declares(t,n)`: a member of t, a member of anything t extends, or a nested type of that name. Keyed on the type ID, because `member(t,_,n,_)` is. Falling back to `WHERE owner = ` merges every - class that shares a display: keycloak has two `ParTest` classes and only one of them extends the base that - declares REALM_NAME, so the display lookup shadowed 18 rows the rules report. + class that shares a display: one measured Java server has two test classes of one display and only one of them extends the base that + declares the constant, so the display lookup shadowed 18 rows the rules report. """ up, _down, nest_in, nm, memo = _rel_index(q) if t in memo: return memo[t] @@ -2852,11 +2853,11 @@ def _same_file_members(q, own_tid, f): Keyed on the type SYMBOL ID, because that is what the rule joins on, and the exporter builds its two maps from different row sets: `tid_of` from anything carrying a type_id (a method row can), `type_in_file` from the type rows only (method_id IS NULL) plus the module nodes. Keying this on the display instead moved 179 - rows off one keycloak target and pulled 168 others in. + rows off one target in a large Java server and pulled 168 others in. Iterating the symbols IN f is also not the same thing and loses rows: `member(t2,c)` resolves the owner display to ONE type id, first id wins, so a type in f owns every method written with that owner display — - including ones in another file entirely. keycloak has two `AbstractOrganizationTest` classes in different + including ones in another file entirely. One large Java server has two abstract test classes of one display in different modules, and 24 of the members the rules report for the one in f are written in the other. """ _memb, _od, _tf, _tid, by_tid = _members(q) diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/tasks.py b/tests/cases/python/framework-hop-is-a-dependent/app/tasks.py index 2f470f4c..152aac68 100644 --- a/tests/cases/python/framework-hop-is-a-dependent/app/tasks.py +++ b/tests/cases/python/framework-hop-is-a-dependent/app/tasks.py @@ -1,4 +1,4 @@ -from celery import shared_task +from taskqueue import shared_task @shared_task From 2539f78e9c53a6c3971bc09081aa8e22ce2936f0 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:59:33 -0700 Subject: [PATCH 018/258] impact, path, context: place a text match in its declaration as [approx], answer a file no graph reads Builds on #1699 (the [text] fallback). What was wrong. When the graph has no declaration for what was asked, the [text] block (#1699) listed the lines that write it, each with the declaration around it, and stopped there. Three shapes stayed unanswered: - "which code prints this message" or "which code reads this table": the right function was one row among comments, docstrings and tests, all shown alike, and nothing said what reaches that function; - "who runs build.sh" or "who reads decls_all.dl": impact called the file name a configuration key, context said only "not indexed: grep it directly", and code that holds the path in a constant (OUT = HERE / "decls_all.dl") and reads it elsewhere was never found; - "which code uses a mapper XML such as JobsMapper.xml": the name fell back to its extension and printed every line with "xml" (63 rows on one project); the mapper's namespace, which names the interface, was not read. The change (plugin scripts only; no engine or rule change; Python, Java and C# graphs, each on its own). - A text match in code of an indexed, non-test file is placed in its declaration and printed as [approx] with its evidence line, a verb read off that line (runs, writes, reads, renders, emits, names) and the graph's callers of that declaration. It is never shown as a call edge and never changes the exit status. - A comment, a docstring or a test is never placed: each stays a [text] row and says which it is ("a comment", "a docstring", "a test"). Python is read with tokenize; Java and C# with a small scanner for strings and comments. - A match held by a constant or a field is followed one step, by name, to the code of the same graph that reads it: its own file, or another file where the name is qualified by its module or type or imported. A plain lowercase name (id, path) is not followed. - An annotation or attribute argument (@TableName("orders"), [Table("orders")]) is placed on the declaration it is written on. A member the parser generated over a whole type (a Lombok accessor) no longer claims every line of it. - A file no graph reads is said to be one (not a configuration key); the code that names it in a string literal is listed, or the answer says no code outside the tests does. The file's own text is read for qualified names the graph declares (a mapper namespace, a bean class), listed as "named by it". - context searches a file name, and a bare environment-variable name no graph declares, as it already did a quoted message. A file name no longer falls back to its extension as a search word. - Only Python, Java and C# graphs get [approx] rows; another language's graph keeps its [text] rows unchanged. Tests. New case approx-outside-graph for python, java and csharp: a script run by a function, a file held by a constant, a SQL table in a query string, a quoted and a bare message or key, a mapper XML (java), each with near-miss controls where the text match is not the answer (the string only in a comment, a docstring or a Javadoc, only in a test, and a declared name that must answer from the graph alone). With the comment, docstring and test filters removed, the three controls of the python case fail. The #1699 case now expects [approx] for its code rows. Suites run on the rebased tree: tests/run.py --lang python (the only failures are the known python/lambda-is-named-by-its-place), --lang java, --lang csharp, tests/surfaces.py, tests/mcp_first.py. Smoke, same questions on a fresh index of a copy, installed build before vs this change after (answers placed in the right declaration / with what reaches it): - Python, a Flask app (6 questions: templates, raised messages, a manifest path held in a constant): before 4/6 placed, 0/6 with callers; after 6/6 placed, 6/6 with callers or an explicit "no caller in the graph". - Java, a Spring Boot app (4 positive: thrown messages, a table name, a mapper XML; 2 negative: application.yml, which no code names): before 2/4 placed (a table row placed in a Lombok setter, the mapper XML answered with 63 "xml" rows), after 4/4 with callers; both negatives now say no code names the file instead of a config-key refusal. - C#, an ASP.NET app (6 questions: logged and thrown messages, a settings file, an env var): before 5/6 placed (the bare env var was not searched), 0/6 with callers; after 6/6, 6/6 with callers or "no caller". - Questions the graph answers (a config key, a declared constant, a declared type) are unchanged on all three. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/skills/axiomcode/SKILL.md | 3 +- .../skills/axiomcode/scripts/ax_text.py | 394 +++++++++++++++++- .../axiomcode/scripts/axiomcode-context | 9 + .../skills/axiomcode/scripts/axiomcode-impact | 6 + .../csharp/approx-outside-graph/case.json | 34 ++ .../csharp/approx-outside-graph/db/seed.sql | 1 + .../approx-outside-graph/scripts/cleanup.sh | 2 + .../approx-outside-graph/scripts/purge.sh | 2 + .../approx-outside-graph/scripts/rebuild.sh | 2 + .../src/App.Tests/JobsTests.cs | 20 + .../approx-outside-graph/src/App/Jobs.cs | 55 +++ .../cases/java/approx-outside-graph/case.json | 38 ++ .../approx-outside-graph/db/JobsMapper.xml | 3 + .../java/approx-outside-graph/db/seed.sql | 1 + .../approx-outside-graph/scripts/cleanup.sh | 2 + .../approx-outside-graph/scripts/purge.sh | 2 + .../approx-outside-graph/scripts/rebuild.sh | 2 + .../src/main/java/app/Jobs.java | 45 ++ .../src/test/java/app/JobsTest.java | 12 + .../python/approx-outside-graph/case.json | 40 ++ .../approx-outside-graph/scripts/cleanup.sh | 2 + .../approx-outside-graph/scripts/purge.sh | 2 + .../approx-outside-graph/scripts/rebuild.sh | 2 + .../python/approx-outside-graph/src/jobs.py | 46 ++ .../python/approx-outside-graph/src/seed.sql | 1 + .../src/tests/test_jobs.py | 9 + .../case.json | 8 +- 27 files changed, 724 insertions(+), 19 deletions(-) create mode 100644 tests/cases/csharp/approx-outside-graph/case.json create mode 100644 tests/cases/csharp/approx-outside-graph/db/seed.sql create mode 100644 tests/cases/csharp/approx-outside-graph/scripts/cleanup.sh create mode 100644 tests/cases/csharp/approx-outside-graph/scripts/purge.sh create mode 100644 tests/cases/csharp/approx-outside-graph/scripts/rebuild.sh create mode 100644 tests/cases/csharp/approx-outside-graph/src/App.Tests/JobsTests.cs create mode 100644 tests/cases/csharp/approx-outside-graph/src/App/Jobs.cs create mode 100644 tests/cases/java/approx-outside-graph/case.json create mode 100644 tests/cases/java/approx-outside-graph/db/JobsMapper.xml create mode 100644 tests/cases/java/approx-outside-graph/db/seed.sql create mode 100644 tests/cases/java/approx-outside-graph/scripts/cleanup.sh create mode 100644 tests/cases/java/approx-outside-graph/scripts/purge.sh create mode 100644 tests/cases/java/approx-outside-graph/scripts/rebuild.sh create mode 100644 tests/cases/java/approx-outside-graph/src/main/java/app/Jobs.java create mode 100644 tests/cases/java/approx-outside-graph/src/test/java/app/JobsTest.java create mode 100644 tests/cases/python/approx-outside-graph/case.json create mode 100644 tests/cases/python/approx-outside-graph/scripts/cleanup.sh create mode 100644 tests/cases/python/approx-outside-graph/scripts/purge.sh create mode 100644 tests/cases/python/approx-outside-graph/scripts/rebuild.sh create mode 100644 tests/cases/python/approx-outside-graph/src/jobs.py create mode 100644 tests/cases/python/approx-outside-graph/src/seed.sql create mode 100644 tests/cases/python/approx-outside-graph/src/tests/test_jobs.py diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 48e1c45a..c2a7fb65 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -11,7 +11,7 @@ are in your tool list; otherwise run `/scripts/axiomcode …` f verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own 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. +**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]` / `[approx]` 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 …]`, @@ -67,6 +67,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[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 | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | +| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges connect, not that a test exercises the change. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py index 101f2313..8b18aa98 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -21,6 +21,9 @@ PER_FILE = 3 # rows from one file before the next file gets a turn _FILE_LIKE = re.compile(r'^[\w./-]*\.[A-Za-z][\w]{0,7}$|/') _FILE_LINE = re.compile(r':\d+(:\d+)?$') +FILE_EXTS = {'xml', 'yml', 'yaml', 'json', 'sql', 'sh', 'bash', 'dl', 'txt', 'md', 'html', 'htm', 'properties', 'toml', 'ini', + 'cfg', 'conf', 'env', 'csv', 'tsv', 'ftl', 'vm', 'jinja', 'j2', 'tmpl', 'mustache', 'hbs', 'py', 'java', 'cs', + 'ts', 'js', 'kt', 'gradle', 'ps1', 'bat', 'cmd', 'lock', 'cshtml', 'razor', 'resx', 'config', 'csproj'} def graph_dbs(repo): @@ -69,7 +72,10 @@ def needles(asked): word = lambda x: bool(re.fullmatch(r'\w+', x)) out = [(s, word(s))] last = re.split(r'[.#:$]', s)[-1] - if last and last != s and word(last) and len(last) >= 3: out.append((last, True)) + # the member of Owner.member; never the extension of a file name (`JobsMapper.xml` is not every line with 'xml') + if last and last != s and word(last) and len(last) >= 3 and last.lower() not in FILE_EXTS: out.append((last, True)) + base = s.rsplit('/', 1)[-1] + if '/' in s and re.search(r'\.[A-Za-z]\w{0,7}$', base) and len(base) >= 4: out.append((base, False)) # a path: code joins it return out @@ -140,24 +146,365 @@ def graph_file(self, rel): self.cache[rel] = found return found + # a member the parser generated (a Lombok accessor, an implicit constructor) spans its whole type from the type's first + # line: it holds every line of the type, and is not what any of them was written in. A synthesized
    $ is kept: + # it is where C# top-level statements are written + _REAL = """AND kind NOT IN ('module', 'library', 'written', 'file') + AND NOT (kind IN ('function', 'method', 'constructor') AND name NOT LIKE '<%' AND EXISTS (SELECT 1 FROM symbols t WHERE t.file = s.file + AND t.kind IN ('class', 'interface', 'enum', 'struct', 'record', 'type') AND t.line = s.line))""" + + def decl(self, rel, line, callable_only=False, kinds=None): + """(display, kind, method_id, name, line) of the innermost declaration around the line (callable_only: the + innermost function, method or constructor; kinds: the innermost of those kinds), or None""" + c, f = self.graph_file(rel) + if not c: return None + kinds = CALLABLE if callable_only else kinds + only = f"AND kind IN ({', '.join(repr(k) for k in kinds)})" if kinds else '' + try: + return c.execute(f"""SELECT display, kind, method_id, name, line FROM symbols s WHERE file = ? AND line <= ? AND end_line >= ? + {self._REAL} {only} ORDER BY line DESC, end_line ASC LIMIT 1""", (f, line, line)).fetchone() + except Exception: return None + + def below(self, rel, line, reach=6): + """the type or callable that starts within `reach` lines below: what an annotation or attribute line is written on""" + c, f = self.graph_file(rel) + if not c: return None + try: + return c.execute(f"""SELECT display, kind, method_id, name, line FROM symbols s WHERE file = ? AND line > ? AND line <= ? + {self._REAL} AND kind IN ({', '.join(repr(k) for k in CALLABLE + TYPES)}) + ORDER BY line ASC, end_line DESC LIMIT 1""", (f, line, line + reach)).fetchone() + except Exception: return None + def of(self, rel, line): """'in ' for the innermost declaration around the line, 'not indexed' for a file no graph holds, '' for a line in an indexed file outside every declaration""" c, f = self.graph_file(rel) if not c: return 'not indexed' - try: - r = c.execute("""SELECT display, kind FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? - AND kind NOT IN ('module', 'library', 'written', 'file') - ORDER BY line DESC, end_line ASC LIMIT 1""", (f, line, line)).fetchone() - except Exception: r = None + r = self.decl(rel, line) return f"in {r[0]}" if r else '' + def lang(self, rel): + """the language of the graph that holds the file, or None""" + c, _f = self.graph_file(rel) + if not c: return None + if id(c) not in self.cache: + try: r = c.execute("SELECT value FROM run WHERE key = 'language'").fetchone() + except Exception: r = None + self.cache[id(c)] = (r[0] or '').lower() if r else '' + return self.cache[id(c)] + + def is_test(self, rel): + """the graph's own test flag for the file (by its path), or the path rule when no graph holds it""" + c, f = self.graph_file(rel) + if c: + try: + r = c.execute("SELECT max(is_test) FROM symbols WHERE file = ?", (f,)).fetchone() + if r and r[0] is not None: return bool(r[0]) + except Exception: pass + return bool(TEST_PATH.search(rel)) + + def users(self, rel, method_id): + """(n, [display]) of the declarations the graph says call `method_id`, surest tiers only (never a by-name guess)""" + c, _f = self.graph_file(rel) + if not c or not method_id: return 0, [] + try: + rows = c.execute("""SELECT DISTINCT s.display FROM call_edges e JOIN symbols s ON s.method_id = e.caller_id + WHERE e.callee_method_id = ? AND e.tier NOT LIKE 'ambiguous%' AND e.caller_id != ? + ORDER BY s.is_test, s.display""", (method_id, method_id)).fetchall() + except Exception: return 0, [] + return len(rows), [r[0] for r in rows] + + +# ── [approx]: the code that holds a text match, placed in its declaration ─────────────────────────────────────────── +# A [text] row says WHERE a line is; the question behind it ("which code prints this message", "who runs build.sh", +# "who reads this table") wants the DECLARATION that does it, and what reaches that declaration. So a match that sits in +# code of an indexed file (not a comment, not a docstring, not a test) is answered by its enclosing declaration with the +# graph's callers of that declaration, labelled [approx]: found by text, placed by the graph, never a call edge. A match +# held by a constant or a field (OUT = HERE / "decls_all.dl") is followed one step, by name, to the code of the same +# graph that reads it. Every row keeps its evidence line. Nothing crosses languages: the declaration and its readers are +# looked up in the one graph that holds the file. +TEST_PATH = re.compile(r'(^|/)(tests?|__tests__|specs?|testdata|fixtures?)/|(^|/)test_[^/]*\.py$|_test\.py$|' + r'Tests?\.(java|kt|cs)$|IT\.java$|(^|/)conftest\.py$') +_CLIKE = {'.java', '.cs', '.kt', '.scala', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.go', '.c', '.h', '.cpp', '.cc', '.swift'} +APPROX_LANGS = ('python', 'java', 'csharp') # measured on these; another language's graph keeps its [text] rows +CALLABLE = ('function', 'method', 'constructor') +HOLDER = ('const', 'variable', 'field', 'property') +TYPES = ('class', 'interface', 'enum', 'struct', 'record', 'type') +ANNOTATION = re.compile(r'\s*(@[A-Za-z_][\w.]*\s*\(|\[[A-Za-z_][\w.]*\s*\()') # a Java/Python decoration, a C# attribute +RUNS = re.compile(r'\b(subprocess|Popen|check_call|check_output|os\.system|os\.exec\w*|os\.spawn\w*|execvp?|ProcessBuilder|' + r'getRuntime\(\)\.exec|Process\.Start|ProcessStartInfo)\b') +WRITES = re.compile(r'\b(write_text|write_bytes|FileWriter|FileOutputStream|Files\.write\w*|File\.Write\w*|StreamWriter|' + r'OpenWrite|File\.Create)\b|\bopen\([^)]*,\s*["\'][wax]') +READS = re.compile(r'\b(open|read_text|read_bytes|load|loads|safe_load|read_csv|read_sql\w*|execute|executemany|' + r'executescript|query|executeQuery|executeUpdate|prepareStatement|prepareCall|createQuery|createNativeQuery|' + r'ExecuteReader\w*|ExecuteNonQuery\w*|ExecuteScalar\w*|FromSqlRaw|SqlQuery|Query\w*|SqlCommand|readAllBytes|readAllLines|readString|FileReader|FileInputStream|getResource\w*|' + r'ReadAllText|ReadAllLines|ReadAllBytes|StreamReader|OpenRead|OpenText|Load|import_module|' + r'getenv|getProperty|GetEnvironmentVariable|GetValue|GetSection|get)\s*\(|\benviron\b|\bprocess\.env\b', re.I) +SQL = re.compile(r'\b(SELECT|INSERT|UPDATE|DELETE|MERGE)\b.*\b(FROM|INTO|SET|WHERE|USING)\b') # a query written in the code +EMITS = re.compile(r'\b(print|println|printf|log|logger|logging|warn\w*|error|info|debug|raise|throw|abort|exit|' + r'Console\.Write\w*|System\.(out|err)|std(out|err)|Write(Line)?|' + r'Log(Information|Warning|Error|Debug|Critical|Trace)(Async)?|_?logger\.\w+)\b', re.I) +RENDERS = re.compile(r'\b(render_template\w*|render_to_string|render|TemplateResponse|get_template|select_template|' + r'ModelAndView|View|PartialView|template_name)\b') +_RANK = {'runs it': 0, 'writes it': 1, 'reads it': 2, 'renders it': 2, 'emits it': 3, 'names it': 4} + + +def _verb(line): + if RUNS.search(line): return 'runs it' + if WRITES.search(line): return 'writes it' + if RENDERS.search(line): return 'renders it' + if READS.search(line) or SQL.search(line): return 'reads it' + if EMITS.search(line): return 'emits it' + return 'names it' + + +def _py_kinds(text): + """{line: [(col0, col1, kind)]} for the strings, docstrings and comments of a Python file (tokenize), or None""" + import io, tokenize + out = {} + def put(sr, sc, er, ec, kind): + for ln in range(sr, er + 1): + out.setdefault(ln, []).append((sc if ln == sr else 0, ec if ln == er else 1 << 30, kind)) + try: + toks = list(tokenize.generate_tokens(io.StringIO(text).readline)) + except (tokenize.TokenError, IndentationError, SyntaxError): return None + sig = [t for t in toks if t.type not in (tokenize.NL, tokenize.COMMENT)] + prev = {id(t): (sig[i - 1] if i else None) for i, t in enumerate(sig)} + nxt = {id(t): (sig[i + 1] if i + 1 < len(sig) else None) for i, t in enumerate(sig)} + fstart = getattr(tokenize, 'FSTRING_START', -1); fend = getattr(tokenize, 'FSTRING_END', -1) + open_f = [] + for t in toks: + if t.type == tokenize.COMMENT: put(*t.start, *t.end, 'comment') + elif t.type == fstart: open_f.append(t) + elif t.type == fend and open_f: + s = open_f.pop(); put(*s.start, *t.end, 'string') + elif t.type == tokenize.STRING: + p, n = prev.get(id(t)), nxt.get(id(t)) + # a string that is a statement on its own is a docstring (or a comment written as one): prose, not code + alone = (p is None or p.type in (tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT)) and \ + (n is None or n.type in (tokenize.NEWLINE, tokenize.ENDMARKER)) + put(*t.start, *t.end, 'doc' if alone else 'string') + return out + + +def _c_kinds(text): + """{line: [(col0, col1, kind)]} for the strings and comments of a C-family file (Java, C#): a small scanner, enough to + tell a literal from a comment; /** */ and /// are comments, a Java text block and a C# verbatim string are strings""" + out = {}; i, n, ln, col = 0, len(text), 1, 0 + def put(sl, sc, el, ec, kind): + for x in range(sl, el + 1): + out.setdefault(x, []).append((sc if x == sl else 0, ec if x == el else 1 << 30, kind)) + while i < n: + ch = text[i] + if ch == '\n': ln += 1; col = 0; i += 1; continue + two = text[i:i + 2] + if two == '//' or two == '/*' or ch in '"\'`': + if two == '//': + end = text.find('\n', i); end = n if end < 0 else end; kind = 'comment' + elif two == '/*': + end = text.find('*/', i + 2); end = n if end < 0 else end + 2; kind = 'comment' + else: + q = '"""' if text.startswith('"""', i) else ch + j = i + len(q); verbatim = i > 0 and text[i - 1] == '@' + while j < n: + if text[j] == '\\' and q != '"""' and not verbatim: j += 2; continue + if verbatim and text.startswith('""', j): j += 2; continue + if text.startswith(q, j): j += len(q); break + if text[j] == '\n' and q in '"\'' and not verbatim: break + j += 1 + end = min(j, n); kind = 'string' + seg = text[i:end]; nl = seg.count('\n') + eln = ln + nl; ecol = (len(seg) - seg.rfind('\n') - 1) if nl else col + len(seg) + put(ln, col, eln, ecol, kind) + ln, col, i = eln, ecol, end + continue + i += 1; col += 1 + return out + + +class Lexed: + """what kind of text each match sits in: code, string, comment or doc (a docstring), per file, read once""" + def __init__(self, repo): + self.repo, self.cache, self.text = repo, {}, {} + + def header(self, rel, a, b): + """lines a..b-1 of the file are all annotations, attributes, their continuations or blank: a declaration's header""" + if rel not in self.text: + try: + with open(os.path.join(self.repo, rel), encoding='utf-8', errors='replace') as fh: self.text[rel] = fh.read().split('\n') + except OSError: self.text[rel] = [] + ls = self.text[rel] + return b - a <= 8 and all(re.match(r'\s*($|@|\[|[)\]},"\'\w=\s]+[),\]]\s*$)', ls[i - 1]) for i in range(a, b) if 0 < i <= len(ls)) + + def kinds(self, rel): + if rel in self.cache: return self.cache[rel] + ext = os.path.splitext(rel)[1].lower(); k = None + try: + with open(os.path.join(self.repo, rel), encoding='utf-8', errors='replace') as fh: text = fh.read() + except OSError: text = None + if text is not None and len(text) < 4 << 20: + if ext in ('.py', '.pyi') or (not ext and text.startswith('#!') and 'python' in text[:80]): k = _py_kinds(text) + elif ext in _CLIKE: k = _c_kinds(text) + self.cache[rel] = k + return k + + def at(self, rel, line, col): + """'code', 'string', 'comment' or 'doc' at line:col, or None when the file is not lexed""" + k = self.kinds(rel) + if k is None: return None + for c0, c1, kind in k.get(line, ()): + if c0 <= col < c1: return kind + return 'code' + + def best(self, rel, line, text, needle, word): + """the kind of the most code-like occurrence of `needle` on the line: a string or code beats a comment""" + pat = re.compile((r'(? ([printed lines], the hits they answer). A hit is answered here when it sits in code of an indexed, non-test + file, inside a declaration: in a string literal when the name asked is a file (its path, as the code writes it), in + a string or in code otherwise. A comment, a docstring, a test and a file no graph reads stay [text] rows""" + used, found, holders, typed = set(), {}, [], {} + for h in hits: + f, ln, t = h + if places.lang(f) not in APPROX_LANGS or places.is_test(f): continue + k = lex.best(f, ln, t, needle, word) + if k is None or k in ('comment', 'doc'): continue + if filelike and k != 'string': continue + d = places.decl(f, ln) + if ANNOTATION.match(t) and not (d and d[1] in CALLABLE + TYPES and lex.header(f, d[4], ln)): + b = places.below(f, ln) # @TableName("orders") above its class + if b and lex.header(f, ln, b[4]): d = b + elif d and d[1] not in CALLABLE: d = places.decl(f, ln, True) or d # a local variable: its function + if d and d[1] in HOLDER and (d[3] or '').startswith('__'): + d = places.decl(f, ln, kinds=TYPES) or d # __tablename__ = "orders": the class + if not d: continue + if d[1] in CALLABLE: + found.setdefault((f, d[0]), {'decl': d, 'rows': []})['rows'].append((ln, t, None)); used.add(h) + elif d[1] in HOLDER and d[3] and re.fullmatch(r'\w+', d[3]) and len(holders) < 6: + holders.append((h, d)); used.add(h) + elif d[1] in TYPES and k == 'string' and ANNOTATION.match(t): + # written on the type itself: an annotation or attribute argument (@TableName("orders"), [Table("orders")]) + typed.setdefault((f, d[0]), (ln, t, d)); used.add(h) + # one step through a constant or a field that holds it: the code of the same graph that reads that name, in its own + # file, or elsewhere where it is written qualified (`cfg.OUT`, `Paths.OUT`) or imported + reached, common = set(), set() + for (f, ln, t), d in holders: + name = d[3] + c0 = places.graph_file(f)[0] + # a plain lowercase word (`id`, `name`, `path`) read by name is every other `id`: the step is not taken + if re.fullmatch(r'[a-z]+', name) or len(name) < 3: common.add((f, ln)); continue + # another file reads it only by a name that points at this holder: a module constant imported or qualified by its + # module (Python), a constant qualified by its type (`Paths.SEED`); a field is followed in its own file only + owner = (d[0].rsplit('.', 1)[0].rsplit('.', 1)[-1]) if '.' in d[0] else '' + if places.lang(f) == 'python' and d[1] in ('const', 'variable') and not owner: + other = re.compile(r'\.' + re.escape(name) + r'(?!\w)|\bimport\b.*\b' + re.escape(name) + r'\b') + elif d[1] == 'const' and owner: + other = re.compile(r'\b' + re.escape(owner) + r'\.' + re.escape(name) + r'(?!\w)|\bimport\s+static\b.*\.' + re.escape(name) + r'\b') + else: + other = None + for rf, rl, rt in grep(repo, name, True): + if (rf, rl) == (f, ln) or places.graph_file(rf)[0] is not c0 or places.is_test(rf): continue + if rf != f and not (other and other.search(rt)): continue + if lex.best(rf, rl, rt, name, True) != 'code': continue + d2 = places.decl(rf, rl, True) + if not d2 or d2[1] not in CALLABLE: continue + found.setdefault((rf, d2[0]), {'decl': d2, 'rows': []})['rows'].append((rl, rt, (name, f, ln, t))) + reached.add((f, ln)) + if not found and not holders and not typed: return [], used + verb = lambda r: _verb(r[1]) + ordered = sorted(found.items(), key=lambda kv: (min(_RANK[verb(r)] for r in kv[1]['rows']), kv[0][0], kv[1]['rows'][0][0])) + out = [] + if ordered or typed: + out.append(f"[approx] the code that {'names the file' if filelike else 'holds'} '{needle}': found as text, placed in its " + f"declaration by the graph; approximate, not call edges ({len(ordered) + len(typed)} declaration(s)):") + for (f, disp), v in ordered[:rows]: + rs = sorted(v['rows'], key=lambda r: (_RANK[verb(r)], r[0])) + ln, t, via = rs[0] + more = f" (+{len(rs) - 1} more line(s) in it)" if len(rs) > 1 else '' + out.append(f" [approx] {verb(rs[0])} {disp} {f}:{ln}{more} | {_cut(t, 100)}") + if via: out.append(f" via {via[0]}, which holds it at {via[1]}:{via[2]} | {_cut(via[3], 80)}") + n, who = places.users(f, v['decl'][2]) + out.append(f" reached from {n} caller(s) in the graph: {', '.join(who[:4])}{' …' if n > 4 else ''}" if n else + " no caller in the graph (an entry point, or reached only through what the graph cannot see)") + if len(ordered) > rows: out.append(f" … +{len(ordered) - rows} more declaration(s)") + for (f, disp), (ln, t, d) in list(typed.items())[:rows]: + out.append(f" [approx] {verb((ln, t, None))} {disp} ({d[1]}) {f}:{ln} | {_cut(t, 100)}") + out.append(f" written on the {d[1]} itself: `impact {disp}` for the code that uses it") + for (f, ln, t), d in holders: + if (f, ln) not in reached: + why = (f"not followed: '{d[3]}' is a common word, so its readers by name would be every other '{d[3]}'" if (f, ln) in common + else f"no code of this graph reads '{d[3]}' by that name") + out.append(f" [approx] held by {d[0]} {f}:{ln} | {_cut(t, 100)} ({why})") + return out, used + + +QUALIFIED = re.compile(r'(? 512 * 1024: return [] + with open(os.path.join(repo, rel), encoding='utf-8', errors='replace') as fh: text = fh.read() + except OSError: return [] + where = {} + for i, l in enumerate(text.split('\n'), 1): + for m in QUALIFIED.finditer(l): + where.setdefault(m.group(0), (i, l)) + if len(where) > 400: break + if not where: return [] + kinds = ', '.join(repr(k) for k in TYPES + CALLABLE) + got = [] + for c in places.cons: + try: lang = (c.execute("SELECT value FROM run WHERE key = 'language'").fetchone() or [''])[0].lower() + except Exception: lang = '' + if lang not in APPROX_LANGS: continue + qs = list(where) + for k in range(0, len(qs), 400): + part = qs[k:k + 400] + try: + got += [(r, where[r[4]]) for r in c.execute( + f"SELECT display, kind, file, line, qualified_name FROM symbols WHERE qualified_name IN ({', '.join('?' * len(part))}) " + f"AND kind IN ({kinds}) AND is_test = 0", part)] + except Exception: pass + if not got: return [] + got.sort(key=lambda g: g[1][0]) + out = [f"[approx] {rel} itself names {len(got)} declaration(s) of the graph by qualified name, which is what the file is bound to:"] + for (disp, kind, f, ln, _q), (fl, ft) in got[:rows]: + out.append(f" [approx] named by it {disp} ({kind}) {f}:{ln} | {rel}:{fl}: {_cut(ft, 80)}") + if len(got) > rows: out.append(f" … +{len(got) - rows} more") + return out + + +def unindexed_file(repo, name): + """the repository files a name asked about denotes when no graph reads them as source; [] when it is not such a file""" + s = name.strip().strip('"\'`').lstrip('./') + if not s or ' ' in s or not re.search(r'\.[A-Za-z]\w{0,7}$', s): return [] + fs = [f for f in files(repo) if f == s or f.endswith('/' + s)] + if not fs: return [] + places = Places(repo) + return [f for f in fs if not places.graph_file(f)[0]] + def block(repo, asked, scope=None, why='unresolved', rows=ROWS): """the text lines to print for names the graph could not answer (`asked`), searched under `scope` when some file lies there, else at the root (and said so). '' when there is nothing to search for""" lines = [] - places = None; rowed = False + places = lex = None; rowed = False; approxed = False for a in dict.fromkeys(asked): tries = needles(a) if not tries: continue @@ -165,12 +512,14 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): for n, w in tries: hits = grep(repo, n, w); used = (n, w) if hits: break + places = places or Places(repo); lex = lex or Lexed(repo) if why == 'string' and hits: # a STRING in a source file is written quoted; the same word bare there is an identifier (a field, a local) - # and another question. A file no graph reads keeps every mention: a YAML value is written bare - places = places or Places(repo) + # and another question. A file no graph reads keeps every mention: a YAML value is written bare. Inside a + # longer literal it is still the string (a table in "SELECT id FROM orders") quoted = re.compile(r'["\'`]' + re.escape(used[0]) + r'["\'`]') - hits = [h for h in hits if quoted.search(h[2]) or not places.graph_file(h[0])[0]] + hits = [h for h in hits if quoted.search(h[2]) or not places.graph_file(h[0])[0] + or lex.best(h[0], h[1], h[2], used[0], used[1]) == 'string'] under = '' if scope: inside = [h for h in hits if scope in h[0]] @@ -190,15 +539,25 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): if paths: lines.append(f"[text] files whose path holds '{tries[0][0]}' ({len(paths)}{'+' if len(paths) == rows else ''}):") lines += [f" [text] {f}" for f in paths] + # the code that holds it, placed in its declaration with what reaches that ([approx]); the rest stay [text]. A file + # no graph reads is also answered the other way round: the declarations the file itself names + ours = unindexed_file(repo, tries[0][0]) + ap, taken = approx(repo, hits, n, w, bool(ours), places, lex, rows) if hits else ([], set()) + if ours and not ap: + ap.append(f"[approx] no code outside the tests names '{n}' in a string literal: nothing the graph holds is seen " + f"to run, read or write {', '.join(ours[:3])} (a path built from pieces is not seen)") + for o in ours[:3]: ap += names_in_file(repo, o, places, rows) + lines += ap; approxed = approxed or any(re.match(r'\s+\[approx\] (\w+ it|named by it) ', l) for l in ap) if not hits: - if not paths and why not in ('string', 'undeclared'): + if not paths and not ap and why not in ('string', 'undeclared'): lines.append(f"[text] {head}, and no line of the repository's files writes it either" + (f" (nor '{n}')" if len(tries) > 1 else '') + ": absent from the text, not only from the graph.") continue - places = places or Places(repo) + hits = [h for h in hits if h not in taken] + if not hits: continue nfiles = len({h[0] for h in hits}) rowed = True - lines.append(f"[text] {head}; the lines that write it{also} — text, not call edges " + lines.append(f"[text] {head}; the {'other ' if taken else ''}lines that write it{also} — text, not call edges " f"({len(hits)} line(s) in {nfiles} file(s){under}):") shown, per = [], {} for f, ln, t in sorted(hits, key=lambda h: (h[0], h[1])): @@ -211,6 +570,11 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): order = firsts + [h for h in shown if h not in firsts] for f, ln, t in order[:rows]: where = places.of(f, ln) + if where != 'not indexed': + # why this line is not an [approx] row: said, so a comment or a test is not read as the answer + k = lex.best(f, ln, t, n, w) + tag = {'comment': 'a comment', 'doc': 'a docstring'}.get(k) or ('a test' if places.is_test(f) else '') + if tag: where = f"{where} · {tag}" if where else tag t = t.strip() t = t if len(t) <= 110 else t[:107] + '…' lines.append(f" [text] {f}:{ln}" + (f" {where}" if where else '') + f" | {t}") @@ -218,7 +582,9 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): if rest > 0: lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'") if not lines: return '' - if rowed and why == 'unresolved': lines.append("next: these [text] rows are leads, not resolved edges — open the one that fits; for code the graph " + if approxed: lines.append("next: [approx] rows are text placed in the declaration that holds it, not resolved edges: read the " + "evidence line, then `impact ` for what a change reaches") + elif rowed and why == 'unresolved': lines.append("next: these [text] rows are leads, not resolved edges — open the one that fits; for code the graph " "does hold, ask again by the name of the declaration a row sits in") return '\n'.join(lines) + '\n' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 81b1858d..730005c9 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1286,6 +1286,15 @@ def quoted_text(argv): if len(s) < 4 or s.startswith('/') or s in asked: continue # a route: the graph's route seeds answer it if ax_text.declared(repo, re.split(r'[.#:$]', re.sub(r'\(.*\)$', '', s))[-1]): continue asked.append(s) + # a file the task names that no graph reads (`who runs build.sh`, `what reads schema.sql`): the code that names it, + # placed in its declaration ([approx], ax_text.py), instead of only "not indexed: grep it directly" + for m in FILE_TOKEN.finditer(task_text(task)): + t = m.group(1) + if t not in asked and len(asked) < 3 and ax_text.unindexed_file(repo, t): asked.append(t) + # an environment variable or a constant-style key the task names bare (`who reads REDIS_URL`) and no graph declares + for m in re.finditer(r'(? 3 else ''}): it has no " + f"declaration and no call edge.\n The code that names it follows, found as text and placed in its declaration.") any_cfg = sum(g.q(f"SELECT count(*) FROM {t}")[0][0] for t in ('ext_config_affects_method', 'ext_config_binding', 'ext_config_key_ref') if g.has(t)) if not any_cfg: die(f"'{s}' looks like a configuration key, and this graph has no configuration facts (the engine's framework extension\n" diff --git a/tests/cases/csharp/approx-outside-graph/case.json b/tests/cases/csharp/approx-outside-graph/case.json new file mode 100644 index 00000000..1c8df659 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/case.json @@ -0,0 +1,34 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a script no graph reads is answered by the method that runs it (its path in a string literal), with the graph's callers of that method, labelled [approx]", + "run": ["impact", "rebuild.sh"], "expect_error": true, + "want": ["'rebuild.sh' is a file no graph reads as source", "[approx] runs it Jobs.Rebuild src/App/Jobs.cs:14", "reached from 1 caller(s) in the graph: Jobs.Nightly", "not call edges"], + "avoid": ["looks like a configuration key", "[resolved]", "[approx] no code outside the tests"]}, + {"why": "a file held by a const is followed one step to the method that reads the const", + "run": ["impact", "seed.sql"], "expect_error": true, + "want": ["[approx] reads it Jobs.LoadSeed src/App/Jobs.cs:24", "via Seed, which holds it at src/App/Jobs.cs:10"]}, + {"why": "a SQL table named only inside a command string lands on the method that runs it", + "run": ["impact", "order_lines"], "expect_error": true, + "want": ["[approx] reads it Jobs.CountLines src/App/Jobs.cs:29", "reached from 1 caller(s) in the graph: Jobs.Report", "[text] db/seed.sql:1 not indexed"]}, + {"why": "a message a task quotes lands on the method that throws it", + "run": ["context", "which code throws 'stock level is negative'"], "expect_error": true, + "want": ["[approx] emits it Jobs.CheckStock src/App/Jobs.cs:41", "reached from 1 caller(s) in the graph: Jobs.Restock"]}, + {"why": "a task that names a script finds the method that runs it", + "run": ["context", "who runs rebuild.sh"], "expect_error": true, + "want": ["[approx] runs it Jobs.Rebuild"]}, + {"why": "control: a message written only in a comment and in a test is not placed in any code; the rows say why", + "run": ["context", "which code prints 'stock went negative'"], "expect_error": true, + "want": ["in Jobs.CheckStock · a comment", "in JobsTests.Message · a test"], + "avoid": ["[approx]"]}, + {"why": "control: a script named only in an XML doc comment is not run by the class that documents it", + "run": ["impact", "cleanup.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'cleanup.sh'", "· a comment"], + "avoid": ["[approx] runs it", "[approx] names it", "[approx] reads it"]}, + {"why": "control: a script named only in a test is not an answer about the code", + "run": ["impact", "purge.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'purge.sh'", "in JobsTests.PurgeIsNotRun · a test"], + "avoid": ["[approx] runs it", "[approx] names it"]}, + {"why": "control: a declared method answers from the graph, with no [approx] rows", + "run": ["impact", "Jobs.Rebuild"], + "want": ["Jobs.Nightly"], + "avoid": ["[approx]"]}]} diff --git a/tests/cases/csharp/approx-outside-graph/db/seed.sql b/tests/cases/csharp/approx-outside-graph/db/seed.sql new file mode 100644 index 00000000..a17df4fa --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/db/seed.sql @@ -0,0 +1 @@ +CREATE TABLE order_lines(id INTEGER, shipped INTEGER); diff --git a/tests/cases/csharp/approx-outside-graph/scripts/cleanup.sh b/tests/cases/csharp/approx-outside-graph/scripts/cleanup.sh new file mode 100644 index 00000000..4d79c9e2 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/scripts/cleanup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo cleaning diff --git a/tests/cases/csharp/approx-outside-graph/scripts/purge.sh b/tests/cases/csharp/approx-outside-graph/scripts/purge.sh new file mode 100644 index 00000000..9919b860 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/scripts/purge.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo purging diff --git a/tests/cases/csharp/approx-outside-graph/scripts/rebuild.sh b/tests/cases/csharp/approx-outside-graph/scripts/rebuild.sh new file mode 100644 index 00000000..4c16df43 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/scripts/rebuild.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo rebuilding "$@" diff --git a/tests/cases/csharp/approx-outside-graph/src/App.Tests/JobsTests.cs b/tests/cases/csharp/approx-outside-graph/src/App.Tests/JobsTests.cs new file mode 100644 index 00000000..29eaea03 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/src/App.Tests/JobsTests.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace App.Tests +{ + public class JobsTests + { + [Fact] + public void PurgeIsNotRun() + { + var script = "purge.sh"; + new App.Jobs().Restock(1); + } + + [Fact] + public void Message() + { + var m = "stock went negative"; + } + } +} diff --git a/tests/cases/csharp/approx-outside-graph/src/App/Jobs.cs b/tests/cases/csharp/approx-outside-graph/src/App/Jobs.cs new file mode 100644 index 00000000..275686c5 --- /dev/null +++ b/tests/cases/csharp/approx-outside-graph/src/App/Jobs.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Data.SqlClient; + +namespace App +{ + public class Jobs + { + private const string Seed = "db/seed.sql"; + + public void Rebuild() + { + Process.Start("bash", "scripts/rebuild.sh --all"); + } + + public void Nightly() + { + Rebuild(); + } + + public string LoadSeed() + { + return File.ReadAllText(Seed); + } + + public int CountLines(SqlConnection c) + { + var cmd = new SqlCommand("SELECT count(*) FROM order_lines WHERE shipped = 1", c); + return (int)cmd.ExecuteScalar(); + } + + public void Report(SqlConnection c) + { + Console.WriteLine("shipped today: " + CountLines(c)); + } + + public int CheckStock(int n) + { + // a stock count below zero used to print "stock went negative" here + if (n < 0) throw new InvalidOperationException("stock level is negative"); + return n; + } + + /// Old notes: the nightly job also ran cleanup.sh, it no longer does. + public void Tidy() + { + } + + public int Restock(int n) + { + return CheckStock(n); + } + } +} diff --git a/tests/cases/java/approx-outside-graph/case.json b/tests/cases/java/approx-outside-graph/case.json new file mode 100644 index 00000000..4799c793 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/case.json @@ -0,0 +1,38 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a script no graph reads is answered by the method that runs it (its path in a string literal), with the graph's callers of that method, labelled [approx]", + "run": ["impact", "rebuild.sh"], "expect_error": true, + "want": ["'rebuild.sh' is a file no graph reads as source", "[approx] runs it Jobs.rebuild src/main/java/app/Jobs.java:12", "reached from 1 caller(s) in the graph: Jobs.nightly", "not call edges"], + "avoid": ["looks like a configuration key", "[resolved]", "[approx] no code outside the tests"]}, + {"why": "a file held by a static final constant is followed one step to the method that reads the constant", + "run": ["impact", "seed.sql"], "expect_error": true, + "want": ["[approx] reads it Jobs.loadSeed src/main/java/app/Jobs.java:20", "via SEED, which holds it at src/main/java/app/Jobs.java:9"]}, + {"why": "a SQL table named only inside a query string lands on the method that runs the query", + "run": ["impact", "order_lines"], "expect_error": true, + "want": ["[approx] reads it Jobs.countLines src/main/java/app/Jobs.java:24", "reached from 1 caller(s) in the graph: Jobs.report", "[text] db/seed.sql:1 not indexed"]}, + {"why": "a message a task quotes lands on the method that throws it", + "run": ["context", "which code throws 'stock level is negative'"], "expect_error": true, + "want": ["[approx] emits it Jobs.checkStock src/main/java/app/Jobs.java:34", "reached from 1 caller(s) in the graph: Jobs.restock"]}, + {"why": "a task that names a script finds the method that runs it", + "run": ["context", "who runs rebuild.sh"], "expect_error": true, + "want": ["[approx] runs it Jobs.rebuild"]}, + {"why": "a mapper XML no graph reads is answered the other way too: the declarations it names by qualified name", + "run": ["impact", "JobsMapper.xml"], "expect_error": true, + "want": ["itself names 1 declaration(s) of the graph by qualified name", "[approx] named by it Jobs (class) src/main/java/app/Jobs.java:8", "db/JobsMapper.xml:1"], + "avoid": ["as 'xml'"]}, + {"why": "control: a message written only in a comment and in a test is not placed in any code; the rows say why", + "run": ["context", "which code prints 'stock went negative'"], "expect_error": true, + "want": ["in Jobs.checkStock · a comment", "in JobsTest.message · a test"], + "avoid": ["[approx]"]}, + {"why": "control: a script named only in a Javadoc comment is not run by the class that documents it", + "run": ["impact", "cleanup.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'cleanup.sh'", "· a comment"], + "avoid": ["[approx] runs it", "[approx] names it", "[approx] reads it"]}, + {"why": "control: a script named only in a test is not an answer about the code", + "run": ["impact", "purge.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'purge.sh'", "in JobsTest.purgeIsNotRun · a test"], + "avoid": ["[approx] runs it", "[approx] names it"]}, + {"why": "control: a declared method answers from the graph, with no [approx] rows", + "run": ["impact", "Jobs.rebuild"], + "want": ["[resolved] Jobs.nightly"], + "avoid": ["[approx]"]}]} diff --git a/tests/cases/java/approx-outside-graph/db/JobsMapper.xml b/tests/cases/java/approx-outside-graph/db/JobsMapper.xml new file mode 100644 index 00000000..ad275665 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/db/JobsMapper.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/cases/java/approx-outside-graph/db/seed.sql b/tests/cases/java/approx-outside-graph/db/seed.sql new file mode 100644 index 00000000..a17df4fa --- /dev/null +++ b/tests/cases/java/approx-outside-graph/db/seed.sql @@ -0,0 +1 @@ +CREATE TABLE order_lines(id INTEGER, shipped INTEGER); diff --git a/tests/cases/java/approx-outside-graph/scripts/cleanup.sh b/tests/cases/java/approx-outside-graph/scripts/cleanup.sh new file mode 100644 index 00000000..4d79c9e2 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/scripts/cleanup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo cleaning diff --git a/tests/cases/java/approx-outside-graph/scripts/purge.sh b/tests/cases/java/approx-outside-graph/scripts/purge.sh new file mode 100644 index 00000000..9919b860 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/scripts/purge.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo purging diff --git a/tests/cases/java/approx-outside-graph/scripts/rebuild.sh b/tests/cases/java/approx-outside-graph/scripts/rebuild.sh new file mode 100644 index 00000000..4c16df43 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/scripts/rebuild.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo rebuilding "$@" diff --git a/tests/cases/java/approx-outside-graph/src/main/java/app/Jobs.java b/tests/cases/java/approx-outside-graph/src/main/java/app/Jobs.java new file mode 100644 index 00000000..eb396cf7 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/src/main/java/app/Jobs.java @@ -0,0 +1,45 @@ +package app; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.ResultSet; + +public class Jobs { + private static final String SEED = "db/seed.sql"; + + public void rebuild() throws Exception { + new ProcessBuilder("bash", "scripts/rebuild.sh", "--all").start(); + } + + public void nightly() throws Exception { + rebuild(); + } + + public String loadSeed() throws Exception { + return Files.readString(Paths.get(SEED)); + } + + public int countLines(Connection c) throws Exception { + ResultSet r = c.createStatement().executeQuery("SELECT count(*) FROM order_lines WHERE shipped = 1"); + return r.getInt(1); + } + + public void report(Connection c) throws Exception { + System.out.println("shipped today: " + countLines(c)); + } + + public int checkStock(int n) { + // a stock count below zero used to print "stock went negative" here + if (n < 0) throw new IllegalStateException("stock level is negative"); + return n; + } + + /** Old notes: the nightly job also ran cleanup.sh, it no longer does. */ + public void tidy() { + } + + public int restock(int n) { + return checkStock(n); + } +} diff --git a/tests/cases/java/approx-outside-graph/src/test/java/app/JobsTest.java b/tests/cases/java/approx-outside-graph/src/test/java/app/JobsTest.java new file mode 100644 index 00000000..c625d911 --- /dev/null +++ b/tests/cases/java/approx-outside-graph/src/test/java/app/JobsTest.java @@ -0,0 +1,12 @@ +package app; + +public class JobsTest { + public void purgeIsNotRun() { + String script = "purge.sh"; + new Jobs().restock(1); + } + + public void message() { + String m = "stock went negative"; + } +} diff --git a/tests/cases/python/approx-outside-graph/case.json b/tests/cases/python/approx-outside-graph/case.json new file mode 100644 index 00000000..c3d57841 --- /dev/null +++ b/tests/cases/python/approx-outside-graph/case.json @@ -0,0 +1,40 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a script no graph reads is answered by the function that runs it (its path in a string literal), with the graph's callers of that function, labelled [approx]", + "run": ["impact", "rebuild.sh"], "expect_error": true, + "want": ["'rebuild.sh' is a file no graph reads as source", "[approx] runs it rebuild src/jobs.py:9", "reached from 1 caller(s) in the graph: nightly", "not call edges"], + "avoid": ["looks like a configuration key", "[resolved]", "[approx] no code outside the tests"]}, + {"why": "a file held by a module constant is followed one step to the function that reads the constant", + "run": ["impact", "seed.sql"], "expect_error": true, + "want": ["[approx] reads it load_seed src/jobs.py:17", "via SEED, which holds it at src/jobs.py:5"]}, + {"why": "a SQL table named only inside a query string lands on the function that runs the query", + "run": ["impact", "order_lines"], "expect_error": true, + "want": ["[approx] reads it count_lines src/jobs.py:21", "reached from 1 caller(s) in the graph: report", "[text] src/seed.sql:1 not indexed"]}, + {"why": "a message a task quotes lands on the function that raises it", + "run": ["context", "which code raises 'stock level is negative'"], "expect_error": true, + "want": ["[approx] emits it check_stock src/jobs.py:31", "reached from 1 caller(s) in the graph: restock"]}, + {"why": "a task that names a script finds the function that runs it, not only 'not indexed'", + "run": ["context", "who runs rebuild.sh"], "expect_error": true, + "want": ["[approx] runs it rebuild src/jobs.py:9"]}, + {"why": "path to a script gets the same rows", + "run": ["path", "*", "rebuild.sh"], "expect_error": true, + "want": ["[approx] runs it rebuild"]}, + {"why": "an environment variable a task names bare lands on the function that reads it", + "run": ["context", "who reads JOBS_DB_URL"], "expect_error": true, + "want": ["[approx] reads it db_url src/jobs.py:"]}, + {"why": "control: a message written only in a comment and in a test is not placed in any code; the rows say why", + "run": ["context", "which code prints 'stock went negative'"], "expect_error": true, + "want": ["in check_stock · a comment", "in test_message · a test"], + "avoid": ["[approx]"]}, + {"why": "control: a script named only in a docstring is not run by the function that documents it", + "run": ["impact", "cleanup.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'cleanup.sh'", "in tidy · a docstring"], + "avoid": ["[approx] runs it", "[approx] names it", "[approx] reads it"]}, + {"why": "control: a script named only in a test is not an answer about the code", + "run": ["impact", "purge.sh"], "expect_error": true, + "want": ["[approx] no code outside the tests names 'purge.sh'", "in test_purge_is_not_run · a test"], + "avoid": ["[approx] runs it", "[approx] names it"]}, + {"why": "control: a declared function answers from the graph, with no [approx] rows", + "run": ["impact", "rebuild"], + "want": ["[resolved] nightly"], + "avoid": ["[approx]"]}]} diff --git a/tests/cases/python/approx-outside-graph/scripts/cleanup.sh b/tests/cases/python/approx-outside-graph/scripts/cleanup.sh new file mode 100644 index 00000000..4d79c9e2 --- /dev/null +++ b/tests/cases/python/approx-outside-graph/scripts/cleanup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo cleaning diff --git a/tests/cases/python/approx-outside-graph/scripts/purge.sh b/tests/cases/python/approx-outside-graph/scripts/purge.sh new file mode 100644 index 00000000..9919b860 --- /dev/null +++ b/tests/cases/python/approx-outside-graph/scripts/purge.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo purging diff --git a/tests/cases/python/approx-outside-graph/scripts/rebuild.sh b/tests/cases/python/approx-outside-graph/scripts/rebuild.sh new file mode 100644 index 00000000..4c16df43 --- /dev/null +++ b/tests/cases/python/approx-outside-graph/scripts/rebuild.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo rebuilding "$@" diff --git a/tests/cases/python/approx-outside-graph/src/jobs.py b/tests/cases/python/approx-outside-graph/src/jobs.py new file mode 100644 index 00000000..f3d2c28a --- /dev/null +++ b/tests/cases/python/approx-outside-graph/src/jobs.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path + +HERE = Path(__file__).parent +SEED = HERE / "seed.sql" + + +def rebuild(): + subprocess.run(["bash", "scripts/rebuild.sh", "--all"], check=True) + + +def nightly(): + rebuild() + + +def load_seed(db): + db.executescript(SEED.read_text()) + + +def count_lines(db): + return db.execute("SELECT count(*) FROM order_lines WHERE shipped = 1").fetchone()[0] + + +def report(db): + print("orders shipped today:", count_lines(db)) + + +def check_stock(n): + # a stock count below zero used to print "stock went negative" here + if n < 0: + raise ValueError("stock level is negative") + return n + + +def tidy(): + """Old notes: the nightly job also ran cleanup.sh, it no longer does.""" + return None + + +def restock(db, n): + return check_stock(n) + + +def db_url(): + import os + return os.environ["JOBS_DB_URL"] diff --git a/tests/cases/python/approx-outside-graph/src/seed.sql b/tests/cases/python/approx-outside-graph/src/seed.sql new file mode 100644 index 00000000..a17df4fa --- /dev/null +++ b/tests/cases/python/approx-outside-graph/src/seed.sql @@ -0,0 +1 @@ +CREATE TABLE order_lines(id INTEGER, shipped INTEGER); diff --git a/tests/cases/python/approx-outside-graph/src/tests/test_jobs.py b/tests/cases/python/approx-outside-graph/src/tests/test_jobs.py new file mode 100644 index 00000000..fbc2b0ae --- /dev/null +++ b/tests/cases/python/approx-outside-graph/src/tests/test_jobs.py @@ -0,0 +1,9 @@ +from jobs import rebuild + + +def test_purge_is_not_run(): + assert "purge.sh" not in str(rebuild) + + +def test_message(): + assert "stock went negative" != "" diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/case.json b/tests/cases/python/text-when-the-graph-has-no-answer/case.json index 0d1a9750..6f4cd9bc 100644 --- a/tests/cases/python/text-when-the-graph-has-no-answer/case.json +++ b/tests/cases/python/text-when-the-graph-has-no-answer/case.json @@ -1,8 +1,8 @@ {"lang": "python", "src": "src", "checks": [ - {"why": "a name no graph declares (an environment variable) is answered with the lines that write it, each labelled [text], the one in code placed in its declaration and the others marked not indexed", + {"why": "a name no graph declares (an environment variable) is answered with the lines that write it: the one in code as [approx] in its declaration with that declaration's callers, the others [text] and marked not indexed", "run": ["impact", "WIDGET_SECRET_KEY"], "expect_error": true, - "want": ["nothing named 'WIDGET_SECRET_KEY'", "[text] the graph has no declaration for 'WIDGET_SECRET_KEY'", "[text] deploy.yml:2 not indexed", "[text] src/widgets.py:5 in load_settings", "[text] NOTES.md:1", "not call edges"], + "want": ["nothing named 'WIDGET_SECRET_KEY'", "[text] the graph has no declaration for 'WIDGET_SECRET_KEY'", "[text] deploy.yml:2 not indexed", "[approx] reads it load_settings src/widgets.py:5", "reached from 1 caller(s) in the graph: handler", "[text] NOTES.md:1", "not call edges"], "avoid": ["[resolved]", "reads or uses it"]}, {"why": "a name written in no source file is searched all the same: here only the case file itself holds it, and says so as text", "run": ["path", "noSuchWidgetThing", "*"], "expect_error": true, @@ -10,10 +10,10 @@ "avoid": ["[text] src/", "[text] NOTES.md"]}, {"why": "path with an undeclared endpoint gets the same [text] rows", "run": ["path", "handler", "WIDGET_SECRET_KEY"], "expect_error": true, - "want": ["[text] src/widgets.py:5 in load_settings"]}, + "want": ["[approx] reads it load_settings src/widgets.py:5"]}, {"why": "a message the task quotes is searched as text and placed in the declaration that raises it", "run": ["context", "why does it raise \"widget went missing\" for a known id"], - "want": ["[text] no declaration is named 'widget went missing'", "[text] src/widgets.py:10 in find_widget", "[text] NOTES.md:1"]}, + "want": ["[text] no declaration is named 'widget went missing'", "[approx] emits it find_widget src/widgets.py:10", "[text] NOTES.md:1"]}, {"why": "a quoted string asked of impact is answered from its literal sites and then from the lines that write it", "run": ["impact", "\"widget went missing\""], "want": ["[string]", "[text] 'widget went missing' is a string", "[text] NOTES.md:1 not indexed"]}, From 271f024e140eca76d74e063508a03133075f9e4b Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:39:47 -0700 Subject: [PATCH 019/258] text search: match a phrase split by an interpolation; directory words are path segments The text tier (the [text] rows, and the [approx] rows placed in a declaration) searched a quoted phrase as one literal run of text. A message written with a value between its words, such as a Python f-string with {scope} between them, a C# $"..." string, a Java String.format or MessageFormat pattern, or a string concatenation, is on no line in that form, so impact, path and context said no line of the repository writes it while the code that prints it was one line away. Measured on the plugin's own scripts: path '*' 'has in its path' returned one comment row and no code. test-impact's text tier for a changed data file under a test tree also names the file's directories ('cases', 'derived', 'java') and matched them as a bare quoted or slash-bounded word in every test file. A changed case directory matched 18 test files that only mention some other 'cases' (another tree's path, a @MethodSource("cases") value, a dict key, a comment). The change: - ax_text.py: a phrase of two or more words is also searched with each gap between two words allowed to hold an interpolation ({..}, %s / %1$s / %(name)s, {0}, " + expr + ", "a " + " b"), quoted or not, on one line; when that finds nothing, one inner word may stand for such a hole (the message quoted with a value in it). The search runs even when a doc or issue writes the phrase as asked, since that line is rarely the code. A gap holds only whitespace and interpolations, so the words of two separate literals do not match. The header says how the phrase was matched, and the [approx] and [text] placement reads the matched span, not the literal phrase. - axiomcode-test-impact: a directory name counts only where the test writes it as that directory, inside a string literal: the segments written before it must be the ones that hold it in the changed file's path and the ones after it (globs allowed) what lies below it. Written alone it must sit in a path-building call (join, Path, Paths.get, Path.Combine, glob, listdir, ...), with the quoted arguments beside it read as its neighbouring segments, and the test must lie at or above that directory (or one level below its parent, a runner in tools/). File names and path tails are matched as before. Tests: - tests/cases/{python,java,csharp}/text-interpolated-phrase: f-string, %-format, concatenation (Python); String.format, concatenation, MessageFormat (Java); $"...", string.Format, concatenation (C#); the message quoted with a value in place of the hole; a doc quoting the phrase as read does not hide the code. Near-miss controls: the words of a phrase in two separate literals are not matched, and a phrase no line writes stays absent. - tests/test_command.py: a data file under cases/ is named by the runner beside it, a glob below it and its full path, and not by another tree's path, an annotation value, a dict key, a comment or a join under another root. Suites: tests/run.py --lang python, --lang java, --lang csharp; tests/test_command.py. python 202 of 203 (the one failure, lambda-is-named-by-its-place, fails the same way on the 0.1.8 tip), java 202 of 202, csharp 70 of 73 (one pending check, and two failures, lambda-is-named-by-its-place and member-owner-is-its-type's pending marker, the same on the 0.1.8 tip). Smoke (a fresh index of a copy, before vs after, one phrase each): - a Python HTTP client: a two-word error prefix with an f-string hole between the words: before, no line writes it; after, one [approx] row in the exception class's string method. - a Java library: a four-word phrase split by a string concatenation: before, no line writes it; after, one [approx] row in the method that throws it. - a C# library: a four-word phrase in a $"..." string: before, no line writes it; after, one [approx] row in the method that throws it. - controls on the same copies: a phrase no line writes stays absent in Java and C#, and three phrases written literally in the Python copy give the same row counts before and after (1, 3, 0). - test-impact on a changed Java case directory (9 files): 18 test files named by directory words before, 2 after, both naming the changed file by its own name. Based on the approx-outside-graph commit (2cde2458) rebased onto 0.1.8, and sits on top of it; the text code it changes is that commit's. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/ax_text.py | 87 ++++++++++++++++--- .../axiomcode/scripts/axiomcode-test-impact | 66 +++++++++++++- .../csharp/text-interpolated-phrase/case.json | 82 +++++++++++++++++ .../text-interpolated-phrase/docs/NOTES.md | 1 + .../src/App/Messages.cs | 29 +++++++ .../java/text-interpolated-phrase/case.json | 82 +++++++++++++++++ .../text-interpolated-phrase/docs/NOTES.md | 1 + .../src/main/java/app/Messages.java | 23 +++++ .../python/text-interpolated-phrase/case.json | 82 +++++++++++++++++ .../text-interpolated-phrase/docs/NOTES.md | 1 + .../text-interpolated-phrase/src/messages.py | 16 ++++ tests/test_command.py | 23 +++++ 12 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 tests/cases/csharp/text-interpolated-phrase/case.json create mode 100644 tests/cases/csharp/text-interpolated-phrase/docs/NOTES.md create mode 100644 tests/cases/csharp/text-interpolated-phrase/src/App/Messages.cs create mode 100644 tests/cases/java/text-interpolated-phrase/case.json create mode 100644 tests/cases/java/text-interpolated-phrase/docs/NOTES.md create mode 100644 tests/cases/java/text-interpolated-phrase/src/main/java/app/Messages.java create mode 100644 tests/cases/python/text-interpolated-phrase/case.json create mode 100644 tests/cases/python/text-interpolated-phrase/docs/NOTES.md create mode 100644 tests/cases/python/text-interpolated-phrase/src/messages.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py index 8b18aa98..a4b5fe2b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -125,6 +125,59 @@ def grep(repo, needle, word): return hits +# ── a phrase written with a value between its words ──────────────────────────────────────────────────────────────── +# A message is rarely written as the words an agent reads: `f"no graph holds '{name}' under the root"` (Python), +# `$"no graph holds '{name}' under the root"` (C#), `String.format("no graph holds '%s' under the root", name)` or +# `"no graph holds '" + name + "' under the root"` (Java). Searched as one literal run of text, "holds under the root" +# is on no line, and the answer said "no line of the repository's files writes it". So a phrase of several words that no +# line writes as asked is searched again with each gap between two words allowed to hold an interpolation (a {..} +# hole, a %s / %1$s / %(name)s placeholder, a " + expr + " concatenation), quoted or not; and, when that finds nothing +# either, with one of its inner words standing for such a hole (the agent quoted the message with a value in it). Both stay +# on one line, and a gap holds only whitespace and interpolations: two literals that happen to hold the words apart +# (`log("stock level"); ... ; warn("is negative")`) are not one phrase. +_Q = r'(?:\\?["\'`])?' +_SLOT = (r'(?:\{[^{}\n]{1,60}\}' # {scope} {0} {x:N2} {x!r} + r'|%(?:\(\w+\))?[-#+ 0,(]*\d*(?:\.\d+)?[a-zA-Z]|%\d+\$[a-zA-Z]' # %s %-5d %(name)s %1$s + r'|["\']\s*\+\s*[^"\'\n;]{1,60}?\s*\+\s*["\']' # " + scope + " + r'|["\']\s*\+\s*["\'])') # "a " + " b": one message in two pieces +_HOLE = _Q + _SLOT + _Q +_GAP = r'(?:\s|' + _HOLE + r')+' + + +def _words(needle): + ws = needle.split() + return ws if len(ws) >= 2 and sum(1 for w in ws if re.search(r'\w', w)) >= 2 else [] + + +def phrase_patterns(needle): + """[(compiled pattern, how it differs from the phrase as asked)] to try in turn for a phrase no line writes as + asked: its words with an interpolation allowed in each gap, then (three words or more) one inner word standing for one""" + ws = _words(needle) + if not ws: return [] + out = [(re.compile(_GAP.join(re.escape(w) for w in ws)), 'with an interpolation between its words')] + if len(ws) >= 3: + # an inner word only: the words at both ends stay literal, so the phrase is still anchored on what was asked + alts = [_GAP.join(_HOLE if j == i else re.escape(w) for j, w in enumerate(ws)) for i in range(1, len(ws) - 1)] + out.append((re.compile('|'.join(f'(?:{a})' for a in alts)), 'with an interpolation in place of one word')) + return out + + +def grep_phrase(repo, needle, have=()): + """-> ([(file, line, text)], pattern, how) for the first of phrase_patterns(needle) that some line other than those + in `have` (the lines that write it as asked) matches, else ([], None, ''). The lines are found by the two longest + words (any variant keeps one of them), then matched whole""" + pats = phrase_patterns(needle) + if not pats: return [], None, '' + keys = sorted({re.sub(r'^\W+|\W+$', '', w) for w in _words(needle)} - {''}, key=len, reverse=True)[:2] + cand = {} + for k in keys: + for h in grep(repo, k, False): cand[(h[0], h[1])] = h + for pat, how in pats: + hits = [h for _k, h in sorted(cand.items()) if h not in have and pat.search(h[2])] + if hits: return hits, pat, how + return [], None, '' + + class Places: """the declaration that holds a line, in whichever graph holds its file""" def __init__(self, repo): @@ -357,8 +410,10 @@ def at(self, rel, line, col): return 'code' def best(self, rel, line, text, needle, word): - """the kind of the most code-like occurrence of `needle` on the line: a string or code beats a comment""" - pat = re.compile((r'(? ([printed lines], the hits they answer). A hit is answered here when it sits in code of an indexed, non-test file, inside a declaration: in a string literal when the name asked is a file (its path, as the code writes it), in a string or in code otherwise. A comment, a docstring, a test and a file no graph reads stay [text] rows""" @@ -379,7 +434,7 @@ def approx(repo, hits, needle, word, filelike, places, lex, rows=ROWS): for h in hits: f, ln, t = h if places.lang(f) not in APPROX_LANGS or places.is_test(f): continue - k = lex.best(f, ln, t, needle, word) + k = lex.best(f, ln, t, pat or needle, word) if k is None or k in ('comment', 'doc'): continue if filelike and k != 'string': continue d = places.decl(f, ln) @@ -427,7 +482,7 @@ def approx(repo, hits, needle, word, filelike, places, lex, rows=ROWS): ordered = sorted(found.items(), key=lambda kv: (min(_RANK[verb(r)] for r in kv[1]['rows']), kv[0][0], kv[1]['rows'][0][0])) out = [] if ordered or typed: - out.append(f"[approx] the code that {'names the file' if filelike else 'holds'} '{needle}': found as text, placed in its " + out.append(f"[approx] the code that {'names the file' if filelike else 'holds'} '{needle}'{f' ({how})' if how else ''}: found as text, placed in its " f"declaration by the graph; approximate, not call edges ({len(ordered) + len(typed)} declaration(s)):") for (f, disp), v in ordered[:rows]: rs = sorted(v['rows'], key=lambda r: (_RANK[verb(r)], r[0])) @@ -508,25 +563,32 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): for a in dict.fromkeys(asked): tries = needles(a) if not tries: continue - hits, used = [], None - for n, w in tries: + hits, used, pat, how = [], None, None, '' + for i, (n, w) in enumerate(tries): hits = grep(repo, n, w); used = (n, w) + if i == 0 and not w and _words(n): + # a message written with a value between its words (an f-string, $"...", String.format, a concatenation). + # Searched even when some line writes it as asked: that line is often a doc or an issue quoting the + # message, and the code that prints it is the line with the hole + extra, p2, h2 = grep_phrase(repo, n, set(hits)) + if extra: hits, pat, how = hits + extra, p2, h2 if hits: break places = places or Places(repo); lex = lex or Lexed(repo) + at = pat or used[0] # what to look for on a matched line: the phrase, or the pattern that found it if why == 'string' and hits: # a STRING in a source file is written quoted; the same word bare there is an identifier (a field, a local) # and another question. A file no graph reads keeps every mention: a YAML value is written bare. Inside a # longer literal it is still the string (a table in "SELECT id FROM orders") quoted = re.compile(r'["\'`]' + re.escape(used[0]) + r'["\'`]') hits = [h for h in hits if quoted.search(h[2]) or not places.graph_file(h[0])[0] - or lex.best(h[0], h[1], h[2], used[0], used[1]) == 'string'] + or lex.best(h[0], h[1], h[2], at, used[1]) == 'string'] under = '' if scope: inside = [h for h in hits if scope in h[0]] if inside: hits = inside; under = f", under {scope}" elif hits: under = f"; none under {scope}, so these are from the whole repository" n, w = used - also = f" (as '{n}')" if n != tries[0][0] else '' + also = f" (as '{n}')" if n != tries[0][0] else (f" ({how})" if how else '') # a name that reads as a file: the files whose path holds it come first, which is what the search by hand was for paths = [] if _FILE_LIKE.search(tries[0][0]) and ' ' not in tries[0][0]: @@ -542,7 +604,7 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): # the code that holds it, placed in its declaration with what reaches that ([approx]); the rest stay [text]. A file # no graph reads is also answered the other way round: the declarations the file itself names ours = unindexed_file(repo, tries[0][0]) - ap, taken = approx(repo, hits, n, w, bool(ours), places, lex, rows) if hits else ([], set()) + ap, taken = approx(repo, hits, n, w, bool(ours), places, lex, rows, pat, how) if hits else ([], set()) if ours and not ap: ap.append(f"[approx] no code outside the tests names '{n}' in a string literal: nothing the graph holds is seen " f"to run, read or write {', '.join(ours[:3])} (a path built from pieces is not seen)") @@ -572,7 +634,7 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): where = places.of(f, ln) if where != 'not indexed': # why this line is not an [approx] row: said, so a comment or a test is not read as the answer - k = lex.best(f, ln, t, n, w) + k = lex.best(f, ln, t, at, w) tag = {'comment': 'a comment', 'doc': 'a docstring'}.get(k) or ('a test' if places.is_test(f) else '') if tag: where = f"{where} · {tag}" if where else tag t = t.strip() @@ -580,7 +642,8 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): lines.append(f" [text] {f}:{ln}" + (f" {where}" if where else '') + f" | {t}") rest = len(hits) - min(len(order), rows) if rest > 0: - lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'") + lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'" if not pat else + f" … +{rest} more line(s): git grep -nP -e '{pat.pattern}'") if not lines: return '' if approxed: lines.append("next: [approx] rows are text placed in the declaration that holds it, not resolved edges: read the " "evidence line, then `impact ` for what a change reaches") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 3c47ddb6..af4c3c8e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1655,6 +1655,63 @@ def needles(rel, program=False): return [n for n in dict.fromkeys(out) if len(n) > 3] +_JOINS = re.compile(r'\b(?:join|joinpath|Path|Paths\.get|Path\.of|Combine|File|Directory\w*|glob|iglob|rglob|listdir|scandir|' + r'iterdir|walk|resolve|getResource\w*|Get(?:Files|Directories))\s*\(|\s/\s*["\']') + + +def names_dir(rel, d, t, text): + """a test `t` names the directory `d` of the changed file `rel` in `text` as that directory: written as a path + segment in a string literal (quoted alone, or inside a path), where the path written around it agrees with where `rel` lies. A path + segment is not a phrase match: `cases` inside `tests/cases/python/...` or a `@MethodSource("cases")` names another + `cases`. So the segments written before it must be the ones that hold it in `rel` (a placeholder such as `{ROOT}` + or a leading `/` stands for any root), and the ones written after it must be what lies below it in `rel` (a glob + such as `*` or `*.json` must match). Written with nothing before it (`os.path.join(HERE, 'cases')`), it counts only + for a test that lies above that directory, or one level below its parent: a runner joins its own directory (or the + one above it) to the name""" + import fnmatch + parts = rel.split('/') + for j in (i for i, p in enumerate(parts[:-1]) if p == d): + for m in re.finditer(r'(? 0 and re.match(r'[\w.*{}$%/-]', text[a - 1]): a -= 1 + while b < len(text) and re.match(r'[\w.*{}$%/-]', text[b]): b += 1 + tok = text[a:b] + quoted = a > 0 and text[a - 1] in '"\'`' and b < len(text) and text[b] in '"\'`/' + if '/' not in tok and not quoted: continue + # inside a string literal on its line (an open quote before it): `test/java's` in a comment is prose + lead = text[text.rfind('\n', 0, a) + 1:a].replace('\\"', '').replace("\\'", '') + if not any(lead.count(q) % 2 for q in '"\'`'): continue + segs = tok.split('/'); k = len(tok[:m.start() - a].split('/')) - 1 + if segs[k] != d: continue + before = [x for x in segs[:k] if x not in ('', '.', '..')] + while before and re.search(r'[{}$%*]', before[0]): before.pop(0) + after = segs[k + 1:] + if after and after[-1] == '': after.pop() + if '/' not in tok: + # a name quoted alone is a path segment only where the line builds a path (os.path.join, Path(...), + # Paths.get, Path.Combine, a glob): a dict key 'python' or @MethodSource("cases") is a value. The quoted + # arguments beside it are its neighbouring segments (join(ROOT, 'tests', 'cases')) + line = lead + text[a:text.find('\n', b) if text.find('\n', b) >= 0 else len(text)] + if not _JOINS.search(line): continue + pre = re.search(r'((?:["\'][\w.-]+["\']\s*,\s*)+)["\']$', lead) + before = [x for x in re.findall(r'["\']([\w.-]+)["\']', pre.group(1)) if x not in ('.', '..')] if pre else [] + post = re.match(r'["\']((?:\s*,\s*["\'][\w.*-]+["\'])+)', text[b:]) + after = re.findall(r'["\']([\w.*-]+)["\']', post.group(1)) if post else [] + if before: + if any(re.search(r'[{}$%]', x) for x in before): continue + if len(before) > j or not all(fnmatch.fnmatchcase(p, x) for p, x in zip(parts[j - len(before):j], before)): continue + else: + # its own directory, or the one above it (a runner in tools/ joining its parent to 'cases'); never the + # repository root by being one level down + held = '/'.join(parts[:j]) + homes = [os.path.dirname(t)]; homes += [os.path.dirname(homes[0])] if os.path.dirname(homes[0]) else [] + if not any(h == '' or held == h or held.startswith(h + '/') for h in homes): continue + below = parts[j + 1:] + if len(after) > len(below) or not all(re.search(r'[{}$%]', x) or fnmatch.fnmatchcase(p, x) for p, x in zip(below, after)): continue + return True + return False + + def loaders(repo, wanted, edited=(), lang=None, others=None): """{changed file: (the needle that matched, [test files naming it])} for files no call edge can reach a test from: changed files outside every indexed language, and code that also runs as a program. The most specific needle any @@ -1665,6 +1722,8 @@ def loaders(repo, wanted, edited=(), lang=None, others=None): test module the runner collects outranks a helper or fixture beside it that names the file more fully.""" if not wanted: return {} want = {f: needles(f, prog) for f, prog in wanted.items()} + # the needles that are one directory of the file (data under a test tree), matched as that directory: names_dir + dirs = {f: {n for n in ns if '/' not in n and n in f.split('/')[:-1] and n != os.path.basename(f)} for f, ns in want.items()} # a path or a file name stands alone (not inside a longer name); a bare word (a directory, a program's name) only # where the text quotes it or puts it in a path, or `cases` would match every sentence about test cases pats = {n: re.compile((r'(? needle index -> [tests] runs = {f: {} for f in wanted} # the same, for test modules the runner collects + def said(f, n, t, text): + if n not in text or not re.search(pats[n], text): return False + return n not in dirs[f] or names_dir(f, n, t, text) for t, text in test_texts(repo): if t in wanted or t in edited: continue if fam and family(t) != fam: if others is not None: for f, ns in want.items(): - if any(n in text and re.search(pats[n], text) for n in ns): others.setdefault(f, set()).add(t) + if any(said(f, n, t, text) for n in ns): others.setdefault(f, set()).add(t) continue for f, ns in want.items(): for k, n in enumerate(ns): - if n in text and re.search(pats[n], text): + if said(f, n, t, text): hits[f].setdefault(k, []).append(t) if fam and collected_by(lang, t): runs[f].setdefault(k, []).append(t) break diff --git a/tests/cases/csharp/text-interpolated-phrase/case.json b/tests/cases/csharp/text-interpolated-phrase/case.json new file mode 100644 index 00000000..b502bf99 --- /dev/null +++ b/tests/cases/csharp/text-interpolated-phrase/case.json @@ -0,0 +1,82 @@ +{ + "lang": "csharp", + "src": "src", + "checks": [ + { + "why": "a message written with an interpolated $\"...\" string hole between its words is found, and placed in the code that throws it; a doc quoting it as read does not hide the code", + "run": [ + "context", + "which code throws 'has in its path'" + ], + "expect_error": true, + "want": [ + "[approx] emits it Messages.RequireScope src/App/Messages.cs:", + "(with an interpolation between its words)", + "[text] docs/NOTES.md:1 not indexed" + ], + "avoid": [ + "no line of the repository's files writes it" + ] + }, + { + "why": "placeholders written with string.Format are gaps too", + "run": [ + "context", + "which code returns 'quota for is exhausted after requests'" + ], + "expect_error": true, + "want": [ + "[approx] names it Messages.Quota src/App/Messages.cs:" + ] + }, + { + "why": "a message built with a concatenation", + "run": [ + "context", + "which code returns 'the archive was never sealed'" + ], + "expect_error": true, + "want": [ + "[approx] names it Messages.Sealed src/App/Messages.cs:" + ] + }, + { + "why": "the message as printed, a value where the hole is, finds the same code", + "run": [ + "path", + "*", + "no indexed file has src in its path" + ], + "expect_error": true, + "want": [ + "[approx] emits it Messages.RequireScope", + "(with an interpolation in place of one word)" + ] + }, + { + "why": "control: the words of a phrase written in two separate literals apart are not the phrase", + "run": [ + "context", + "which code returns 'the ledger is out of balance'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "Messages.FarApart", + "src/App/Messages.cs:" + ] + }, + { + "why": "control: a phrase no line writes, even with holes, is still absent", + "run": [ + "context", + "which code prints 'the vault was never opened'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "src/App/Messages.cs:" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/text-interpolated-phrase/docs/NOTES.md b/tests/cases/csharp/text-interpolated-phrase/docs/NOTES.md new file mode 100644 index 00000000..644a5293 --- /dev/null +++ b/tests/cases/csharp/text-interpolated-phrase/docs/NOTES.md @@ -0,0 +1 @@ +Seen in the log: has in its path (the scope was cut from the message). diff --git a/tests/cases/csharp/text-interpolated-phrase/src/App/Messages.cs b/tests/cases/csharp/text-interpolated-phrase/src/App/Messages.cs new file mode 100644 index 00000000..1001c775 --- /dev/null +++ b/tests/cases/csharp/text-interpolated-phrase/src/App/Messages.cs @@ -0,0 +1,29 @@ +using System; + +namespace App +{ + public class Messages + { + public void RequireScope(string scope) + { + throw new ArgumentException($"no indexed file has '{scope}' in its path"); + } + + public string Quota(string user, int n) + { + return string.Format("quota for {0} is exhausted after {1} requests", user, n); + } + + public string Sealed(string name) + { + return "the archive " + name + " was never sealed"; + } + + public object[] FarApart(int n) + { + var note = "the ledger is"; + var total = n + 1; + return new object[] { note, total, "out of balance" }; + } + } +} diff --git a/tests/cases/java/text-interpolated-phrase/case.json b/tests/cases/java/text-interpolated-phrase/case.json new file mode 100644 index 00000000..a2123062 --- /dev/null +++ b/tests/cases/java/text-interpolated-phrase/case.json @@ -0,0 +1,82 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "a message written with String.format hole between its words is found, and placed in the code that throws it; a doc quoting it as read does not hide the code", + "run": [ + "context", + "which code throws 'has in its path'" + ], + "expect_error": true, + "want": [ + "[approx] emits it Messages.requireScope src/main/java/app/Messages.java:", + "(with an interpolation between its words)", + "[text] docs/NOTES.md:1 not indexed" + ], + "avoid": [ + "no line of the repository's files writes it" + ] + }, + { + "why": "placeholders written with a concatenation are gaps too", + "run": [ + "context", + "which code returns 'quota for is exhausted after requests'" + ], + "expect_error": true, + "want": [ + "[approx] names it Messages.quota src/main/java/app/Messages.java:" + ] + }, + { + "why": "a message built with MessageFormat", + "run": [ + "context", + "which code returns 'the archive was never sealed'" + ], + "expect_error": true, + "want": [ + "[approx] names it Messages.sealed src/main/java/app/Messages.java:" + ] + }, + { + "why": "the message as printed, a value where the hole is, finds the same code", + "run": [ + "path", + "*", + "no indexed file has src in its path" + ], + "expect_error": true, + "want": [ + "[approx] emits it Messages.requireScope", + "(with an interpolation in place of one word)" + ] + }, + { + "why": "control: the words of a phrase written in two separate literals apart are not the phrase", + "run": [ + "context", + "which code returns 'the ledger is out of balance'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "Messages.farApart", + "src/main/java/app/Messages.java:" + ] + }, + { + "why": "control: a phrase no line writes, even with holes, is still absent", + "run": [ + "context", + "which code prints 'the vault was never opened'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "src/main/java/app/Messages.java:" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/java/text-interpolated-phrase/docs/NOTES.md b/tests/cases/java/text-interpolated-phrase/docs/NOTES.md new file mode 100644 index 00000000..644a5293 --- /dev/null +++ b/tests/cases/java/text-interpolated-phrase/docs/NOTES.md @@ -0,0 +1 @@ +Seen in the log: has in its path (the scope was cut from the message). diff --git a/tests/cases/java/text-interpolated-phrase/src/main/java/app/Messages.java b/tests/cases/java/text-interpolated-phrase/src/main/java/app/Messages.java new file mode 100644 index 00000000..b3140761 --- /dev/null +++ b/tests/cases/java/text-interpolated-phrase/src/main/java/app/Messages.java @@ -0,0 +1,23 @@ +package app; + +import java.text.MessageFormat; + +public class Messages { + public void requireScope(String scope) { + throw new IllegalArgumentException(String.format("no indexed file has '%s' in its path", scope)); + } + + public String quota(String user, int n) { + return "quota for " + user + " is exhausted after " + n + " requests"; + } + + public String sealed(String name) { + return MessageFormat.format("the archive {0} was never sealed", name); + } + + public Object[] farApart(int n) { + String note = "the ledger is"; + int total = n + 1; + return new Object[] {note, total, "out of balance"}; + } +} diff --git a/tests/cases/python/text-interpolated-phrase/case.json b/tests/cases/python/text-interpolated-phrase/case.json new file mode 100644 index 00000000..d1641bed --- /dev/null +++ b/tests/cases/python/text-interpolated-phrase/case.json @@ -0,0 +1,82 @@ +{ + "lang": "python", + "src": "src", + "checks": [ + { + "why": "a message written with an f-string hole between its words is found, and placed in the code that throws it; a doc quoting it as read does not hide the code", + "run": [ + "context", + "which code throws 'has in its path'" + ], + "expect_error": true, + "want": [ + "[approx] emits it require_scope src/messages.py:", + "(with an interpolation between its words)", + "[text] docs/NOTES.md:1 not indexed" + ], + "avoid": [ + "no line of the repository's files writes it" + ] + }, + { + "why": "placeholders written with a % format are gaps too", + "run": [ + "context", + "which code returns 'quota for is exhausted after requests'" + ], + "expect_error": true, + "want": [ + "[approx] names it quota src/messages.py:" + ] + }, + { + "why": "a message built with a concatenation", + "run": [ + "context", + "which code returns 'the archive was never sealed'" + ], + "expect_error": true, + "want": [ + "[approx] names it sealed src/messages.py:" + ] + }, + { + "why": "the message as printed, a value where the hole is, finds the same code", + "run": [ + "path", + "*", + "no indexed file has src in its path" + ], + "expect_error": true, + "want": [ + "[approx] emits it require_scope", + "(with an interpolation in place of one word)" + ] + }, + { + "why": "control: the words of a phrase written in two separate literals apart are not the phrase", + "run": [ + "context", + "which code returns 'the ledger is out of balance'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "far_apart", + "src/messages.py:" + ] + }, + { + "why": "control: a phrase no line writes, even with holes, is still absent", + "run": [ + "context", + "which code prints 'the vault was never opened'" + ], + "expect_error": true, + "avoid": [ + "[approx]", + "src/messages.py:" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/text-interpolated-phrase/docs/NOTES.md b/tests/cases/python/text-interpolated-phrase/docs/NOTES.md new file mode 100644 index 00000000..644a5293 --- /dev/null +++ b/tests/cases/python/text-interpolated-phrase/docs/NOTES.md @@ -0,0 +1 @@ +Seen in the log: has in its path (the scope was cut from the message). diff --git a/tests/cases/python/text-interpolated-phrase/src/messages.py b/tests/cases/python/text-interpolated-phrase/src/messages.py new file mode 100644 index 00000000..1c5723bb --- /dev/null +++ b/tests/cases/python/text-interpolated-phrase/src/messages.py @@ -0,0 +1,16 @@ +def require_scope(scope): + raise ValueError(f"no indexed file has '{scope}' in its path") + + +def quota(user, n): + return "quota for %s is exhausted after %d requests" % (user, n) + + +def sealed(name): + return "the archive " + name + " was never sealed" + + +def far_apart(n): + note = "the ledger is" + total = n + 1 + return note, total, "out of balance" diff --git a/tests/test_command.py b/tests/test_command.py index e9a227ae..7d90a315 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -394,6 +394,29 @@ def tree(files): ti.package_tier(d, ['src/main/java/a/Parser.java'])[1], {'files': ['src/test/java/a/ParserTest.java', 'src/test/java/a/TestParser.java'], 'not_tests': 1}) shutil.rmtree(d) +# the text tier: a directory of a changed data file is named by a test only where the test writes it as THAT directory. +# A path segment is not a phrase match: `cases` inside another tree's path, a @MethodSource("cases"), a dict key or a +# comment names some other `cases`, and matched every test of the repository +CASE = 'graph/test/java/cases/70-orders/src/derived/Order.java' +d = tree({'graph/test/java/tools/runner.py': "CASES = os.path.join(os.path.dirname(HERE), 'cases')\n", + 'graph/test/java/check_cases.py': "for c in glob.glob('cases/*/src/**', recursive=True): pass\n", + 'tests/test_paths.py': "ROOT = 'graph/test/java/cases/'\n", + 'tests/cases/python/x/test_a.py': "p = os.path.join(HERE, 'tests', 'cases', 'python')\n", + 'tests/test_other_tree.py': "fx = 'test/python/cases/10-async'\n", + 'src/test/java/a/RatesTest.java': '@MethodSource("cases")\nvoid rates() {}\n', + 'tests/test_prose.py': "# the derived/ and test/java/cases layouts differ\nkinds = {'derived': 1}\n", + 'tests/test_join_other.py': "base = os.path.join(ROOT, 'build', 'derived')\n"}) +got = ti.loaders(d, {CASE: False}) +check("a data file under cases/ is named by the runner beside it, a glob below it and its full path; not by another " + "tree's path, an annotation value, a dict key, a comment or a join under another root", + (got[CASE][0], got[CASE][1]), ('cases', ['graph/test/java/check_cases.py', 'graph/test/java/tools/runner.py', 'tests/test_paths.py'])) +check("names_dir: a glob below the directory must match what lies below it (control)", + ti.names_dir(CASE, 'cases', 't.py', "glob('cases/*/case.json')"), False) +check("names_dir: a path written with its own parents agrees (control)", + ti.names_dir(CASE, 'derived', 'tests/t.py', "p = 'java/cases/70-orders/src/derived'"), True) +check("names_dir: the same word under another parent does not", + ti.names_dir(CASE, 'derived', 'tests/t.py', "p = 'python/cases/70-orders/src/derived'"), False) +shutil.rmtree(d) check("a runner given only another language's files prints no command", ti.command_for('python', ['tests/Foo.cs'], [], None, '.'), None) check("java drops a .py file from a file-named selection", ti.command_for('java', ['src/test/java/ATest.java', 'tests/test_a.py'], []), 'mvn test -Dtest=ATest') From 687651e913cdfc480520141740533716804a767f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:19:35 -0700 Subject: [PATCH 020/258] freshness: a graph a newer axiomcode built is never rebuilt by an older one, and a moved baseline is said Two failures seen with the graph refresher. 1. Two plugin versions over one repository rebuilt each other's graph. The file table records what built the graph (IMPACT_VERSION, the engine, the rules), and any difference counted as "built by an older axiomcode": so an installed plugin whose IMPACT_VERSION was 37 found a graph built at 38, rebuilt it with its older engine, then a worktree's newer plugin rebuilt it back. Each rebuild replaced the other's facts, and answers in between came from whichever had won last. 2. `axiomcode graph` rebuilt a stale graph as an explicit index. An explicit index moves the baseline that `changed` and `test-impact` measure edits against to the tree it indexed, so after the verb ran on an edited working tree the edit became the baseline and test-impact said "no changed declaration" over a real edit, with nothing saying why. What changed: - ax_fresh.py: a difference in what built the graph is now ordered where it can be, IMPACT_VERSION first, then the engine's version. A graph ahead on either is a newer build: it is not stale for that reason, the refresher never rebuilds it (whatever files changed), a wait does not wait for a rebuild that will not come, and every answer from it says "graph built by a newer axiomcode (...); answering from it as is", with rows in edited files still marked. A difference with no order (other rules at the same versions) rebuilds as before. The engine is looked at once per status check, as before. - axiomcode-build: an index over a graph a newer axiomcode built keeps it and says so; AXIOMCODE_REINDEX=1 rebuilds it with the running one on purpose. A corrupt graph is rebuilt as before. - axiomcode-graph: rebuilding a stale graph keeps the baseline, as the background refresh does. - axiomcode-changed / axiomcode-test-impact: when the baseline holds uncommitted edits (an explicit index of an edited tree), the answer says when and by what the baseline was set, names the files whose edits it absorbed, and how to count them (`test-impact `), rather than a bare "no change". axiomcode-build records when and by what an explicit index moved the baseline (out/base-set). Tests: - tests/freshness.py newer: a higher IMPACT_VERSION, or the same one and a later engine, is a newer build; with and without an edit the refresher does not rebuild it, `index` keeps it, the answer is immediate, unmarked or marked as due, and names the newer build. Near-miss control: a lower IMPACT_VERSION, or an earlier engine, is still stale and rebuilt by the refresher. Against the previous ax_fresh.py the newer checks fail (the refresher rebuilt the newer graph) and the older-build controls pass. - tests/graph_verb.py: after `axiomcode graph` rebuilds an edited tree, `changed` still names the edit; control: an explicit index of an edited tree does move the baseline, and `changed` and `test-impact` now say so, naming the file. The first check fails against the previous axiomcode-graph. Suites on the rebased tree: tests/freshness.py 58/58, tests/graph_verb.py all pass, tests/refresh.py --lang python all pass, tests/hook_languages.py 7/7, tests/changed_range.py pass, tests/run.py --lang java 186/186. tests/run.py --lang python 156/161 and --lang csharp 53/58 (1 pending): the only failures are lambda-is-named-by-its-place in both languages and the csharp member-owner-is-its-type pending marker, which fail the same way on the unchanged tip. tests/fastpath.py and tests/indexed_tree.py build TypeScript cases and were not run locally. Smoke, installed build against this change on the same engine, one dev project per language (a copy indexed fresh; the graph's recorded IMPACT_VERSION set one above the running one to stand for a newer build, a file edited): - refresher over the newer graph with a file edited: installed rebuilt it with the older engine on 3 of 3 projects (the recorded IMPACT_VERSION went from 39 back to 38); this change rebuilt 0 of 3, kept 39, and every impact answer carried the "built by a newer axiomcode" line (answered in 1.2 to 3.7 s). - control, a graph recorded one IMPACT_VERSION below the running one: rebuilt on 3 of 3 by both. - a method body edited, then `axiomcode graph` (which rebuilds the stale graph): installed, `changed` found 0 changed declarations and test-impact said "no changed declaration" on 3 of 3; this change, `changed` named the edited method and test-impact did not say that on 3 of 3. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/ax_fresh.py | 118 ++++++++++++++---- .../skills/axiomcode/scripts/axiomcode-build | 14 ++- .../axiomcode/scripts/axiomcode-changed | 37 +++++- .../skills/axiomcode/scripts/axiomcode-graph | 5 +- .../axiomcode/scripts/axiomcode-test-impact | 3 +- tests/freshness.py | 110 +++++++++++++++- tests/graph_verb.py | 13 ++ 7 files changed, 268 insertions(+), 32 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 85a8ce7b..52a8951e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -28,6 +28,7 @@ ax_fresh.py lock take the build lock on an fd the calling shell holds open ax_fresh.py count the source files of each language, walked as the parser walks ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps + ax_fresh.py newer exit 0 (saying so) when a newer axiomcode built the graph: never rebuilt by this one Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on @@ -417,25 +418,41 @@ def built_by(engine, langs=None): 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 ( -> )". Only what shapes this graph's languages counts (see WHAT BUILT - THE GRAPH). 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 '' +def _vnum(v): + """a version as a tuple of numbers ('37' -> (37,), '0.1.7' -> (0, 1, 7)), None when it is not one""" + try: return tuple(int(x) for x in str(v).split('.')) + except (TypeError, ValueError): return None + +def _newer(old, impact, now_e): + """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id)""" + oi, ni = _vnum(old.get('impact')), _vnum(impact) + if oi and ni and oi != ni: + return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" if oi > ni else '' + ov, nv = old.get('engine_version'), (now_e[0] if now_e else None) + if _vnum(ov) and _vnum(nv) and _vnum(ov) > _vnum(nv): + return f"graph built by a newer axiomcode (engine {ov}, this one is {nv})" + return '' + +def built_by_state(repo, t=None): + """(older, newer): what engine_change and newer_build return, found with one look at the engine. NEVER A DOWNGRADE + comes first: a graph a newer axiomcode built is newer whatever else differs, and only a graph that is not is judged + by the per-language key (only what shapes this graph's languages counts, see WHAT BUILT THE GRAPH)""" + 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 '' + if not t: return '', '' old = t.get('built_by') rules, impact = plugin_id() eng = current_engine(repo) if not isinstance(old, dict): now_e = engine_id(eng, engine_lang_list(t.get('lang')) or None) if eng else None 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})" + return f"graph built by an older axiomcode (one that did not record its engine -> {new})", '' # a table from before the per-language key recorded a hash of the whole engine: it is compared the same way, so # the upgrade itself rebuilds nothing that was current langs = old.get('engine_langs') now_e = engine_id(eng, langs) if eng else None + newer = _newer(old, impact, now_e) + if newer: return '', newer diff = [] if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: h = _seen_hash(repo, now_e) @@ -450,15 +467,42 @@ def engine_change(repo, t=None): else: 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 '' + return (f"graph built by an older axiomcode ({'; '.join(diff)})" if diff else ''), '' + +def newer_build(repo, t=None): + """'' unless the graph was built by a NEWER axiomcode than the one running now, else one line saying so. + NEVER A DOWNGRADE. Two plugin versions over one repository (an installed plugin's hooks and a checkout's own, in a + worktree) each took the other's graph for "built by another axiomcode" and rebuilt it with its own engine: the older + one rebuilt the newer graph with older rules, the newer one rebuilt it back, and every answer in between came from + whichever had won last. A difference is ordered where it can be: IMPACT_VERSION first (a number every change to the + exported facts raises), then the engine's version; a graph ahead on either is kept and answered from as it is. A + difference with no order (another hash at the same versions) is not a downgrade and is judged as before. A newer + IMPACT_VERSION is not re-exported either (rewarm): that would record the older version over the newer one's""" + return built_by_state(repo, t)[1] + +def newer_note(n): + """what an answer from a graph a newer axiomcode built says""" + return (f"graph refresh: {n}; answering from it as is, and this older axiomcode does not rebuild it; rows in files " + "edited since are marked. Update this axiomcode, or `AXIOMCODE_REINDEX=1 axiomcode index` rebuilds it with this one") + +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 ( -> )". Only what shapes this graph's languages counts (see WHAT BUILT + THE GRAPH). 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. + A graph a NEWER axiomcode built is not one to rebuild: '' (newer_build says what it is)""" + return built_by_state(repo, t)[0] def export_behind(t): """the IMPACT_VERSION the graph's facts were exported with, when it is not this plugin's (a table with the - per-language key only: an older one rebuilds for it), else ''""" + per-language key only: an older one rebuilds for it), else ''. A HIGHER one is not behind: a newer axiomcode + exported it (newer_build), and re-exporting would record this older version over it""" old = (t or {}).get('built_by') if not isinstance(old, dict) or 'index' not in old: return '' - have = str(old.get('impact')) - return have if have != plugin_id()[1] else '' + have, now = str(old.get('impact')), plugin_id()[1] + if have == now: return '' + if _vnum(have) and _vnum(now) and _vnum(have) > _vnum(now): return '' + return have 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 @@ -710,13 +754,15 @@ 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) - eng = engine_change(repo) + eng, newer = built_by_state(repo) + # a graph a newer axiomcode built: answered from as it is, never rebuilt here (newer_build), and said on every answer + nw = {'newer': newer} if newer else {} 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 busy: return dict(state='building', **pending(repo), **nw) + return dict(state='stale', changed=[unbuilt_row(repo)], added=[], removed=[], **nw) if unbuilt(repo) else dict(state='fresh', **nw) + d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed, **nw) if eng: d['engine'] = eng if busy and not (changed or added or removed): d.update(pending(repo)) if not busy and failed_on(st, c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') @@ -860,6 +906,13 @@ def worker(repo): time.sleep(debounce) t = load_table(repo) if not has_graph(repo): return 0 + nb = newer_build(repo, t) if t else '' + if nb: + # NEVER A DOWNGRADE: whatever changed, this older axiomcode does not rebuild a newer one's graph; the + # newer one's own refresh does, and every answer meanwhile says so and marks the edited files' rows + write_state(repo, state='newer', checked=time.time(), newer=nb, checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')) + print(f"{time.strftime('%H:%M:%S')} refresh: {nb}; not rebuilding it with this older one", flush=True) + return 0 if not t: # a graph from before the file table: one build with its own parameters, which rebuilds only if git # says the tree moved, and records the table either way; nothing to compare until then @@ -933,7 +986,7 @@ def wait(repo, seconds): if s['state'] == 'unknown': kick(repo, 'a query'); return s # a graph from before the file table: its first refresh records one # a FIRST build's other languages are not waited for: there is no graph of theirs to refresh, only a compile # that can take an hour (#1555). A rebuild's are, for the bounded while any refresh is. - if s['state'] in ('fresh', 'no graph') or s.get('failed') or s.get('first'): return s + if s['state'] in ('fresh', 'no graph') or s.get('failed') or s.get('first') or s.get('newer'): return s if s['state'] == 'stale': kick(repo, 'a query') # idempotent: a no-op while a worker holds its lock if time.time() >= end: return s time.sleep(0.5) @@ -942,6 +995,10 @@ def wait_baseline(repo, seconds): """for `changed` and `test-impact`: when HEAD moved since the baseline was set, start the refresher and wait for it to move the baseline (0.2 s when no file changed, a build of HEAD's text when the tree is dirty). '' or a note.""" if not enabled(repo) or not base_moved(repo): return '' + if newer_build(repo): + return (f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}), and the graph was built by a newer " + "axiomcode, which this one does not rebuild: the baseline stays where it was, so this answer also counts what the " + "new commits changed") kick(repo, 'a query'); end = time.time() + seconds while time.time() < end: time.sleep(0.3) @@ -991,6 +1048,7 @@ def note(s, marked=None, named=None, off=False): graph predating the edit that wrote X, not X being absent. `off`: the refresher is switched off, nothing rebuilds""" # 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('newer'): first = (first + '\n' if first else '') + newer_note(s['newer']) 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 @@ -1009,6 +1067,9 @@ def note(s, marked=None, named=None, off=False): if named: rows += '; ' + ', '.join(f"'{n}' is written in {f}" for n, f in named[:3]) + \ ", edited since the graph was built: a declaration added there is not in this graph yet, so finding nothing by that name does not mean it is absent" + if s.get('newer'): + return first + (f"graph refresh: this answer is from that graph, which predates edits to {head}" + + (rows if marked is not None else '; read those files for their current text')) if off: return first + (f"graph refresh: OFF (AXIOMCODE_NO_REFRESH is set), no refresh is running — this answer is from a graph that " f"predates edits to {head}" + (rows if marked is not None else '; read those files for their current text') + @@ -1177,7 +1238,7 @@ def wait_fresh(repo, seconds, say=False, files=()): while True: s = status(repo) 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.get('failed') or s.get('newer'): return False # nothing here rebuilds a newer axiomcode's graph if s['state'] == 'stale': kick(repo, 'a query') now = time.time() if now >= end: return False @@ -1213,12 +1274,14 @@ def with_note(n): # the answer as it is, then passthrough() 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() - return with_note(pending_note(s)) - if not s.get('failed') and not off: kick(repo, 'a query') + # given now, and says which languages it cannot see yet; one a newer axiomcode built says that + if not note(s): passthrough() + return with_note(note(s)) + # a graph a newer axiomcode built is never rebuilt here: no refresh is started or waited for, as with refresh off + hold = off or bool(s.get('newer')) + if not s.get('failed') and not hold: kick(repo, 'a query') as_json = '--json' in argv - if fresh and not s.get('failed') and not off: + if fresh and not s.get('failed') and not hold: left = expected_left(repo) 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) @@ -1229,7 +1292,7 @@ def with_note(n): # the answer as it is, then marked, n, touched = mark_answer(out, stale, as_json) named = names_in_edits(repo, query_names(verb, argv, repo), stale) touched = touched or bool(named) - if touched and not fresh and not s.get('failed') and not off: + if touched and not fresh and not s.get('failed') and not hold: budget = float(os.environ.get('AXIOMCODE_FRESH_WAIT') or 30) left = expected_left(repo) if budget > 0 and not compiling(repo) and (left is None or left <= budget): @@ -1251,6 +1314,7 @@ def with_note(n): # the answer as it is, then 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 {}), + **({'newer': s['newer']} if s.get('newer') 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' @@ -1320,6 +1384,12 @@ def main(argv): if r.get('refreshed_at'): print(f"built {r['refreshed_at']} ({r.get('refresh_reason', '')})" + (f"; last checked {r['checked_at']}" if r.get('checked_at') else '')) return 0 if cmd == 'kick': kick(repo); return 0 + if cmd == 'newer': + # what axiomcode-build asks before it rebuilds: exit 0, saying why, when the graph here was built by a newer + # axiomcode, which this one must not rebuild (newer_build); 1 otherwise + n = newer_build(repo) + if n: print(f"{n}: kept, not rebuilt by this older axiomcode; update it, or AXIOMCODE_REINDEX=1 rebuilds the graph with this one") + return 0 if n else 1 if cmd == 'baseline': n = wait_baseline(repo, float(argv[3]) if len(argv) > 3 else 30) if n: print(n, file=sys.stderr) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 75938dbc..1271e1b5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -181,8 +181,12 @@ set_base(){ # commit's edits would stay "changed" and test-impact would select for all of them, growing with each commit. # Not the working tree either: after a pull, the edits still uncommitted are exactly what `changed` must show. (cd "$REPO" && git rev-parse 'HEAD^{tree}' 2>/dev/null) > "$OUT/base-tree.new" && mv "$OUT/base-tree.new" "$OUT/base-tree" || rm -f "$OUT/base-tree.new" - elif [ -n "$1" ]; then echo "$1" > "$OUT/base-tree" - else rm -f "$OUT/base-tree"; fi + elif [ -n "$1" ]; then + # an explicit index sets the baseline to the tree it indexed; when that is not HEAD's (edits were uncommitted), the + # edits are IN the baseline from now on, and `changed` / `test-impact` say when and by what (base-set) + [ "$(cat "$OUT/base-tree" 2>/dev/null)" = "$1" ] || printf '%s\t%s\n' "$(date +%s)" "${AXIOMCODE_REFRESH_REASON:-axiomcode index}" > "$OUT/base-set" + echo "$1" > "$OUT/base-tree" + else rm -f "$OUT/base-tree" "$OUT/base-set"; fi } # the commit the baseline follows, written LAST: `changed` waits while it differs from HEAD, so it must not say "moved" # before the baseline's graph exists (keep_base_graph), or `changed` reads a baseline with no graph for it @@ -247,6 +251,12 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # A GRAPH A QUERY FOUND CORRUPT (ax_fresh.quarantine leaves .axiomcode/out/corrupt) is rebuilt even though no file moved: # the files still match the table, so without this the build said "up to date" over a graph that could not be read. if [ -f "$OUT/corrupt" ]; then echo "rebuilding: $(head -1 "$OUT/corrupt")"; fi +# A GRAPH A NEWER AXIOMCODE BUILT is never rebuilt by this older one (ax_fresh.py newer_build): two plugin versions over +# one repository rebuilt each other's graph back and forth. It is kept whatever changed, and every answer says so; +# AXIOMCODE_REINDEX=1 rebuilds it with this one on purpose. A corrupt graph is rebuilt all the same. +if [ -z "${AXIOMCODE_REINDEX:-}" ] && [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && NEWER="$(AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" newer "$REPO")"; then + echo "$NEWER"; exit 0 +fi if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index c67c4132..a06d034c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""axiomcode changed [] […] [--range .. | --staged] [--old --new --file ] [--impact] [--json] +"""axiomcode changed [] [… [--whole]] [--range .. | --staged] [--old --new --file ] [--impact] [--json] which declarations an edit changed, and HOW — then (--impact) what that reaches. The graph describes the tree as it was when it was built (the build stamps the commit). An edit is read against that version: @@ -19,7 +19,7 @@ The output is one line per changed declaration with the target `axiomcode impact of them as one change set (the union) and prints its answer. A method whose only change is its body is a `method` target; a signature change with one parameter changed is `Owner.m(param)`; a field is `Owner.field`; a type header is `Type`. """ -import difflib, importlib.machinery, importlib.util, json, os, re, subprocess, sys +import difflib, importlib.machinery, importlib.util, json, os, re, subprocess, sys, time HERE = os.path.dirname(os.path.abspath(__file__)) _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) @@ -107,6 +107,32 @@ def strip_code(t, strings=True, hash_comments=True): return ''.join(out) class Changed: + def _absorbed(self): + """the source files whose edits the BASELINE holds: it was set to a working tree that differed from HEAD (an explicit + index of an edited tree), so those edits are the baseline's own and an answer measured against it cannot see them. + [] while HEAD has moved past the baseline's commit (the refresh is moving it; those are new commits, not edits)""" + bc = os.path.join(self.repo, '.axiomcode', 'out', 'base-commit') + bc = open(bc).read().strip() if os.path.exists(bc) else '' + head = (sh('git', 'rev-parse', 'HEAD', cwd=self.repo) or '').strip() + if bc and head and bc != head: return [] + out = sh('git', 'diff', '--name-only', 'HEAD', self.base_tree, cwd=self.repo) or '' + return [f for f in out.splitlines() if re.search(CODE_EXT, f)] + + def baseline_note(self): + """one line when the baseline holds edits HEAD does not (base_absorbed): when it was set and by what, and how to + count those edits anyway; '' otherwise""" + fs = self.base_absorbed + if not fs: return '' + when = by = '' + try: + ts, by = open(os.path.join(self.repo, '.axiomcode', 'out', 'base-set')).read().rstrip('\n').split('\t', 1) + when = ' ' + time.strftime('%Y-%m-%d %H:%M', time.localtime(int(ts))) + except (OSError, ValueError): pass + head = ', '.join(fs[:4]) + (f" … +{len(fs) - 4}" if len(fs) > 4 else '') + return (f"baseline moved: it was set{when}{' by ' + by if by else ' by an explicit index'} to the working tree as it was " + f"then, not to HEAD, so the uncommitted edits to {head} are part of the baseline and are NOT counted here; " + f"name the files to count them whole: `test-impact {' '.join(fs[:4])}` (or `changed --whole …`)") + def __init__(self, repo, baseline=False): self.g = G(repo); self.repo = self.g.repo st = os.path.join(self.repo, '.axiomcode', 'out', 'stamp') @@ -126,6 +152,7 @@ class Changed: self.base_tree = b if b and sh('git', 'cat-file', '-e', b, cwd=self.repo) is not None else self.indexed_tree self.tree_differs = bool(self.base_tree) and (sh('git', 'rev-parse', 'HEAD^{tree}', cwd=self.repo) or '').strip() != self.base_tree if baseline: self.indexed_tree = self.base_tree # reading the baseline's own graph: its spans ARE the baseline's + self.base_absorbed = self._absorbed() if self.tree_differs else [] self._mapped = {} self.spans = {} # file -> [(line, end, id, kind)] for r in self.g.sym.values(): @@ -784,16 +811,20 @@ def main(argv): s_ = branch_suggestion(C.repo) if s_: suggest = dict(ref=s_[0], commits=s_[1], range=f"{s_[0]}..HEAD") if outside: notes.append(('', outside_note(outside), None)) + # A BASELINE THAT HOLDS EDITS (an explicit index of an edited tree, or a rebuild run as one) hides them: said, with how + # to count them, rather than a bare "no change" over a real edit. Only when the question is the working tree as a whole + base_note = C.baseline_note() if mode == 'worktree' and not a and not (old_f or new_f) else '' base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), - 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, + 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, 'baseline_note': base_note, 'suggest_range': suggest, 'suggest_note': sugg_line}, indent=1)) return 3 if fanout_empty else 0 if range_note: print(f"note: {range_note}") + if base_note: print(f"note: {base_note}") if not results and not notes: print(f"no change to a declaration the graph knows ({base_desc})") if sugg_line: print(f"next: {sugg_line}") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index b117bc73..199a9524 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -281,7 +281,10 @@ def build(repo, page, library=None): ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else f"rebuilding the graph as it was indexed ({flags}) …", flush=True) - t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph')) + # a REFRESH, not an index: the baseline `changed` and `test-impact` measure edits against stays where it was, as the + # background refresh keeps it. Rebuilt as an explicit index it moved to the edited tree, and the edits it held dropped + # out of both ("no changed declaration" over a real edit) + t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph', AXIOMCODE_KEEP_BASE='1')) return export(repo, page, how=f"graph rebuilt ({flags}) in {time.time() - t0:.0f} s.") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index af4c3c8e..e91d5d72 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1838,6 +1838,7 @@ def main(argv): by_target[t] = e; targets.append(t) repo = next((a for a in passthru if os.path.isdir(a)), '.') heads = [f"note: {changed['range_note']}"] if changed.get('range_note') else [] + if changed.get('baseline_note'): heads.append(f"note: {changed['baseline_note']}") # A CHANGED TEST FILE IS ITSELF A TEST TO RUN: a new test file, or an edited one, has no test reaching it — it is one edited = sorted({e['file'] for e in entries if e.get('file', '').endswith(CODE) and (e.get('is_test') or TESTY.search(e['file']))}) # WHAT NO CALL EDGE REACHES: files outside every indexed language (fixtures, case data), and changed code that is @@ -1977,7 +1978,7 @@ def main(argv): **({'tests_outside_src': out_src} if out_src and not tests else {}), 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, 'named_in_other_language_tests': len(other_lang), - 'range_note': changed.get('range_note'), + 'range_note': changed.get('range_note'), 'baseline_note': changed.get('baseline_note'), 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, diff --git a/tests/freshness.py b/tests/freshness.py index f7f385c9..5750502b 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -28,6 +28,9 @@ cap at most AXIOMCODE_REFRESH_MAX background rebuilds run at once on the machine: a third waits while two run, and runs once one ends (queued, never dropped); control: with room for three, none waits lock the engine compile lock is taken over when its owner process is dead, never while it lives, however old + newer a graph a NEWER axiomcode built (higher IMPACT_VERSION, or a later engine) is never rebuilt by this older one, + by the refresher or by `index`, edited or not; answers come from it at once and say so. The near-miss: an + OLDER build is still stale and rebuilt 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 @@ -425,6 +428,111 @@ def per_language_checks(): shutil.rmtree(work, ignore_errors=True) +# ── newer ───────────────────────────────────────────────────────────────────────────────────────────────────────── +NEWER_BUILD = "import os, sys; open(os.path.join(sys.argv[2], 'built-by-this-one'), 'a').write('x\\n')\n" + + +def newer_checks(): + """a graph built by a NEWER axiomcode (a higher IMPACT_VERSION, or the same one and a later engine) is never rebuilt + by this older one: not by the refresher (whatever changed), not by `index`; every answer comes from it at once and + says so. The near-miss: a graph built by an OLDER axiomcode is still rebuilt, by the refresher and by `index`""" + work = tempfile.mkdtemp(prefix='axiomcode-newer-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e1 = fake_engine(os.path.join(work, 'e1'), version='1.0.0'); os.environ['AXIOMCODE_ENGINE'] = e1 + mine = ax_fresh.plugin_id()[1] + # the stand-in build the refresher runs: it only records that it ran (AXIOMCODE_BASH runs it in place of bash) + fb = os.path.join(work, 'fake_build.py'); open(fb, 'w').write(NEWER_BUILD) + runner = os.path.join(work, 'runner.sh'); open(runner, 'w').write(f'#!/bin/sh\nexec "{sys.executable}" "{fb}" "$@"\n'); os.chmod(runner, 0o755) + driver = os.path.join(work, 'driver.py'); open(driver, 'w').write(DRIVER) + + def repo_by(name, **by): + repo = fake_repo(work, name); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + tp = os.path.join(repo, '.axiomcode/out/files.json'); t = json.load(open(tp)) + t['built_by'].update(by); json.dump(t, open(tp, 'w')) + return repo + + def worker(repo): + env = dict(os.environ, AXIOMCODE_BASH=runner, AXIOMCODE_REFRESH_DEBOUNCE='0.05', + AXIOMCODE_REFRESH_SLOTS=os.path.join(work, 'slots')) # not the machine's own build slots + for k in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_GRAPH'): env.pop(k, None) + subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'worker', repo], env=env, capture_output=True, text=True, timeout=60) + return os.path.exists(os.path.join(repo, 'built-by-this-one')) + + def query(repo): + env = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_FRESH', 'AXIOMCODE_GRAPH')} + 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=dict(env, AXIOMCODE_FRESH_WAIT='30')) + return r.stdout, r.stderr, time.time() - t0 + + up = str(int(mine) + 1); down = str(int(mine) - 1) + repo = repo_by('newer', impact=up) + s = ax_fresh.status(repo); want = f"graph built by a newer axiomcode (IMPACT_VERSION {up}, this one has {mine})" + check("newer: a higher IMPACT_VERSION is a newer build, not an older one to rebuild; with no edit the graph is fresh", + s.get('state') == 'fresh' and s.get('newer') == want and ax_fresh.engine_change(repo) == '', s) + out, err, took = query(repo) + check(f"newer: the answer comes from it at once ({took:.1f}s), unmarked, and says a newer axiomcode built it and it is not rebuilt", + out.strip() == ROWS.strip() and want in err and 'not rebuild' in err and 'rebuilding' not in err and took < 10, (out, err)) + n = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'newer', repo], capture_output=True, text=True) + u = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'uptodate', repo, 'python', '', ''], capture_output=True, text=True) + check("newer: `index` is told to keep it (ax_fresh.py newer exits 0 and says why), and finds the files up to date", + n.returncode == 0 and want in n.stdout and 'AXIOMCODE_REINDEX=1' in n.stdout and u.returncode == 0, (n.stdout, u.stdout)) + check("newer: the refresher does not rebuild it", not worker(repo) and ax_fresh.read_state(repo).get('state') == 'newer', ax_fresh.read_state(repo)) + # an edit: still never rebuilt; the answer marks the edited file's rows, waits for nothing, and says why no refresh comes + open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') + s = ax_fresh.status(repo); out, err, took = query(repo) + check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer') == want, s) + check("newer: with a file edited the refresher still does not rebuild it", not worker(repo), '') + check(f"newer: with a file edited the answer marks that file's rows, does not wait ({took:.1f}s), and names no rebuild", + 'shop/api.py:5 - calls it' + ax_fresh.MARK in out and want in err and 'predates edits to shop/api.py' in err + and 'queued' not in err and 'rebuilding' not in err and 'waiting' not in err and took < 10, (out, err)) + w = ax_fresh.wait(repo, 5) + check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", w.get('newer') == want, w) + # the same IMPACT_VERSION and a later engine is newer too + repo = repo_by('newer-engine', engine_version='1.0.1') + check("newer: the same IMPACT_VERSION and a later engine version is a newer build", + ax_fresh.status(repo).get('newer') == "graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)" and not worker(repo), + ax_fresh.status(repo)) + # ── composed with the per-language key: NEVER A DOWNGRADE is decided first ── + # a newer graph whose own language's rules differ from this engine's is still not rebuilt: that difference is + # the newer axiomcode's, not a staleness this one can fix + repo = repo_by('newer-own-rules', impact=up, engine_hash='0' * 40, engine_stat='0' * 40) + check("newer: a newer build whose own language's engine files differ is still a newer build, not an older one", + ax_fresh.status(repo).get('newer') == want and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) + # a higher IMPACT_VERSION is not re-exported either (rewarm): that would record this older version over it + worker(repo) + check("newer: the refresher does not re-export a newer build's facts; the table keeps the newer IMPACT_VERSION", + json.load(open(os.path.join(repo, '.axiomcode/out/files.json')))['built_by'].get('impact') == up + and ax_fresh.export_behind(json.load(open(os.path.join(repo, '.axiomcode/out/files.json')))) == '', '') + # ── the near-miss: an OLDER build is this one's to bring up to date ── + # with the per-language key a lower IMPACT_VERSION changes no graph: it is re-exported (rewarm), not rebuilt + repo = repo_by('older', impact=down) + s = ax_fresh.status(repo) + check("control: a lower IMPACT_VERSION is an older build: not a newer one, and behind on its export", + s.get('state') == 'fresh' and not s.get('newer') and ax_fresh.export_behind(json.load(open(os.path.join(repo, '.axiomcode/out/files.json')))) == down, s) + n = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'newer', repo], capture_output=True, text=True) + check("control: `index` is not told to keep an older build (ax_fresh.py newer exits 1, silent)", n.returncode == 1 and not n.stdout.strip(), n.stdout) + rebuilt = worker(repo) + check("control: the refresher brings an older IMPACT_VERSION up to this one by re-exporting, with no rebuild", + not rebuilt and json.load(open(os.path.join(repo, '.axiomcode/out/files.json')))['built_by'].get('impact') == mine, ax_fresh.read_state(repo)) + # a table from before the per-language key: a lower IMPACT_VERSION is stale and rebuilt, as it was recorded + repo = repo_by('older-legacy', impact=down) + tp = os.path.join(repo, '.axiomcode/out/files.json'); t = json.load(open(tp)); t['built_by'].pop('index', None); json.dump(t, open(tp, 'w')) + s = ax_fresh.status(repo) + check("control: in a table from before the per-language key, a lower IMPACT_VERSION is stale, named as older, not newer", + s.get('state') == 'stale' and f"IMPACT_VERSION {down} -> {mine}" in s.get('engine', '') and s.get('engine', '').startswith('graph built by an older axiomcode') + and not s.get('newer'), s) + check("control: the refresher rebuilds that older build", worker(repo), ax_fresh.read_state(repo)) + repo = repo_by('older-engine', engine_version='0.9.0', engine_hash='0' * 40, engine_stat='0' * 40) + check("control: an earlier engine at the same IMPACT_VERSION is an older build, and is rebuilt", + ax_fresh.status(repo).get('engine', '').startswith('graph built by an older axiomcode (engine 0.9.0') and not ax_fresh.status(repo).get('newer') + and worker(repo), ax_fresh.status(repo)) + finally: + if saved is None: os.environ.pop('AXIOMCODE_ENGINE', None) + else: os.environ['AXIOMCODE_ENGINE'] = saved + shutil.rmtree(work, ignore_errors=True) + + # ── cap ─────────────────────────────────────────────────────────────────────────────────────────────────────────── FAKE_BUILD = r'''#!{py} # stands in for `bash axiomcode-build `: logs when it runs, takes a while, and records the table a build would @@ -588,7 +696,7 @@ def mcp_checks(): if __name__ == '__main__': - prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); cap_checks(); lock_checks(); named_checks(); mcp_checks() + prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); cap_checks(); lock_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/graph_verb.py b/tests/graph_verb.py index 5c9fa025..0e3de1b4 100644 --- a/tests/graph_verb.py +++ b/tests/graph_verb.py @@ -106,6 +106,19 @@ def check(ok, why, detail=''): check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') + # the rebuild is a refresh: the baseline stays HEAD's tree, so the edit is still an edit to `changed`/`test-impact` + c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) + check('refund_order' in c.stdout and 'baseline moved' not in c.stdout + ti.stdout, + 'stale: the rebuild keeps the baseline: `changed` still names the edit, and nothing says the baseline moved', + c.stdout + c.stderr + ti.stdout) + # control: an explicit index of the edited tree DOES move the baseline (#1222), and now says so where the edit vanished + with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef void_order(n):\n return 0\n') + b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) + c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) + check(b.returncode == 0 and 'refund_order' not in c.stdout and 'void_order' not in c.stdout + and 'baseline moved' in c.stdout and 'shop/orders.py' in c.stdout and 'axiomcode index' in c.stdout and 'baseline moved' in ti.stdout, + 'control: an explicit index of an edited tree moves the baseline, and `changed` and `test-impact` say so, naming the file', + b.stdout[-300:] + c.stdout + c.stderr + ti.stdout) # ── control: indexed with no --lang, every language present ────────────────────────────────────────── mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) From 1670d33cab16279ab28a04f54e52e1e2d3371ec4 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:02:49 -0700 Subject: [PATCH 021/258] index: the first line names the engine; a checkout with dangling engine links is refused What was wrong: axiomcode-build takes the checkout its plugin sits in as the engine only when parser/dist/index.js is there. A worktree whose parser/dist, dist and node_modules were links into a build directory that had since been deleted failed that test, so the build took the next engine in its order (the one recorded from an earlier build, or the installed one on PATH) and said nothing. The graph was built by another engine's parser and rules, and 15 of that worktree's checks failed as if its fix were wrong. Nothing in the build output, the file table or the graph said which engine had built it. The change: - axiomcode-build: the engine is chosen first, and the first line of every build (explicit index, background refresh, repair) names the engine, its version, how it was found and the parser it runs, with the real path when parser/dist is a link. ax_fresh.py which_engine decides what that line says: - the checkout has its own engine sources (bin/, graph/, package.json, parser/src) and one of its built parts (parser/dist, dist, node_modules) is a link whose target is gone: the build is refused, naming each dangling link, where it points, the engine it would have used instead, how to relink or rebuild, and AXIOMCODE_ENGINE to build with the other engine on purpose. Nothing is built or changed. - the checkout has its own engine sources but its parser is simply not built (a fresh clone): the build falls back as before, and its first line says loudly that the engine is NOT this checkout's, which one it is, and why. - the checkout's own engine, AXIOMCODE_ENGINE, or an installed engine with no checkout of its own around the plugin: one quiet line. A part of the engine in use that dangles is named on a warning line. - The engine identity is recorded with the graph: the file table's built_by gains engine_origin (this checkout, AXIOMCODE_ENGINE, installed, or "fallback from : ") and the parser's real path, and index_meta gains engine, engine_version, engine_origin, parser and built_with (the build's first line). `ax_fresh.py status` prints it. - Answers: every query answer from a graph a fallback engine built says so on a "graph built by:" line naming the engine, its version and why the checkout's own was not used (and a built_with field in --json freshness). An answer from a graph the checkout's own or an installed engine built says nothing extra. The MCP server carries the line into the tool's answer, as it does the "graph refresh:" lines. - status() reads the file table once instead of three times per query. Based on fix/no-downgrade-rebuild (d35ef2e2, rebased onto 0.1.8 with one conflict in axiomcode-test-impact resolved by keeping both the baseline_note and same_name_not_tests fields), which also changes ax_fresh.py and axiomcode-build. Tests: tests/engine_choice.py gains the engine line and the recorded origin: - a checkout whose parser/dist links to a deleted build directory: refused, nothing run, the first line names the link and the engine avoided (before this change: the engine on PATH was run, silently); - AXIOMCODE_ENGINE over that checkout builds with the named engine on purpose; - an unbuilt checkout falls back to the engine on PATH, first line loud; - control, parser/dist linked to a built parser: the checkout's own engine, one quiet line with the link's real path; - near miss, a plugin copied outside any checkout with the engine on PATH: one quiet line, no warning; - the file table records engine_origin, and an answer from a fallback-built graph names the engine while one from a graph built by an installed engine or the checkout's own says nothing. Against the previous scripts 15 of these checks fail and the two quiet controls pass. Suites on the rebased tree: tests/engine_choice.py ok, tests/freshness.py 58/58, tests/mcp.py ok, tests/corrupt_graph.py ok, tests/test_command.py ok, tests/graph_verb.py ok, tests/hook_languages.py 7/7, tests/run.py --lang java 186/186, --lang python 185/186 and --lang csharp 55/58 (1 pending): the only failures are lambda-is-named-by-its-place in python and csharp and the csharp member-owner-is-its-type pending marker, which fail the same way on the unchanged tip. tests/fastpath.py and tests/indexed_tree.py build TypeScript cases and were not run locally. Smoke, one dev project per language (a copy, indexed fresh each time), the plugin run from a worktree whose parser/dist, dist and node_modules link to a deleted build directory, from a worktree linked to the installed build, and from a plugin copied outside any checkout: - dangling links, before this change: all 3 indexed with exit 0, silently, by the engine on PATH (not the checkout's); the first query then said the graph was built by another axiomcode and started a rebuild. - dangling links, after: all 3 refused in about 1 s, exit 1, the first line naming parser/dist, dist and node_modules, where each points, and the engine avoided; no graph written. - parser not built, after: all 3 built by the engine on PATH, the first line saying loudly it is not the checkout's; index_meta records the origin, and a query answer on each carries one "graph built by:" line. - good links (to the installed build), after: all 3 said "engine: this checkout ...; parser ... -> /parser/dist", no warning, no line added to answers; index_meta names the checkout. - installed only (a plugin copied outside any checkout, the engine on PATH), after: one quiet line on all 3, 0 warnings, index_meta origin "installed". Index times were unchanged by the engine line (12 to 14 s for the java and csharp projects, 91 to 263 s for the python one on a loaded machine, where the variation is the machine). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/mcp/server.py | 5 +- .../skills/axiomcode/scripts/ax_fresh.py | 104 +++++++++++++++++- .../skills/axiomcode/scripts/axiomcode-build | 65 +++++++---- tests/engine_choice.py | 97 +++++++++++++++- 4 files changed, 241 insertions(+), 30 deletions(-) diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 025d4b29..5303efdf 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -132,8 +132,9 @@ def run(args, cwd=None, timeout=900): return (f"axiomcode {args[0] if args else ''} did not answer within {timeout} s. Nothing was changed; " f"see {os.path.join(repo, '.axiomcode', 'build.log')} if a build was running, and ask again.") out = (r.stdout or '') + (('\n' + r.stderr.strip()) if r.returncode and r.stderr.strip() else '') - # an answer given from a graph that predates some edit says so, and names the files (#1305) - if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith('graph refresh:')) + # an answer given from a graph that predates some edit says so, and names the files (#1305); one given from a graph a + # fallback engine built, in place of the checkout's own, names that engine + if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith(('graph refresh:', 'graph built by:'))) 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 52a8951e..1c789b03 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -29,6 +29,9 @@ ax_fresh.py count the source files of each language, walked as the parser walks ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps ax_fresh.py newer exit 0 (saying so) when a newer axiomcode built the graph: never rebuilt by this one + ax_fresh.py which-engine [] + the build's first line: the engine and parser it uses and where they came + from; exit 3 when the checkout's own engine parts dangle (never built over) Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on @@ -406,16 +409,92 @@ def index_id(): except OSError: return '?' def built_by(engine, langs=None): - """what a build with `engine` of a graph of `langs` records in its file table""" + """what a build with `engine` of a graph of `langs` records in its file table: with the engine, where it came from + (AXIOMCODE_ENGINE_ORIGIN, set by axiomcode-build from which_engine) and the parser it ran, so an answer can name + what built the graph""" rules, impact = plugin_id() d = dict(rules=rules, impact=impact, index=index_id()) langs = engine_lang_list(langs) if engine and engine_ok(engine): version, content, sig = engine_id(engine, langs or None) - d.update(engine=engine, engine_version=version, engine_hash=content(), engine_stat=sig) + d.update(engine=os.path.normpath(engine), engine_version=version, engine_hash=content(), engine_stat=sig, + parser=os.path.realpath(os.path.join(engine, 'parser', 'dist'))) if langs: d['engine_langs'] = langs + if os.environ.get('AXIOMCODE_ENGINE_ORIGIN'): d['engine_origin'] = os.environ['AXIOMCODE_ENGINE_ORIGIN'] return d +# ── WHICH ENGINE A BUILD USES, SAID ON ITS FIRST LINE ──────────────────────────────────────────────────────────────── +# A checkout's engine is its sources (bin/, graph/, parser/src) plus parts that are built or linked in: parser/dist (the +# parser), dist/ (the compiled bundle stage) and node_modules (tsx, which runs the bundle stage from source). A worktree +# whose parser/dist was a link into a build directory that had since been deleted was not "built" to axiomcode-build, +# which then took the next engine in its order (the installed one) without a word: the worktree's own rules were never +# run, and 15 of its checks failed as if its fix were wrong. So every build names the engine and parser it uses and where +# they came from, a checkout with its own engine sources whose parts DANGLE refuses to build with another engine, and one +# whose parts are simply not built says loudly that another engine is used instead. A build from an installed engine +# with no checkout of its own around it says one quiet line. +ENGINE_PARTS = (('parser', os.path.join('parser', 'dist')), ('compiled bundle stage', 'dist'), ('dependencies', 'node_modules')) + +def engine_parts(d): + """[(name, relative path, state, target)] of a checkout's built parts: state is ok, link (ok, through a symlink), + dangling (a symlink whose target is gone) or missing""" + out = [] + for name, rel in ENGINE_PARTS: + p = os.path.join(d, rel) + if os.path.islink(p): + t = os.readlink(p) + out.append((name, rel, 'link' if os.path.exists(p) else 'dangling', t)) + else: out.append((name, rel, 'ok' if os.path.exists(p) else 'missing', '')) + return out + +def has_engine_sources(d): + """d is a checkout of the engine's sources (not only an installed package): bin/, graph/, package.json, parser/src""" + return engine_ok(d) and os.path.isdir(os.path.join(d, 'parser', 'src')) + +def _parser_of(engine): + p = os.path.join(engine, 'parser', 'dist') + return p + (f" -> {os.path.realpath(p)}" if os.path.islink(p) else '') + +def which_engine(engine, how, walk): + """(lines, refuse, origin) for the engine axiomcode-build chose: `how` it was found, `walk` the checkout the plugin's + scripts sit in ('' when none). origin is what the file table and index_meta record: 'this checkout', 'AXIOMCODE_ENGINE', + 'installed' (no checkout engine of its own), or 'fallback from : ...' naming what the checkout was missing""" + engine = os.path.normpath(engine) # found through a link it reads /bin/../lib/... + try: version = json.load(open(os.path.join(engine, 'package.json'))).get('version', '?') + except (OSError, ValueError): version = '?' + same = bool(walk) and os.path.realpath(walk) == os.path.realpath(engine) + parts = engine_parts(walk) if walk and has_engine_sources(walk) else [] + dangling = [(r, t) for n, r, s, t in parts if s == 'dangling'] + if walk and not same and parts and how != 'AXIOMCODE_ENGINE': + if dangling: + gone = '; '.join(f"{r} is a link to {t}, which does not exist" for r, t in dangling) + return ([f"❌ engine: not building with another engine in place of this checkout's own. {walk} has its own engine " + f"sources, but {gone}; the build would have used engine {version} at {engine} ({how}) instead, " + f"so the graph would not be this checkout's.", + f" Relink or rebuild it (ln -sfn /{dangling[0][0]} {os.path.join(walk, dangling[0][0])}, " + f"or npm install && npm run build there), or set AXIOMCODE_ENGINE={engine} to build with that engine on purpose."], + True, f"refused: {dangling[0][0]} dangling") + missing = [r for n, r, s, t in parts if s == 'missing' and n == 'parser'] + why = f"{', '.join(missing) or 'its parser'} is not built there" + return ([f"⚠️ engine: {version} at {engine} ({how}), NOT this checkout's own: {walk} has engine sources but {why}; " + f"parser {_parser_of(engine)}. Build it there (npm install && npm run build) to use its own rules."], + False, f"fallback from {walk}: {why}") + if same: origin, first = 'this checkout', f"engine: this checkout {engine} ({version}); parser {_parser_of(engine)}" + elif how == 'AXIOMCODE_ENGINE': origin, first = 'AXIOMCODE_ENGINE', f"engine: {version} at {engine} (AXIOMCODE_ENGINE); parser {_parser_of(engine)}" + else: origin, first = 'installed', f"engine: {version} at {engine} ({how}); parser {_parser_of(engine)}" + lines = [first] + # the engine used has a part that dangles (node_modules gone, dist/ still there): it may run, from what is left + for n, r, s, t in engine_parts(engine): + if s == 'dangling': lines.append(f"⚠️ engine: {os.path.join(engine, r)} ({n}) is a link to {t}, which does not exist") + return lines, False, origin + +def built_with(t): + """one line naming the engine that built a graph when it was NOT the checkout's own (its file table's built_by), or ''. + An answer from such a graph says so every time: its rules are not the ones the checkout would run""" + b = (t or {}).get('built_by') + if not isinstance(b, dict) or not str(b.get('engine_origin', '')).startswith('fallback'): return '' + return (f"graph built by: engine {b.get('engine_version', '?')} at {b.get('engine')} ({b['engine_origin']}); " + "its answers come from that engine's rules, not this checkout's") + def _label(version, h): return f"{version} {h[:8]}" if h else version def _vnum(v): @@ -749,14 +828,17 @@ def unbuilt_row(repo): def status(repo): if not has_graph(repo): return dict(state='no graph') if graph_broken(repo): return dict(state='building' if building(repo) else 'stale', changed=['.axiomcode/out/graph.sqlite (the pointer to the graph is broken)'], added=[], removed=[]) - c = changes(repo) + t = load_table(repo) + c = changes(repo, t) st = read_state(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) - eng, newer = built_by_state(repo) - # a graph a newer axiomcode built: answered from as it is, never rebuilt here (newer_build), and said on every answer + eng, newer = built_by_state(repo, t) + # a graph a newer axiomcode built: answered from as it is, never rebuilt here (newer_build), and said on every answer; + # one a fallback engine built in place of the checkout's own names that engine on every answer (built_with) nw = {'newer': newer} if newer else {} + if built_with(t): nw['built_with'] = built_with(t) 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 @@ -1021,7 +1103,7 @@ def refreshed(repo): d = {} try: con = sqlite3.connect(f"file:{os.path.join(out_dir(repo), 'graph.sqlite')}?mode=ro", uri=True) - d = dict(con.execute("SELECT key, value FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_commit')").fetchall()); con.close() + d = dict(con.execute("SELECT key, value FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_commit','built_with')").fetchall()); con.close() except Exception: pass st = read_state(repo) if st.get('checked'): d['checked_at'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(st['checked'])); d['checked_by'] = st.get('checked_by') or st.get('reason', '') @@ -1048,6 +1130,7 @@ def note(s, marked=None, named=None, off=False): graph predating the edit that wrote X, not X being absent. `off`: the refresher is switched off, nothing rebuilds""" # 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('built_with'): first = s['built_with'] + ('\n' + first if first else '') if s.get('newer'): first = (first + '\n' if first else '') + newer_note(s['newer']) if s.get('state') not in ('stale', 'building'): return first if s.get('engine'): @@ -1315,6 +1398,7 @@ def with_note(n): # the answer as it is, then 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 {}), **({'newer': s['newer']} if s.get('newer') else {}), + **({'built_with': s['built_with']} if s.get('built_with') 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' @@ -1382,8 +1466,16 @@ def main(argv): r = refreshed(repo) print(s['state'] + (': ' + note(s) if note(s) else '')) if r.get('refreshed_at'): print(f"built {r['refreshed_at']} ({r.get('refresh_reason', '')})" + (f"; last checked {r['checked_at']}" if r.get('checked_at') else '')) + if r.get('built_with'): print(f"built with {r['built_with']}") return 0 if cmd == 'kick': kick(repo); return 0 + if cmd == 'which-engine': + # ax_fresh.py which-engine []: what axiomcode-build prints first. Exit 3 when it + # must not build (the checkout's own engine parts dangle); the last line, "@origin ", is for the build + lines, refuse, origin = which_engine(argv[3], argv[4], argv[5] if len(argv) > 5 else '') + for l in lines: print(l) + print(f"@origin {origin}") + return 3 if refuse else 0 if cmd == 'newer': # what axiomcode-build asks before it rebuilds: exit 0, saying why, when the graph here was built by a newer # axiomcode, which this one must not rebuild (newer_build); 1 otherwise diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 1271e1b5..6246c20d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -18,22 +18,6 @@ set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "${1:-.}" && pwd)" -# A REBUILD THAT NAMES NO LANGUAGE KEEPS THE LANGUAGES THE INDEX CHOSE. Every rebuild comes here: an explicit `index`, -# the background refresh after an edit or a commit, a query that repairs a broken graph, `graph`. One that named no -# language detected every language in the tree, so on a repository indexed with --lang csharp it solved a few stray -# .ts and .js files too (a JavaScript Souffle compile of up to an hour), left a graph for each that later queries -# answered from, and recorded the detected list as the index's choice, so every refresh after it did the same. When -# the graph here was built with an explicit --lang (lang_auto false in its file table), a request with no language -# keeps that --lang, and the --src with it when none is given; a graph whose languages were detected is detected again. -if [ -z "${AXIOMCODE_LANG:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ]; then - KEPT="$(python3 "$H/ax_fresh.py" chosen "$REPO" 2>/dev/null || true)" - if [ -n "$KEPT" ]; then - AXIOMCODE_LANG="${KEPT%%$'\t'*}"; export AXIOMCODE_LANG - [ -n "${AXIOMCODE_SRC:-}" ] || { AXIOMCODE_SRC="${KEPT#*$'\t'}"; export AXIOMCODE_SRC; } - [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "keeping the languages this graph was indexed with (--lang $AXIOMCODE_LANG${AXIOMCODE_SRC:+ --src $AXIOMCODE_SRC}); pass --lang, or AXIOMCODE_REINDEX=1, to choose again" - fi -fi -SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" # THE ENGINE IS CHOSEN THE SAME WAY EVERY TIME, AND A BUILD THAT FINDS NONE SAYS WHERE IT LOOKED. The background # refresh runs this script from a hook, with the hook's PATH, the hook's Node and, installed from a marketplace, from a # plugin COPIED outside any checkout; an explicit `axiomcode index` runs it from inside the npm package. Each found an @@ -94,6 +78,35 @@ fi for t in "${TRIED[@]}"; do echo " - $t" >&2; done exit 1; } AXIOMCODE_ENGINE="$ENGINE" +# THE FIRST LINE NAMES THE ENGINE AND PARSER THIS BUILD USES, AND WHERE THEY CAME FROM (ax_fresh.py which_engine). A +# worktree whose parser/dist linked to a deleted build directory was not "built" above, so its build silently took the +# installed engine and the graph was not the worktree's. A checkout with its own engine sources whose built parts DANGLE +# is refused; one whose parts are simply not built says loudly that another engine is used; an installed engine with no +# checkout of its own says one quiet line. Where it came from goes into the file table and the graph (engine_origin). +WALKED=""; [ "$WALK" != / ] && WALKED="$WALK" +ERC=0; EOUT="$(python3 "$H/ax_fresh.py" which-engine "$REPO" "$ENGINE" "${ENGINE_HOW:-}" "$WALKED" 2>/dev/null)" || ERC=$? +AXIOMCODE_ENGINE_ORIGIN="$(printf '%s\n' "$EOUT" | sed -n 's/^@origin //p')"; export AXIOMCODE_ENGINE_ORIGIN +ENGINE_LINE="$(printf '%s\n' "$EOUT" | sed '/^@origin /d')" +if [ "$ERC" = 3 ]; then printf '%s\n' "$ENGINE_LINE" >&2; exit 1; fi +[ -n "$ENGINE_LINE" ] || ENGINE_LINE="engine: $ENGINE (${ENGINE_HOW:-})" +printf '%s\n' "$ENGINE_LINE" +export AXIOMCODE_BUILT_WITH="${ENGINE_LINE%%$'\n'*}" +# A REBUILD THAT NAMES NO LANGUAGE KEEPS THE LANGUAGES THE INDEX CHOSE. Every rebuild comes here: an explicit `index`, +# the background refresh after an edit or a commit, a query that repairs a broken graph, `graph`. One that named no +# language detected every language in the tree, so on a repository indexed with --lang csharp it solved a few stray +# .ts and .js files too (a JavaScript Souffle compile of up to an hour), left a graph for each that later queries +# answered from, and recorded the detected list as the index's choice, so every refresh after it did the same. When +# the graph here was built with an explicit --lang (lang_auto false in its file table), a request with no language +# keeps that --lang, and the --src with it when none is given; a graph whose languages were detected is detected again. +if [ -z "${AXIOMCODE_LANG:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ]; then + KEPT="$(python3 "$H/ax_fresh.py" chosen "$REPO" 2>/dev/null || true)" + if [ -n "$KEPT" ]; then + AXIOMCODE_LANG="${KEPT%%$'\t'*}"; export AXIOMCODE_LANG + [ -n "${AXIOMCODE_SRC:-}" ] || { AXIOMCODE_SRC="${KEPT#*$'\t'}"; export AXIOMCODE_SRC; } + [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "keeping the languages this graph was indexed with (--lang $AXIOMCODE_LANG${AXIOMCODE_SRC:+ --src $AXIOMCODE_SRC}); pass --lang, or AXIOMCODE_REINDEX=1, to choose again" + fi +fi +SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" # THE FILES OF EACH LANGUAGE, COUNTED AS THE PARSER AND THE REFRESHER SEE THEM: the parser's skip list and git's ignore # rules (ax_fresh.py count). A `find` with its own shorter skip list counted a generated tree the parser never reads — # the "typescript 15108 files" of a 431-file project — and could pick a different main language than the last build. @@ -343,15 +356,25 @@ ENGINE_RC=0 REASON="$(printf %s "${AXIOMCODE_REFRESH_REASON:-axiomcode index}" | tr -d "'")" # WHEN AND WHY THIS GRAPH WAS BUILT, in the graph itself (index_meta): refreshed_at (UTC), refresh_reason (`axiomcode # index`, or what the background refresher saw: files changed, HEAD moved, found by an edit, a query or the timer), and -# the tree and commit it describes. Written before the swap: a write to the live graph later would change its mtime, +# the tree and commit it describes, and the engine that built it. Written before the swap: a write to the live graph later would change its mtime, # which keys the impact-facts cache, and cost the next query a re-export. -stamp_meta(){ python3 - "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$REASON" "$INDEXED_TREE" "$HEAD_SHA" <<'PY' 2>/dev/null || true -import sqlite3, sys +stamp_meta(){ AXIOMCODE_ENGINE="$ENGINE" python3 - "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$REASON" "$INDEXED_TREE" "$HEAD_SHA" <<'PY' 2>/dev/null || true +import os, sqlite3, sys db, at, reason, tree, commit = sys.argv[1:6] +# and WHAT BUILT IT: the engine, where it came from (this checkout, AXIOMCODE_ENGINE, installed, or a fallback from a +# checkout whose own parser was not built) and the build's first line, so any later reader can name it +e = os.path.normpath(os.environ['AXIOMCODE_ENGINE']) if os.environ.get('AXIOMCODE_ENGINE') else '' +try: + import json; ver = json.load(open(os.path.join(e, 'package.json'))).get('version', '?') +except Exception: ver = '?' +eng = [('engine', e), ('engine_version', ver), ('engine_origin', os.environ.get('AXIOMCODE_ENGINE_ORIGIN', '')), + ('parser', os.path.realpath(os.path.join(e, 'parser', 'dist')) if e else ''), + ('built_with', os.environ.get('AXIOMCODE_BUILT_WITH', '').lstrip('\u26a0\ufe0f ').strip())] with sqlite3.connect(db) as c: - c.execute("DELETE FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_tree','refreshed_commit')") + c.execute("DELETE FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_tree','refreshed_commit'," + "'engine','engine_version','engine_origin','parser','built_with')") c.executemany("INSERT INTO index_meta VALUES (?,?)", [('refreshed_at', at), ('refresh_reason', reason), - ('refreshed_tree', tree), ('refreshed_commit', commit)]) + ('refreshed_tree', tree), ('refreshed_commit', commit)] + eng) PY } tiers(){ python3 -c 'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); print(", ".join(f"{t} {n}" for t, n in c.execute("SELECT tier, count(*) FROM call_edges GROUP BY tier ORDER BY 2 DESC")))' "$1"; } diff --git a/tests/engine_choice.py b/tests/engine_choice.py index 39565d57..4b28c2e2 100644 --- a/tests/engine_choice.py +++ b/tests/engine_choice.py @@ -12,7 +12,7 @@ python3 tests/engine_choice.py """ -import os, re, shutil, subprocess, sys, tempfile +import json, os, re, shutil, subprocess, sys, tempfile ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUGIN = os.path.join(ROOT, 'plugins', 'axiomcode') @@ -43,6 +43,99 @@ def chosen(work, clone, path_dirs, engine_env=None, repo='repo', full=False): return (got, r.stdout + r.stderr) if full else got +def checkout(path, label, parser): + """an engine checkout with its own sources (parser/src) and a plugin copy in it; parser is 'built', 'missing', + 'dangling' (a link to a deleted build directory) or a directory to link parser/dist to""" + engine(path, label, parser == 'built') + os.makedirs(os.path.join(path, 'parser', 'src'), exist_ok=True) + if parser == 'dangling': + os.symlink(os.path.join(os.path.dirname(path), 'deleted-build', 'parser', 'dist'), os.path.join(path, 'parser', 'dist')) + elif parser not in ('built', 'missing'): + os.symlink(parser, os.path.join(path, 'parser', 'dist')) + shutil.copytree(PLUGIN, os.path.join(path, 'plugins', 'axiomcode')) + + +def first_line(out): + return (out.strip().splitlines() or [''])[0] + + +def says_which_engine(work, at): + """THE FIRST LINE NAMES THE ENGINE AND PARSER, AND WHERE THEY CAME FROM. A worktree whose parser/dist linked to a + deleted build directory was indexed with the installed engine without a word (15 of its checks then failed as if its + fix were wrong): that is refused now; a checkout that is simply not built says loudly it is not used; a good link and + an installed engine with no checkout around it each say one quiet line.""" + bad = [] + checkout(at('wt-dangling'), 'wt-dangling', 'dangling') + checkout(at('wt-unbuilt'), 'wt-unbuilt', 'missing') + checkout(at('wt-linked'), 'wt-linked', os.path.join(at('global'), 'parser', 'dist')) + # a dangling parser/dist: refused, nothing is run, and the line names the link, where it points and the engine it avoided + got, out = chosen(work, at('wt-dangling'), [at('pathbin')], None, 'repo', full=True) + fl = first_line(out) + if got != 'none': bad.append(f"a checkout whose parser/dist dangles is not built with another engine: ran {got}") + for want in ('❌ engine:', 'parser/dist is a link to', 'does not exist', at('global'), 'AXIOMCODE_ENGINE='): + if want not in out: bad.append(f"the refusal names {want!r}: {out[-600:]}") + if not fl.startswith('❌ engine:'): bad.append(f"the refusal is the first line: {fl!r}") + # asked for on purpose, AXIOMCODE_ENGINE builds with the other engine even over a dangling checkout + got, out = chosen(work, at('wt-dangling'), [at('pathbin')], at('global'), 'repo', full=True) + if got != 'global' or not first_line(out).startswith('engine: ') or 'AXIOMCODE_ENGINE' not in first_line(out): + bad.append(f"AXIOMCODE_ENGINE over a dangling checkout is used, said quietly: ran {got}, {first_line(out)!r}") + # an unbuilt checkout (parser/dist absent) falls back, and its first line says loudly that it is not this checkout's + got, out = chosen(work, at('wt-unbuilt'), [at('pathbin')], None, 'repo', full=True) + fl = first_line(out) + if got != 'global': bad.append(f"an unbuilt checkout falls back to the built engine on PATH: ran {got}") + if not (fl.startswith('⚠️ engine:') and "NOT this checkout's own" in fl and at('global') in fl and 'parser/dist is not built' in fl): + bad.append(f"the fallback is the first line, loudly: {fl!r}") + # control, a good link: the checkout's own engine, one quiet line naming where its parser comes from + got, out = chosen(work, at('wt-linked'), [at('pathbin')], None, 'repo', full=True) + fl = first_line(out) + if got != 'wt-linked': bad.append(f"a checkout whose parser/dist links to a built parser uses itself: ran {got}") + if not (fl.startswith('engine: this checkout') and '-> ' + os.path.realpath(os.path.join(at('global'), 'parser', 'dist')) in fl): + bad.append(f"a good link says one quiet line naming the parser's real place: {fl!r}") + # near-miss, installed only: a plugin copied outside any checkout, the engine on PATH: one quiet line, no warning + got, out = chosen(work, at('cache'), [at('pathbin')], None, 'repo', full=True) + fl = first_line(out) + if got != 'global' or not fl.startswith('engine: ') or at('global') not in fl: + bad.append(f"an installed run names its engine on one quiet line: ran {got}, {fl!r}") + if '⚠' in out or '❌ engine' in out: bad.append(f"an installed run warns about nothing: {out[:400]}") + return bad + + +def answers_name_the_engine(): + """the engine's origin is recorded with the graph (built_by.engine_origin), and every answer from a graph a fallback + engine built names that engine; one built by the checkout's own or an installed engine says nothing extra""" + bad = [] + sys.path.insert(0, os.path.join(PLUGIN, 'skills', 'axiomcode', 'scripts')) + import ax_fresh + os.environ['AXIOMCODE_NO_ENGINE_CHECK'] = '1' + with tempfile.TemporaryDirectory() as work: + eng = os.path.join(work, 'installed'); engine(eng, 'installed', True) + open(os.path.join(eng, 'package.json'), 'w').write('{"version": "9.9.9"}') + for origin, loud in (('fallback from /wt: parser/dist is not built there', True), ('installed', False), ('this checkout', False)): + repo = os.path.join(work, 'r-' + origin.split()[0]) + os.makedirs(os.path.join(repo, '.axiomcode', 'out')) + open(os.path.join(repo, 'a.py'), 'w').write('x = 1\n') + open(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() + os.environ['AXIOMCODE_ENGINE_ORIGIN'] = origin + by = ax_fresh.built_by(eng) + if by.get('engine_origin') != origin or by.get('engine') != eng: + bad.append(f"the file table records where the engine came from: {by.get('engine_origin')!r}") + json.dump(dict(lang='python', lang_auto=False, src='', src_arg='', library='', built=0, + files=ax_fresh.snapshot(repo, 'python', repo), built_by=by), + open(os.path.join(repo, '.axiomcode', 'out', 'files.json'), 'w')) + r = subprocess.run([sys.executable, '-c', 'import sys; sys.path.insert(0, sys.argv[1]); import ax_fresh; ' + 'sys.exit(ax_fresh.query(sys.argv[2], "impact", [sys.executable, "-c", "print(\'answer\')"]))', + os.path.join(PLUGIN, 'skills', 'axiomcode', 'scripts'), repo], + capture_output=True, text=True, timeout=60, env=dict(os.environ, AXIOMCODE_NO_REFRESH='1')) + said = [l for l in r.stderr.splitlines() if l.startswith('graph built by:')] + if loud and not (said and '9.9.9' in said[0] and eng in said[0] and 'fallback from /wt' in said[0]): + bad.append(f"an answer from a graph a fallback engine built names that engine: {r.stdout!r} {r.stderr!r}") + if not loud and said: + bad.append(f"an answer from a graph built by {origin!r} says nothing about the engine: {said}") + if 'answer' not in r.stdout: bad.append(f"the answer itself is given: {r.stdout!r} {r.stderr!r}") + os.environ.pop('AXIOMCODE_ENGINE_ORIGIN', None); os.environ.pop('AXIOMCODE_NO_ENGINE_CHECK', None) + return bad + + def main(): bad = [] with tempfile.TemporaryDirectory() as work: @@ -95,6 +188,8 @@ def main(): bad.append(f"with no engine anywhere, the message names {want!r}: {out[-600:]}") if got != 'none': bad.append(f"with no engine anywhere, nothing is run: ran {got}") + bad += says_which_engine(work, at) + bad += answers_name_the_engine() for b in bad: print('FAIL', b) print('ok' if not bad else f'{len(bad)} failure(s)') From 9b1991ef40cb0ef50774311259f2038c801dc9e7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:40:32 -0700 Subject: [PATCH 022/258] hooks: list the same impact rows whichever path answered; run fastpath.py in CI The edit hooks (changes.py, enrich.py) answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (axiomcode-impact, dl/impact.dl) when it declines. The rules put `alongside` rows (a sibling of the same type, a type declared in the same file) into `direct`; the fast path never makes them. So the same edit printed a different "reads / uses it" list depending on which path answered. Retyping the parameter of a Java method with no caller printed 1 row from the fast path and 3 from the rules, two of them siblings; on C# 1 against 2; on a Python method of a class, nothing against 2. Decision: alongside is not a use (no call, no reference, only that a fix touching one often touches the other), and a hook line is read as "what breaks", so neither path lists it there. `axiomcode impact` still prints it under its own heading, and --json still carries it in `direct`, unchanged. The change - graph_sql.hook_direct(j): the `direct` rows a hook lists, from either path's dict: every tier except alongside (HOOK_HIDDEN). - changes.py and enrich.py take their rows through it; hooks/validate.py counts the hook's "reads / uses it" line against the same rows, so it does not report the corrected line as a wrong count. - tests/fastpath.py compares `direct` through hook_direct, the function the hooks use, instead of dropping alongside rows in the test itself. It fails if the fast path ever emits an alongside row, and fails if the rules emitted none on any shape (so the tier is never compared on nothing). A fifth shape asks for the caller by bare name, where the rules' answer is only siblings. Then it runs hooks/changes.py on one edit per language twice, as it is and with the fast path switched off, and requires the two printed lists to name the same rows and no alongside row. - tests/fastpath_cases/python: the two callers are methods of one class, so the Python case has siblings too (as a free function it had none, and the tier went unchecked there). - CI: the engine job runs `python3 tests/fastpath.py --lang ` for python, java and csharp after the suite. Nothing in CI ran it before. Not typescript: its case needs the TypeScript engine. Checks run (on this commit and its base rebased onto the release branch tip) - tests/fastpath.py: python 6 of 6, java 6 of 6, csharp 6 of 6. - Hook smoke, same edit, both paths: python [] and [], java [Invoice.subtotalLabel] and [Invoice.subtotalLabel], csharp [Invoice.SubtotalLabel] and [Invoice.SubtotalLabel]. Before: the rules path added Invoice.Invoice and Invoice.totalLabel (java), Invoice.TotalLabel (csharp), Invoice. and Invoice.total_label (python). - Near miss: changes.py reading `direct` unfiltered fails the hook check on java and csharp (rules path lists alongside rows); fastpath.py comparing unfiltered `direct` fails 4 of 6 java shapes, each on alongside rows only. - tests/run.py: python 179 of 180, java 186 of 186, csharp 55 of 58. The FAIL lines (python and csharp lambda-is-named-by-its-place, csharp member-owner-is-its-type pending but passing) are the same on the release branch tip without this change. - tests/hook_languages.py 7 of 7, tests/enrich_lines.py 44 of 44, tests/test_command.py all passed. Seen, not changed here: the fast path locates a caller at its declaration line where the rules give the call site line (Invoice.java:8 against :9), and only the rules report unresolved calls inside. Neither changes which rows are listed. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .github/workflows/ci.yml | 11 +++ plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/enrich.py | 4 +- plugins/axiomcode/hooks/validate.py | 3 +- .../skills/axiomcode/scripts/graph_sql.py | 15 ++++ tests/fastpath.py | 88 ++++++++++++++++--- tests/fastpath_cases/python/orders/invoice.py | 10 +-- 7 files changed, 110 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 776ce8d4..96bbf45a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -448,6 +448,17 @@ 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 }} + # The hooks answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (dl/impact.dl) only when + # it declines, so the two must list the same rows for the same edit: tests/fastpath.py indexes a small case, asks + # both on each target shape, and runs hooks/changes.py on one edit through each path. Same parser and engine + # cache as the suite above. Not typescript: its case needs the TypeScript engine; javascript has no case. + - name: the hooks' fast path agrees with the rules (${{ matrix.lang }}) + if: matrix.lang == 'python' || matrix.lang == 'java' || matrix.lang == 'csharp' + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + run: python3 tests/fastpath.py --lang ${{ matrix.lang }} + # 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 diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 5a44f4da..939e530b 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -75,7 +75,7 @@ def impact(d): for d, j in results: hd = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(hd + " (impact unavailable)"); continue - con = j.get('contract', []); dr = sorted((x for x in j.get('direct', []) if x.get('certainty') != 'alongside'), key=lambda x: (rank.get(x['certainty'], 9), x['display'])); rc = j.get('reached', []); ts = j.get('tests', []) + con = j.get('contract', []); dr = sorted(graph_sql.hook_direct(j), key=lambda x: (rank.get(x['certainty'], 9), x['display'])); rc = j.get('reached', []); ts = j.get('tests', []) prod = [x for x in dr if x['role'] in ('produces', 'writes')]; reads = [x for x in dr if x['role'] in ('reads', 'uses')] # THE TIER TRAVELS WITH THE ROW OR IT IS NOT READ. The printed command labels every row [resolved] / # [by name] / [text]; this line dropped the label, so four rows of dataflow -- two of them reflective diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index a24f6836..e780b776 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -195,9 +195,7 @@ def impact(d): for d, j in results: head = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(head + " (impact unavailable)"); continue - # `alongside` (same file, same type; no call, no reference) is not a user: an answer from before it had its own list - # still carries it in `direct`, so it is dropped here too - con = j.get('contract', []); dr = [x for x in j.get('direct', []) if x.get('certainty') != 'alongside']; rc = j.get('reached', []); ts = j.get('tests', []) + con = j.get('contract', []); dr = graph_sql.hook_direct(j); rc = j.get('reached', []); ts = j.get('tests', []) # ordered as ax_edges.DIRECT_ORDER and impact's CERT are: an edge the engine asserted outranks a # name or a text match, and neither a hand-off nor a truncated fan-out outranks a resolved call. rank = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'in scope': 4, 'by name': 5, 'text': 6} diff --git a/plugins/axiomcode/hooks/validate.py b/plugins/axiomcode/hooks/validate.py index 8b10e117..bc893390 100644 --- a/plugins/axiomcode/hooks/validate.py +++ b/plugins/axiomcode/hooks/validate.py @@ -18,6 +18,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) from ax_contract import subtokens, is_synthetic # the annotation's own tokeniser, so this checks its claim +from graph_sql import hook_direct # the rows the hook lists, as the hook picks them HERE = os.path.dirname(os.path.abspath(__file__)) def hook(script, event, tool, inp, cwd, session='validate', extra=None): @@ -194,7 +195,7 @@ def check_change(self, text, rel, old_text, new_text): m3 = re.match(r' (produces / writes|reads / uses) it \((\d+)\): (.*)', l) if m3: roles = ('produces', 'writes') if m3.group(1).startswith('produces') else ('reads', 'uses') - rows = [x for x in j.get('direct', []) if x['role'] in roles] + rows = [x for x in hook_direct(j) if x['role'] in roles] self.fact(int(m3.group(2)) == len(rows), f"change {cur['symbol']}: {m3.group(1)} count {m3.group(2)} vs {len(rows)}") for nm in re.findall(r'(\S+) \S+:\d+', m3.group(3)): self.fact(any(x['display'] == nm for x in rows), f"change {cur['symbol']}: {nm} is not a {m3.group(1)} entry in impact") # the hop clause is optional because the line has carried one since the shim's depth was fixed — and diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 3d168396..7723be82 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -266,6 +266,21 @@ def _at(q, ids): return out +# THE ROWS A HOOK LISTS, WHICHEVER PATH ANSWERED. The hooks take `direct` from impact_shaped below when it answers and +# from `axiomcode-impact --json` when it declines. The rules also put `alongside` rows in `direct` (a sibling of the +# same type, a type declared in the same file): no call, no reference, only that a fix touching one often touches the +# other. impact_shaped never makes them, so the same edit listed them among "reads / uses it" when the rules answered +# and not when the fast path did: on a Java method with no caller, 1 row against 3. They are not uses, and a hook's +# line is read as "what breaks", so neither path lists them there. `axiomcode impact` still prints them under their +# own heading, and --json still carries them in `direct`. +HOOK_HIDDEN = frozenset({'alongside'}) + + +def hook_direct(j): + """the `direct` rows of an impact answer (either path's dict) that a hook lists: all but the HOOK_HIDDEN tiers.""" + return [x for x in (j or {}).get('direct', []) if x.get('certainty') not in HOOK_HIDDEN] + + def impact_shaped(repo, target, depth=DEPTH, tests_shown=3): """the same dict shape `hooks/changes.py` already formats from `axiomcode impact --json`, so the hook's presentation is untouched by the swap. `reached` and `tests` are lists because the formatter takes len() of diff --git a/tests/fastpath.py b/tests/fastpath.py index 8b016a23..69c002a1 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -13,8 +13,10 @@ python3 tests/fastpath.py [--lang python|java|csharp|typescript] [] -One small case per language, each with a function taking one parameter and two callers of it, and the same four -shapes asked of each. Defaults to Python, which indexes without a TypeScript engine compile; `--lang typescript` +One small case per language, each with a function taking one parameter and two callers of it (two methods of one +class, so each caller has a sibling), and the same shapes asked of each. Then the hook itself (hooks/changes.py) is run +on one edit twice, once as it is and once with the fast path switched off, and the two lists it prints must name the +same rows. Defaults to Python, which indexes without a TypeScript engine compile; `--lang typescript` is the original two-root TypeScript case, unchanged. A case dir given without --lang takes the language from its case.json, else Python. Indexes the case if it has no graph, and leaves the graph where it found it. """ @@ -32,13 +34,16 @@ # still belongs to the rules, so declining there is the right answer, not a gap. The same four shapes in every # language: the function, the function with its parameter, a CALLER with its parameter, and a parameter the # function does not have. Each target is written the way `changed` names it in that language. +# The fifth shape, the caller by bare name, has NO use at all in these cases, only siblings: on it the rules' answer +# is nothing but `alongside` rows, so it is the shape where listing them would be most of what the hook says. def shapes(fn, caller, param='cents'): - return [(fn, True), (f'{fn}({param})', True), (f'{caller}({param})', True), (f'{fn}(nosuch)', False)] + return [(fn, True), (f'{fn}({param})', True), (f'{caller}({param})', True), (f'{fn}(nosuch)', False), + (caller, True)] LANGS = { 'typescript': (os.path.join(HERE, 'cases', 'typescript', 'scope-spanning-two-roots'), - shapes('formatAmount', 'subtotalLabel')), - 'python': (os.path.join(FP, 'python'), shapes('format_amount', 'subtotal_label')), + shapes('formatAmount', 'subtotalLabel')[:4]), + 'python': (os.path.join(FP, 'python'), shapes('format_amount', 'Invoice.subtotal_label')), 'java': (os.path.join(FP, 'java'), shapes('Format.formatAmount', 'Invoice.subtotalLabel')), 'csharp': (os.path.join(FP, 'csharp'), shapes('Format.FormatAmount', 'Invoice.SubtotalLabel')), } @@ -56,18 +61,67 @@ def _args(argv): case, sh = LANGS[lang] return lang, os.path.abspath(a.case) if a.case else case, sh -# `alongside` rows (a sibling of the same type, a type in the same file) are not dependents: no call, no reference, -# only a co-change hint. The fast path covers the contract, resolved and by-name tiers (hooks/changes.py) and never -# emits them, so they are left out of the comparison and COUNTED instead. A free function has no siblings, so the -# TypeScript case never had one; a method in a class does, which is why the Java and C# cases need this. +# `direct` is compared as the HOOKS take it, through graph_sql.hook_direct, the one function both hooks read their rows +# through: every tier, except `alongside` (a sibling of the same type, a type in the same file: no call, no reference, +# only a co-change hint), which neither path lists in a hook. The rules still emit alongside rows; they are counted, +# and a run where the rules never emitted one fails, because then this check would pass on nothing. ALONG = 'alongside' def rels(d): d = d or {} return dict(contract=sorted({x['display'] for x in d.get('contract', [])}), - direct=sorted({x['display'] for x in d.get('direct', []) if x.get('certainty') != ALONG}), + direct=sorted({x['display'] for x in graph_sql.hook_direct(d)}), reached=len(d.get('reached', [])), tests=len(d.get('tests', []))) +# THE HOOK ON ONE EDIT, BOTH PATHS. The comparison above is of the dicts; this is of what the hook PRINTS, because +# that is what an agent reads, and a hook that took `direct` straight from the dict instead of through hook_direct +# would pass the check above and still list siblings when the rules answered. The edit retypes (Python: extends) the +# caller's parameter, which is the `Owner.m(param)` shape `changed` emits; the caller has no use, only a sibling. +EDITS = { + 'python': ('orders/invoice.py', 'def subtotal_label(self, cents):', 'def subtotal_label(self, cents, sep=": "):'), + 'java': ('src/main/java/shop/orders/Invoice.java', 'subtotalLabel(long cents)', 'subtotalLabel(int cents)'), + 'csharp': ('Orders/Invoice.cs', 'SubtotalLabel(long cents)', 'SubtotalLabel(int cents)'), +} +HOOK = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'changes.py') +# the rules path is taken by making the fast path decline, exactly as it does for a shape it does not cover +RUN_HOOK = ("import runpy, sys; sys.path.insert(0, sys.argv[1]); import graph_sql\n" + "if sys.argv[2] == 'rules': graph_sql.impact_shaped = lambda *a, **k: None\n" + "sys.argv = [sys.argv[3]]; runpy.run_path(sys.argv[0], run_name='__main__')") +ROW = __import__('re').compile(r'\[([^\]]+)\] (\S+) \S+:\d+') + +def hook_rows(case, lang, path): + rel, old, new = EDITS[lang] + sid = f'fastpath-{path}-{os.getpid()}' + ev = dict(hook_event_name='PreToolUse', tool_name='Edit', cwd=case, session_id=sid, + tool_input=dict(file_path=os.path.join(case, rel), old_string=old, new_string=new)) + r = subprocess.run([sys.executable, '-c', RUN_HOOK, SCR, path, HOOK], input=json.dumps(ev), capture_output=True, + text=True, cwd=case, timeout=60) + try: os.remove(os.path.join(case, '.axiomcode', f'hooks-state-{sid}.json')) + except OSError: pass + try: text = json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] + except Exception: return None, (r.stdout + r.stderr)[-300:] + rows = [m for l in text.splitlines() if l.lstrip().startswith(('reads / uses it', 'produces / writes it')) + for m in ROW.findall(l)] + return rows, text + +def check_hook(case, lang): + if lang not in EDITS: return 0 + got = {} + for path in ('fast', 'rules'): + rows, text = hook_rows(case, lang, path) + if rows is None: + print(f"FAIL hook ({path}): no block for the edit: {text!r}"); return 1 + tag = '[fast path]' if path == 'fast' else '[rules]' + if tag not in text: + print(f"FAIL hook ({path}): the block does not say {tag} answered, so this compared the wrong path"); return 1 + if any(c == ALONG for c, _ in rows): + print(f"FAIL hook ({path}): lists `alongside` rows as uses: {sorted(d for c, d in rows if c == ALONG)}"); return 1 + got[path] = sorted({d for _, d in rows}) + if got['fast'] != got['rules']: + print(f"FAIL hook: the same edit lists different rows by path: fast={got['fast']} rules={got['rules']}"); return 1 + print(f"ok hook on {EDITS[lang][0]}: both paths list {got['fast']}") + return 0 + def main(argv=None): LANG, CASE, SHAPES = _args(sys.argv[1:] if argv is None else argv) built = os.path.exists(os.path.join(CASE, '.axiomcode', 'out', 'graph.sqlite')) @@ -75,7 +129,7 @@ def main(argv=None): if not built: r = subprocess.run(['bash', AX, 'index', CASE, '--lang', LANG], capture_output=True, text=True) if r.returncode: print("FAIL index: " + (r.stderr or r.stdout)[-400:]); return 1 - bad = 0 + bad = 0; along = set() try: for target, must_answer in SHAPES: fast = graph_sql.impact_shaped(CASE, target) @@ -92,6 +146,9 @@ def main(argv=None): '--json', '--depth', '12'], capture_output=True, text=True) try: j = json.loads(cli.stdout) except Exception: print(f"FAIL {target!r}: the rules gave no JSON to compare against"); bad += 1; continue + along |= {x['display'] for x in j.get('direct', []) if x.get('certainty') == ALONG} + if any(x.get('certainty') == ALONG for x in fast.get('direct', [])): + print(f"FAIL {target!r}: the fast path emitted `alongside` rows, which it has no rule for"); bad += 1; continue a, b = rels(fast), rels(j) if a != b: print(f"FAIL {target!r}: fast path and rules disagree") @@ -101,11 +158,16 @@ def main(argv=None): else: al = sorted({x['display'] for x in j.get('alongside', [])}) print(f"ok {target!r}: {len(a['direct'])} direct, {a['reached']} reached — identical to the rules" - + (f" (the rules also list {len(al)} `alongside` row(s), not compared: {', '.join(al)})" if al else '')) + + (f" (neither lists the rules' {len(al)} `alongside` row(s) as direct: {', '.join(al)})" if al else '')) + if LANG in EDITS: + if not along: + print(f"FAIL the rules emitted no `alongside` row on any shape, so the tier was never compared"); bad += 1 + bad += check_hook(CASE, LANG) finally: if not keep: import shutil; shutil.rmtree(os.path.join(CASE, '.axiomcode'), ignore_errors=True) - print(f"\n{LANG}: {len(SHAPES) - bad} of {len(SHAPES)} shape(s) ok" + (" - FAILED" if bad else "")) + n = len(SHAPES) + (LANG in EDITS) + print(f"\n{LANG}: {n - bad} of {n} check(s) ok" + (" - FAILED" if bad else "")) return 1 if bad else 0 if __name__ == '__main__': diff --git a/tests/fastpath_cases/python/orders/invoice.py b/tests/fastpath_cases/python/orders/invoice.py index 74edcb0f..ad06f99b 100644 --- a/tests/fastpath_cases/python/orders/invoice.py +++ b/tests/fastpath_cases/python/orders/invoice.py @@ -1,9 +1,9 @@ from core.format import format_amount -def subtotal_label(cents): - return "Subtotal: " + format_amount(cents) +class Invoice: + def subtotal_label(self, cents): + return "Subtotal: " + format_amount(cents) - -def total_label(cents): - return "Total: " + format_amount(cents) + def total_label(self, cents): + return "Total: " + format_amount(cents) From f534aa347b386068dc90010f0ff5dc0e7a0e77f0 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:07:51 -0700 Subject: [PATCH 023/258] csharp: resolve field reads and writes, so Type.Field names its own readers Fixes #1445 What was wrong The C# engine had no rule that resolved a member access to a field. property_access (graph/csharp/engine/resolution/properties.dl) turns a member access that names a property into an accessor call, and nothing did the same for a field or a const. There was no field-access.csv export and no fieldAccess in the C# bundle adapter, so field_access was empty for C#. A reader of a field outside its own type was a name match, and a read of another type's same-named field (`_r.Limit` under `AuditOptions.Limit`) was listed as this field's reader. The plugin index also stored every C# member access twice: the MEMBER_ACCESS row, and its MEMBER_NAME child as a bare NAME_REFERENCE. A bare name is a by-name reader whatever its qualifier says, and MEMBER_ACCESS was not a qualified kind in axiomcode-impact, so the qualifier rule (`AuditOptions.SectionName` excludes `RetryOptions.SectionName`) never ran for C#. The change - graph/csharp/engine/call-edge-generation/field_access.dl: a field_access(site, caller, field, prov, tier, access) rule shaped like property_access. The qualifier is typed by expr_type (an instance access) or named as a type by ref_names_type (a static field or a const), then member_lookup(..., "field", f); an unqualified name the parser bound (ref_denotes FIELD) is taken as given, except a member access's own name child. The direction reuses properties.dl's write and read-write tests (assignment, object initializer, compound assignment, ++ and --). Tiers known_edge, multi_inferred and boundary_lib. Only resolved sites are rows: an unresolved C# member access is not known to be a field access. - export_manifest.tsv exports it as field-access.csv; decls_all.dl regenerated; graph/bundle/languages.ts maps raw.fieldAccess for C#; schema.ts lists csharp for the field_access values, with a note on what C# leaves out (unresolved sites, properties, enum members). - axiomcode-index: a C# MEMBER_NAME child is not stored as a ref of its own; its entity kind moves to the member access that stands for it. axiomcode-impact: MEMBER_ACCESS is a qualified kind. IMPACT_VERSION 39. - dl/impact.dl: fa_line. A name match on a line where the engine bound the access (to this field, or to another field of that name) adds nothing. A lambda written on the same line as the access is a different callable, so fa_known (per caller) let it through as an [in scope] reader that reads nothing. - axiomcode-path: the file-and-line G.enclosing is enclosing_at (the same rename as the impact paging fix), so a lambda label no longer raises a TypeError. - tests/cases/csharp/member-owner-is-its-type: the pending field_access check passes and is no longer pending, with a second near-miss control (`RetryOptions.Limit` lists `RetryService.Attempts` as resolved, and no AuditService reader), a readwrite site (`_o.Limit += 1`, a resolved writer and reader), and the const control (`RetryService.RetrySection` is not under `AuditOptions.SectionName`). The file:line pending check passed on the base already; its marker is removed. - tests/cases/csharp/lambda-is-named-by-its-place: the field initializer check wanted "field _twice", and the field is now displayed with its owner ("field Orders._twice"); it avoided any " 604, B 0 -> 402. | target | before: readers | after: readers | |---|---|---| | A: instance field with readers and writers | 11 in scope, 6 by name; 3 writers | 16 resolved; 8 writers | | A: static field whose name a library type also declares | 9 by name | 3 resolved, 6 by name (the library member) | | A: instance field of a struct | 4 in scope, 16 by name | 18 resolved, 1 in scope | | A: instance field of a nested class | 1 in scope, 10 by name | 9 resolved, 1 by name | | A: instance field read through a wrapper type | 52 in scope | 51 resolved | | B: const repeated on many sibling types, first type | none | 2 resolved | | B: the same const, second sibling type | none | 1 resolved | | B: delegate-typed field on an abstract base | 3 in scope | 2 resolved | | B: instance field of a collection subclass | 3 in scope | 2 resolved | | B: string const | 2 in scope, 2 by name | 4 resolved | Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- graph/bundle/languages.ts | 3 + graph/bundle/schema.ts | 19 ++-- .../call-edge-generation/field_access.dl | 105 ++++++++++++++++++ graph/csharp/souffle/decls_all.dl | 6 + graph/csharp/souffle/export_manifest.tsv | 1 + .../skills/axiomcode/scripts/axiomcode-impact | 4 +- .../skills/axiomcode/scripts/axiomcode-index | 13 ++- .../skills/axiomcode/scripts/dl/impact.dl | 10 +- .../lambda-is-named-by-its-place/case.json | 4 +- .../csharp/member-owner-is-its-type/case.json | 19 ++-- .../src/App/AuditService.cs | 1 + 11 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 graph/csharp/engine/call-edge-generation/field_access.dl diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index baee2954..dc86c997 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -376,6 +376,9 @@ const CSHARP: LanguageAdapter = { dispatchCandidates: { file: 'dispatch-candidates.csv', columns: [0, 1, 2] }, // (prov, type) — no "how"; every row is a construction typeInstantiated: { file: 'resolution-type-instantiated.csv', columns: [1], constant: 'new' }, + // site, caller, field, fieldProvenance, tier, access: the Java shape (#1445). Only resolved + // sites have a row; a property is a call and stays in call edges through its accessor. + fieldAccess: { file: 'field-access.csv', columns: [0, 1, 2, 3, 4, 5] }, }, ir: { // A C# method row carries BOTH its module and its type, and the type is empty for a diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 23e6b0ad..b4544ded 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -523,15 +523,15 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'fields', column: 'provenance', value: 'client', languages: ['java', 'typescript'], meaning: 'Declared in the analysed project.' }, { table: 'fields', column: 'provenance', value: 'lib', languages: ['java', 'typescript'], meaning: 'Declared in a staged library IR.' }, { table: 'fields', column: 'provenance', value: 'generated', languages: J, meaning: 'Declared by a compile-time annotation processor and synthesised by the bundle, same shape and same reason as methods.provenance `generated`.' }, - { table: 'field_access', column: 'access', value: 'read', languages: ['java', 'typescript'], meaning: 'The value is used and not replaced.' }, - { table: 'field_access', column: 'access', value: 'write', languages: ['java', 'typescript'], meaning: 'The value is replaced without being read: a plain assignment `f = v`.' }, - { table: 'field_access', column: 'access', value: 'readwrite', languages: ['java', 'typescript'], meaning: 'The value is read and replaced at the one site: a compound assignment `f += v`, or `f++` / `--f`. One row, not two — a consumer asking "who writes f" and one asking "who reads f" must both match it.' }, - { table: 'field_access', column: 'tier', value: 'known_edge', languages: ['java', 'typescript'], meaning: 'Exactly one field resolved. Stronger than the call_edges tier of the same name: a field is not virtually dispatched, so this IS the storage location the access binds to.' }, - { table: 'field_access', column: 'tier', value: 'multi_inferred', languages: ['java', 'typescript'], meaning: 'A sound SET: the receiver has more than one possible type, or two unrelated ancestors declare the name (which Java itself treats as ambiguous). Each member is one row.' }, - { table: 'field_access', column: 'tier', value: 'boundary_lib', languages: ['java', 'typescript'], meaning: 'The field is declared in a staged library type. field_id is set and resolves in `fields` with provenance lib.' }, + { table: 'field_access', column: 'access', value: 'read', languages: ['java', 'typescript', 'csharp'], meaning: 'The value is used and not replaced.' }, + { table: 'field_access', column: 'access', value: 'write', languages: ['java', 'typescript', 'csharp'], meaning: 'The value is replaced without being read: a plain assignment `f = v`.' }, + { table: 'field_access', column: 'access', value: 'readwrite', languages: ['java', 'typescript', 'csharp'], meaning: 'The value is read and replaced at the one site: a compound assignment `f += v`, or `f++` / `--f`. One row, not two — a consumer asking "who writes f" and one asking "who reads f" must both match it.' }, + { table: 'field_access', column: 'tier', value: 'known_edge', languages: ['java', 'typescript', 'csharp'], meaning: 'Exactly one field resolved. Stronger than the call_edges tier of the same name: a field is not virtually dispatched, so this IS the storage location the access binds to.' }, + { table: 'field_access', column: 'tier', value: 'multi_inferred', languages: ['java', 'typescript', 'csharp'], meaning: 'A sound SET: the receiver has more than one possible type, or two unrelated ancestors declare the name (which Java itself treats as ambiguous). Each member is one row.' }, + { table: 'field_access', column: 'tier', value: 'boundary_lib', languages: ['java', 'typescript', 'csharp'], meaning: 'The field is declared in a staged library type. field_id is set and resolves in `fields` with provenance lib.' }, { table: 'field_access', column: 'tier', value: 'ambiguous_unknown', languages: ['java', 'typescript'], meaning: 'Declared blind spot: the receiver could not be typed, or the name is not a member of the type it was typed to. field_id is NULL. Never dropped, and never replaced by a match on simple name.' }, - { table: 'field_access', column: 'field_provenance', value: 'client', languages: ['java', 'typescript'], meaning: 'The field is declared in the analysed project.' }, - { table: 'field_access', column: 'field_provenance', value: 'lib', languages: ['java', 'typescript'], meaning: 'The field is declared in a staged library IR.' }, + { table: 'field_access', column: 'field_provenance', value: 'client', languages: ['java', 'typescript', 'csharp'], meaning: 'The field is declared in the analysed project.' }, + { table: 'field_access', column: 'field_provenance', value: 'lib', languages: ['java', 'typescript', 'csharp'], meaning: 'The field is declared in a staged library IR.' }, // type_use.* (#663) { table: 'type_use', column: 'tier', value: 'known_edge', languages: ['java', 'typescript'], meaning: 'Exactly one type. A type reference is not dispatched, so this IS the declaration the name denotes.' }, @@ -765,8 +765,9 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'java', table: 'type_use', note: 'EVERY DEPTH is here, unlike the receiver-typing relations the engine uses internally, which filter to depth 0. A field of type `Map` produces three rows. Filter on `depth = 0` when you want the type an expression has rather than every type its declaration mentions.' }, { language: 'java', table: 'type_use', note: 'A TYPE_VARIABLE reference (`T`, `E`) is not a row: it names the declaration\'s own parameter, not a type. Where the parameter has a written bound the USE resolves to that bound and IS a row, so ` void f(T t)` records a use of Node.' }, { language: 'java', table: 'type_use', note: 'The reference rows carry no line in the Java IR (every all-type-references row has an empty startLine), so this table has no position columns. Use owner_method_id, or owner_type_id plus the types row, to locate a use.' }, - { language: 'all', table: 'field_access', note: 'JAVA AND TYPESCRIPT. The table is declared in every bundle and is EMPTY for Python, JavaScript and C# (C# fills `fields`, not `field_access`), so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field".' }, + { language: 'all', table: 'field_access', note: 'JAVA, TYPESCRIPT AND C#. The table is declared in every bundle and is EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field".' }, { language: 'all', table: 'fields', note: 'JAVA, TYPESCRIPT AND C#. EMPTY for Python and JavaScript: a Python attribute is a symbols row of kind field. In C# it holds true fields only; a property is a symbols row of kind field with a property id, and its accessors are methods rows (PROPERTY_GET, PROPERTY_SET, PROPERTY_INIT).' }, + { language: 'csharp', table: 'field_access', note: 'ONLY RESOLVED SITES ARE ROWS: there is no ambiguous_unknown tier for C#. A C# member access is a field, a property, an event or a method group until resolution says which, so an unresolved one is not known to be a field access (#1445). A PROPERTY is not here either: reading it is a call, in call_edges with kind property_read or property_write. Enum members are not rows, because `fields` does not list them.' }, { language: 'typescript', table: 'field_access', note: 'AN ACCESSOR IS NOT HERE. `get url()` read as `c.url` is a CALL, and call_edges already carries it with kind PROPERTY_READ or PROPERTY_WRITE (#703). The two tables are disjoint by construction: this one holds properties, call_edges holds accessors. Ask both when you want every read of a member.' }, { language: 'typescript', table: 'field_access', note: 'AN ELEMENT ACCESS IS NOT HERE either: `obj["x"]` with a literal key is a different node kind and is not yet a site. A known gap, not a silent one.' }, { language: 'typescript', table: 'field_access', note: 'A METHOD IS NOT A SITE. The callee of `obj.m()` is a PROPERTY_ACCESS node (37% of them, measured on one TypeScript library), and `const f = obj.m` reads a method as a value; neither is a data edge, and admitting them would fill the ambiguous_unknown tier with sites the engine HAS resolved elsewhere. Both are excluded and counted in ext_field_site_excluded with reasons method_callee and method_value.' }, diff --git a/graph/csharp/engine/call-edge-generation/field_access.dl b/graph/csharp/engine/call-edge-generation/field_access.dl new file mode 100644 index 00000000..af939052 --- /dev/null +++ b/graph/csharp/engine/call-edge-generation/field_access.dl @@ -0,0 +1,105 @@ +// ============================================================================ +// CALL-EDGE-GEN · FIELD ACCESS AS A DATA EDGE (#1445, the C# half of #663) +// +// One row per (member access, resolved field), with the direction the data moves and +// how well the site resolved: the same six columns Java and TypeScript export, so the +// bundle reads one shape for every front end and `impact Type.Field` answers a C# +// field from the engine rather than from a name match. +// +// WHY IT WAS MISSING. A C# member access `x.Name` is resolved by the engine, not the +// parser (resolution/properties.dl says why: whether `Name` is a field, a property or +// an event is a resolution outcome). properties.dl turned the PROPERTY outcome into an +// accessor call and nothing turned the FIELD outcome into anything, so `_o.Limit` and +// `AuditOptions.SectionName` had no edge at all. A reader of another type's same-named +// field was then indistinguishable from a reader of this one. +// +// NOTHING NEW IS RESOLVED HERE. The joins are property_access's, with "field" in place +// of "property" in the member lookup: the qualifier typed by expr_type (an instance +// access) or named as a type by ref_names_type (a static field or a const), then +// member_lookup, which already carries C#'s hiding law and the client-to-library base +// chain. An unqualified name the parser bound itself (ref_denotes FIELD) is taken as +// the parser gave it, as property_access_unqualified takes a bare property. +// +// A PROPERTY IS NOT HERE. It is a call, and call_chain.dl emits it through its accessor. +// The two are disjoint by construction: member_lookup's "field" kind holds fields and +// consts, its "property" kind holds properties. An ENUM MEMBER is not here either: the +// bundle's `fields` table does not list C# enum members, so a row naming one would +// point at nothing. +// +// ONLY RESOLVED SITES HAVE A ROW. Java and TypeScript also emit an ambiguous_unknown row +// for a field-access site that did not resolve, because their parsers say which sites +// are field accesses. A C# MEMBER_ACCESS is a field, a property, an event, a method +// group or a namespace step until resolution says which, so an unresolved one is not +// known to be a field access at all, and a row for it would claim a blind spot on a +// field that may not exist. Unresolved member accesses stay counted where they already +// are (member_lookup_miss_real, external_property_read). +// +// TIERS: +// known_edge exactly one field. A field is not virtually dispatched, so this is +// the storage location the access binds to. +// multi_inferred 2+: the receiver typed to more than one type, and each named a field. +// boundary_lib the field is declared in a staged library type. +// ============================================================================ + +// ── RESOLUTION ────────────────────────────────────────────────────────────── +// `x.Name` on an instance: the qualifier's type declares (or inherits) a field `Name`. +// A member access that is the callee of an invocation is a method, never a field. +cs_field_access_target(prov, acc, f) :- + expr_kind(prov, acc, "MEMBER_ACCESS"), + !member_access_is_callee(prov, acc), + expr_qualifier_child(prov, acc, q), + expr_member_name_child(prov, acc, nameExpr), + expr_written_name(prov, nameExpr, n), + expr_type(prov, q, recvGk), + member_lookup(prov, recvGk, n, "field", f). + +// `Type.Name`: a static field or a const read through a type name. +cs_field_access_target(prov, acc, f) :- + expr_kind(prov, acc, "MEMBER_ACCESS"), + !member_access_is_callee(prov, acc), + expr_qualifier_child(prov, acc, q), + expr_member_name_child(prov, acc, nameExpr), + expr_written_name(prov, nameExpr, n), + ref_names_type(prov, q, gk), + member_lookup(prov, gk, n, "field", f). + +// `Name` unqualified, with an implicit `this` or the declaring type: the parser binds +// these itself. The MEMBER_NAME child of a member access is excluded: the access is +// the site, and counting its name child as well would store every qualified access +// twice. +cs_field_access_target(prov, e, f) :- + ref_denotes(prov, e, "FIELD", f), + !cs_member_name_expr(prov, e). +cs_member_name_expr(prov, e) :- expr_member_name_child(prov, _, e). + +// ── ACCESS DIRECTION ──────────────────────────────────────────────────────── +// properties.dl's write / read-write tests, reused so the two relations cannot disagree +// about whether a position writes: +// write a plain assignment or an object-initializer target; the old value is not read +// readwrite a compound assignment or `++` / `--` +// read everything else +cs_field_access_kind(prov, e, "write") :- cs_field_access_target(prov, e, _), expr_is_write_target(prov, e). +cs_field_access_kind(prov, e, "readwrite") :- cs_field_access_target(prov, e, _), expr_is_readwrite_target(prov, e), + !expr_is_write_target(prov, e). +cs_field_access_kind(prov, e, "read") :- cs_field_access_target(prov, e, _), + !expr_is_write_target(prov, e), !expr_is_readwrite_target(prov, e). + +// ── TIER ──────────────────────────────────────────────────────────────────── +cs_field_access_count(prov, e, c) :- cs_field_access_target(prov, e, _), + c = count : { cs_field_access_target(prov, e, _) }. +cs_field_access_class(prov, e, "known_edge") :- cs_field_access_count(prov, e, 1). +cs_field_access_class(prov, e, "multi_inferred") :- cs_field_access_count(prov, e, c), c >= 2. + +// ── THE EXPORTED ROW — field_access(Site, Caller, Field, Prov, Tier, Access) ─ +// The caller is call_from_expr's, the same attribution an accessor edge gets: the +// enclosing method, or the type for a field initializer. Client sites only, as every +// other exported edge. +field_access(e, caller, f, "client", cls, acc) :- + cs_field_access_target("client", e, f), field_decl("client", _, _, f), + call_from_expr("client", e, caller), + cs_field_access_class("client", e, cls), cs_field_access_kind("client", e, acc). +field_access(e, caller, f, "lib", "boundary_lib", acc) :- + cs_field_access_target("client", e, f), field_decl("lib", _, _, f), + !field_decl("client", _, _, f), + call_from_expr("client", e, caller), + cs_field_access_kind("client", e, acc). diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 25be747d..2462cca0 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -146,6 +146,10 @@ .decl cs_ef_context_base(c0:symbol) .decl cs_ef_interceptor_register(c0:symbol) .decl cs_ef_save_call(c0:symbol,c1:symbol) +.decl cs_field_access_class(c0:symbol,c1:symbol,c2:symbol) +.decl cs_field_access_count(c0:symbol,c1:symbol,c2:number) +.decl cs_field_access_kind(c0:symbol,c1:symbol,c2:symbol) +.decl cs_field_access_target(c0:symbol,c1:symbol,c2:symbol) .decl cs_framework_base(c0:symbol) .decl cs_framework_callback(c0:symbol,c1:symbol,c2:symbol) .decl cs_framework_callback_prefix(c0:symbol,c1:symbol,c2:symbol) @@ -176,6 +180,7 @@ .decl cs_lower(c0:symbol,c1:symbol) .decl cs_med_dispatch(c0:symbol,c1:symbol,c2:symbol) .decl cs_med_sender_type(c0:symbol) +.decl cs_member_name_expr(c0:symbol,c1:symbol) .decl cs_mock_arg_matcher(c0:symbol) .decl cs_msg_builder(c0:symbol,c1:symbol) .decl cs_msg_bus_send(c0:symbol) @@ -324,6 +329,7 @@ .decl external_recv_name(c0:symbol,c1:symbol,c2:symbol) .decl external_target(c0:symbol,c1:symbol,c2:symbol) .decl external_type_name(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl field_access(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl field_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl field_in_type(c0:symbol,c1:symbol,c2:symbol) .decl field_initializer(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/csharp/souffle/export_manifest.tsv b/graph/csharp/souffle/export_manifest.tsv index a48d6138..19fc1f9c 100644 --- a/graph/csharp/souffle/export_manifest.tsv +++ b/graph/csharp/souffle/export_manifest.tsv @@ -39,3 +39,4 @@ entry_reachable entry-reachable.csv type_ancestor_type resolution-type-ancestor-type.csv override_pair resolution-virtual-override.csv framework_edge framework-edge.csv +field_access field-access.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 2200ec67..db7bdff2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -140,7 +140,7 @@ def merge_reasons(why, others): if not same: return why, others return why + ', ' + ', '.join(o[len(head):] for o in same), [o for o in others if o not in same] LOCAL_KINDS = {'LOCAL_VARIABLE', 'PARAMETER', 'LAMBDA_PARAMETER', 'VARIABLE', 'PARAM'} # a ref the parser says is a local, not a member -QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS'} +QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS', 'MEMBER_ACCESS'} # MEMBER_ACCESS: C# (#1445) MEMBER_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names # the fixtures a test framework runs before a test, own or inherited: a language-neutral convention table, printed as such FIXTURE_DECOR = re.compile(r'^(Before\w*|BeforeEach|BeforeAll|BeforeClass|fixture|setup\w*)$', re.I) @@ -767,7 +767,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 = '41' # 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '44' # 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 9130f20d..0515d1ac 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -154,6 +154,10 @@ A = { # a member's name (`obj.Count`, `this.value`) is in potentialQualifiedName; a bare name is in literalValue expr=dict(file='all-csharp-expressions.csv', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'csModuleLinkHash'), refKinds={'NAME_REFERENCE', 'MEMBER_ACCESS'}, entityKind='referencedEntityKind', + # `_o.Limit` is a MEMBER_ACCESS row AND a NAME_REFERENCE child `Limit` in the MEMBER_NAME role: one access, + # stored once, as the qualified access it is (#1445). Kept twice, the child was a BARE reference, and a bare + # name elsewhere is a by-name reader whatever its qualifier says, so `_r.Limit` was listed under AuditOptions.Limit + memberName=dict(role='edgeRole', value='MEMBER_NAME', parent='parentExpressionHash', id='csExpressionUniqueHash'), # the literal TYPE column is literalKind here, not literalType as in java/typescript/python litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='literalValue'), comments=dict(file='all-csharp-comments.csv', text='commentText', kind='commentKind', line='startLine', fileVia=('modules', 'csModuleLinkHash')), @@ -450,14 +454,21 @@ c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── e = A['expr']; refs = []; lits = [] namecol = e['name'] if isinstance(e['name'], str) else dict(x.split(':') for x in e['name']) +mname = e.get('memberName'); child_ek = {} +if mname: # the member-name child's entity kind, for the access that stands for it (the parent's own is UNKNOWN until resolved) + for r in rows(e['file']): + if r.get(mname['role']) == mname['value'] and r.get(mname['parent']): child_ek[r[mname['parent']]] = r.get(e['entityKind'], '') for r in rows(e['file']): k = r.get(e['kind'], '') + if mname and r.get(mname['role']) == mname['value'] and r.get(mname['parent']): continue if k in e['refKinds']: col = namecol if isinstance(namecol, str) else namecol.get(k) n = (r.get(col, '') if col else '') or (r.get(e['nameFallback'], '') if e.get('nameFallback') else '') if not n: continue n = n.rsplit('.', 1)[-1] - refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, r.get(e['entityKind'], ''))) + ek = r.get(e['entityKind'], '') + if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek + refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: v = (r.get(e['litValue']) or '') if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 151f4997..6b470ecb 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -442,7 +442,8 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) -fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll). +fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), + !fa_line(q, f, l). // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) @@ -509,6 +510,13 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl // a caller the engine bound — to this field, or to a DIFFERENT field of the same name. Either way its // name matches say nothing further: the first is already reported above with its direction, and the // second touches another declaration entirely and was previously printed as touching this one. +// A LINE the engine bound, to this field or to another field of the same name: a name match on that line says nothing +// more, whichever callable the line is attributed to. fa_known works per caller, and a lambda written on the same line +// as the access (`m.GetOrAdd(T.Culture + k, x => ...)`) is a different callable, so its name match on T.Culture came +// back as a second, [in scope] reader that reads nothing (#1445). +.decl fa_line(q:symbol, f:symbol, l:number) +fa_line(q, f, l) :- target(q, "field", fl, _), fa_bound(_, fl, _, f, l). +fa_line(q, f, l) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(_, fl2, _, f, l), fl2 != fl, field(fl2, _, n, _, _). .decl fa_known(q:symbol, c:symbol) 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, _, _). 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 index f9860d7b..40c1f884 100644 --- a/tests/cases/csharp/lambda-is-named-by-its-place/case.json +++ b/tests/cases/csharp/lambda-is-named-by-its-place/case.json @@ -30,8 +30,8 @@ "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", " AuditOptions.SectionName; public void Enable() { _o.Enabled = true; } public AuditOptions Options() => _o; + public void Raise() { _o.Limit += 1; } } From 65d61a9762ce07794073a628ba5e63f987dc25a9 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:47:37 -0700 Subject: [PATCH 024/258] csharp: conventional MVC routes, minimal-API variants, gRPC/HTTP client and service shapes Fixes #1437, #1438, #1439, #1440, #1481, #1484, #1485, #1488, #1533 Refs #1490 What was wrong. The C# destination rules (framework-behavior/destinations.dl) read a handful of written shapes and dropped the rest, so a handler was no entry point, a send had no remote edge, or a gRPC client was linked [exact] to the wrong service: - #1437: every controller route needed a verb or route attribute on the action, and nothing read MapControllerRoute or its pattern. - #1438, #1439, #1440: MapMethods, a handler held in a local, and a route-group prefix passed into a RouteGroupBuilder helper were not followed. - #1484, #1488, #1533: a client from GrpcClientFactory.CreateClient(), an inline new X.XClient(ch).Rpc(), and an HttpClient read from a static property or field (inline and through a var) had no written type name, so they sent nothing. - #1485: a service base written OrdersBase after using static, or reached through a project base class, was never read. - #1481: the gRPC key was / alone, so a client matched every same-named service in any namespace. The change. - Controllers: an action is a public, non-static, non-override, non-[NonAction] method of a non-abstract class named Controller or deriving from Controller/ControllerBase. Conventional routes (MapControllerRoute, MapRoute, MapDefaultControllerRoute, MapAreaControllerRoute) route every action with no attribute route and not in an [ApiController]: the pattern with {controller}, {action} and {area} filled in, the controller or action taken from defaults: new { ... } when the pattern omits it, and a template-less verb attribute narrowing the verb. A [Route("[controller]/[action]")] class also serves its unattributed actions (only when the template names [action]). - Minimal APIs: MapMethods reads its verbs from argument 1 and its handler from argument 2; a handler in a local is followed to its initializer; a group passed to a helper takes the prefix of each call site's builder. - Receivers: one relation, recv_value_written_name, names the type written at a value's source (object creation, a locator or client factory type argument, a property or a static or instance field), inline or through a var. The client factory is its own knob, not cs_di_resolve. - gRPC servers: the base may be Base after using static, and the service class may reach it through its own bases (type_ancestor). - gRPC join: each end gets the namespaces its can resolve in (a written qualifier, the using static namespace, or the enclosing namespaces plus the file's and global usings; framework and gRPC runtime namespaces are not evidence). With one service of a name the edge stays [exact]; with several, those whose namespaces meet the client's stay [exact] and the others are dropped; when none meets, all are kept at a new rung, service_name. - context (plugin, language-neutral): a route the question quotes seeded its handler from ext_remote_unsent alone, so a handler that a client in the graph reaches (now in ext_remote_edge) stopped being named as serving the route once these rules linked its client. It now reads ext_remote_edge too, and a method the engine names as a sender is no longer labelled as serving the route its URL literal spells. Not done: #1490 (gRPC JSON transcoding) needs a new parser layer for .proto files, so it stays open. Tests. New tests/run.py case csharp/context-route-a-client-reaches (a MapGet lambda in an AddRoute(IEndpointRouteBuilder) method, reached by a client; control: the client is not labelled as serving it); it fails 0/2 without the context change. New remote cases, each with near-miss controls: 05-mvc-conventional-routes, 06-grpc-variants, 07-http-client-receivers; 04-minimal-api-variants covers #1438-#1440. Every golden line was read. remote-edge-test 7/7 (01-04 unchanged), entry-points-test ok (both fixtures unchanged), graph/test/csharp/run-tests.sh "cases: 18 passed, 0 failed" and every tool check ok; tests/run.py --lang csharp 57/60: the only FAIL lines are the two the tip already has (lambda-is-named-by-its-place, member-owner-is-its-type pending marker), checked on a tip checkout; tests/run.py java cross-process-route and context-answers-what-was-asked 14/14, python route-table-reaches-view 9/9. tests/fastpath.py not run (it builds TypeScript). Smoke on two C# projects from the corpus (fresh index, real copies, before on the installed build, after on this tree): - an 85-file MVC project: http entry points 30 -> 80, every new one a conventionally routed controller action (impact on one now names it an http entry point instead of "a body change stays there"); remote_unsent 31 -> 81 (no client in the tree); remote edges, unserved and undetermined unchanged. - a 254-file solution with an MVC app and a minimal API: remote edges 4 -> 13, remote_unsent 30 -> 25, unserved and undetermined unchanged, entry points unchanged. The 9 new edges are integration tests sending through an HttpClient read from a static property; each was checked against the source (6 exact, 3 route_shape from one interpolated path built in a loop over three routes). context on a GET route served by three handlers names the same three before and after, and next: points at the same handler file in both. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/framework-behavior/destinations.dl | 314 +++++++++++++++++- .../csharp/engine/framework-behavior/knobs.dl | 41 +++ graph/csharp/souffle/decls_all.dl | 86 +++++ graph/test/csharp/README.md | 4 + .../04-minimal-api-variants/expected.remote | 16 + .../src/Api/Features/Endpoints.cs | 54 +++ .../src/Api/Features/Parcels.cs | 34 ++ .../src/Api/Program.cs | 39 +++ .../src/Web/DepotClient.cs | 42 +++ .../expected.remote | 11 + .../src/Client/ShopClient.cs | 21 ++ .../src/Web/Controllers/Controllers.cs | 69 ++++ .../src/Web/Program.cs | 7 + .../remote/06-grpc-variants/expected.remote | 10 + .../06-grpc-variants/src/Client/Callers.cs | 48 +++ .../src/Client/LooseCaller.cs | 16 + .../06-grpc-variants/src/Server/Services.cs | 46 +++ .../07-http-client-receivers/expected.remote | 5 + .../src/Api/Endpoints.cs | 20 ++ .../src/Checks/Checks.cs | 32 ++ .../axiomcode/scripts/axiomcode-context | 18 +- .../context-route-a-client-reaches/case.json | 9 + .../src/App/App.csproj | 3 + .../src/App/Orders.cs | 25 ++ 24 files changed, 951 insertions(+), 19 deletions(-) create mode 100644 graph/test/csharp/remote/04-minimal-api-variants/expected.remote create mode 100644 graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Endpoints.cs create mode 100644 graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Parcels.cs create mode 100644 graph/test/csharp/remote/04-minimal-api-variants/src/Api/Program.cs create mode 100644 graph/test/csharp/remote/04-minimal-api-variants/src/Web/DepotClient.cs create mode 100644 graph/test/csharp/remote/05-mvc-conventional-routes/expected.remote create mode 100644 graph/test/csharp/remote/05-mvc-conventional-routes/src/Client/ShopClient.cs create mode 100644 graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Controllers/Controllers.cs create mode 100644 graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Program.cs create mode 100644 graph/test/csharp/remote/06-grpc-variants/expected.remote create mode 100644 graph/test/csharp/remote/06-grpc-variants/src/Client/Callers.cs create mode 100644 graph/test/csharp/remote/06-grpc-variants/src/Client/LooseCaller.cs create mode 100644 graph/test/csharp/remote/06-grpc-variants/src/Server/Services.cs create mode 100644 graph/test/csharp/remote/07-http-client-receivers/expected.remote create mode 100644 graph/test/csharp/remote/07-http-client-receivers/src/Api/Endpoints.cs create mode 100644 graph/test/csharp/remote/07-http-client-receivers/src/Checks/Checks.cs create mode 100644 tests/cases/csharp/context-route-a-client-reaches/case.json create mode 100644 tests/cases/csharp/context-route-a-client-reaches/src/App/App.csproj create mode 100644 tests/cases/csharp/context-route-a-client-reaches/src/App/Orders.cs diff --git a/graph/csharp/engine/framework-behavior/destinations.dl b/graph/csharp/engine/framework-behavior/destinations.dl index 4c7a0e87..06ada5b9 100644 --- a/graph/csharp/engine/framework-behavior/destinations.dl +++ b/graph/csharp/engine/framework-behavior/destinations.dl @@ -48,9 +48,7 @@ call_recv_written_name(e, n) :- // …and a property read off another value, `services.Mediator.Send(..)`: a minimal-API // endpoint takes its services as one parameter object, and the receiver is that // object's property, whose declared type name is what the project wrote. -call_recv_written_name(e, n) :- - call_receiver_expr("client", e, r), property_access("client", r, p, _), - property_type_name("client", p, n, _), n != "". +// (recv_value_written_name below) // …and a `var` from the service locator, which holds its type argument call_recv_written_name(e, n) :- call_receiver_expr("client", e, r), @@ -59,6 +57,47 @@ call_recv_written_name(e, n) :- type_ref_owner("client", tr, init, "EXPRESSION"), type_ref_context("client", tr, "METHOD_TYPE_ARGUMENT"), !type_ref_parent("client", tr, _, _, _), type_ref_name("client", tr, n). +// The same shapes with no local in between, and a local holding a member read. Each names +// the type the project WROTE at the value's source: +// `new Pricing.PricingClient(ch).Quote(..)` the created type +// `factory.CreateClient("o").Place(..)` the type argument of a +// service locator or client factory +// `Shared.Client.GetAsync(..)`, `Shared.Field.GetAsync(..)` a property's or field's declared type +// `var c = Shared.Client; c.GetAsync(..)` the same, through a `var` +// A client factory is its own knob (cs_client_factory_generic), not cs_di_resolve, which +// also names messaging receivers. +recv_value_written_name(x, n) :- expr_new_type_ref("client", x, tr), type_ref_name("client", tr, n). +recv_value_written_name(x, n) :- csite("client", x), call_callee_name("client", x, f), cs_di_resolve(f), + type_ref_owner("client", tr, x, "EXPRESSION"), type_ref_context("client", tr, "METHOD_TYPE_ARGUMENT"), + !type_ref_parent("client", tr, _, _, _), type_ref_name("client", tr, n). +recv_value_written_name(x, n) :- csite("client", x), call_callee_name("client", x, f), cs_client_factory_generic(f), + type_ref_owner("client", tr, x, "EXPRESSION"), type_ref_context("client", tr, "METHOD_TYPE_ARGUMENT"), + !type_ref_parent("client", tr, _, _, _), type_ref_name("client", tr, n). +recv_value_written_name(x, n) :- property_access("client", x, p, _), property_type_name("client", p, n, _), n != "". +recv_value_written_name(x, n) :- expr_kind("client", x, "MEMBER_ACCESS"), + expr_qualifier_child("client", x, q), expr_member_name_child("client", x, nm), expr_written_name("client", nm, fn), + ref_names_type("client", q, gk), member_lookup("client", gk, fn, "field", f), + field_type_name("client", f, n, _), n != "". +recv_value_written_name(x, n) :- expr_kind("client", x, "MEMBER_ACCESS"), + expr_qualifier_child("client", x, q), expr_member_name_child("client", x, nm), expr_written_name("client", nm, fn), + expr_type("client", q, gk), member_lookup("client", gk, fn, "field", f), + field_type_name("client", f, n, _), n != "". +call_recv_written_name(e, n) :- call_receiver_expr("client", e, r), recv_value_written_name(r, n). +call_recv_written_name(e, n) :- + call_receiver_expr("client", e, r), + ref_denotes("client", r, "LOCAL_VARIABLE", v), var_is_implicit("client", v), + var_value_initializer("client", v, init), recv_value_written_name(init, n). +call_recv_written_name(e, n) :- + call_receiver_expr("client", e, r), + ref_denotes("client", r, "LOCAL_VARIABLE", v), var_is_implicit("client", v), + var_value_initializer("client", v, init), ref_denotes("client", init, "PROPERTY", p), + property_type_name("client", p, n, _), n != "". +call_recv_written_name(e, n) :- + call_receiver_expr("client", e, r), + ref_denotes("client", r, "LOCAL_VARIABLE", v), var_is_implicit("client", v), + var_value_initializer("client", v, init), ref_denotes("client", init, "FIELD", f), + field_type_name("client", f, n, _), n != "". + // ───────────────────────────────────────────────────────────────────────────── // 2. gRPC // ───────────────────────────────────────────────────────────────────────────── @@ -97,12 +136,22 @@ grpc_service_of_name(n, svc, sfx) :- grpc_name_segs(n, svc, tail), svc != "", // ── the server end ────────────────────────────────────────────────────────── // A method a service class OVERRIDES from `.Base` serves `/`. Only an // override: every rpc is a virtual on the generated base, and a helper written beside -// the rpcs is not one. -grpc_serves(m, d) :- - heritage_slot("client", t, _, bn, _, _), - cs_grpc_server_suffix(sfx), grpc_service_of_name(bn, svc, sfx), - method_owner("client", t, m), method_modifier("client", m, "override"), - method_name("client", m, n), d = cat(svc, cat("/", n)). +// the rpcs is not one. The base is written `.Base` (optionally qualified), or +// `Base` alone after `using static .;`, and the service class may reach it +// through the project's own bases (`StockService : AuditedStockBase : Stock.StockBase`). +// grpc_base_at(T0, Svc): the type whose heritage WRITES the generated base. +grpc_base_at(t0, svc) :- heritage_slot("client", t0, _, bn, _, _), + cs_grpc_server_suffix(sfx), grpc_service_of_name(bn, svc, sfx). +grpc_base_at(t0, svc) :- grpc_static_base(t0, svc, _). +grpc_static_base(t0, svc, st) :- heritage_slot("client", t0, _, bn, _, _), !contains(".", bn), + type_module("client", t0, mod), using_static_type("client", mod, st), last_segment(st, svc), + cs_grpc_server_suffix(sfx), bn = cat(svc, sfx). +last_segment_demand(st) :- using_static_type("client", _, st). +grpc_service_type(gk, t0, svc) :- grpc_base_at(t0, svc), type_group("client", t0, gk). +grpc_service_type(gk, t0, svc) :- type_ancestor("client", gk, agk), grpc_base_at(t0, svc), type_group("client", t0, agk). +grpc_serves_at(m, d, t0) :- grpc_service_type(gk, t0, svc), method_in_type("client", gk, m), + method_modifier("client", m, "override"), method_name("client", m, n), d = cat(svc, cat("/", n)). +grpc_serves(m, d) :- grpc_serves_at(m, d, _). // ── the client end ────────────────────────────────────────────────────────── // A call on a receiver written `.Client`. The call name is the rpc, or the rpc @@ -129,9 +178,73 @@ grpc_send_primary(e, from, d) :- grpc_send_site(e, from, svc, n), grpc_name_ends_async(n) :- grpc_send_site(_, _, _, n), cs_grpc_async_suffix(a), strlen(n) > strlen(a), substr(n, strlen(n) - strlen(a), strlen(a)) = a. +// ── which namespace each end's `` can be in ────────────────────────────── +// The generated `` is never in source, so each end is read for the namespaces its +// written name could resolve in: a qualifier it wrote (`Widgets.Rpc.Catalog.CatalogBase`, +// relative to each enclosing namespace too), the namespace a `using static` names, or, +// unqualified, the enclosing namespaces and the file's and the project's `using`s. Two +// services called Catalog in two namespaces are then told apart by whether the client's +// namespaces and the server's meet. Framework namespaces (System, Microsoft) and the +// gRPC runtime's own never hold a generated service and are not evidence. +grpc_ns_demand(ns) :- grpc_base_at(t0, _), type_namespace("client", t0, ns). +grpc_ns_demand(ns) :- grpc_send_name(e, _), grpc_send_ns_home(e, ns). +grpc_ns_dot(ns, i) :- grpc_ns_demand(ns), i = range(0, strlen(ns)), substr(ns, i, 1) = ".". +grpc_ns_prefix(ns, ns) :- grpc_ns_demand(ns), ns != "". +grpc_ns_prefix(ns, p) :- grpc_ns_dot(ns, i), p = substr(ns, 0, i). +grpc_name_prefix(n, q) :- grpc_name_prev_dot(n, p), q = substr(n, 0, p). +grpc_global_using(ns) :- using_decl("client", _, ns, _, u), using_is_global("client", u), + !using_is_static("client", u), !using_is_alias("client", u). + +// the server: T0 is the type whose heritage names the base +grpc_written_base(t0, bn) :- heritage_slot("client", t0, _, bn, _, _), cs_grpc_server_suffix(sfx), + grpc_service_of_name(bn, _, sfx). +grpc_srv_ns(t0, q) :- grpc_written_base(t0, bn), grpc_name_prefix(bn, q). +grpc_srv_ns(t0, cat(p, cat(".", q))) :- grpc_written_base(t0, bn), grpc_name_prefix(bn, q), + type_namespace("client", t0, ns), grpc_ns_prefix(ns, p). +grpc_srv_ns(t0, p) :- grpc_written_base(t0, bn), !grpc_name_has_prev(bn), + type_namespace("client", t0, ns), grpc_ns_prefix(ns, p). +grpc_srv_ns(t0, u) :- grpc_written_base(t0, bn), !grpc_name_has_prev(bn), + type_module("client", t0, mod), using_namespace("client", mod, u). +grpc_srv_ns(t0, u) :- grpc_written_base(t0, bn), !grpc_name_has_prev(bn), grpc_global_using(u). +grpc_srv_ns(t0, q) :- grpc_static_base(t0, _, st), grpc_name_last_dot(st, l), q = substr(st, 0, l). +grpc_name_demand(st) :- using_static_type("client", _, st), contains(".", st). + +// the client: the name its receiver wrote, and the namespace the calling method is in +grpc_send_name(e, rn) :- call_recv_written_name(e, rn), cs_grpc_client_suffix(sfx), + grpc_service_of_name(rn, _, sfx), csite("client", e). +grpc_send_ns_home(e, ns) :- grpc_send_name(e, _), call_from("client", e, from), + method_owner("client", t, from), type_namespace("client", t, ns). +grpc_cli_ns(e, q) :- grpc_send_name(e, rn), grpc_name_prefix(rn, q). +grpc_cli_ns(e, cat(p, cat(".", q))) :- grpc_send_name(e, rn), grpc_name_prefix(rn, q), + grpc_send_ns_home(e, ns), grpc_ns_prefix(ns, p). +grpc_cli_ns(e, p) :- grpc_send_name(e, rn), !grpc_name_has_prev(rn), grpc_send_ns_home(e, ns), grpc_ns_prefix(ns, p). +grpc_cli_ns(e, u) :- grpc_send_name(e, rn), !grpc_name_has_prev(rn), call_module("client", e, mod), + using_namespace("client", mod, u). +grpc_cli_ns(e, u) :- grpc_send_name(e, rn), !grpc_name_has_prev(rn), grpc_global_using(u). +grpc_ns_not_evidence(ns) :- grpc_cli_ns(_, ns), cs_grpc_runtime_namespace(ns). +grpc_ns_not_evidence(ns) :- grpc_cli_ns(_, ns), cs_framework_namespace_root(r), ns = r. +grpc_ns_not_evidence(ns) :- grpc_cli_ns(_, ns), cs_framework_namespace_root(r), + strlen(ns) > strlen(r), substr(ns, 0, strlen(r) + 1) = cat(r, "."). + +// ── the join ──────────────────────────────────────────────────────────────── +// One service of that name: [exact], as before. Several: those whose namespaces meet the +// client's stay [exact] and the others are dropped; when none meets (the namespace +// cannot be read), every one is kept, one rung down, at `service_name`. +grpc_pair(e, from, to, d, t0) :- grpc_send_cand(e, from, d), grpc_serves_at(to, d, t0), from != to. +grpc_pair_t0(e, d, t0) :- grpc_pair(e, _, _, d, t0). +grpc_pair_n(e, d, k) :- grpc_pair_t0(e, d, _), k = count : { grpc_pair_t0(e, d, _) }. +grpc_pair_scoped(e, d, t0) :- grpc_pair_t0(e, d, t0), grpc_cli_ns(e, ns), !grpc_ns_not_evidence(ns), + grpc_srv_ns(t0, ns). +grpc_has_scoped(e, d) :- grpc_pair_scoped(e, d, _). +remote_edge_via(e, from, to, tr, d, conf) :- + cs_remote_transport_grpc(tr), cs_remote_confidence_exact(conf), + grpc_pair(e, from, to, d, t0), grpc_pair_n(e, d, 1). remote_edge_via(e, from, to, tr, d, conf) :- cs_remote_transport_grpc(tr), cs_remote_confidence_exact(conf), - grpc_send_cand(e, from, d), grpc_serves(to, d), from != to. + grpc_pair(e, from, to, d, t0), grpc_pair_n(e, d, k), k > 1, grpc_pair_scoped(e, d, t0). +remote_edge_via(e, from, to, tr, d, conf) :- + cs_remote_transport_grpc(tr), cs_remote_confidence_service_name(conf), + grpc_pair(e, from, to, d, _), grpc_pair_n(e, d, k), k > 1, !grpc_has_scoped(e, d). remote_send_at(e, from, tr, d) :- cs_remote_transport_grpc(tr), grpc_send_primary(e, from, d). serves_destination(m, tr, d) :- cs_remote_transport_grpc(tr), grpc_serves(m, d). @@ -229,17 +342,147 @@ http_route_raw(m, p2, verb) :- ctl_route_c(m, p, verb), tok_at_a(p, i), method_n p2 = cat(substr(p, 0, i), cat(n, substr(p, i + 8, strlen(p) - i - 8))). http_route_raw(m, p, verb) :- ctl_route_c(m, p, verb), !contains("[action]", p). +// ── the server: MVC controllers and their actions ─────────────────────────── +// An action is a public instance method of a controller: a non-abstract class named +// `Controller` or deriving from Controller/ControllerBase, not [NonAction], not an +// override of the framework's own virtuals. +mvc_ctl(t, c) :- type_decl("client", tn, _, _, "CLASS", t), !type_modifier("client", t, "abstract"), + !type_modifier("client", t, "static"), cs_http_controller_suffix(sfx), ends_with_suffix(tn, sfx), + c = substr(tn, 0, strlen(tn) - strlen(sfx)). +mvc_ctl(t, tn) :- type_decl("client", tn, _, _, "CLASS", t), !type_modifier("client", t, "abstract"), + !type_modifier("client", t, "static"), cs_http_controller_suffix(sfx), !ends_with_suffix(tn, sfx), + heritage_slot("client", t, _, bn, _, _), last_segment(bn, b), cs_http_controller_base(b). +mvc_non_action(m) :- attr_on(m, "METHOD", n, _), cs_http_non_action_attr(n). +mvc_action(t, m) :- mvc_ctl(t, _), method_owner("client", t, m), method_kind("client", m, "METHOD"), + member_access("client", m, "PUBLIC"), !method_is_static("client", m), + !method_modifier("client", m, "override"), !mvc_non_action(m). + +// Attribute routing, the class's route with no route on the action: every action of a +// `[Route("[controller]/[action]")]` class is served there, for any verb. Only when the +// class template names [action], so each action has a route of its own; a public method +// under a template without it would share its sibling's route, and is more often a +// helper someone forgot to mark [NonAction] than a route anyone calls. +ctl_action(m, "", verb) :- mvc_action(t, m), ctl_base(t, b), contains("[action]", b), + !method_has_verb(m), !method_has_route_tmpl(m), cs_http_verb_any(verb). + +// ── the server: conventional routes ───────────────────────────────────────── +// `app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}")` routes +// every action that has no attribute route: its class has no [Route], it has no template +// of its own, and it is not in an [ApiController] (which requires attribute routing). +// The route is the pattern with {controller} and {action} (and {area}) filled in; a verb +// attribute with no template narrows the verb. A pattern that does not spell +// {controller} or {action} takes it from the call's defaults, `new { controller = "X" }`. +conv_route_call(e, k) :- call_callee_name("client", e, n), cs_http_conventional_route(n, k), csite("client", e). +conv_pattern(e, p) :- conv_route_call(e, k), http_arg_string(e, k, p). +conv_pattern(e, p) :- call_callee_name("client", e, n), cs_http_default_route(n, p), csite("client", e). +conv_pattern(e, p) :- call_callee_name("client", e, n), cs_http_area_route(n, _, k), csite("client", e), + http_arg_string(e, k, p). +conv_area_fixed(e, a) :- call_callee_name("client", e, n), cs_http_area_route(n, k, _), csite("client", e), + http_arg_string(e, k, a). +// a default: `defaults: new { controller = "Reports" }`. The parser writes an anonymous +// object's members as its children in order, each name followed by its value. +// (demanded by the callee's name, not by conv_pattern: the pattern's value is a string +// read through str_value, and so are these) +conv_call(e) :- conv_route_call(e, _). +conv_call(e) :- call_callee_name("client", e, n), cs_http_default_route(n, _), csite("client", e). +conv_call(e) :- call_callee_name("client", e, n), cs_http_area_route(n, _, _), csite("client", e). +conv_default_lit(e, key, raw) :- conv_call(e), http_arg(e, _, a), expr_kind("client", a, "ANONYMOUS_OBJECT"), + expr_child("client", a, _, kp, k), expr_written_name("client", k, key), cs_http_route_value_key(key), + expr_child("client", a, _, vp, val), to_number(vp) = to_number(kp) + 1, expr_literal("client", val, _, raw). +str_demand(raw) :- conv_default_lit(_, _, raw). +conv_default(e, key, v) :- conv_default_lit(e, key, raw), str_value(raw, v). + +// where `{name` opens a token in a pattern, and the `}` that closes it +conv_tok_at(p, nm, i) :- conv_pattern(_, p), cs_http_route_value_key(nm), tok = cat("{", nm), + contains(tok, p), i = range(0, strlen(p) - strlen(tok)), substr(p, i, strlen(tok)) = tok, + cs_route_token_end(substr(p, i + strlen(tok), 1)). +conv_has(p, nm) :- conv_tok_at(p, nm, _). +conv_tok_span(p, nm, i, j) :- conv_tok_at(p, nm, i), + j = min k : { k = range(i, strlen(p)), substr(p, k, 1) = "}" }. + +mvc_type_attr_routed(t) :- ctl_has_base(t). +mvc_type_attr_routed(t) :- attr_on(t, "TYPE", n, _), cs_http_api_controller_attr(n). +mvc_method_attr_routed(m) :- method_has_route_tmpl(m). +mvc_method_attr_routed(m) :- method_verb_attr(m, _, a), attr_has_template(a). +mvc_area(t, a) :- attr_on(t, "TYPE", n, at), cs_http_area_attr(n), attr_template(at, a). +mvc_has_area(t) :- mvc_area(t, _). +conv_action(t, m, c, mn) :- mvc_action(t, m), mvc_ctl(t, c), !mvc_type_attr_routed(t), !mvc_method_attr_routed(m), + method_name("client", m, mn). +conv_ctl_ok(e, p, t) :- conv_pattern(e, p), conv_has(p, "controller"), conv_action(t, _, _, _). +conv_ctl_ok(e, p, t) :- conv_pattern(e, p), !conv_has(p, "controller"), conv_default(e, "controller", c), + conv_action(t, _, c, _). +conv_act_ok(e, p, m) :- conv_pattern(e, p), conv_has(p, "action"), conv_action(_, m, _, _). +conv_act_ok(e, p, m) :- conv_pattern(e, p), !conv_has(p, "action"), conv_default(e, "action", a), + conv_action(_, m, _, a). +conv_area_ok(e, p, t, "") :- conv_pattern(e, p), !conv_has(p, "area"), !conv_area_fixed(e, _), + conv_action(t, _, _, _), !mvc_has_area(t). +conv_area_ok(e, p, t, a) :- conv_pattern(e, p), conv_has(p, "area"), !conv_area_fixed(e, _), mvc_area(t, a). +conv_area_ok(e, p, t, a) :- conv_pattern(e, p), conv_area_fixed(e, a), mvc_area(t, a). +conv_use(p, m, c, mn, a) :- conv_action(t, m, c, mn), conv_ctl_ok(e, p, t), conv_act_ok(e, p, m), + conv_area_ok(e, p, t, a). + +// fill the tokens in turn: controller, then action, then area +conv_s1(p, c, s) :- conv_use(p, _, c, _, _), conv_tok_span(p, "controller", i, j), + s = cat(substr(p, 0, i), cat(c, substr(p, j + 1, strlen(p) - j - 1))). +conv_s1(p, c, p) :- conv_use(p, _, c, _, _), !conv_has(p, "controller"). +conv_s2_demand(s, mn) :- conv_use(p, _, c, mn, _), conv_s1(p, c, s). +conv_s2_at(s, i) :- conv_s2_demand(s, _), tok = "{action", contains(tok, s), + i = range(0, strlen(s) - strlen(tok)), substr(s, i, strlen(tok)) = tok, cs_route_token_end(substr(s, i + 7, 1)). +conv_s2_has(s) :- conv_s2_at(s, _). +conv_s2(s, mn, o) :- conv_s2_demand(s, mn), conv_s2_at(s, i), j = min k : { k = range(i, strlen(s)), substr(s, k, 1) = "}" }, + o = cat(substr(s, 0, i), cat(mn, substr(s, j + 1, strlen(s) - j - 1))). +conv_s2(s, mn, s) :- conv_s2_demand(s, mn), !conv_s2_has(s). +conv_s3_demand(o, a) :- conv_use(p, _, c, mn, a), conv_s1(p, c, s), conv_s2(s, mn, o). +conv_s3_at(s, i) :- conv_s3_demand(s, _), tok = "{area", contains(tok, s), + i = range(0, strlen(s) - strlen(tok)), substr(s, i, strlen(tok)) = tok, cs_route_token_end(substr(s, i + 5, 1)). +conv_s3_has(s) :- conv_s3_at(s, _). +conv_s3(s, a, o) :- conv_s3_demand(s, a), conv_s3_at(s, i), j = min k : { k = range(i, strlen(s)), substr(s, k, 1) = "}" }, + o = cat(substr(s, 0, i), cat(a, substr(s, j + 1, strlen(s) - j - 1))). +conv_s3(s, a, s) :- conv_s3_demand(s, a), !conv_s3_has(s). +conv_route(m, r) :- conv_use(p, m, c, mn, a), conv_s1(p, c, s1), conv_s2(s1, mn, s2), conv_s3(s2, a, r). +conv_verb(m, verb) :- conv_route(m, _), method_verb_attr(m, verb, _). +conv_verb(m, verb) :- conv_route(m, _), !method_has_verb(m), cs_http_verb_any(verb). +http_route_raw(m, r, verb) :- conv_route(m, r), conv_verb(m, verb). + // ── the server: minimal APIs ──────────────────────────────────────────────── -map_call(e, verb) :- call_callee_name("client", e, n), cs_http_map_verb(n, verb), csite("client", e). +map_verb_call(e, verb) :- call_callee_name("client", e, n), cs_http_map_verb(n, verb), csite("client", e). +map_methods_call(e) :- call_callee_name("client", e, n), cs_http_map_methods(n), csite("client", e). map_group_call(e) :- call_callee_name("client", e, n), cs_http_map_group(n), csite("client", e). +// MapMethods: the verbs a collection spells, `new[] { "HEAD", "OPTIONS" }`, `["PUT"]`, +// `new List { ... }` or `new[] { HttpMethods.Head }`, inline or in a local. +// A list none of whose elements reads as a verb serves any verb, like a bare [Route]. +map_verbs_expr(e, l) :- map_methods_call(e), http_arg(e, 1, l). +map_verbs_expr(e, i) :- map_methods_call(e), http_arg(e, 1, l), + ref_denotes("client", l, "LOCAL_VARIABLE", v), var_value_initializer("client", v, i). +map_verb_elem(e, x) :- map_verbs_expr(e, l), expr_child("client", l, _, _, init), + expr_kind("client", init, "INITIALIZER"), expr_child("client", init, _, _, x). +map_verb_elem(e, x) :- map_verbs_expr(e, l), expr_child("client", l, "COLLECTION_ELEMENT", _, x). +str_demand(raw) :- map_verb_elem(_, x), expr_literal("client", x, _, raw). +map_methods_verb(e, verb) :- map_verb_elem(e, x), expr_literal("client", x, _, raw), str_value(raw, verb), + cs_http_method_member(_, verb). +map_methods_verb(e, verb) :- map_verb_elem(e, x), expr_kind("client", x, "MEMBER_ACCESS"), + expr_member_name_child("client", x, nm), expr_written_name("client", nm, mn), cs_http_method_member(mn, verb). +map_methods_has_verb(e) :- map_methods_verb(e, _). + +map_call(e, verb) :- map_verb_call(e, verb). +map_call(e, verb) :- map_methods_verb(e, verb). +map_call(e, verb) :- map_methods_call(e), !map_methods_has_verb(e), cs_http_verb_any(verb). + +// the handler is argument 1, or 2 on MapMethods; written there, or held in a local +map_handler_at(e, 1) :- map_verb_call(e, _). +map_handler_at(e, 2) :- map_methods_call(e). +map_handler_expr(e, h) :- map_handler_at(e, k), http_arg(e, k, h). +map_handler_expr(e, i) :- map_handler_at(e, k), http_arg(e, k, h), + ref_denotes("client", h, "LOCAL_VARIABLE", v), var_value_initializer("client", v, i). + // the handler: a method group, `Type.Method`, or a lambda (its own method row) -map_handler(e, m) :- map_call(e, _), http_arg(e, 1, h), expr_anon_decl("client", h, m). -map_handler(e, m) :- map_call(e, _), http_arg(e, 1, h), +map_handler(e, m) :- map_handler_expr(e, h), expr_anon_decl("client", h, m). +map_handler(e, m) :- map_handler_expr(e, h), expr_kind("client", h, "NAME_REFERENCE"), expr_ref_unknown("client", h), expr_written_name("client", h, n), !local_binds("client", h, _), expr_ultimate_type_group("client", h, gk), member_lookup("client", gk, n, "method", m). -map_handler(e, m) :- map_call(e, _), http_arg(e, 1, h), +map_handler(e, m) :- map_handler_expr(e, h), expr_kind("client", h, "MEMBER_ACCESS"), expr_qualifier_child("client", h, q), expr_member_name_child("client", h, nm), expr_written_name("client", nm, n), delegate_qualifier_type("client", q, gk), @@ -253,7 +496,7 @@ gp_demand(init) :- gp_demand(r), ref_denotes("client", r, "LOCAL_VARIABLE", v), gp_demand(rr) :- gp_demand(r), csite("client", r), call_receiver_expr("client", r, rr). gp_is_local(r) :- gp_demand(r), ref_denotes("client", r, "LOCAL_VARIABLE", v), var_initializer("client", v, _). gp_is_call(r) :- gp_demand(r), csite("client", r), call_receiver_expr("client", r, _). -group_prefix(r, "") :- gp_demand(r), !gp_is_local(r), !gp_is_call(r). +group_prefix(r, "") :- gp_demand(r), !gp_is_local(r), !gp_is_call(r), !gp_is_param(r). group_prefix(r, p) :- gp_demand(r), ref_denotes("client", r, "LOCAL_VARIABLE", v), var_initializer("client", v, init), group_prefix(init, p). group_prefix(r, p) :- gp_demand(r), csite("client", r), !map_group_call(r), @@ -261,9 +504,46 @@ group_prefix(r, p) :- gp_demand(r), csite("client", r), !map_group_call(r), group_prefix(r, g) :- gp_demand(r), map_group_call(r), http_arg_string(r, 0, g), call_receiver_expr("client", r, rr), group_prefix(rr, ""). group_prefix(r, p) :- gp_demand(r), map_group_call(r), http_arg_string(r, 0, g), - call_receiver_expr("client", r, rr), group_prefix(rr, pp), pp != "", !starts_slash(g), p = cat(pp, cat("/", g)). + call_receiver_expr("client", r, rr), group_prefix(rr, pp), pp != "", strlen(pp) < 512, !starts_slash(g), + p = cat(pp, cat("/", g)). group_prefix(r, p) :- gp_demand(r), map_group_call(r), http_arg_string(r, 0, g), - call_receiver_expr("client", r, rr), group_prefix(rr, pp), pp != "", starts_slash(g), p = cat(pp, g). + call_receiver_expr("client", r, rr), group_prefix(rr, pp), pp != "", strlen(pp) < 512, starts_slash(g), + p = cat(pp, g). +// (the length bound only stops a helper that maps a sub-group of itself from recursing) + +// A group handed to a helper: `app.MapGroup("/api/orders").MapOrderEndpoints()` with +// `MapOrderEndpoints(this RouteGroupBuilder g) { g.MapGet(...); }`. The parameter takes +// the prefix of the builder at EACH call site, so a helper mapped under two groups +// serves under both. A call binds the helper when it resolved to it, or, since the +// builder type is never staged and such a call resolves to nothing, when it names the +// one route-builder extension of that name and arity in scope. Without a bound call +// the parameter is a root, as before. +gp_bind_param(w, p, k) :- param_decl("client", w, pos, _, p), param_type_name("client", p, tn, _), + cs_http_route_builder_type(tn), k = to_number(pos). +gp_w_nparams(w, n) :- gp_bind_param(w, _, _), n = count : { param_decl("client", w, _, _, _) }. +gp_ext_named(w, n, mod) :- gp_bind_param(w, p, 0), extension_method("client", w, p), + method_name("client", w, n), extension_in_scope("client", mod, w). +gp_named_call(c, n, mod) :- call_callee_name("client", c, n), gp_ext_named(_, n, mod), csite("client", c), + call_module("client", c, mod), call_receiver_expr("client", c, _), !expr_resolves_to_method("client", c, _). +gp_c_nargs(c, n) :- gp_named_call(c, _, _), n = count : { arg_index(c, _, _) }. +gp_c_nargs(c, n) :- gp_bind_param(w, _, _), expr_resolves_to_method("client", c, w), csite("client", c), + n = count : { arg_index(c, _, _) }. +gp_named_cand(c, w) :- gp_named_call(c, n, mod), gp_ext_named(w, n, mod), gp_c_nargs(c, na), gp_w_nparams(w, na + 1). +gp_named_ncand(c, k) :- gp_named_cand(c, _), k = count : { gp_named_cand(c, _) }. +gp_helper_call(c, w) :- gp_bind_param(w, _, _), expr_resolves_to_method("client", c, w), csite("client", c). +gp_helper_call(c, w) :- gp_named_cand(c, w), gp_named_ncand(c, 1). +// extension form: the receiver is parameter 0 and argument k-1 is parameter k +gp_bind_arg(p, a) :- gp_helper_call(c, w), extension_method("client", w, _), gp_c_nargs(c, na), + gp_w_nparams(w, na + 1), gp_bind_param(w, p, 0), call_receiver_expr("client", c, a). +gp_bind_arg(p, a) :- gp_helper_call(c, w), extension_method("client", w, _), gp_c_nargs(c, na), + gp_w_nparams(w, na + 1), gp_bind_param(w, p, k), k > 0, arg_index(c, k - 1, a). +// a plain or statically written call: argument k is parameter k +gp_bind_arg(p, a) :- gp_helper_call(c, w), gp_c_nargs(c, na), gp_w_nparams(w, na), + gp_bind_param(w, p, k), arg_index(c, k, a). +gp_param_arg(r, a) :- gp_demand(r), ref_denotes("client", r, "PARAMETER", p), gp_bind_arg(p, a). +gp_demand(a) :- gp_param_arg(_, a). +gp_is_param(r) :- gp_param_arg(r, _). +group_prefix(r, p) :- gp_param_arg(r, a), group_prefix(a, p). http_route_raw(m, p, verb) :- map_call(e, verb), map_handler(e, m), http_arg_string(e, 0, t), call_receiver_expr("client", e, r), group_prefix(r, ""), p = t. diff --git a/graph/csharp/engine/framework-behavior/knobs.dl b/graph/csharp/engine/framework-behavior/knobs.dl index 1ead8281..5ccea7c7 100644 --- a/graph/csharp/engine/framework-behavior/knobs.dl +++ b/graph/csharp/engine/framework-behavior/knobs.dl @@ -26,8 +26,23 @@ cs_grpc_client_member_not_rpc("NewInstance"). cs_framework_namespace_root("System"). cs_framework_namespace_root("Microsoft"). +// The gRPC runtime's own namespaces. A file `using` them says nothing about which +// namespace a generated service is in, so they never tell two same-named services apart. +cs_grpc_runtime_namespace("Grpc.Core"). +cs_grpc_runtime_namespace("Grpc.Net.Client"). +cs_grpc_runtime_namespace("Grpc.Net.ClientFactory"). +cs_grpc_runtime_namespace("Grpc.AspNetCore.Server"). +cs_grpc_runtime_namespace("Google.Protobuf"). +cs_grpc_runtime_namespace("Google.Protobuf.WellKnownTypes"). +// A factory whose type argument is the client it returns: +// `factory.CreateClient("orders")` (Grpc.Net.ClientFactory). +cs_client_factory_generic("CreateClient"). + cs_remote_transport_grpc("grpc"). cs_remote_confidence_exact("exact"). +// a gRPC send whose service name several services share, none in a namespace the +// client can see: every one is kept, a rung below exact +cs_remote_confidence_service_name("service_name"). // ── HTTP: ASP.NET Core on the server ──────────────────────────────────────── // Attribute routing. `Route` carries a template and no verb; each Http carries a @@ -44,12 +59,38 @@ cs_http_verb_attr("HttpOptions", "OPTIONS"). cs_http_verb_attr("HttpOptionsAttri // The suffix ASP.NET strips from a controller's class name for the [controller] token. cs_http_controller_suffix("Controller"). // Minimal APIs: `app.MapGet(template, handler)`, and a route group's prefix. +// Controllers: the bases that make a class a controller whatever its name, the attribute +// that keeps a public method from being an action, an [Area], and [ApiController], which +// requires attribute routing (conventional routes never reach it). +cs_http_controller_base("Controller"). +cs_http_controller_base("ControllerBase"). +cs_http_non_action_attr("NonAction"). cs_http_non_action_attr("NonActionAttribute"). +cs_http_area_attr("Area"). cs_http_area_attr("AreaAttribute"). +cs_http_api_controller_attr("ApiController"). cs_http_api_controller_attr("ApiControllerAttribute"). +// Conventional routes: the method and the argument its pattern is in. The default route +// has its pattern fixed; an area route names the area in one argument, the pattern in another. +cs_http_conventional_route("MapControllerRoute", 1). +cs_http_conventional_route("MapRoute", 1). +cs_http_area_route("MapAreaControllerRoute", 1, 2). +cs_http_area_route("MapAreaRoute", 1, 2). +cs_http_default_route("MapDefaultControllerRoute", "{controller=Home}/{action=Index}/{id?}"). +// the route values a pattern or its defaults name, and what may follow a token's name +cs_http_route_value_key("controller"). cs_http_route_value_key("action"). cs_http_route_value_key("area"). +cs_route_token_end("}"). cs_route_token_end("="). cs_route_token_end(":"). cs_route_token_end("?"). cs_http_map_verb("MapGet", "GET"). cs_http_map_verb("MapPost", "POST"). cs_http_map_verb("MapPut", "PUT"). cs_http_map_verb("MapDelete", "DELETE"). cs_http_map_verb("MapPatch", "PATCH"). cs_http_map_group("MapGroup"). +// `app.MapMethods(template, verbs, handler)`: the verbs are a collection in argument 1, +// each a literal or a `HttpMethods.Head` member (cs_http_method_member below), and the +// handler is argument 2. +cs_http_map_methods("MapMethods"). +// The builder types an endpoint-mapping helper takes, `this RouteGroupBuilder g`: the +// prefix of the group each call site passes carries into the helper's MapX calls. +cs_http_route_builder_type("RouteGroupBuilder"). +cs_http_route_builder_type("IEndpointRouteBuilder"). // ── HTTP: the client ──────────────────────────────────────────────────────── // A receiver whose written type is HttpClient, and the verb each of its calls sends. diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 2462cca0..7c615ee7 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -136,12 +136,38 @@ .decl client_glued_base(c0:symbol,c1:symbol) .decl client_path(c0:symbol) .decl client_shift(c0:symbol,c1:symbol,c2:number) +.decl conv_act_ok(c0:symbol,c1:symbol,c2:symbol) +.decl conv_action(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl conv_area_fixed(c0:symbol,c1:symbol) +.decl conv_area_ok(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl conv_call(c0:symbol) +.decl conv_ctl_ok(c0:symbol,c1:symbol,c2:symbol) +.decl conv_default(c0:symbol,c1:symbol,c2:symbol) +.decl conv_default_lit(c0:symbol,c1:symbol,c2:symbol) +.decl conv_has(c0:symbol,c1:symbol) +.decl conv_pattern(c0:symbol,c1:symbol) +.decl conv_route(c0:symbol,c1:symbol) +.decl conv_route_call(c0:symbol,c1:number) +.decl conv_s1(c0:symbol,c1:symbol,c2:symbol) +.decl conv_s2(c0:symbol,c1:symbol,c2:symbol) +.decl conv_s2_at(c0:symbol,c1:number) +.decl conv_s2_demand(c0:symbol,c1:symbol) +.decl conv_s2_has(c0:symbol) +.decl conv_s3(c0:symbol,c1:symbol,c2:symbol) +.decl conv_s3_at(c0:symbol,c1:number) +.decl conv_s3_demand(c0:symbol,c1:symbol) +.decl conv_s3_has(c0:symbol) +.decl conv_tok_at(c0:symbol,c1:symbol,c2:number) +.decl conv_tok_span(c0:symbol,c1:symbol,c2:number,c3:number) +.decl conv_use(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl conv_verb(c0:symbol,c1:symbol) .decl conversion_candidate_type(c0:symbol,c1:symbol,c2:symbol) .decl conversion_target_name(c0:symbol,c1:symbol,c2:symbol) .decl cs_alias_framework_type(c0:symbol,c1:symbol) .decl cs_amqp_bind(c0:symbol) .decl cs_amqp_publish(c0:symbol) .decl cs_attr_suffix(c0:symbol,c1:symbol) +.decl cs_client_factory_generic(c0:symbol) .decl cs_di_resolve(c0:symbol) .decl cs_ef_context_base(c0:symbol) .decl cs_ef_interceptor_register(c0:symbol) @@ -162,18 +188,29 @@ .decl cs_grpc_client_member_not_rpc(c0:symbol) .decl cs_grpc_client_suffix(c0:symbol) .decl cs_grpc_map_service(c0:symbol) +.decl cs_grpc_runtime_namespace(c0:symbol) .decl cs_grpc_server_suffix(c0:symbol) .decl cs_heritage_in_source(c0:symbol,c1:symbol) +.decl cs_http_api_controller_attr(c0:symbol) +.decl cs_http_area_attr(c0:symbol) +.decl cs_http_area_route(c0:symbol,c1:number,c2:number) .decl cs_http_client_factory_method(c0:symbol) .decl cs_http_client_method(c0:symbol,c1:symbol) .decl cs_http_client_type(c0:symbol) +.decl cs_http_controller_base(c0:symbol) .decl cs_http_controller_suffix(c0:symbol) +.decl cs_http_conventional_route(c0:symbol,c1:number) .decl cs_http_declarative_attr(c0:symbol,c1:symbol) +.decl cs_http_default_route(c0:symbol,c1:symbol) .decl cs_http_map_group(c0:symbol) +.decl cs_http_map_methods(c0:symbol) .decl cs_http_map_verb(c0:symbol,c1:symbol) .decl cs_http_method_member(c0:symbol,c1:symbol) +.decl cs_http_non_action_attr(c0:symbol) .decl cs_http_request_message_type(c0:symbol) .decl cs_http_route_attr(c0:symbol) +.decl cs_http_route_builder_type(c0:symbol) +.decl cs_http_route_value_key(c0:symbol) .decl cs_http_send_message_method(c0:symbol) .decl cs_http_verb_any(c0:symbol) .decl cs_http_verb_attr(c0:symbol,c1:symbol) @@ -198,8 +235,10 @@ .decl cs_remote_confidence_binding(c0:symbol) .decl cs_remote_confidence_exact(c0:symbol) .decl cs_remote_confidence_route_shape(c0:symbol) +.decl cs_remote_confidence_service_name(c0:symbol) .decl cs_remote_transport_grpc(c0:symbol) .decl cs_remote_transport_http(c0:symbol) +.decl cs_route_token_end(c0:symbol) .decl cs_servicebus_handler_event(c0:symbol) .decl cs_string_passthrough(c0:symbol) .decl cs_string_type(c0:symbol) @@ -344,23 +383,54 @@ .decl generated_accessor_read(c0:symbol,c1:symbol,c2:symbol) .decl generated_property(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl generated_property_type(c0:symbol,c1:symbol,c2:symbol) +.decl gp_bind_arg(c0:symbol,c1:symbol) +.decl gp_bind_param(c0:symbol,c1:symbol,c2:number) +.decl gp_c_nargs(c0:symbol,c1:number) .decl gp_demand(c0:symbol) +.decl gp_ext_named(c0:symbol,c1:symbol,c2:symbol) +.decl gp_helper_call(c0:symbol,c1:symbol) .decl gp_is_call(c0:symbol) .decl gp_is_local(c0:symbol) +.decl gp_is_param(c0:symbol) +.decl gp_named_call(c0:symbol,c1:symbol,c2:symbol) +.decl gp_named_cand(c0:symbol,c1:symbol) +.decl gp_named_ncand(c0:symbol,c1:number) +.decl gp_param_arg(c0:symbol,c1:symbol) +.decl gp_w_nparams(c0:symbol,c1:number) .decl group_member_substituted(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl group_prefix(c0:symbol,c1:symbol) +.decl grpc_base_at(c0:symbol,c1:symbol) +.decl grpc_cli_ns(c0:symbol,c1:symbol) +.decl grpc_global_using(c0:symbol) +.decl grpc_has_scoped(c0:symbol,c1:symbol) .decl grpc_name_demand(c0:symbol) .decl grpc_name_dot(c0:symbol,c1:number) .decl grpc_name_ends_async(c0:symbol) .decl grpc_name_has_prev(c0:symbol) .decl grpc_name_last_dot(c0:symbol,c1:number) +.decl grpc_name_prefix(c0:symbol,c1:symbol) .decl grpc_name_prev_dot(c0:symbol,c1:number) .decl grpc_name_segs(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_ns_demand(c0:symbol) +.decl grpc_ns_dot(c0:symbol,c1:number) +.decl grpc_ns_not_evidence(c0:symbol) +.decl grpc_ns_prefix(c0:symbol,c1:symbol) +.decl grpc_pair(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl grpc_pair_n(c0:symbol,c1:symbol,c2:number) +.decl grpc_pair_scoped(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_pair_t0(c0:symbol,c1:symbol,c2:symbol) .decl grpc_send_cand(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_send_name(c0:symbol,c1:symbol) +.decl grpc_send_ns_home(c0:symbol,c1:symbol) .decl grpc_send_primary(c0:symbol,c1:symbol,c2:symbol) .decl grpc_send_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl grpc_serves(c0:symbol,c1:symbol) +.decl grpc_serves_at(c0:symbol,c1:symbol,c2:symbol) .decl grpc_service_of_name(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_service_type(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_srv_ns(c0:symbol,c1:symbol) +.decl grpc_static_base(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_written_base(c0:symbol,c1:symbol) .decl heritage_has_ctor_args(c0:symbol,c1:symbol,c2:symbol) .decl heritage_of_entity(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl heritage_resolves(c0:symbol,c1:symbol,c2:symbol,c3:symbol) @@ -433,6 +503,14 @@ .decl map_call(c0:symbol,c1:symbol) .decl map_group_call(c0:symbol) .decl map_handler(c0:symbol,c1:symbol) +.decl map_handler_at(c0:symbol,c1:number) +.decl map_handler_expr(c0:symbol,c1:symbol) +.decl map_methods_call(c0:symbol) +.decl map_methods_has_verb(c0:symbol) +.decl map_methods_verb(c0:symbol,c1:symbol) +.decl map_verb_call(c0:symbol,c1:symbol) +.decl map_verb_elem(c0:symbol,c1:symbol) +.decl map_verbs_expr(c0:symbol,c1:symbol) .decl marker_scrub(c0:symbol,c1:symbol) .decl mb_demand(c0:symbol) .decl med_arg_clash(c0:symbol,c1:symbol) @@ -578,6 +656,13 @@ .decl msg_request_site(c0:symbol,c1:symbol,c2:symbol) .decl msg_type_arg(c0:symbol,c1:symbol) .decl msg_typed_arg(c0:symbol) +.decl mvc_action(c0:symbol,c1:symbol) +.decl mvc_area(c0:symbol,c1:symbol) +.decl mvc_ctl(c0:symbol,c1:symbol) +.decl mvc_has_area(c0:symbol) +.decl mvc_method_attr_routed(c0:symbol) +.decl mvc_non_action(c0:symbol) +.decl mvc_type_attr_routed(c0:symbol) .decl name_to_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl nested_declares(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl new_target_decl_name(c0:symbol,c1:symbol,c2:symbol,c3:symbol) @@ -694,6 +779,7 @@ .decl query_clause_body(c0:symbol,c1:symbol,c2:symbol) .decl query_clause_descending(c0:symbol,c1:symbol) .decl query_clause_source(c0:symbol,c1:symbol,c2:symbol) +.decl recv_value_written_name(c0:symbol,c1:symbol) .decl ref_binds_value(c0:symbol,c1:symbol) .decl ref_denotes(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl ref_denotes_base(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/test/csharp/README.md b/graph/test/csharp/README.md index 78c6e880..b3af0241 100644 --- a/graph/test/csharp/README.md +++ b/graph/test/csharp/README.md @@ -222,6 +222,10 @@ bases are not in the source, and stubbing them would make them the project's own | `01-grpc` | a call on a `.Client` (an injected field, a local built with `new`, a parameter, a using alias through a primary constructor; blocking, `Async` and streaming) reaches the `override` of the same rpc on a `.Base` subclass. Two services with an rpc of the same name stay apart. An rpc nothing serves is `unserved`, one nothing calls is `unsent`. Controls: `System.Net.Http.HttpClient` and `ControllerBase` have the generated SHAPE through their namespace and are not gRPC; a non-override helper on a service is not an rpc | | `02-http` | an ASP.NET Core route reached by an `HttpClient` call. Server: attribute routing (`[Route("api/[controller]")]`, `Http` templates, an absolute `/health`) and minimal APIs (`MapGroup` prefixes through a local and a fluent call, method-group and lambda handlers, an optional `{day:int?}`). Client: literals, `+`, interpolation with a field-held base path, a local, `TrimEnd`, `SendAsync` with an `HttpRequestMessage`, a declarative interface, and a string-path wrapper bound at its callers. Route precedence keeps `/widgets/{id}` off `/widgets/featured`, and a whole URL in a leading hole followed by a query is not a base address. A verb mismatch and an unknown path are `unserved`, a parameter-only URL is `undetermined`, and unused routes are `unsent` | | `03-messaging` | a send reaching its consumer across a broker or a bus. By NAME: Kafka (`IProducer` from a field or a `ProducerBuilder` chain with the topic in a string local, `Subscribe` in the consuming method with a single topic or an array), RabbitMQ (a publish through an exchange binding, the default exchange written `""` or `string.Empty`, a fanout binding to a server-named queue held in a variable, a channel from `CreateChannelAsync` or `CreateModel`), Azure Service Bus (`CreateSender` to the `ProcessMessageAsync` handler). By TYPE: MassTransit (`IConsumer`, `Publish(new { })`, `IRequestClient`), NServiceBus (`IHandleMessages`, a session or bus from `GetRequiredService` or `GetService`, `Send(address, message)`), an event bus (`IIntegrationEventHandler`, a base-typed local assigned a concrete message on each branch, and an outbox publishing the base, which is undetermined and keeps its subtypes' handlers from reading as unsent). Controls: MediatR's in-process `INotificationHandler` is never a destination; an unconsumed topic is `unserved`, a topic from a parameter `undetermined`, an unsent consumer `unsent` | +| `04-minimal-api-variants` | minimal-API registrations beyond `MapGet(template, handler)`. `MapMethods` with its verbs in argument 1 (`new[] { ... }`, `[...]`, a `List` in a local, `HttpMethods.Delete`) and its handler in argument 2; a handler held in a local (a lambda, a method group); a group passed to a helper, as the receiver of a `this RouteGroupBuilder` extension (one helper mapped under two groups serves under both) and as a plain argument, and an `IEndpointRouteBuilder` helper called on the app. Controls: an inline lambda, a verb list nothing can read (any verb), a GET to a PUT-only route is `unserved`, a mapped-nowhere lambda in a local is no handler, and a same-named helper out of scope is not bound and keeps its bare template | +| `05-mvc-conventional-routes` | controller actions routed by convention (#1437): `MapControllerRoute` with named arguments and a `{controller=Home}/{action=Index}/{id?}` pattern, a second pattern with no `{controller}` whose controller comes from `defaults: new { controller = "..." }`, a `[HttpPost]` with no template narrowing the verb, and a `[Route("[controller]/[action]")]` class whose unattributed actions are each served at their own route. Controls: `[NonAction]`, private and static methods, an abstract controller, an attribute-routed class (conventional routes never reach it, and its unmarked public method under a template with no `[action]` is not served) | +| `06-grpc-variants` | gRPC ends the name join missed: a client from `GrpcClientFactory.CreateClient()` in a `var` and inline (#1484), an inline `new X.XClient(ch).Rpc()` (#1488), a service base written `OrdersBase` after `using static` and one inherited through a project base class (#1485), and two services named `Registry` in two namespaces (#1481): a client that sees one namespace (a `using`, or a qualifier it wrote) reaches that one only, and a client that sees neither keeps both one rung down, at `service_name` | +| `07-http-client-receivers` | an `HttpClient` read from a member (#1533): a static property inline and through a `var`, a static field, an instance property of a field. Control: the same property through a local declared `HttpClient`, and a string property that is no client | ## The corpus diff --git a/graph/test/csharp/remote/04-minimal-api-variants/expected.remote b/graph/test/csharp/remote/04-minimal-api-variants/expected.remote new file mode 100644 index 00000000..5764576c --- /dev/null +++ b/graph/test/csharp/remote/04-minimal-api-variants/expected.remote @@ -0,0 +1,16 @@ +edge http exact /admin/items/{id} Web.Services.DepotClient.Purge/1 -> ItemAdmin.Purge/1 +edge http exact /any Web.Services.DepotClient.Anything/0 -> ProbeEndpoints.Anything/0 +edge http exact /api/parcels/{id} Web.Services.DepotClient.Parcel/1 -> Api.Features.Parcels.ParcelEndpoints.GetParcel/1 +edge http exact /api/users/{id} Web.Services.DepotClient.User/1 -> GadgetEndpoints.User/1 +edge http exact /dials Web.Services.DepotClient.TurnDial/0 -> ProbeEndpoints.TurnDial/1 +edge http exact /gadgets Web.Services.DepotClient.Gadgets/0 -> Program./0 +edge http exact /gadgets/{id} Web.Services.DepotClient.Gadget/1 -> GadgetEndpoints.Show/1 +edge http exact /gauges Web.Services.DepotClient.SetGauge/1 -> ProbeEndpoints.SetGauge/1 +edge http exact /health Web.Services.DepotClient.Health/0 -> HealthEndpoints.Health/0 +edge http exact /meters Web.Services.DepotClient.ClearMeter/0 -> ProbeEndpoints.ClearMeter/0 +edge http exact /probe Web.Services.DepotClient.Probe/0 -> ProbeEndpoints.Probe/0 +edge http exact /v1/items/{id} Web.Services.DepotClient.OldItem/1 -> ItemEndpoints.GetItem/1 +edge http exact /v2/items/{id} Web.Services.DepotClient.NewItem/1 -> ItemEndpoints.GetItem/1 +unsent http - /crates/{id} Api.Features.Crates.CrateEndpoints.GetCrate/1 +unsent http - /gizmos Program./0 +unserved http - /gauges Web.Services.DepotClient.ReadGauge/0 diff --git a/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Endpoints.cs b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Endpoints.cs new file mode 100644 index 00000000..75a2724c --- /dev/null +++ b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Endpoints.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +public static class ProbeEndpoints +{ + public static IResult Probe() => Results.Ok(); + public static IResult SetGauge(int value) => Results.Ok(value); + public static IResult ClearMeter() => Results.NoContent(); + public static IResult TurnDial(int by) => Results.Ok(by); + public static IResult Anything() => Results.Ok(); + public static string[] Verbs() => new[] { "GET" }; +} + +public static class GadgetEndpoints +{ + public static IResult Show(int id) => Results.Ok(id); + public static IResult User(int id) => Results.Ok(id); +} + +public static class ItemEndpoints +{ + // mapped under two groups: it serves under both prefixes + public static RouteGroupBuilder MapItemEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/{id}", GetItem); + return group; + } + + public static IResult GetItem(int id) => Results.Ok(id); +} + +public static class ItemAdmin +{ + // a plain parameter, not an extension + public static void MapAdmin(RouteGroupBuilder admin) + { + admin.MapDelete("/items/{id}", Purge); + } + + public static IResult Purge(int id) => Results.NoContent(); +} + +public static class HealthEndpoints +{ + public static void MapHealth(this IEndpointRouteBuilder routes) + { + routes.MapGet("/health", Health); + } + + public static IResult Health() => Results.Ok(); +} + +public static class Store { public static int[] All() => new int[0]; } diff --git a/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Parcels.cs b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Parcels.cs new file mode 100644 index 00000000..71674fe8 --- /dev/null +++ b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Features/Parcels.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Api.Features.Parcels +{ + public static class ParcelEndpoints + { + // the one of two same-named helpers that Program.cs has in scope + public static RouteGroupBuilder MapParcelEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/{id}", GetParcel); + return group; + } + + public static IResult GetParcel(int id) => Results.Ok(id); + } +} + +namespace Api.Features.Crates +{ + public static class CrateEndpoints + { + // same name, another namespace, never in scope where it would be called: + // it is not bound, and its route keeps the bare template + public static RouteGroupBuilder MapParcelEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/crates/{id}", GetCrate); + return group; + } + + public static IResult GetCrate(int id) => Results.Ok(id); + } +} diff --git a/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Program.cs b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Program.cs new file mode 100644 index 00000000..f3985316 --- /dev/null +++ b/graph/test/csharp/remote/04-minimal-api-variants/src/Api/Program.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Api.Features.Parcels; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +// MapMethods: the verbs are argument 1 and the handler argument 2 +app.MapMethods("/probe", new[] { "HEAD", "OPTIONS" }, ProbeEndpoints.Probe); +app.MapMethods("/gauges", ["PUT"], ProbeEndpoints.SetGauge); +app.MapMethods("/meters", new[] { HttpMethods.Delete }, ProbeEndpoints.ClearMeter); +var verbs = new List { "PATCH" }; +app.MapMethods("/dials", verbs, ProbeEndpoints.TurnDial); +// control: a verb list nothing here can read serves any verb +app.MapMethods("/any", ProbeEndpoints.Verbs(), ProbeEndpoints.Anything); + +// a handler held in a local: a lambda and a method group +Func listGadgets = () => Results.Ok(Store.All()); +app.MapGet("/gadgets", listGadgets); +Func showGadget = GadgetEndpoints.Show; +app.MapGet("/gadgets/{id}", showGadget); +// control: the same lambda written inline +app.MapGet("/gizmos", () => Results.Ok(Store.All())); +// control: a lambda in a local that is never mapped is no handler +Func unused = () => Results.Ok(Store.All()); + +// a group handed to a helper, by an extension call and by a plain one +app.MapGroup("/api/parcels").MapParcelEndpoints(); +var legacy = app.MapGroup("/v1/items"); +legacy.MapItemEndpoints(); +var current = app.MapGroup("/v2/items").RequireAuthorization(); +current.MapItemEndpoints(); +ItemAdmin.MapAdmin(app.MapGroup("/admin")); +// control: a local group mapped directly, and a helper called on the app itself +var users = app.MapGroup("/api/users"); +users.MapGet("/{id}", GadgetEndpoints.User); +app.MapHealth(); + +app.Run(); diff --git a/graph/test/csharp/remote/04-minimal-api-variants/src/Web/DepotClient.cs b/graph/test/csharp/remote/04-minimal-api-variants/src/Web/DepotClient.cs new file mode 100644 index 00000000..60ff8787 --- /dev/null +++ b/graph/test/csharp/remote/04-minimal-api-variants/src/Web/DepotClient.cs @@ -0,0 +1,42 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace Web.Services +{ + public class DepotClient + { + private readonly HttpClient _http; + + public DepotClient(HttpClient http) { _http = http; } + + public Task Probe() => + _http.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/probe")); + + public Task SetGauge(int v) => _http.PutAsync($"/gauges?value={v}", null); + + public Task ClearMeter() => _http.DeleteAsync("/meters"); + + public Task TurnDial() => _http.PatchAsync("/dials", null); + + // the verb does not match: /gauges serves PUT only + public Task ReadGauge() => _http.GetStringAsync("/gauges"); + + public Task Anything() => _http.GetStringAsync("/any"); + + public Task Gadgets() => _http.GetStringAsync("/gadgets"); + + public Task Gadget(int id) => _http.GetStringAsync($"/gadgets/{id}"); + + public Task Parcel(int id) => _http.GetStringAsync($"/api/parcels/{id}"); + + public Task OldItem(int id) => _http.GetStringAsync($"/v1/items/{id}"); + + public Task NewItem(int id) => _http.GetStringAsync($"/v2/items/{id}"); + + public Task Purge(int id) => _http.DeleteAsync($"/admin/items/{id}"); + + public Task User(int id) => _http.GetStringAsync($"/api/users/{id}"); + + public Task Health() => _http.GetStringAsync("/health"); + } +} diff --git a/graph/test/csharp/remote/05-mvc-conventional-routes/expected.remote b/graph/test/csharp/remote/05-mvc-conventional-routes/expected.remote new file mode 100644 index 00000000..b7cfec37 --- /dev/null +++ b/graph/test/csharp/remote/05-mvc-conventional-routes/expected.remote @@ -0,0 +1,11 @@ +edge http exact /Home/Index/{id?} Shop.Client.ShopClient.Home/0 -> Shop.Web.Controllers.HomeController.Index/0 +edge http exact /Orders/History Shop.Client.ShopClient.History/0 -> Shop.Web.Controllers.OrdersController.History/0 +edge http exact /Reports/Yearly/{id?} Shop.Client.ShopClient.Yearly/1 -> Shop.Web.Controllers.ReportsController.Yearly/1 +edge http exact /Widgets/Details/{id?} Shop.Client.ShopClient.Widget/1 -> Shop.Web.Controllers.WidgetsController.Details/1 +edge http exact /Widgets/Save/{id?} Shop.Client.ShopClient.SaveWidget/1 -> Shop.Web.Controllers.WidgetsController.Save/1 +edge http exact /api/gadgets/{id} Shop.Client.ShopClient.Gadget/1 -> Shop.Web.Controllers.GadgetsController.Details/1 +edge http exact /reports/Yearly/{year:int} Shop.Client.ShopClient.Yearly/1 -> Shop.Web.Controllers.ReportsController.Yearly/1 +unsent http - /Orders/Detail/{id} Shop.Web.Controllers.OrdersController.Detail/1 +unserved http - /gadgets/describe/{@0} Shop.Client.ShopClient.Describe/1 +unserved http - /gadgets/details/{@0} Shop.Client.ShopClient.Gadget2/1 +unserved http - /widgets/helper/{@0} Shop.Client.ShopClient.Helper/1 diff --git a/graph/test/csharp/remote/05-mvc-conventional-routes/src/Client/ShopClient.cs b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Client/ShopClient.cs new file mode 100644 index 00000000..6c53e6c0 --- /dev/null +++ b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Client/ShopClient.cs @@ -0,0 +1,21 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace Shop.Client; + +public class ShopClient +{ + private readonly HttpClient _http; + public ShopClient(HttpClient http) { _http = http; } + + public Task Widget(int id) => _http.GetStringAsync($"/widgets/details/{id}"); + public Task SaveWidget(int id) => _http.PostAsync($"/Widgets/Save/{id}", null); + public Task Home() => _http.GetStringAsync("/Home/Index"); + public Task Gadget(int id) => _http.GetStringAsync($"/api/gadgets/{id}"); + public Task History() => _http.GetStringAsync("/orders/history"); + public Task Yearly(int year) => _http.GetStringAsync($"/reports/yearly/{year}"); + // controls: not actions, so unserved + public Task Helper(int id) => _http.GetStringAsync($"/widgets/helper/{id}"); + public Task Describe(int id) => _http.GetStringAsync($"/gadgets/describe/{id}"); + public Task Gadget2(int id) => _http.GetStringAsync($"/gadgets/details/{id}"); +} diff --git a/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Controllers/Controllers.cs b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Controllers/Controllers.cs new file mode 100644 index 00000000..60a23f10 --- /dev/null +++ b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Controllers/Controllers.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Shop.Web.Controllers; + +// conventionally routed: no route attribute on the class or the action +public class WidgetsController : Controller +{ + public IActionResult Details(int id) => View(WidgetRepo.Find(id)); + + // a verb attribute with no template keeps the conventional route and narrows the verb + [HttpPost] + public IActionResult Save(int id) => Ok(WidgetRepo.Find(id)); + + // control: not an action + [NonAction] + public object Helper(int id) => WidgetRepo.Find(id); + + // control: not public + private object Hidden(int id) => WidgetRepo.Find(id); + + // control: static is never an action + public static object Shared(int id) => WidgetRepo.Find(id); +} + +// the pattern's controller default +public class HomeController : Controller +{ + public IActionResult Index() => View(); +} + +// attribute-routed: conventional routes never reach it +[Route("api/gadgets")] +public class GadgetsController : Controller +{ + [HttpGet("{id}")] + public IActionResult Details(int id) => Ok(WidgetRepo.Find(id)); + + // control: under a class route with no [action] token an unmarked public method is + // not served, and conventional routes never reach an attribute-routed class + public object Describe(int id) => WidgetRepo.Find(id); +} + +// a class route with an [action] token: each public action is served, any verb +[Route("[controller]/[action]")] +public class OrdersController : Controller +{ + public IActionResult History() => Ok(WidgetRepo.Find(0)); + + [HttpGet("{id}")] + public IActionResult Detail(int id) => Ok(WidgetRepo.Find(id)); +} + +// a controller whose route is fixed by the second pattern's defaults +public class ReportsController : Controller +{ + public IActionResult Yearly(int year) => Ok(WidgetRepo.Find(year)); +} + +// control: abstract, and not a controller by name +public abstract class BaseController : Controller +{ + public IActionResult Ping() => Ok(); +} + +public class WidgetRepo +{ + public static object Find(int id) => id; + public object Lookup(int id) => id; +} diff --git a/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Program.cs b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Program.cs new file mode 100644 index 00000000..3b82e442 --- /dev/null +++ b/graph/test/csharp/remote/05-mvc-conventional-routes/src/Web/Program.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Builder; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); +app.MapControllerRoute("reports", "reports/{action}/{year:int}", defaults: new { controller = "Reports" }); +app.Run(); diff --git a/graph/test/csharp/remote/06-grpc-variants/expected.remote b/graph/test/csharp/remote/06-grpc-variants/expected.remote new file mode 100644 index 00000000..9d257d00 --- /dev/null +++ b/graph/test/csharp/remote/06-grpc-variants/expected.remote @@ -0,0 +1,10 @@ +edge grpc exact Orders/Amend Depot.Client.ChannelCaller.Amend/1 -> Depot.Rpc.OrdersService.Amend/2 +edge grpc exact Orders/Cancel Depot.Client.FactoryCaller.Cancel/0 -> Depot.Rpc.OrdersService.Cancel/2 +edge grpc exact Orders/Place Depot.Client.FactoryCaller.Place/0 -> Depot.Rpc.OrdersService.Place/2 +edge grpc exact Pricing/Quote Depot.Client.ChannelCaller.Quote/0 -> Depot.Rpc.PricingService.Quote/2 +edge grpc exact Pricing/Refund Depot.Client.ChannelCaller.Refund/0 -> Depot.Rpc.PricingService.Refund/2 +edge grpc exact Registry/Lookup Elsewhere.QualifiedCaller.Run/1 -> Gadgets.Rpc.GadgetRegistry.Lookup/2 +edge grpc exact Registry/Lookup Widgets.Client.WidgetCaller.Run/1 -> Widgets.Rpc.WidgetRegistry.Lookup/2 +edge grpc exact Stock/Reserve Depot.Client.ChannelCaller.Reserve/1 -> Depot.Rpc.StockService.Reserve/2 +edge grpc service_name Registry/Lookup Elsewhere.LooseCaller.Run/1 -> Gadgets.Rpc.GadgetRegistry.Lookup/2 +edge grpc service_name Registry/Lookup Elsewhere.LooseCaller.Run/1 -> Widgets.Rpc.WidgetRegistry.Lookup/2 diff --git a/graph/test/csharp/remote/06-grpc-variants/src/Client/Callers.cs b/graph/test/csharp/remote/06-grpc-variants/src/Client/Callers.cs new file mode 100644 index 00000000..55575e81 --- /dev/null +++ b/graph/test/csharp/remote/06-grpc-variants/src/Client/Callers.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Grpc.Net.Client; +using Grpc.Net.ClientFactory; +using Depot.Rpc; + +namespace Depot.Client +{ + public class FactoryCaller + { + private readonly GrpcClientFactory _factory; + public FactoryCaller(GrpcClientFactory factory) { _factory = factory; } + + // a client from the factory, held in a var and used inline + public void Place() + { + var client = _factory.CreateClient("orders"); + client.Place(new Request()); + } + public void Cancel() => _factory.CreateClient("orders").Cancel(new Request()); + } + + public class ChannelCaller + { + private readonly GrpcChannel _ch; + public ChannelCaller(GrpcChannel ch) { _ch = ch; } + + // an inline new, and the same through a var + public void Quote() => new Pricing.PricingClient(_ch).Quote(new Request()); + public void Refund() + { + var client = new Pricing.PricingClient(_ch); + client.Refund(new Request()); + } + public void Reserve(Stock.StockClient s) => s.Reserve(new Request()); + public void Amend(Orders.OrdersClient o) => o.Amend(new Request()); + } +} + +namespace Widgets.Client +{ + using Widgets.Rpc; + + // Registry resolves to Widgets.Rpc.Registry, never Gadgets.Rpc.Registry + public class WidgetCaller + { + public void Run(Registry.RegistryClient client) => client.Lookup(new Request()); + } +} diff --git a/graph/test/csharp/remote/06-grpc-variants/src/Client/LooseCaller.cs b/graph/test/csharp/remote/06-grpc-variants/src/Client/LooseCaller.cs new file mode 100644 index 00000000..6f8ff4c7 --- /dev/null +++ b/graph/test/csharp/remote/06-grpc-variants/src/Client/LooseCaller.cs @@ -0,0 +1,16 @@ +using Grpc.Net.Client; + +namespace Elsewhere +{ + // control: no namespace or using says which Registry this is, so both stay candidates + public class LooseCaller + { + public void Run(Registry.RegistryClient client) => client.Lookup(new Request()); + } + + // the qualifier names the namespace: Gadgets.Rpc's Registry only + public class QualifiedCaller + { + public void Run(Gadgets.Rpc.Registry.RegistryClient client) => client.Lookup(new Request()); + } +} diff --git a/graph/test/csharp/remote/06-grpc-variants/src/Server/Services.cs b/graph/test/csharp/remote/06-grpc-variants/src/Server/Services.cs new file mode 100644 index 00000000..9b5b22a0 --- /dev/null +++ b/graph/test/csharp/remote/06-grpc-variants/src/Server/Services.cs @@ -0,0 +1,46 @@ +using System.Threading.Tasks; +using Grpc.Core; +using static Depot.Rpc.Orders; + +namespace Depot.Rpc +{ + // the base written after `using static` + public class OrdersService : OrdersBase + { + public override Task Place(Request r, ServerCallContext c) => null; + public override Task Cancel(Request r, ServerCallContext c) => null; + public override Task Amend(Request r, ServerCallContext c) => null; + } + + // the base reached through a project base class + public abstract class AuditedStockBase : Stock.StockBase + { + public void Audit() { } + } + public class StockService : AuditedStockBase + { + public override Task Reserve(Request r, ServerCallContext c) => null; + } + + public class PricingService : Pricing.PricingBase + { + public override Task Quote(Request r, ServerCallContext c) => null; + public override Task Refund(Request r, ServerCallContext c) => null; + } +} + +// two services called Registry, in two namespaces +namespace Widgets.Rpc +{ + public class WidgetRegistry : Registry.RegistryBase + { + public override Task Lookup(Request r, ServerCallContext c) => null; + } +} +namespace Gadgets.Rpc +{ + public class GadgetRegistry : Registry.RegistryBase + { + public override Task Lookup(Request r, ServerCallContext c) => null; + } +} diff --git a/graph/test/csharp/remote/07-http-client-receivers/expected.remote b/graph/test/csharp/remote/07-http-client-receivers/expected.remote new file mode 100644 index 00000000..8bc4bdd8 --- /dev/null +++ b/graph/test/csharp/remote/07-http-client-receivers/expected.remote @@ -0,0 +1,5 @@ +edge http exact /count Depot.Checks.DepotChecks.Typed/0 -> Depot.Api.DepotEndpoints.Count/0 +edge http exact /health Depot.Checks.DepotChecks.Inline/0 -> Depot.Api.DepotEndpoints.Health/0 +edge http exact /ready Depot.Checks.DepotChecks.Nested/0 -> Depot.Api.DepotEndpoints.Ready/0 +edge http exact /stats Depot.Checks.DepotChecks.Local/0 -> Depot.Api.DepotEndpoints.Stats/0 +edge http exact /version Depot.Checks.DepotChecks.StaticField/0 -> Depot.Api.DepotEndpoints.Version/0 diff --git a/graph/test/csharp/remote/07-http-client-receivers/src/Api/Endpoints.cs b/graph/test/csharp/remote/07-http-client-receivers/src/Api/Endpoints.cs new file mode 100644 index 00000000..e4e9f61b --- /dev/null +++ b/graph/test/csharp/remote/07-http-client-receivers/src/Api/Endpoints.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Builder; + +namespace Depot.Api; + +public static class DepotEndpoints +{ + public static string Health() => "ok"; + public static string Count() => "0"; + public static string Stats() => "1"; + public static string Version() => "2"; + public static string Ready() => "3"; + public static void Map(WebApplication app) + { + app.MapGet("/health", Health); + app.MapGet("/count", Count); + app.MapGet("/stats", Stats); + app.MapGet("/version", Version); + app.MapGet("/ready", Ready); + } +} diff --git a/graph/test/csharp/remote/07-http-client-receivers/src/Checks/Checks.cs b/graph/test/csharp/remote/07-http-client-receivers/src/Checks/Checks.cs new file mode 100644 index 00000000..23eab34f --- /dev/null +++ b/graph/test/csharp/remote/07-http-client-receivers/src/Checks/Checks.cs @@ -0,0 +1,32 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace Depot.Checks; + +public static class Shared +{ + public static HttpClient Client { get; } = new HttpClient(); + public static readonly HttpClient Field = new HttpClient(); + public static string Name { get; } = "depot"; +} + +public class Holder +{ + public HttpClient Http { get; set; } +} + +public class DepotChecks +{ + private readonly Holder _holder = new Holder(); + + // a static property, read inline and through a var + public Task Inline() => Shared.Client.GetAsync("/health"); + public Task Local() { var c = Shared.Client; return c.GetAsync("/stats"); } + // control: the same property through a local declared HttpClient already linked + public Task Typed() { HttpClient c = Shared.Client; return c.GetAsync("/count"); } + // a static field, and an instance property of a field + public Task StaticField() => Shared.Field.GetAsync("/version"); + public Task Nested() => _holder.Http.GetAsync("/ready"); + // control: a string property is not a client + public int NotAClient() => Shared.Name.Length; +} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 730005c9..398d91b2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -342,8 +342,16 @@ def route_seeds(g, text): if parts and parts not in asked: asked.append(parts) if not asked: return [] hits = [] # (symbol id, route as written) + # A SENDER DOES NOT SERVE THE ROUTE IT SENDS TO. The engine names every send it read (ext_remote_edge c0, + # ext_remote_unserved c0); a URL literal inside one is a request, and labelling that method "serves the route" + # put a client above the handler. + senders, servers = set(), set() + for tbl, col in (('ext_remote_edge', 'c0'), ('ext_remote_unserved', 'c0')): + if g.has(tbl): senders |= {r[0] for r in g.q(f"SELECT DISTINCT {col} FROM {tbl}")} + for tbl, col in (('ext_remote_edge', 'c1'), ('ext_remote_unsent', 'c0')): + if g.has(tbl): servers |= {r[0] for r in g.q(f"SELECT DISTINCT {col} FROM {tbl}")} def serve(mid, route): - if mid in g.sym: hits.append((mid, route)) + if mid in g.sym and (mid not in senders or mid in servers): hits.append((mid, route)) # the path a url_dispatch edge says its view is served at: the engine composed it through every include() that # mounts the table, so it is the one source that names the handler of `/api/v1/status/` when no line spells it tables = set() # route files whose every literal path the engine already composed @@ -356,7 +364,13 @@ def route_seeds(g, text): # a route the engine answered is not searched again as text: the literal scan below is the fallback for a route # no edge carries, and on an answered one it only adds the table's module and every literal that spells a suffix asked = [a for a in asked if not any(_route_eq(_route_parts(r), a) for _s, r in hits)] - for tbl in ('ext_remote_unserved', 'ext_remote_unsent') if asked else (): + # A HANDLER THAT SOMETHING SENDS TO IS STILL THE HANDLER. ext_remote_unsent lists a route only while no client in + # the graph reaches it; once a send links, the handler moves to ext_remote_edge (c1, at destination c3), and reading + # only the unlinked tables dropped exactly the routes the graph understood best. + if asked and g.has('ext_remote_edge'): + for r in g.q("SELECT DISTINCT c1, c3 FROM ext_remote_edge"): + if any(_route_eq(_route_parts(r['c3']), a) for a in asked): serve(r['c1'], r['c3']) + for tbl in ('ext_remote_unsent',) if asked else (): if g.has(tbl): for r in g.q(f"SELECT c0, c2 FROM {tbl}"): if any(_route_eq(_route_parts(r['c2']), a) for a in asked): serve(r['c0'], r['c2']) diff --git a/tests/cases/csharp/context-route-a-client-reaches/case.json b/tests/cases/csharp/context-route-a-client-reaches/case.json new file mode 100644 index 00000000..815dc16c --- /dev/null +++ b/tests/cases/csharp/context-route-a-client-reaches/case.json @@ -0,0 +1,9 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a route handler a client in the graph reaches still serves its route: the handler moves from remote_unsent to remote_edge once a send links, and context must read it there too", + "run": ["context", "GET /api/orders"], + "want": ["OrderListEndpoint. src/App/Orders.cs:8 serves the route /api/orders\n"]}, + {"why": "control: the client that sends to the route does not serve it", + "run": ["context", "GET /api/orders"], + "avoid": ["OrderChecks.ListFirst src/App/Orders.cs:24 serves the route"], + "want": ["OrderListEndpoint.AddRoute"]}]} diff --git a/tests/cases/csharp/context-route-a-client-reaches/src/App/App.csproj b/tests/cases/csharp/context-route-a-client-reaches/src/App/App.csproj new file mode 100644 index 00000000..9185da91 --- /dev/null +++ b/tests/cases/csharp/context-route-a-client-reaches/src/App/App.csproj @@ -0,0 +1,3 @@ + + net8.0enable + diff --git a/tests/cases/csharp/context-route-a-client-reaches/src/App/Orders.cs b/tests/cases/csharp/context-route-a-client-reaches/src/App/Orders.cs new file mode 100644 index 00000000..97ceb226 --- /dev/null +++ b/tests/cases/csharp/context-route-a-client-reaches/src/App/Orders.cs @@ -0,0 +1,25 @@ +namespace App; + +public sealed class OrderListEndpoint +{ + public void AddRoute(IEndpointRouteBuilder app) + { + app.MapGet("api/orders", + async (int? page, OrderStore s) => await Task.FromResult(Results.Ok(s.Page(page ?? 0)))); + } +} + +public sealed class OrderStore +{ + public string[] Page(int n) => new[] { "a" }; +} + +public static class Shared +{ + public static HttpClient Client { get; } = new HttpClient(); +} + +public sealed class OrderChecks +{ + public Task ListFirst() => Shared.Client.GetStringAsync("/api/orders?page=0"); +} From cd5653bf71fa8c4d859121690007587a33c98c89 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:03:41 -0700 Subject: [PATCH 025/258] csharp: Main by signature, class-mock constructors, SetUpFixture scope, Section:Key config keys Fixes #1451, #1501, #1503, #1495, #1540, #1443, #1424, #1556, #1436 What was wrong - #1451: every static method named Main was a main entry point, whatever its signature, so a helper `static string Main()` its own callers reach was reported as runtime-invoked. - #1503: MSTest's [GlobalTestInitialize] / [GlobalTestCleanup] (3.10+) were not test entry points. - #1501: an NUnit [SetUpFixture]'s [OneTimeSetUp] runs before every NUnit test in its namespace, and an MSTest assembly initializer before every MSTest test of its project; no rule scoped a fixture beyond its own type, so a change reached only from one reached no test. - #1495: `new Mock(args)`, `Substitute.ForPartsOf(args)` and `Substitute.For(args)` run C's constructor through the proxy, but the site's only edge was to the library, so impact on the constructor left those tests out. - #1540: a protected member named by string in Moq's `Protected().Setup/Verify("Name")` had no reference at all. - #1443: `impact Widgets:MaxCount` read the key as `Owner.m:local` and answered "nothing named 'Widgets'". Under it, C# string literals were stored with their quotes (`""Widgets:MaxCount""`), so no string-literal rule (a quoted target, a field named in a literal, a route key) ever matched C#. - #1556: a lambda written inside one test method is owned by the class, so its callees were credited to every test of the class as [fixture]. On one line (`[Fact] void T() => M(() => F());`) the lambda and its test tie on span, and the lambda could be taken as the parent. - #1424: test-impact --why printed a fixture-selected test's own name as its route; the JSON chain stopped at the test. The change - engine (C#): cs_main_shape keeps a static Main only when it returns void, int, Task or Task and takes nothing or one string[]; the lifecycle knob gains GlobalTestInitialize / GlobalTestCleanup; dispatch.dl adds an event_dispatch edge from a class-mock site to the constructor its argument count selects (a leading MockBehavior.X not counted, a lambda-built mock skipped, an interface or implicit constructor giving nothing), beside the site's own row, as the mediator hop is. - impact: runs_before facts carry an NUnit set-up fixture to the NUnit tests of its namespace and the ones nested in it, within its project, and MSTest assembly initializers to the project's MSTest tests; stub rows for a member a Protected() setup names on a type the file mocks (or a base of it); a `Section:Key` target is a configuration key when a .cs literal or an appsettings*.json path spells it, joined on the literal (`by name` beside an IConfiguration read, `text` elsewhere) and the settings line; a callable inside a test method reaches that test only; the redundant "not credited" line no longer names a fixture the count already credits through. - index: C# literals are stored unquoted (@"..." and $"..." too). - test-impact --why prints `Test <- Fixture -> ... -> Changed`. - IMPACT_VERSION 42 (0.1.8 has 40, an open fix branch 41). checked on the issues' repros with the installed build, no change here. Tests - graph/test/csharp: entry-points golden gains an async Task Main and the two MSTest hooks, with controls `static string Main()` and `static int Main(int)` absent; new tools/mock-constructor-test.sh with an interface mock, a lambda-built mock, an implicit constructor, a List and another receiver's ForPartsOf as controls. - tests/cases: csharp/setup-fixture-runs-for-its-namespace (another namespace, xUnit tests in the namespace and another project as controls), csharp/mock-runs-the-constructor-it-names (a MockBehavior overload, a lambda-built mock, an unmocked type's Fee and an unnamed Levy as controls), csharp/config-key-by-section-path (the same leaf under another section as control), java/fixture-route-in-test-impact-why. - Suites, on the tree rebased onto 0.1.8: graph/test/csharp/run-tests.sh (18 cases at 100% cover/agree/fan, every tool test ok, the new one included); tests/run.py --lang csharp 68 of 71, the two failures (lambda-is-named-by-its-place's field check, member-owner-is-its-type's stale pending marker) identical on the tip; --lang java 187 of 187; --lang python 183 of 184 (the known lambda-is-named-by-its-place control, failing on the tip too); tests/tiers.py, tests/query_rules.py, tests/test_command.py, tests/hook_languages.py, tests/enrich_lines.py all passed. - Smoke, installed build vs this branch, fresh index of an rsync copy each, on three corpus projects: project A (about 250 files): class-mock constructor edges 0 -> 7 (every Substitute.For(args) site grep finds); tests reaching one entity constructor 16 -> 21, another 3 -> 5; C# literals stored with quotes 1656 -> 0; main entry points unchanged (3). project B (about 750 files): stub rows for a member Protected() names by string 0 -> 8 (every string-literal site; the 7 nameof() sites are not read); class-mock constructor edges 0 -> 2 (the 2 class mocks with arguments; the 2 interface mocks and 1 unstaged type give none); `impact` on a two-segment `Section:Key` read through IConfiguration went from "no local named " to its 5 readers; quoted literals 8779 -> 0; main entry points unchanged (16). project C (about 110 files): tests reaching a method only an NUnit [SetUpFixture]'s [OneTimeSetUp] calls 9 -> 21, all 19 NUnit tests of that project now through the set-up fixture (7 of them were by name only). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/framework-behavior/dispatch.dl | 29 ++++ .../engine/framework-behavior/entry-points.dl | 9 +- .../csharp/engine/framework-behavior/knobs.dl | 20 +++ graph/csharp/souffle/decls_all.dl | 11 ++ graph/test/csharp/entry-points/expected.entry | 6 + graph/test/csharp/entry-points/src/App.cs | 17 ++ .../csharp/mock-constructor/expected.dispatch | 4 + .../mock-constructor/src/PriceRuleTests.cs | 38 +++++ .../csharp/tools/mock-constructor-test.sh | 41 +++++ .../skills/axiomcode/scripts/ax_edges.py | 2 +- .../skills/axiomcode/scripts/axiomcode-impact | 161 +++++++++++++++++- .../skills/axiomcode/scripts/axiomcode-index | 4 + .../axiomcode/scripts/axiomcode-test-impact | 8 +- .../skills/axiomcode/scripts/dl/impact.dl | 19 ++- .../config-key-by-section-path/App/App.csproj | 2 + .../config-key-by-section-path/App/Reader.cs | 16 ++ .../App/appsettings.json | 7 + .../config-key-by-section-path/case.json | 14 ++ .../Shop.Tests/PriceRuleTests.cs | 26 +++ .../Shop.Tests/Shop.Tests.csproj | 3 + .../Shop/PriceRule.cs | 17 ++ .../Shop/Shop.csproj | 1 + .../case.json | 88 ++++++++++ .../App.Tests/App.Tests.csproj | 3 + .../App.Tests/Deep/DeepTests.cs | 9 + .../App.Tests/Other/OtherTests.cs | 15 ++ .../App.Tests/PricerTests.cs | 21 +++ .../App.Tests/XunitTests.cs | 9 + .../App/App.csproj | 1 + .../App/WidgetPricer.cs | 8 + .../Ms.Tests/Hooks.cs | 18 ++ .../Ms.Tests/Ms.Tests.csproj | 3 + .../Price-old.cs | 8 + .../case.json | 76 +++++++++ .../case.json | 6 + .../cents-old.java | 6 + .../src/main/java/app/Money.java | 6 + .../src/test/java/app/MoneyTest.java | 14 ++ 38 files changed, 734 insertions(+), 12 deletions(-) create mode 100644 graph/test/csharp/mock-constructor/expected.dispatch create mode 100644 graph/test/csharp/mock-constructor/src/PriceRuleTests.cs create mode 100755 graph/test/csharp/tools/mock-constructor-test.sh create mode 100644 tests/cases/csharp/config-key-by-section-path/App/App.csproj create mode 100644 tests/cases/csharp/config-key-by-section-path/App/Reader.cs create mode 100644 tests/cases/csharp/config-key-by-section-path/App/appsettings.json create mode 100644 tests/cases/csharp/config-key-by-section-path/case.json create mode 100644 tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/PriceRuleTests.cs create mode 100644 tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/Shop.Tests.csproj create mode 100644 tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/PriceRule.cs create mode 100644 tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/Shop.csproj create mode 100644 tests/cases/csharp/mock-runs-the-constructor-it-names/case.json create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/App.Tests.csproj create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Deep/DeepTests.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Other/OtherTests.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/PricerTests.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/XunitTests.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/App.csproj create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/WidgetPricer.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Hooks.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Ms.Tests.csproj create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/Price-old.cs create mode 100644 tests/cases/csharp/setup-fixture-runs-for-its-namespace/case.json create mode 100644 tests/cases/java/fixture-route-in-test-impact-why/case.json create mode 100644 tests/cases/java/fixture-route-in-test-impact-why/cents-old.java create mode 100644 tests/cases/java/fixture-route-in-test-impact-why/src/main/java/app/Money.java create mode 100644 tests/cases/java/fixture-route-in-test-impact-why/src/test/java/app/MoneyTest.java diff --git a/graph/csharp/engine/framework-behavior/dispatch.dl b/graph/csharp/engine/framework-behavior/dispatch.dl index 8b8e07a1..c5311ef1 100644 --- a/graph/csharp/engine/framework-behavior/dispatch.dl +++ b/graph/csharp/engine/framework-behavior/dispatch.dl @@ -155,3 +155,32 @@ med_dispatch(e, m) :- med_declared_handler(e, m). med_send_matcher(e) :- med_send_arg(e, a), call_callee_name("client", a, n0), last_segment(n0, n), cs_mock_arg_matcher(n). call_chain_edge(e, from, "-", m, "client", "event_dispatch", kind) :- med_send_site(e, from, _, _), med_dispatch(e, m), !med_send_matcher(e), from != m, invocation_site("client", e, kind). + +// ── A CLASS MOCK RUNS ITS TYPE'S CONSTRUCTOR ──────────────────────────────── +// `new Mock(2m)` and `Substitute.ForPartsOf(2m)` construct a proxy +// subclass of PriceRule, and the proxy's constructor calls PriceRule(decimal) with the +// arguments given. The site's own target is the library's (Mock's constructor, +// ForPartsOf), so `impact` on the constructor left those tests out and `path` called the +// two independent (#1495). The hop is ADDED beside the site's row, on the tier a +// framework-run hop takes (event_dispatch), as the mediator's is above; the site's own +// resolution is unchanged. The overload is chosen by argument count, as for `new C(args)`. +// Knobs: cs_mock_new_type, cs_mock_factory, cs_mock_behavior_arg. +mock_ctor_site(e, gk) :- expr_new_type_ref("client", e, tr), type_ref_name("client", tr, n0), last_segment(n0, n), + cs_mock_new_type(n), type_ref_arg("client", tr, "0", ar), type_ref_resolves("client", ar, gk). +mock_ctor_site(e, gk) :- call_receiver_text("client", e, rn0), last_segment(rn0, rn), call_callee_name("client", e, sn), + cs_mock_factory(rn, sn), call_type_argc("client", e, "1"), + type_ref_owner("client", tr, e, "EXPRESSION"), type_ref_context("client", tr, "METHOD_TYPE_ARGUMENT"), + !type_ref_parent("client", tr, _, _, _), type_ref_resolves("client", tr, gk). +last_segment_demand(n) :- expr_new_type_ref("client", _, tr), type_ref_name("client", tr, n). +last_segment_demand(n) :- call_receiver_text("client", e, n), call_callee_name("client", e, sn), cs_mock_factory(_, sn). +// `new Mock(() => new C(1))` hands Moq the construction to run: that lambda's own +// `new` is the call, and the site passes no constructor arguments. +mock_ctor_lambda(e) :- mock_ctor_site(e, _), arg_index(e, 0, a), expr_kind("client", a, "LAMBDA"). +mock_ctor_behavior(e) :- mock_ctor_site(e, _), arg_index(e, 0, a), expr_written_name("client", a, w), + cs_mock_behavior_arg(b), strlen(w) > strlen(b), substr(w, 0, strlen(b) + 1) = cat(b, "."). +mock_ctor_argc(e, ac) :- mock_ctor_site(e, _), !mock_ctor_lambda(e), !mock_ctor_behavior(e), call_argc("client", e, ac). +mock_ctor_argc(e, ac) :- mock_ctor_site(e, _), !mock_ctor_lambda(e), mock_ctor_behavior(e), call_argc("client", e, ac0), + ac = to_string(to_number(ac0) - 1). +mock_ctor(e, ctor) :- mock_ctor_site(e, gk), mock_ctor_argc(e, ac), type_ctor("client", gk, ctor), ctor_accepts_argc("client", ctor, ac). +call_chain_edge(e, from, "-", ctor, "client", "event_dispatch", kind) :- mock_ctor(e, ctor), call_from("client", e, from), + from != ctor, invocation_site("client", e, kind). diff --git a/graph/csharp/engine/framework-behavior/entry-points.dl b/graph/csharp/engine/framework-behavior/entry-points.dl index 36d582dd..aefb7bcd 100644 --- a/graph/csharp/engine/framework-behavior/entry-points.dl +++ b/graph/csharp/engine/framework-behavior/entry-points.dl @@ -11,8 +11,13 @@ // about which method is the handler. // ============================================================================ -// main: a static Main, and the method top-level statements compile to -entry_point(m, "main") :- method_name("client", m, "Main"), method_modifier("client", m, "static"). +// main: a static Main of a shape C# accepts as an entry point (void, int, Task or Task, +// with no parameter or one string[]), and the method top-level statements compile to +entry_point(m, "main") :- method_name("client", m, "Main"), method_modifier("client", m, "static"), cs_main_shape(m). +cs_main_shape(m) :- method_name("client", m, "Main"), method_return_type_name("client", m, rt), cs_main_return(rt), + method_paramc("client", m, "0"). +cs_main_shape(m) :- method_name("client", m, "Main"), method_return_type_name("client", m, rt), cs_main_return(rt), + method_paramc("client", m, "1"), param_decl("client", m, "0", _, p), param_type_name("client", p, _, pt), cs_main_param(pt). entry_point(m, "main") :- method_is_top_level_entry("client", m). // test: a test method, and the set-up and tear-down around it diff --git a/graph/csharp/engine/framework-behavior/knobs.dl b/graph/csharp/engine/framework-behavior/knobs.dl index 5ccea7c7..1a27058d 100644 --- a/graph/csharp/engine/framework-behavior/knobs.dl +++ b/graph/csharp/engine/framework-behavior/knobs.dl @@ -275,6 +275,26 @@ cs_test_lifecycle_attr("OneTimeSetUp"). cs_test_lifecycle_attr("OneTimeTearD cs_test_lifecycle_attr("TestInitialize"). cs_test_lifecycle_attr("TestCleanup"). cs_test_lifecycle_attr("ClassInitialize"). cs_test_lifecycle_attr("ClassCleanup"). cs_test_lifecycle_attr("AssemblyInitialize"). cs_test_lifecycle_attr("AssemblyCleanup"). +// MSTest 3.10+: run around EVERY test of the assembly (#1503) +cs_test_lifecycle_attr("GlobalTestInitialize"). cs_test_lifecycle_attr("GlobalTestCleanup"). + +// cs_main_return(Written) / cs_main_param(Written): the shapes C# accepts as a program's +// entry point. A static `Main` of any other shape, `static string Main()` on a helper, is +// an ordinary method its callers reach, and was a main entry point (#1451). +cs_main_return("void"). cs_main_return("int"). cs_main_return("Int32"). cs_main_return("System.Int32"). +cs_main_return("Task"). cs_main_return("Task"). cs_main_return("Task"). +cs_main_return("System.Threading.Tasks.Task"). cs_main_return("System.Threading.Tasks.Task"). +cs_main_param("string[]"). cs_main_param("String[]"). cs_main_param("System.String[]"). + +// cs_mock_new_type(Type) / cs_mock_factory(Receiver, Method): building a CLASS mock runs the +// mocked type's constructor with the arguments given, through the proxy subclass the +// library generates: `new Mock(args)` (Moq) and `Substitute.ForPartsOf(args)`, +// `Substitute.For(args)` (NSubstitute). cs_mock_behavior_arg is a leading argument +// that configures the mock and is not passed on (Moq's `MockBehavior.Strict`) (#1495). +cs_mock_new_type("Mock"). +cs_mock_factory("Substitute", "ForPartsOf"). +cs_mock_factory("Substitute", "For"). +cs_mock_behavior_arg("MockBehavior"). // cs_framework_callback(Base, Method, Reason): a framework calls Method on a type that // derives from Base or implements it, directly or through the project's own bases. // Base is the written simple name without type arguments. Method "*" is every public diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 7c615ee7..e36dafe8 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -215,10 +215,16 @@ .decl cs_http_verb_any(c0:symbol) .decl cs_http_verb_attr(c0:symbol,c1:symbol) .decl cs_lower(c0:symbol,c1:symbol) +.decl cs_main_param(c0:symbol) +.decl cs_main_return(c0:symbol) +.decl cs_main_shape(c0:symbol) .decl cs_med_dispatch(c0:symbol,c1:symbol,c2:symbol) .decl cs_med_sender_type(c0:symbol) .decl cs_member_name_expr(c0:symbol,c1:symbol) .decl cs_mock_arg_matcher(c0:symbol) +.decl cs_mock_behavior_arg(c0:symbol) +.decl cs_mock_factory(c0:symbol,c1:symbol) +.decl cs_mock_new_type(c0:symbol) .decl cs_msg_builder(c0:symbol,c1:symbol) .decl cs_msg_bus_send(c0:symbol) .decl cs_msg_bus_type(c0:symbol,c1:symbol) @@ -614,6 +620,11 @@ .decl method_span(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_type_arity(c0:symbol,c1:symbol,c2:symbol) .decl method_verb_attr(c0:symbol,c1:symbol,c2:symbol) +.decl mock_ctor(c0:symbol,c1:symbol) +.decl mock_ctor_argc(c0:symbol,c1:symbol) +.decl mock_ctor_behavior(c0:symbol) +.decl mock_ctor_lambda(c0:symbol) +.decl mock_ctor_site(c0:symbol,c1:symbol) .decl module_assembly(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl module_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl module_file(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/test/csharp/entry-points/expected.entry b/graph/test/csharp/entry-points/expected.entry index 30699201..5fa6b8e9 100644 --- a/graph/test/csharp/entry-points/expected.entry +++ b/graph/test/csharp/entry-points/expected.entry @@ -1,11 +1,17 @@ entry http App.OrdersController.List entry lifecycle App.Poller.ExecuteAsync +entry main App.AsyncProgram.Main entry main App.Program.Main +entry test App.MsTests.AfterEvery +entry test App.MsTests.Every entry test App.MsTests.Runs entry test App.NunitTests.Before entry test App.NunitTests.Works entry test App.XunitTests.Lists entry test App.XunitTests.Many +reachable App.AsyncProgram.Main +reachable App.MsTests.AfterEvery +reachable App.MsTests.Every reachable App.MsTests.Runs reachable App.NunitTests.Before reachable App.NunitTests.Works diff --git a/graph/test/csharp/entry-points/src/App.cs b/graph/test/csharp/entry-points/src/App.cs index c10db14c..a48f7532 100644 --- a/graph/test/csharp/entry-points/src/App.cs +++ b/graph/test/csharp/entry-points/src/App.cs @@ -18,6 +18,21 @@ public class NotEntry public void Main() { } // not static: not an entry point } + public static class Names + { + public static string Main() => "widgets"; // returns string: not an entry point (#1451) + } + + public static class Tool + { + public static int Main(int x) => x; // an int parameter: not an entry point + } + + public static class AsyncProgram + { + public static async Task Main() { await Task.Yield(); return 0; } // main: Task, no parameter + } + public class Worker { public void Run() => Helper(); // reachable from Main @@ -56,5 +71,7 @@ [Test] public void Works() { } // test public class MsTests { [TestMethodAttribute] public void Runs() { } // test, written with the suffix + [GlobalTestInitialize] public static void Every(TestContext c) { } // test (MSTest 3.10 lifecycle, #1503) + [GlobalTestCleanup] public static void AfterEvery(TestContext c) { } // test } } diff --git a/graph/test/csharp/mock-constructor/expected.dispatch b/graph/test/csharp/mock-constructor/expected.dispatch new file mode 100644 index 00000000..ca1108ff --- /dev/null +++ b/graph/test/csharp/mock-constructor/expected.dispatch @@ -0,0 +1,4 @@ +App.PriceRuleTests.ForTwo() App.PriceRule.(decimal,decimal) +App.PriceRuleTests.MoqBehaviorTwo() App.PriceRule.(decimal,decimal) +App.PriceRuleTests.MoqOne() App.PriceRule.(decimal) +App.PriceRuleTests.PartsOfOne() App.PriceRule.(decimal) diff --git a/graph/test/csharp/mock-constructor/src/PriceRuleTests.cs b/graph/test/csharp/mock-constructor/src/PriceRuleTests.cs new file mode 100644 index 00000000..3a12d1ac --- /dev/null +++ b/graph/test/csharp/mock-constructor/src/PriceRuleTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using Moq; +using NSubstitute; +using Xunit; + +namespace App +{ + public interface IPricer { decimal Price(decimal a); } + + public class PriceRule + { + public PriceRule(decimal rate) { Rate = rate; } + public PriceRule(decimal rate, decimal floor) { Rate = rate + floor; } + public virtual decimal Rate { get; } + } + + public class Plain { public virtual int N() => 1; } + + public static class Other { public static T ForPartsOf(params object[] a) => default!; } + + public class PriceRuleTests + { + // a class mock runs the constructor its arguments select (#1495) + [Fact] public void MoqOne() => Assert.Equal(2m, new Mock(2m) { CallBase = true }.Object.Rate); + [Fact] public void MoqBehaviorTwo() => Assert.Equal(3m, new Mock(MockBehavior.Loose, 2m, 1m).Object.Rate); + [Fact] public void PartsOfOne() => Assert.Equal(2m, Substitute.ForPartsOf(2m).Rate); + [Fact] public void ForTwo() => Assert.Equal(3m, Substitute.For(2m, 1m).Rate); + + // controls: an interface mock has no constructor to run; a mock built from a lambda runs the lambda's + // own `new` (a call of its own, not this hop); a type with only its implicit constructor; a generic + // collection that is not a mock; a ForPartsOf on a receiver that is not NSubstitute's + [Fact] public void InterfaceMock() => Assert.NotNull(new Mock().Object); + [Fact] public void LambdaMock() => Assert.NotNull(new Mock(() => new PriceRule(3m)).Object); + [Fact] public void ImplicitCtor() => Assert.NotNull(new Mock().Object); + [Fact] public void NotAMock() => Assert.NotNull(new List(4)); + [Fact] public void OtherFactory() => Assert.Null(Other.ForPartsOf(2m)); + } +} diff --git a/graph/test/csharp/tools/mock-constructor-test.sh b/graph/test/csharp/tools/mock-constructor-test.sh new file mode 100755 index 00000000..60698704 --- /dev/null +++ b/graph/test/csharp/tools/mock-constructor-test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# A CLASS MOCK RUNS ITS TYPE'S CONSTRUCTOR (#1495): `new Mock(args)` (Moq) and +# `Substitute.ForPartsOf(args)` / `Substitute.For(args)` (NSubstitute) reach the +# constructor of C that the arguments select, a leading `MockBehavior.X` not counted. +# +# The Roslyn score cannot see this hop (the site's own target is the library's, and the +# packages are not in the source), so the event_dispatch edges are rendered by qualified +# name and diffed against mock-constructor/expected.dispatch. The controls (an interface +# mock, a mock built from a lambda, a type with only an implicit constructor, a List, +# a ForPartsOf on another receiver) are pinned by being absent from the golden. +# +# mock-constructor-test.sh [--bless] +# ───────────────────────────────────────────────────────────────────────────── +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$HERE"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +[ -f "$ROOT/parser/dist/index.js" ] || { echo "mock-constructor-test: SKIP (parser not built)"; exit 0; } +command -v souffle >/dev/null || { echo "mock-constructor-test: SKIP (no souffle)"; exit 0; } +CASE="$ROOT/graph/test/csharp/mock-constructor" +W="$(cd "$(mktemp -d)" && pwd -P)"; trap 'rm -rf "$W"' EXIT +node "$ROOT/parser/dist/index.js" "$CASE/src" mock-ctor false "$W/ir" --per-language > "$W/parse.log" 2>&1 \ + || { echo " ✗ parser failed"; tail -3 "$W/parse.log"; exit 1; } +bash "$ROOT/graph/csharp/souffle/devrun.sh" "$W/ir" "$W/engine" > "$W/engine.log" 2>&1 \ + || { echo " ✗ engine failed"; grep -m3 '^Error' "$W/engine.log"; exit 1; } +python3 - "$W/ir/csharp" "$W/engine/out" > "$W/actual.dispatch" <<'PY' +import csv, os, sys +ir, out = sys.argv[1:3] +lbl = {r['csMethodUniqueHash']: r['qualifiedName'] + '(' + r['signature'] + ')' for r in csv.DictReader(open(os.path.join(ir, 'all-csharp-methods.csv'), newline='', encoding='utf-8'), delimiter='\t')} +rows = [r for r in csv.reader(open(os.path.join(out, 'call-chain-edges.csv'), newline='', encoding='utf-8'), delimiter='\t') if r] +print('\n'.join(sorted({f"{lbl.get(r[1], r[1])}\t{lbl.get(r[3], r[3])}" for r in rows if r[5] == 'event_dispatch'}))) +PY +if [ "${1:-}" = "--bless" ]; then cp "$W/actual.dispatch" "$CASE/expected.dispatch"; echo "mock-constructor-test: blessed ($(grep -c . "$CASE/expected.dispatch") rows)"; exit 0; fi +[ -f "$CASE/expected.dispatch" ] || { echo " ✗ no expected.dispatch (run with --bless)"; exit 1; } +n=$(grep -c . "$CASE/expected.dispatch") +[ "$n" -ge 1 ] || { echo " ✗ the golden holds no edge"; exit 1; } +if diff -q "$CASE/expected.dispatch" "$W/actual.dispatch" >/dev/null; then + echo "mock-constructor-test: ok ($n edges)" +else + echo " ✗ mock constructor edges changed"; diff -u "$CASE/expected.dispatch" "$W/actual.dispatch" | sed 's/^/ /' | head -40; exit 1 +fi diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 68d17673..ac901e3e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -175,7 +175,7 @@ def legend(tiers): # …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. TIER_WHY = { - 'event_dispatch': 'sends the request or event this handles — a framework runs it for what is sent here, no call site names it', + 'event_dispatch': 'sends the request or event this handles, or builds the class mock whose proxy runs this constructor — a framework runs it for what is sent here, no call site names it', } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index db7bdff2..4919ce1e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -428,6 +428,17 @@ class Impact: near = self.g.q("SELECT name, count(*) n FROM decorations GROUP BY name ORDER BY n DESC LIMIT 8") die(f"nothing carries {s} in this graph." + (" Decorations here: " + ', '.join(f"@{x[0]} ({x[1]})" for x in near) if near else '')) return [('decoration', f"{s} ({len(ids)} declaration(s) carry it)", ids)] + # A C# CONFIGURATION KEY is written `Section:Key` (IConfiguration's separator), which is the shape of `Owner.m:v` + # below: asked as a local, `Widgets:MaxCount` looked `Widgets` up as a method and answered "nothing named + # 'Widgets'" (#1443). It is a key when its first segment names no method and the key is written somewhere: in a + # string literal of a .cs file or as a path in an appsettings*.json. + if self.CS_KEY_RE.match(s): + lits = self.cs_config_literals().get(s.lower(), []) + defs = self.cs_config_defs({s.lower()}) if not lits else [None] + # `Orders:MaxCount` beside a method `Orders` is still the key, unless that method has a local of the name + loc = ':' in s and s.count(':') == 1 and any(self.refs_in(mid, s.split(':')[1], LOCAL_KINDS) for mid in self.methods(s.split(':')[0], soft=True) or []) + if (lits or defs) and not loc: + return [('config', f"configuration key {s} (read by its string on {len(lits)} line(s))", s.lower())] m = re.match(r'^(.+?):([A-Za-z_]\w*)$', s) if m and '/' not in m.group(2): # Owner.m:v — a local mids = self.methods(m.group(1)); found = [] @@ -736,6 +747,138 @@ class Impact: def nonsource_hits(self, names): """-> [(name, file, line)] where one of `names` is written as a whole token in a non-source file""" return self._nonsource().hits(names) + # A C# set-up that runs for tests OUTSIDE its own type, which no call and no type scope carries: + # NUnit [SetUpFixture] class: its [OneTimeSetUp] / [OneTimeTearDown] run once around every test in the + # class's namespace and the namespaces nested in it (the whole assembly for a class in no namespace); + # MSTest [AssemblyInitialize] / [AssemblyCleanup] once around the assembly's tests, and [GlobalTestInitialize] / + # [GlobalTestCleanup] (3.10+) around each of them. + # The assembly is the project: the nearest directory above the file holding a .csproj. (#1501, #1503) + NS_FIXTURE_TYPE = {'SetUpFixture'} + NS_FIXTURE_METHOD = {'OneTimeSetUp', 'OneTimeTearDown'} + ASSEMBLY_FIXTURE = {'AssemblyInitialize', 'AssemblyCleanup', 'GlobalTestInitialize', 'GlobalTestCleanup'} + CS_NOT_NUNIT = {'Fact', 'TestMethod', 'DataTestMethod'} + CS_MSTEST = {'TestMethod', 'DataTestMethod'} + def wide_fixtures(self, tests, decs): + """-> {(test, fixture)}: a C# fixture the runner runs before tests of other types (see above)""" + g = self.g + strip = lambda n: n[:-9] if n.endswith('Attribute') and len(n) > 9 else n + dn = lambda i: {strip(d) for d in decs.get(i, ())} + wide = [] + for i, sy in g.sym.items(): + if not (sy.get('method_id') and (sy.get('file') or '').endswith('.cs')): continue + ds = dn(i) + if ds & self.ASSEMBLY_FIXTURE: wide.append((i, None)) + elif ds & self.NS_FIXTURE_METHOD: + t = next((r[0] for r in g.q("SELECT id FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL AND name = ? AND file = ?", + (sy.get('owner') or '').split('.')[-1], sy['file'])), None) + if t and dn(t) & self.NS_FIXTURE_TYPE: wide.append((i, self.cs_namespace(i))) # '' = the global namespace + if not wide: return set() + proj = {} + def project(f): + d = os.path.dirname(f) + if d in proj: return proj[d] + cur = d + while True: + try: hit = any(n.endswith('.csproj') for n in os.listdir(os.path.join(g.repo, cur) if cur else g.repo)) + except OSError: hit = False + if hit or not cur: break + cur = os.path.dirname(cur) + proj[d] = cur if hit else None + return proj[d] + out = set() + cs_tests = [m for m in tests if (g.sym.get(m, {}).get('file') or '').endswith('.cs')] + for fx, ns in wide: + p = project(g.sym[fx]['file']) + # a runner's fixture runs for ITS tests: NUnit's for no [Fact] (xUnit) or [TestMethod] (MSTest), MSTest's for + # [TestMethod] / [DataTestMethod] only + for m in cs_tests: + if m == fx or project(g.sym[m]['file']) != p: continue + if ns is not None and dn(m) & self.CS_NOT_NUNIT: continue + if ns is None and not dn(m) & self.CS_MSTEST: continue + if ns: # '' (no namespace): the whole assembly + tn = self.cs_namespace(m) + if not (tn == ns or tn.startswith(ns + '.')): continue + out.add((m, fx)) + return out + def cs_namespace(self, i): + """the namespace a C# member is declared in: its qualified name less the owner's and its own""" + sy = self.g.sym[i] + q = next((r[0] for r in self.g.q("SELECT qualified_name FROM symbols WHERE id = ?", i)), '') or '' + tail = '.' + '.'.join(x for x in (sy.get('owner'), sy.get('name')) if x) + return q[:-len(tail)] if q.endswith(tail) else q.rsplit('.', 2)[0] if q.count('.') >= 2 else '' + # Moq's `mock.Protected().Setup("Fee")` / `.Verify("Fee", ...)` names a protected member BY STRING: no call + # site, so a rename breaks the test only at run time and `impact` on the member did not list it (#1540). Each such + # literal is a stub row on the member of that name on a type the file mocks (`Mock`) or a base of it: it names + # the member and runs none of it, which is what the tier "stub" already says for `Setup(x => x.F())`. + PROTECTED_CALL = re.compile(r'\bProtected\s*\(\s*\)\s*\.\s*(?:Setup|Verify)\w*\s*(?:<(?:[^<>()]|<(?:[^<>()]|<[^<>()]*>)*>)*>)?\s*\(\s*"(\w+)"') + MOCK_OF = re.compile(r'\bMock\s*<\s*([\w.]+)\s*>') + def protected_name_stubs(self): + g = self.g + if not g.has('literals'): return [] + out = [] + for f in sorted({r[0] for r in g.q("SELECT DISTINCT file FROM literals WHERE file LIKE '%.cs'")}): + L = self.lines(f); text = '\n'.join(L) + if 'Protected' not in text: continue + mocked = {t.split('.')[-1] for t in self.MOCK_OF.findall(text)} + if not mocked: continue + types = {r[0] for t in mocked for r in g.q("SELECT id FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL AND name = ?", t)} + if g.has('type_ancestors'): + types |= {r[0] for t in list(types) for r in g.q("SELECT ancestor_type_id FROM type_ancestors WHERE type_id = ?", t)} + owners = {r[0] for t in types for r in g.q("SELECT name FROM symbols WHERE id = ?", t)} + callers = [(sy['line'], sy['end_line'] or sy['line'], i) for i, sy in g.sym.items() + if sy.get('file') == f and sy.get('method_id') and sy.get('line')] + for (ln,) in g.q("SELECT DISTINCT line FROM literals WHERE file = ? AND line > 0", f): + if ln - 1 >= len(L): continue + # `mock.Protected()` may end the line above and `.Setup("Name", ...)` open this one + for n in set(self.PROTECTED_CALL.findall(L[ln - 1]) + self.PROTECTED_CALL.findall((L[ln - 2] if ln > 1 else '') + ' ' + L[ln - 1])): + c = min((b - a, i) for a, b, i in callers if a <= ln <= b)[1] if any(a <= ln <= b for a, b, _ in callers) else None + if not c: continue + for (m,) in g.q("SELECT id FROM symbols WHERE method_id IS NOT NULL AND name = ?", n): + if (g.sym.get(m) or {}).get('owner', '').split('.')[-1] in owners: out.append((c, m, ax_edges.STUB_TIER, f, ln)) + return out + # ── C# configuration keys (#1443) ───────────────────────────────────────────────────────────────────────── + # The C# engine emits no configuration facts, so a key is joined on what is WRITTEN: the string literal a reader passes + # to IConfiguration (`config["Widgets:MaxCount"]`, `GetValue("Widgets:MaxCount")`) and the path that defines it in + # an appsettings*.json. Keys are case-insensitive there, so both sides are compared lowercased. + CS_KEY_RE = re.compile(r'^[A-Za-z_][\w.-]*(?::[A-Za-z0-9_][\w.-]*)+$') + CS_CONFIG_READ = re.compile(r'\[\s*@?"|\b(?:GetValue|GetSection|GetRequiredSection|GetConnectionString|Bind|Configure|BindConfiguration)\b') + def cs_config_literals(self): + """-> {lowercased key: [(callable, file, line, the line reads configuration)]}: the key-shaped literals of .cs files""" + if getattr(self, '_cs_cfg_lits', None) is not None: return self._cs_cfg_lits + g = self.g; out = collections.defaultdict(list) + if g.has('literals'): + for v, f, ln in g.q("SELECT value, file, line FROM literals WHERE file LIKE '%.cs' AND value LIKE '%:%' AND line > 0"): + if not self.CS_KEY_RE.match(v or ''): continue + c = self.at(f, ln) + if not c: continue + L = self.lines(f) + out[v.lower()].append((c, f, ln, bool(ln - 1 < len(L) and self.CS_CONFIG_READ.search(L[ln - 1])))) + self._cs_cfg_lits = out + return out + def cs_config_defs(self, keys): + """-> [(key, file, line)]: where an appsettings*.json defines one of `keys` (lowercased `A:B:C` paths)""" + keys = {k for k in keys if ':' in k} + if not keys: return [] + out = [] + for rel in self.nonsource_files(): + b = os.path.basename(rel).lower() + if not (b.startswith('appsettings') and b.endswith('.json')): continue + try: L = open(os.path.join(self.g.repo, rel), errors='replace').read().split('\n') + except OSError: continue + # the path of each `"name":` line, from the nesting of the lines above it: settings files are written one + # property per line, and a line-level walk keeps the line number json.load would throw away + stack = []; depth = 0 + for i, line in enumerate(L, 1): + for mm in re.finditer(r'"((?:[^"\\]|\\.)*)"\s*:|[{}\[\]]', line): + t = mm.group(0) + if t in '{[': depth += 1 + elif t in '}]': + depth -= 1; stack = [x for x in stack if x[0] <= depth] + else: + stack = [x for x in stack if x[0] < depth] + [(depth, mm.group(1))] + path = ':'.join(n for _, n in stack).lower() + if path in keys: out.append((path, rel, i)) + return out KEYFILE_EXT = {'.properties','.yml','.yaml','.conf','.ini','.cfg','.env'} def config_key_sites(self, keys): """a key DEFINED or overridden in a settings file, matched on the canonical form so the spelling there @@ -767,7 +910,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 = '44' # 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '45' # 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -808,7 +951,9 @@ class Impact: by_file.setdefault(sy['file'], []).append((sy['line'], sy['end_line'], i)) lex = [] for rows in by_file.values(): - rows.sort(key=lambda r: (r[0], -r[1])) + # on ONE line (`[Fact] public void T() => M(() => F());`) the spans tie: a compiler-named member (``) + # is the one inside, so it sorts after the declaration that writes it + rows.sort(key=lambda r: (r[0], -r[1], (g.sym[r[2]].get('name') or '').startswith('<'))) stack = [] for a, b, i in rows: while stack and stack[-1][1] < a: stack.pop() @@ -951,7 +1096,8 @@ class Impact: # a chained route link's edge sits on the line of its own verb and path, where `registration` labels it link_line = ax_registration.route_site_lines(g.q, g.site_file) W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) - for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) + for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + + self.protected_name_stubs()) # a 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( @@ -1150,6 +1296,7 @@ class Impact: tm += sorted(scripts) fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or s['name'] in FIXTURE_NAMES or any(FIXTURE_DECOR.match(d) for d in decs.get(i, ())))] W('test_method', [(m,) for m in tm]); W('fixture', [(m,) for m in fx]) + W('runs_before', sorted(self.wide_fixtures(tm, decs))) # ── which fixture runs before which test, when nothing calls it ──────────────────────────────────────── # pytest hands a fixture to a test BY THE NAME OF A PARAMETER, and the fixture that serves a whole directory # is declared in `conftest.py`, which is not the test's file and is imported by nothing. Neither end is a call @@ -1352,6 +1499,7 @@ class Impact: hit = names & enum_consts.get(disp, set()) if hit: sw.append((i, tid, str(len(hit)))) W('throws_', sorted(set(thr))); W('catches', sorted(set(cat_))); W('switch_over', sorted(set(sw))) + W('cs_config_literal', sorted({(k, c, 'by name' if rd else 'text', f, l) for k, rows in self.cs_config_literals().items() for c, f, l, rd in rows})) W('config', cfg); W('config_key_known', [(k,) for k in sorted(keys)]); W('bean', beans); W('injected', sorted(set(inj))) # a bean another class registers by naming it in an annotation: the query is graph_sql's, so the rules and the # hook's fast path read the same rows @@ -1477,7 +1625,7 @@ class Impact: for f in payload: want.add(f['name']) elif kind == 'config': want.add(payload) cfgkeys = {payload for kind, lab, payload in targets if kind == 'config'} - prof(' per-query: targets walked'); _nonsource = sorted(set(self.nonsource_hits(want)) | set(self.config_key_sites(cfgkeys))); g.write('nonsource', _nonsource, F); g.write('qual_name', sorted(set(qn)), F); prof(' per-query: non-source scanned') + prof(' per-query: targets walked'); _nonsource = sorted(set(self.nonsource_hits(want)) | set(self.config_key_sites(cfgkeys)) | set(self.cs_config_defs(cfgkeys))); g.write('nonsource', _nonsource, F); g.write('qual_name', sorted(set(qn)), F); prof(' per-query: non-source scanned') g.write('key_cap', [(int(os.environ.get('AXIOMCODE_KEY_CAP', '4')),)], F) g.write('key_use_cap', [(int(os.environ.get('AXIOMCODE_KEY_USE_CAP', '4')),)], F) g.write('target', T, F); g.write('textuse', sorted(set(textuse)), F); g.write('importuse', sorted(set(importuse)), F); g.write('inside_target', sorted(set(inside)), F) @@ -2255,7 +2403,7 @@ 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])) if cert != 'alongside'], 'alongside': [{'id': c, 'display': g.disp(c), 'role': 'co-located', 'why': why, 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (g.disp(x[0]), x[2], x[4], x[0])) if cert == 'alongside'], '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, [])], + '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 (([m] + I.chain(fx, parent)) if fx else chains.get(m, []))], **({'script': True, 'run': script_cmd(m)} if m in script_ids else {})} 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, @@ -2595,7 +2743,8 @@ def main(argv): # 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), + and not why.startswith('stubs a method of this name')}) - set(tests) - set(stubbed) + - {v[1] for v in tests.values() if isinstance(v, tuple) and len(v) > 1 and v[1]}, # a fixture the count credits through key=lambda m: (g.disp(m), g.loc(m))) # a callable in a file whose test is counted runs when that file does (`main` under a script's main guard, the # describe block around a counted `it`): the count already credits it diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 0515d1ac..3c627eb6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -471,6 +471,10 @@ for r in rows(e['file']): refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: v = (r.get(e['litValue']) or '') + # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived + # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched + if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') + if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0))) c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?)", lits) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index e91d5d72..f1fc4c35 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -2040,7 +2040,13 @@ def main(argv): if why: print("\n why each one is here:") for i, r in sorted(tests.items(), key=lambda kv: (kv[1].get('hops') or 99, kv[1].get('display', '')))[:limit]: - chain = " -> ".join(r.get('chain') or []) or "reaches it directly" + ch = r.get('chain') or [] + # a fixture-selected test's route runs test <- fixture -> ... -> the change: the first hop is the runner + # handing the fixture's result to the test, not a call the test makes (#1424) + if r.get('via') and len(ch) > 1 and ch[1] == r['via']: + chain = f"{ch[0]} <- " + " -> ".join(ch[1:]) + else: + chain = " -> ".join(ch) or "reaches it directly" print(f" {r.get('display')} [{r.get('certainty')}] {chain}") if replaced: # one line however many: an abstract class runs nothing by name, so the command names what extends it diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6b470ecb..a6256397 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -100,6 +100,9 @@ .decl fixture_scope(fx:symbol, n:symbol, p:symbol) .input fixture_scope .decl file_scope(f:symbol, p:symbol) .input file_scope .decl autouse_fixture(fx:symbol) .input autouse_fixture +// runs_before(m, fx) a C# set-up the runner runs for tests outside its type: an NUnit [SetUpFixture]'s +// [OneTimeSetUp] for its namespace, MSTest's assembly-wide initializers for the project +.decl runs_before(m:symbol, fx:symbol) .input runs_before // injects(m, fx) the language engine resolved m's request to fixture fx (Python: the runner's own // visibility, the decorator's `name=`, star imports and `pytest_plugins`) // injected_fixture(fx) fx is injected by name and runs only for what requests it: not every test of its file @@ -131,6 +134,9 @@ .decl nonsource(n:symbol, f:symbol, l:number) .input nonsource .decl qual_name(s:symbol, n:symbol) .input qual_name .decl config(k:symbol, m:symbol, why:symbol) .input config +// cs_config_literal(k, c, cert, f, l) a C# string literal inside c spelling the configuration key k (lowercased `A:B`); +// cert is `by name` where the line reads IConfiguration, `text` elsewhere (#1443) +.decl cs_config_literal(k:symbol, c:symbol, cert:symbol, f:symbol, l:number) .input cs_config_literal .decl config_key_known(k:symbol) .input config_key_known .decl bean(n:symbol, t:symbol, kind:symbol) .input bean .decl bean_factory(m:symbol, t:symbol) .input bean_factory @@ -271,6 +277,7 @@ contract(q, s, "extends / implements it") :- target(q, "clinit", t, _), extends( // a CONFIGURATION KEY: the methods the container binds it into — no call reaches them, so nothing else would find these direct(q, m, "reads", cat("reads this configuration key (", why, ")"), "resolved", "", 0) :- target(q, "config", k, _), config(k, m, why). // the binding site: the line that ties the key to the code, and the one that must be edited with it +direct(q, c, "reads", "reads this configuration key by its string (the C# engine has no configuration facts: joined on the key as written)", cert, f, l) :- target(q, "config", k, _), cs_config_literal(k, c, cert, f, l). direct(q, c, "produces", cat("BINDS the key here, at the @", dn, " placeholder — this is the line a rename must change"), "resolved", f, l) :- target(q, "config", k, _), config_site(k, c, dn, f, l). // a type the container INJECTS: its consumers receive it without a call the graph can see @@ -704,6 +711,7 @@ seed_of(q, m) :- target(q, "decoration", m, _). seed_of(q, t) :- target(q, "newconst", t, _). seed_of(q, c) :- target(q, "newconst", t, _), switch_over(c, t, _). seed_of(q, m) :- target(q, "config", k, _), config(k, m, _). +seed_of(q, c) :- target(q, "config", k, _), cs_config_literal(k, c, _, _, _). seed_of(q, t) :- target(q, "field", fl, _), field(fl, t, _, _, _). seed_of(q, m) :- target(q, "type", t, _), member(t, m, _, _), kind(m, k), (k = "method" ; k = "constructor" ; k = "function" ; k = "module"). seed_of(q, t) :- target(q, "type", t, _). @@ -964,7 +972,13 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // JUnit file holds several @Nested classes, and a helper private to one of them is not written for a sibling's // tests. A helper that belongs to no type -- a module-level function, an arrow beside the describe block -- has no // scope narrower than the file, and keeps it. -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). +// ...except a callable written INSIDE a test method (a C# or Java lambda, a local function): it belongs to that test, +// not to its type. Owned by the class, a `mock.Setup(s => ...)` or `Func f = () => ...` in one test carried its +// callees to every test of the class as [fixture], and the test holding it lost its own route (#1556). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), !in_test_body(c), decl_file(c, f), is_test_file(f). +.decl in_test_body(c:symbol) +in_test_body(c) :- lex_in(m, c), test_method(m), !test_method(c). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, _), in_test_body(c), lex_in(m, c), test_method(m). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -1031,6 +1045,7 @@ uses_fixture(m, fx) :- dec_literal(t, "usefixtures", n, _, _), typ(t, _, _), sco // A module's `pytestmark` and the ini `usefixtures` option apply the marker to every test under their path (#1528). uses_fixture(m, fx) :- usefixtures_scope(p, n), decl_file(m, f), file_scope(f, p), test_method(m), fixture_scope(fx, n, q), file_scope(f, q), m != fx. +uses_fixture(m, fx) :- runs_before(m, fx), test_method(m). uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m, f), file_scope(f, p), test_method(m), m != fx. // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. @@ -1063,7 +1078,7 @@ 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). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), !in_test_body(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 ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/cases/csharp/config-key-by-section-path/App/App.csproj b/tests/cases/csharp/config-key-by-section-path/App/App.csproj new file mode 100644 index 00000000..0e47010c --- /dev/null +++ b/tests/cases/csharp/config-key-by-section-path/App/App.csproj @@ -0,0 +1,2 @@ +net8.0 + diff --git a/tests/cases/csharp/config-key-by-section-path/App/Reader.cs b/tests/cases/csharp/config-key-by-section-path/App/Reader.cs new file mode 100644 index 00000000..564bad3f --- /dev/null +++ b/tests/cases/csharp/config-key-by-section-path/App/Reader.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; + +namespace App.Widgets; + +public sealed class Reader(IConfiguration config) +{ + public string? Raw() => config["Widgets:MaxCount"]; + public int Typed() => config.GetValue("Widgets:MaxCount"); + public string? Min() => config["Widgets:MinCount"]; + public string? Orders() => config["Orders:MaxCount"]; +} + +public class Gate(Reader r) +{ + public bool Open() => r.Typed() > 0; +} diff --git a/tests/cases/csharp/config-key-by-section-path/App/appsettings.json b/tests/cases/csharp/config-key-by-section-path/App/appsettings.json new file mode 100644 index 00000000..a5982f88 --- /dev/null +++ b/tests/cases/csharp/config-key-by-section-path/App/appsettings.json @@ -0,0 +1,7 @@ +{ + "Widgets": { + "MaxCount": 5, + "MinCount": 1 + }, + "Orders": { "MaxCount": 9 } +} diff --git a/tests/cases/csharp/config-key-by-section-path/case.json b/tests/cases/csharp/config-key-by-section-path/case.json new file mode 100644 index 00000000..a584cde2 --- /dev/null +++ b/tests/cases/csharp/config-key-by-section-path/case.json @@ -0,0 +1,14 @@ +{"lang": "csharp", + "checks": [ + {"why": "a C# configuration key written Section:Key is a key: its readers by the key string, what reaches them, and the appsettings.json line that defines it (#1443)", + "run": ["impact", "Widgets:MaxCount"], + "want": ["configuration key Widgets:MaxCount", "Reader.Raw", "Reader.Typed", "App/appsettings.json:3", "Gate.Open"], + "avoid": ["nothing named 'Widgets'", "Reader.Min", "Reader.Orders", "appsettings.json:6"]}, + {"why": "near miss: the same leaf under another section is another key", + "run": ["impact", "Orders:MaxCount"], + "want": ["Reader.Orders", "App/appsettings.json:6"], + "avoid": ["Reader.Raw", "Reader.Typed"]}, + {"why": "a C# string literal is stored without its quotes, so a quoted-string question finds it", + "run": ["impact", "\"Widgets:MinCount\""], + "want": ["Reader.Min"], + "avoid": ["appears in no literal"]}]} diff --git a/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/PriceRuleTests.cs b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/PriceRuleTests.cs new file mode 100644 index 00000000..b9dd0d6e --- /dev/null +++ b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/PriceRuleTests.cs @@ -0,0 +1,26 @@ +using Moq; +using Moq.Protected; +using NSubstitute; +using Shop; +using Xunit; + +namespace Shop.Tests; + +public class PriceRuleTests +{ + [Fact] public void MoqWithRate() => Assert.Equal(2m, new Mock(2m) { CallBase = true }.Object.Rate); + [Fact] public void PartsOfWithRate() => Assert.Equal(2m, Substitute.ForPartsOf(2m).Rate); + [Fact] public void DirectWithRate() => Assert.Equal(2m, new PriceRule(2m).Rate); + [Fact] public void MoqWithFloor() => Assert.Equal(3m, new Mock(MockBehavior.Loose, 2m, 1m).Object.Rate); + [Fact] public void MoqFromLambda() => Assert.Equal(4m, new Mock(() => new PriceRule(4m, 0m)).Object.Rate); + + [Fact] + public void FeeIsStubbed() + { + var rule = new Mock(1m) { CallBase = true }; + rule.Protected().Setup("Fee").Returns(0m); + rule.Protected().Verify("Fee", Times.Never()); + rule.Protected() + .Setup>("Tip"); + } +} diff --git a/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/Shop.Tests.csproj b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/Shop.Tests.csproj new file mode 100644 index 00000000..dda0a06d --- /dev/null +++ b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop.Tests/Shop.Tests.csproj @@ -0,0 +1,3 @@ +net8.0 + + diff --git a/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/PriceRule.cs b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/PriceRule.cs new file mode 100644 index 00000000..2485449c --- /dev/null +++ b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/PriceRule.cs @@ -0,0 +1,17 @@ +namespace Shop; + +public class PriceRule +{ + public PriceRule(decimal rate) { Rate = rate; } + public PriceRule(decimal rate, decimal floor) { Rate = rate + floor; } + public virtual decimal Rate { get; } + public virtual decimal Apply(decimal a) => a + Fee(); + protected virtual decimal Fee() => 1m; + protected virtual decimal Levy() => 2m; + protected virtual System.Threading.Tasks.Task Tip() => System.Threading.Tasks.Task.FromResult(0m); +} + +public class Coupon +{ + protected virtual decimal Fee() => 3m; +} diff --git a/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/Shop.csproj b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/Shop.csproj new file mode 100644 index 00000000..d3b2f030 --- /dev/null +++ b/tests/cases/csharp/mock-runs-the-constructor-it-names/Shop/Shop.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/mock-runs-the-constructor-it-names/case.json b/tests/cases/csharp/mock-runs-the-constructor-it-names/case.json new file mode 100644 index 00000000..dff64ffa --- /dev/null +++ b/tests/cases/csharp/mock-runs-the-constructor-it-names/case.json @@ -0,0 +1,88 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a class mock built with constructor arguments runs the constructor they select: new Mock(args) and Substitute.ForPartsOf(args) reach C(decimal) beside the direct new C(2m) (#1495)", + "run": [ + "impact", + "Shop/PriceRule.cs:5", + "--tests" + ], + "want": [ + "PriceRuleTests::MoqWithRate", + "PriceRuleTests::PartsOfWithRate", + "PriceRuleTests::DirectWithRate", + "PriceRuleTests::FeeIsStubbed" + ], + "avoid": [ + "PriceRuleTests::MoqWithFloor", + "PriceRuleTests::MoqFromLambda" + ] + }, + { + "why": "near miss: a leading MockBehavior is not a constructor argument, so new Mock(MockBehavior.Loose, 2m, 1m) selects C(decimal, decimal); a mock built from a lambda reaches it through the lambda's own new; that lambda is inside one test, so it reaches that test and no other test of the class (#1556)", + "run": [ + "impact", + "Shop/PriceRule.cs:6", + "--tests" + ], + "want": [ + "PriceRuleTests::MoqWithFloor", + "PriceRuleTests::MoqFromLambda" + ], + "avoid": [ + "PriceRuleTests::MoqWithRate", + "PriceRuleTests::PartsOfWithRate", + "PriceRuleTests::DirectWithRate", + "PriceRuleTests::FeeIsStubbed" + ] + }, + { + "why": "a protected member Moq's Protected().Setup/Verify names by string is stubbed there: a rename breaks the test at run time, so it is listed as a stub (#1540)", + "run": [ + "impact", + "PriceRule.Fee", + "--tests" + ], + "want": [ + "[stubs it]", + "Shop.Tests/PriceRuleTests.cs" + ] + }, + { + "why": "the Protected() call may end the line above the Setup that names the member", + "run": [ + "impact", + "PriceRule.Tip", + "--tests" + ], + "want": [ + "[stubs it]" + ] + }, + { + "why": "near miss: a protected member of the same name on a type the test does not mock, and a protected member the string does not name, are not stubbed", + "run": [ + "impact", + "Coupon.Fee", + "--tests" + ], + "avoid": [ + "[stubs it]", + "stub it on a mock" + ] + }, + { + "why": "near miss: Levy is not named by the setup", + "run": [ + "impact", + "PriceRule.Levy", + "--tests" + ], + "avoid": [ + "[stubs it]", + "stub it on a mock" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/App.Tests.csproj b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/App.Tests.csproj new file mode 100644 index 00000000..e592ef70 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/App.Tests.csproj @@ -0,0 +1,3 @@ +net8.0 + + diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Deep/DeepTests.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Deep/DeepTests.cs new file mode 100644 index 00000000..e3584653 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Deep/DeepTests.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; + +namespace App.Tests.Deep; + +public class DeepTests +{ + [Test] + public void Nested() => Assert.That(1, Is.EqualTo(1)); +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Other/OtherTests.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Other/OtherTests.cs new file mode 100644 index 00000000..90b0e9ae --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/Other/OtherTests.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; + +namespace Elsewhere.Tests; + +public class OtherTests +{ + [Test] + public void Outside() => Assert.That(1, Is.EqualTo(1)); +} + +public class XunitInSameAssembly +{ + [Xunit.Fact] + public void NotNunit() { } +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/PricerTests.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/PricerTests.cs new file mode 100644 index 00000000..12f152a2 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/PricerTests.cs @@ -0,0 +1,21 @@ +using App; +using NUnit.Framework; + +namespace App.Tests; + +[SetUpFixture] +public class GlobalSetup +{ + [OneTimeSetUp] + public void Boot() => new WidgetPricer().Price(1); +} + +[TestFixture] +public class PricerTests +{ + [SetUp] + public void Init() => new WidgetPricer().Tax(1); + + [Test] + public void Price_Doubles() => Assert.That(2, Is.EqualTo(2)); +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/XunitTests.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/XunitTests.cs new file mode 100644 index 00000000..3dbe20ba --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App.Tests/XunitTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace App.Tests; + +public class XunitTests +{ + [Fact] + public void RunByXunit() => Assert.Equal(1, 1); +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/App.csproj b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/App.csproj new file mode 100644 index 00000000..d3b2f030 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/App.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/WidgetPricer.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/WidgetPricer.cs new file mode 100644 index 00000000..1b11899c --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/App/WidgetPricer.cs @@ -0,0 +1,8 @@ +namespace App; + +public class WidgetPricer +{ + public int Price(int q) => q * 2; + public int Tax(int q) => q + 1; + public int Discount(int q) => q - 1; +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Hooks.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Hooks.cs new file mode 100644 index 00000000..6e40b3a0 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Hooks.cs @@ -0,0 +1,18 @@ +using App; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ms.Tests; + +[TestClass] +public class Hooks +{ + [GlobalTestInitialize] + public static void EveryTest(TestContext c) => new WidgetPricer().Discount(1); +} + +[TestClass] +public class OrderTests +{ + [TestMethod] + public void Places() => Assert.AreEqual(1, 1); +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Ms.Tests.csproj b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Ms.Tests.csproj new file mode 100644 index 00000000..2c270ebd --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Ms.Tests/Ms.Tests.csproj @@ -0,0 +1,3 @@ +net8.0 + + diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Price-old.cs b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Price-old.cs new file mode 100644 index 00000000..97a8b5dc --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/Price-old.cs @@ -0,0 +1,8 @@ +namespace App; + +public class WidgetPricer +{ + public int Price(int q) => q * 3; + public int Tax(int q) => q + 1; + public int Discount(int q) => q - 1; +} diff --git a/tests/cases/csharp/setup-fixture-runs-for-its-namespace/case.json b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/case.json new file mode 100644 index 00000000..2f1158b6 --- /dev/null +++ b/tests/cases/csharp/setup-fixture-runs-for-its-namespace/case.json @@ -0,0 +1,76 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "an NUnit [SetUpFixture]'s [OneTimeSetUp] runs before every NUnit test in its namespace and the namespaces under it, which no call and no type scope carries (#1501)", + "run": [ + "impact", + "WidgetPricer.Price", + "--tests" + ], + "want": [ + "PricerTests::Price_Doubles", + "DeepTests::Nested", + "via GlobalSetup.Boot" + ], + "avoid": [ + "OtherTests::Outside", + "XunitTests::RunByXunit", + "XunitInSameAssembly::NotNunit", + "OrderTests::Places", + "callable(s) in test code listed above reach it" + ] + }, + { + "why": "control: a [SetUp] on the fixture itself is credited to that fixture's tests only, as before", + "run": [ + "impact", + "WidgetPricer.Tax", + "--tests" + ], + "want": [ + "tests: 1 of", + "PricerTests::Price_Doubles" + ], + "avoid": [ + "DeepTests::Nested" + ] + }, + { + "why": "MSTest's [GlobalTestInitialize] (3.10+) runs before every MSTest test of its assembly, in another class too, and not in another project (#1503)", + "run": [ + "impact", + "WidgetPricer.Discount", + "--tests" + ], + "want": [ + "OrderTests::Places", + "via Hooks.EveryTest" + ], + "avoid": [ + "PricerTests::Price_Doubles", + "DeepTests::Nested" + ] + }, + { + "why": "test-impact --why prints a fixture-selected test's whole route, the fixture included (#1424)", + "run": [ + "test-impact", + "{repo}", + "--why", + "--old", + "{repo}/Price-old.cs", + "--new", + "{repo}/App/WidgetPricer.cs", + "--file", + "App/WidgetPricer.cs" + ], + "want": [ + "DeepTests.Nested <- GlobalSetup.Boot -> WidgetPricer.Price" + ], + "avoid": [ + "[fixture] DeepTests.Nested\n" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/java/fixture-route-in-test-impact-why/case.json b/tests/cases/java/fixture-route-in-test-impact-why/case.json new file mode 100644 index 00000000..fb34c8ba --- /dev/null +++ b/tests/cases/java/fixture-route-in-test-impact-why/case.json @@ -0,0 +1,6 @@ +{"lang": "java", + "checks": [ + {"why": "test-impact --why prints a fixture-selected test's whole route: the test, the fixture the runner hands it, and the calls from there to the change (#1424)", + "run": ["test-impact", "{repo}", "--why", "--old", "{repo}/cents-old.java", "--new", "{repo}/src/main/java/app/Money.java", "--file", "src/main/java/app/Money.java"], + "want": ["MoneyTest.positive <- MoneyTest.bounds -> Money.cents", "MoneyTest.roundTrip -> Money.cents"], + "avoid": ["[fixture] MoneyTest.positive\n"]}]} diff --git a/tests/cases/java/fixture-route-in-test-impact-why/cents-old.java b/tests/cases/java/fixture-route-in-test-impact-why/cents-old.java new file mode 100644 index 00000000..064cb3f1 --- /dev/null +++ b/tests/cases/java/fixture-route-in-test-impact-why/cents-old.java @@ -0,0 +1,6 @@ +package app; + +public class Money { + static long cents(long units) { return units * 1000; } + static long units(long cents) { return cents / 100; } +} diff --git a/tests/cases/java/fixture-route-in-test-impact-why/src/main/java/app/Money.java b/tests/cases/java/fixture-route-in-test-impact-why/src/main/java/app/Money.java new file mode 100644 index 00000000..52241219 --- /dev/null +++ b/tests/cases/java/fixture-route-in-test-impact-why/src/main/java/app/Money.java @@ -0,0 +1,6 @@ +package app; + +public class Money { + static long cents(long units) { return units * 100; } + static long units(long cents) { return cents / 100; } +} diff --git a/tests/cases/java/fixture-route-in-test-impact-why/src/test/java/app/MoneyTest.java b/tests/cases/java/fixture-route-in-test-impact-why/src/test/java/app/MoneyTest.java new file mode 100644 index 00000000..4cb17ce8 --- /dev/null +++ b/tests/cases/java/fixture-route-in-test-impact-why/src/test/java/app/MoneyTest.java @@ -0,0 +1,14 @@ +package app; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class MoneyTest { + static Stream bounds() { return Stream.of(Money.cents(1), Money.cents(2)); } + @ParameterizedTest + @MethodSource("bounds") + void positive(long v) { } + @Test + void roundTrip() { Money.units(Money.cents(3)); } +} From 37b4ef7529e9c088390931d74f6a10b5713ef6a5 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:27:48 -0700 Subject: [PATCH 026/258] csharp: event handlers, method group hand-offs, base calls through an unstaged base, and extension receivers Fixes #1449, fixes #1550, fixes #1450, fixes #1431, fixes #1472, fixes #1535 Refs #1435 What was wrong - #1449: a handler added with += to a field-like event had no user. delegates.dl followed fields and properties only, so neither the += nor Ticked?.Invoke(...) was joined, although an event is stored and invoked as a delegate field is. - #1550: a method group passed as an argument (new Timer(Sweep, ...), Run(Report)) or added to an event of an unstaged type (ProcessExit += OnExit) had no user; the name was recorded and no rule read it. - #1450: base.M() in a class whose base is unstaged had neither a target nor a label, so the site was ambiguous_unknown and impact offered it by name as a caller of every sibling class's M. - #1431: every unstaged heritage entry added external:. beside a member found on the receiver's own type, and kept the site off known_edge. On a class after position 0, and on a struct, the entry is an interface, and C# lookup on a class or struct receiver never reaches an interface member. - #1472: an in-source this-IServiceCollection extension called on an unstaged ServiceCollection receiver matched no clause: the nominal clause needs ancestors an unstaged type does not have, and the by-name clause needs equal names. - #1535: with --library, the nominal clause walked type_self_or_ancestor, which has no row for a library group, so an in-source extension on a library-typed receiver was labelled a member of the library type, and staging the library made the answer worse. The change (C# engine rules only) - delegates.dl: an event is one more followed member kind (bare, qualified, its own Invoke node). A method group in argument position, or the value of =, += or ??= whose target is not a followed member, is method_group_handoff; call_chain.dl emits it as a callback_registered edge of kind ref from the enclosing method, the tier the JavaScript engine gives the same hand-off. Lambdas are left out (they are their own method). Every overload of the name is a candidate, each a hand-off, never a call. - external-types.dl: base.M() in a class with no resolved base class and an unresolved entry at position 0 is external:.. The overload rules for a found member name only entries member lookup can reach (type_member_base_unresolved: position 0 of a class, every entry of an interface), and call_chain.dl keys site_leaves_client on the same relation. A miss still uses the looser type_hierarchy_incomplete. The library-receiver labels are withheld where a firm in-source extension candidate exists. - extensions.dl: (5) an in-source receiver whose own or inherited unresolved heritage entry has the this-parameter's name matches; (6) a library-typed receiver matches through the library's own self-or-ancestor; (7) where both sides are unstaged under different names and nothing else matched, the extension is a guess: a candidate beside the kept external label, so the site is one of a set. - tests/run.py: a case may name a "library" root to stage. Controls: a += on a delegate field and its invoke are unchanged; a lambda argument's call stays a call from the lambda; a method nobody passes stays local; a handler on an event nothing raises is not reached; base.M() through an in-source base still binds; an in-source interface stays resolved; an unstaged entry at position 0 of a class keeps the call one of a set; an extension on an in-source this type never matches an unstaged receiver or an unrelated library receiver. On the installed engine the 14 fix checks of the five new cases fail and the 11 controls pass; with this change all 25 pass. Left open: #1435. .razor and .cshtml files are not read by the C# parser, which is TypeScript (file discovery and a Razor to C# extraction with line mapping); the no-regression gate cannot measure a TypeScript change without a build, so it is left for a parser unit. The bundle vocabulary (graph/bundle/schema.ts) does not yet list callback_registered and ref for C#, so a build logs them as undocumented values; that is a TypeScript documentation change left for the same reason. Suites: graph/test/csharp/run-tests.sh (Roslyn-scored): 18 of 18 cases, every tools gate ok, staging PASS. tests/run.py --lang csharp: 80 of 83 checks, 1 pending; the two failures are the known pre-existing ones (lambda-is-named-by-its-place, and the pending marker on member-owner-is-its-type). engine-invariants.py now lists callback_registered. Smoke (two C# corpus members, fresh copies, installed engine vs this change): - member A (254 files): ambiguous_unknown 1669 -> 1659. 7 base.M() calls through an unstaged framework base are named (external:.OnModelCreating and the like); 3 field-like event invokes now reach the 4 subscribed handlers (value candidates 0 -> 4); 1 method group hand-off; 1 unreachable interface label dropped. No other site changed. - member B (757 files): ambiguous_unknown 8499 -> 8487, known_edge 13426 -> 13613, multi_inferred 4215 -> 4148. 432 callback_registered edges at 299 method group sites (174 methods; 8 sampled by hand, all correct, including a local function); 187 sites lose an unreachable interface label (external:IDisposable.X, external:ICloneable.Clone) and become known_edge; 90 calls of in-source extensions on unstaged receivers (a library class against a this-parameter typed as its interface or base) become one of a set; 12 base calls and invokes named. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/call-edge-generation/call_chain.dl | 27 +++++++- graph/csharp/engine/resolution/delegates.dl | 65 +++++++++++++++++-- graph/csharp/engine/resolution/extensions.dl | 65 +++++++++++++++++++ .../engine/resolution/external-types.dl | 41 ++++++++++-- .../engine/resolution/type-hierarchy.dl | 18 +++++ graph/csharp/souffle/decls_all.dl | 11 ++++ .../11-delegate-fields/src/DelegateFields.cs | 2 +- graph/test/csharp/engine-invariants.py | 2 +- .../event-handler-is-followed/case.json | 19 ++++++ .../event-handler-is-followed/src/Ticker.cs | 26 ++++++++ .../src/Widgets.csproj | 6 ++ .../extension-on-library-receiver/case.json | 16 +++++ .../lib/Example.Kit/Example.Kit.csproj | 1 + .../lib/Example.Kit/Kit.cs | 10 +++ .../src/app/App.cs | 17 +++++ .../src/app/App.csproj | 2 + .../extension-on-unstaged-receiver/case.json | 14 ++++ .../src/App.csproj | 10 +++ .../src/Program.cs | 10 +++ .../src/Widgets.cs | 18 +++++ .../csharp/method-group-handoff/case.json | 24 +++++++ .../method-group-handoff/src/Widgets.cs | 25 +++++++ .../method-group-handoff/src/Widgets.csproj | 6 ++ .../csharp/unstaged-base-member/case.json | 22 +++++++ .../unstaged-base-member/src/Contexts.cs | 16 +++++ .../csharp/unstaged-base-member/src/Orders.cs | 30 +++++++++ .../unstaged-base-member/src/Widgets.csproj | 10 +++ tests/run.py | 3 +- 28 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 tests/cases/csharp/event-handler-is-followed/case.json create mode 100644 tests/cases/csharp/event-handler-is-followed/src/Ticker.cs create mode 100644 tests/cases/csharp/event-handler-is-followed/src/Widgets.csproj create mode 100644 tests/cases/csharp/extension-on-library-receiver/case.json create mode 100644 tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Example.Kit.csproj create mode 100644 tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Kit.cs create mode 100644 tests/cases/csharp/extension-on-library-receiver/src/app/App.cs create mode 100644 tests/cases/csharp/extension-on-library-receiver/src/app/App.csproj create mode 100644 tests/cases/csharp/extension-on-unstaged-receiver/case.json create mode 100644 tests/cases/csharp/extension-on-unstaged-receiver/src/App.csproj create mode 100644 tests/cases/csharp/extension-on-unstaged-receiver/src/Program.cs create mode 100644 tests/cases/csharp/extension-on-unstaged-receiver/src/Widgets.cs create mode 100644 tests/cases/csharp/method-group-handoff/case.json create mode 100644 tests/cases/csharp/method-group-handoff/src/Widgets.cs create mode 100644 tests/cases/csharp/method-group-handoff/src/Widgets.csproj create mode 100644 tests/cases/csharp/unstaged-base-member/case.json create mode 100644 tests/cases/csharp/unstaged-base-member/src/Contexts.cs create mode 100644 tests/cases/csharp/unstaged-base-member/src/Orders.cs create mode 100644 tests/cases/csharp/unstaged-base-member/src/Widgets.csproj diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index fdd5d50a..18ded41f 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -38,6 +38,10 @@ // site in process (a mediator's Send reaching the handler for the // request type). Added beside the site's row, by // framework-behavior/dispatch.dl. +// callback_registered NOT the site's own target either: a method group handed +// over as a value (an argument, or `+=` on an event the engine +// does not know), which the receiver of it may invoke. Added +// beside the call it is an argument of (resolution/delegates.dl). // ============================================================================ // ── THE SITE UNIVERSE ─────────────────────────────────────────────────────── @@ -96,8 +100,11 @@ call_target_count(prov, e, n) :- // A site whose receiver type has an INCOMPLETE HIERARCHY is never known_edge. The // member may well be declared in the base nobody staged, so the one target that // resolved is a target and so is whatever the missing base declares. Saying -// known_edge there claims monomorphism the engine cannot support. -site_leaves_client(prov, e) :- call_recv_type(prov, e, gk), type_hierarchy_incomplete(prov, gk). +// known_edge there claims monomorphism the engine cannot support. Only an entry +// member lookup can reach counts (type_members_incomplete): an unstaged interface +// after a class's base, or on a struct, declares nothing a class or struct receiver +// can bind to (#1431). +site_leaves_client(prov, e) :- call_recv_type(prov, e, gk), type_members_incomplete(prov, gk). site_leaves_client(prov, e) :- client_calls_lib(prov, e, _, _). // A site whose target is an UNSTAGED type leaves the client too. Without this every // `Console.WriteLine` in a client-only run is counted as an engine blind spot, and on @@ -228,6 +235,22 @@ call_chain_edge(e, caller, "-", node, "client", "boundary_lib", kind) :- call_from("client", e, caller), invocation_site("client", e, kind). +// ── HAND-OFF EDGES: A METHOD GROUP PASSED ON (#1550) ──────────────────────── +// `new Timer(Sweep, ...)`, `Run(Report)`, `ProcessExit += OnExit`: the enclosing +// method hands the method to code that may invoke it (resolution/delegates.dl, +// method_group_handoff). Tier `callback_registered`, the tier the JavaScript engine +// gives the same hand-off, so no reader takes it for a call written there; kind +// `ref`, Java's kind for `X::m`, the same construct. FromExpr is the method group +// itself, positioned where the name is written. +// +// Not an invocation_site, for the reason the primary-constructor edge below is not: +// the site universe is the parser's call list, and a method group is not a call. The +// call it is an argument of (`Run`, `new Timer`) keeps its own row and tier. +call_chain_edge(e, caller, "-", m, "client", "callback_registered", "ref") :- + method_group_handoff("client", e, m), + method_decl("client", _, _, _, m), + call_from_expr("client", e, caller). + // ── SYNTHESISED EDGES: A PRIMARY CONSTRUCTOR'S BASE INVOCATION ────────────── // `class D(int a) : B(a)` calls B's constructor with no call syntax anywhere in the // body. The kind says primary_ctor_base so a synthesised edge is never mistaken for diff --git a/graph/csharp/engine/resolution/delegates.dl b/graph/csharp/engine/resolution/delegates.dl index 13eb9773..8f6d31ae 100644 --- a/graph/csharp/engine/resolution/delegates.dl +++ b/graph/csharp/engine/resolution/delegates.dl @@ -45,9 +45,24 @@ // right-hand side (`f = Make()`) stores the call's return value, which is not // followed. // +// A FIELD-LIKE EVENT IS FOLLOWED AS A FIELD IS (#1449). `public event EventHandler? +// Ticked;` is a delegate field the compiler wraps in add and remove accessors: `+=` +// adds the handler to the same invocation list, and `Ticked?.Invoke(...)` inside the +// class calls through it. So an event member is one more member kind here, with its +// own Invoke node. An event with explicit accessors cannot be invoked by name (CS0079), +// so treating every event this way adds no call that does not compile; its `+=` keeps +// the event_subscribe accessor edge (properties.dl) beside what is stored. +// +// A METHOD GROUP HANDED OVER (#1550). A method group that is not stored in a member +// this file follows -- an argument (`new Timer(Sweep, ...)`, `Run(Report)`, +// `token.Register(OnStop)`) or the value assigned to a member the engine does not know +// (`AppDomain.CurrentDomain.ProcessExit += OnExit`) -- is handed to code that may +// invoke it, and nothing else names it. method_group_handoff below records it, and +// call_chain.dl emits it as a `callback_registered` edge from the enclosing method. +// // NOT HANDLED, and left exactly as before: a delegate that reaches the field from a -// parameter or a local (`this.f = f`), a local or parameter of delegate type invoked -// directly, and an event (`+=` on an event is an accessor call, accessor-edges). +// parameter or a local (`this.f = f`), and a local or parameter of delegate type +// invoked directly. // ============================================================================ // ── THE MEMBER AN EXPRESSION DENOTES ──────────────────────────────────────── @@ -59,6 +74,8 @@ delegate_member_ref(prov, e, mem) :- expr_kind(prov, e, "NAME_REFERENCE"), ref_denotes(prov, e, "FIELD", mem). delegate_member_ref(prov, e, mem) :- expr_kind(prov, e, "NAME_REFERENCE"), ref_denotes(prov, e, "PROPERTY", mem). +delegate_member_ref(prov, e, mem) :- + expr_kind(prov, e, "NAME_REFERENCE"), ref_denotes(prov, e, "EVENT", mem). delegate_member_ref(prov, e, mem) :- expr_kind(prov, e, "MEMBER_ACCESS"), expr_qualifier_child(prov, e, q), expr_member_name_child(prov, e, nm), @@ -71,6 +88,7 @@ delegate_qualifier_type(prov, q, gk) :- expr_type(prov, q, gk). delegate_qualifier_type(prov, q, gk) :- ref_names_type(prov, q, gk). delegate_member_kind("field"). delegate_member_kind("property"). +delegate_member_kind("event"). // The null-forgiving `!` and parentheses around a callee or a receiver name the // same member: `named!(url)`, `(f)(url)`. @@ -130,15 +148,18 @@ delegate_value_method(prov, e, m) :- delegate_stored_expr(prov, e), expr_anon_de // the plain positions METHOD_GROUP, but not a name inside `?:` or `??`, so the name // is looked up here rather than read off that mark. Every overload of the name is a // candidate: which one the conversion picks needs the delegate's parameter types. -delegate_value_method(prov, e, m) :- - delegate_stored_expr(prov, e), +delegate_value_method(prov, e, m) :- delegate_group_method(prov, e, m). +delegate_group_demand(prov, e) :- delegate_stored_expr(prov, e). +delegate_group_demand(prov, e) :- delegate_handed_expr(prov, e). +delegate_group_method(prov, e, m) :- + delegate_group_demand(prov, e), expr_kind(prov, e, "NAME_REFERENCE"), expr_ref_unknown(prov, e), expr_written_name(prov, e, n), !local_binds(prov, e, _), expr_ultimate_type_group(prov, e, gk), member_lookup(prov, gk, n, "method", m). // `this.M` and `Type.M`. -delegate_value_method(prov, e, m) :- - delegate_stored_expr(prov, e), +delegate_group_method(prov, e, m) :- + delegate_group_demand(prov, e), expr_kind(prov, e, "MEMBER_ACCESS"), expr_qualifier_child(prov, e, q), expr_member_name_child(prov, e, nm), expr_written_name(prov, nm, n), @@ -160,6 +181,36 @@ delegate_member_holds(prov, mem, m) :- delegate_member_holds_any(prov, mem) :- delegate_member_holds(prov, mem, _). +// ── A METHOD GROUP HANDED OVER, NOT STORED IN A FOLLOWED MEMBER (#1550) ──── +// An argument, and the branches inside it (`?:`, `??`, a cast, parentheses, +// `new Action(M)`). A lambda argument is left out: it is its own method, already +// defined inside the caller, and an edge to it would say the same thing twice. +delegate_arg_expr(prov, e) :- expr_child(prov, _, "ARGUMENT", _, e). +delegate_arg_expr(prov, c) :- delegate_arg_expr(prov, p), delegate_value_branch(prov, p, c). +// The value of `=`, `+=` or `??=` whose target is not a member this file follows: +// an event or a property of an unstaged type, `AppDomain.CurrentDomain.ProcessExit +// += OnExit`. A target that IS followed stores the method in its envelope instead. +delegate_handed_value(prov, v) :- + expr_kind(prov, a, "ASSIGNMENT"), expr_operator(prov, a, "="), + expr_child(prov, a, "ASSIGNMENT_TARGET", _, t), !delegate_member_ref(prov, t, _), + expr_child(prov, a, "ASSIGNMENT_VALUE", _, v). +delegate_handed_value(prov, v) :- + expr_kind(prov, a, "COMPOUND_ASSIGNMENT"), expr_operator(prov, a, op), delegate_storing_op(op), + expr_child(prov, a, "ASSIGNMENT_TARGET", _, t), !delegate_member_ref(prov, t, _), + expr_child(prov, a, "ASSIGNMENT_VALUE", _, v). +delegate_handed_value(prov, c) :- delegate_handed_value(prov, p), delegate_value_branch(prov, p, c). +delegate_handed_expr(prov, e) :- delegate_arg_expr(prov, e). +delegate_handed_expr(prov, e) :- delegate_handed_value(prov, e). + +// method_group_handoff(Prov, Expr, Method): Expr, in one of those positions, converts +// the method group naming Method. The same lookup a stored value gets, so every +// overload of the name is a candidate: which one the conversion picks needs the +// delegate's parameter types, and each is exported as a hand-off, never as a call. +method_group_handoff(prov, e, m) :- + delegate_handed_expr(prov, e), !expr_anon_decl(prov, e, _), + delegate_group_method(prov, e, m), + method_decl(prov, _, _, _, m). + // ── A CALL THROUGH THE MEMBER ─────────────────────────────────────────────── // delegate_member_call(Prov, Site, Member). // @@ -192,6 +243,8 @@ delegate_member_owner(prov, f, t) :- field_decl(prov, _, t, f). delegate_member_owner(prov, p, t) :- property_decl(prov, _, t, p). delegate_member_name(prov, f, n) :- field_decl(prov, n, _, f). delegate_member_name(prov, p, n) :- property_decl(prov, n, _, p). +delegate_member_owner(prov, ev, t) :- event_decl(prov, _, t, ev). +delegate_member_name(prov, ev, n) :- event_decl(prov, n, _, ev). // delegate_invoke_node(Member, NodeId) -- one per member some call goes through. delegate_invoke_node(mem, id) :- diff --git a/graph/csharp/engine/resolution/extensions.dl b/graph/csharp/engine/resolution/extensions.dl index cfebbc73..23516414 100644 --- a/graph/csharp/engine/resolution/extensions.dl +++ b/graph/csharp/engine/resolution/extensions.dl @@ -246,6 +246,42 @@ extension_match(prov, e, m) :- call_recv_type_name(prov, e, tn), extension_recv_name(prov, m, tn). +// (5) THROUGH THE RECEIVER'S UNSTAGED HERITAGE. An in-source class that derives from +// or implements an unstaged type the `this` parameter names: `class AppServices : +// IServiceCollection` against `this IServiceCollection`. The heritage entry is +// written with the same name the parameter is, so the match is as firm as (3). (No +// test that the `this` type is itself unresolved: that negation would sit inside the +// typing recursion, and a heritage entry that failed to resolve names no staged type.) +extension_match(prov, e, m) :- + call_callee_name(prov, e, n), + expr_ultimate_module(prov, e, mod), + extension_in_scope(prov, mod, m), + method_name(prov, m, n), + call_argc(prov, e, ac), method_accepts_extension_argc(prov, m, ac), + call_recv_type(prov, e, rgk), + type_self_or_ancestor(prov, rgk, agk), + type_base_unresolved(prov, agk, _, tn, _), + extension_recv_name(prov, m, tn). + +// (6) A LIBRARY-TYPED RECEIVER (#1535). With --library, `Kit.Run()` returns the +// staged `Report`, and `Describe(this Report r)` has its `this` type resolved to that +// same library group. The nominal clause (1) walks type_self_or_ancestor, which is +// keyed on the CLIENT's groups and has no row for a library group, not even the +// reflexive one, so the extension matched only without the library and staging it +// made the answer worse. The library's own ancestors are followed the same way. +extension_lib_self_or_ancestor(gk, gk) :- type_group("lib", _, gk). +extension_lib_self_or_ancestor(gk, agk) :- type_ancestor("lib", gk, agk). +extension_match(prov, e, m) :- + call_callee_name(prov, e, n), + expr_ultimate_module(prov, e, mod), + extension_in_scope(prov, mod, m), + method_name(prov, m, n), + call_argc(prov, e, ac), method_accepts_extension_argc(prov, m, ac), + call_recv_type(prov, e, rgk), + type_group("lib", _, rgk), + extension_recv_type(prov, m, tgk), + extension_lib_self_or_ancestor(rgk, tgk). + // THE GATE. An extension method is considered only where instance lookup found // nothing applicable, which is the language's rule and not an optimisation: a type // with its own `Select` must bind to its own, and offering both makes every LINQ @@ -253,6 +289,34 @@ extension_match(prov, e, m) :- extension_candidate(prov, e, m) :- extension_match(prov, e, m), call_instance_lookup_failed(prov, e). +// The candidates of the matching clauses (1)-(6), without rung (7) below: what an +// external label on an unstaged receiver is withheld for (external-types.dl). +extension_candidate_matched(prov, e) :- extension_match(prov, e, _), call_instance_lookup_failed(prov, e). + +// (7) BOTH SIDES UNSTAGED, UNDER DIFFERENT NAMES (#1472): `ServiceCollection services; +// services.AddWidgetRules()` against `AddWidgetRules(this IServiceCollection s)`. The +// receiver's type is unstaged, so the engine has no ancestors for it, and the `this` +// type is unstaged too, so neither nominal nor by-name matching can say whether one +// implements the other. It is the documented DI registration shape, and dropping it +// hid the caller from `impact`. So it is a candidate, but never a firm one: the site +// keeps its external label beside it and reads as one of a set. Taken only where no +// other extension matched the site, so it never widens an answer (3) already gave. +// An in-source `this` type is not this rung: an unstaged type cannot derive from it. +extension_loose_match(prov, e, m) :- + call_callee_name(prov, e, n), + expr_ultimate_module(prov, e, mod), + extension_in_scope(prov, mod, m), + method_name(prov, m, n), + call_argc(prov, e, ac), method_accepts_extension_argc(prov, m, ac), + call_recv_type_name(prov, e, tn), !call_recv_type(prov, e, _), + external_type_name(prov, mod, tn, _), + !extension_recv_type(prov, m, _), + !extension_recv_is_generic(prov, m), !extension_recv_is_object(prov, m), + extension_recv_name(prov, m, xn), xn != tn. +extension_candidate(prov, e, m) :- + extension_loose_match(prov, e, m), + call_instance_lookup_failed(prov, e), + !extension_match(prov, e, _). // A candidate the engine has real evidence for, as opposed to one it reached by // assuming an unanswerable question was answered "nothing". Only a FIRM candidate @@ -297,6 +361,7 @@ extension_is_guess(prov, e, m) :- extension_match(prov, e, m), extension_recv_is_generic(prov, m), !call_recv_type(prov, e, _). +extension_is_guess(prov, e, m) :- extension_loose_match(prov, e, m), !extension_match(prov, e, _). // An extension method is STATIC and is never virtually dispatched: the receiver is // an ordinary argument, so the target is exact and needs no fan. diff --git a/graph/csharp/engine/resolution/external-types.dl b/graph/csharp/engine/resolution/external-types.dl index 837f10ef..a4ea3c14 100644 --- a/graph/csharp/engine/resolution/external-types.dl +++ b/graph/csharp/engine/resolution/external-types.dl @@ -271,7 +271,9 @@ external_target(prov, e, label) :- external_type_name(prov, mod, tn, _), call_callee_name(prov, e, m), !call_recv_type(prov, e, _), - !extension_candidate(prov, e, _), + // A candidate of the loose rung (7) in extensions.dl keeps the label, so the + // site reads as one of a set rather than committing to the guess. + !extension_candidate_matched(prov, e), label = cat("external:", cat(tn, cat(".", m))). // ── AN OPERATOR ON AN UNSTAGED OPERAND TYPE ───────────────────────────────── @@ -324,12 +326,18 @@ external_target(prov, e, label) :- // the answer is uncertain. So an external target is added ALONGSIDE the local one. // The local edge is not removed -- it may well be right -- and the site becomes // multi_inferred, which is the truthful claim: it is one of these two. +// +// ONLY AN ENTRY LOOKUP CAN REACH (#1431). `class Order : OrderBase, IComparable` +// has an unstaged entry, but it is an interface after the base class, and lookup on +// an Order receiver never considers an interface's members: `external:IComparable.Total` +// beside `Order.Total` named a target that cannot exist. type_member_base_unresolved +// keeps position 0 of a class (a base class or an interface, undecidable by name) and +// every entry of an interface. external_target(prov, e, label) :- call_recv_type(prov, e, gk), - type_hierarchy_incomplete(prov, gk), + type_member_base_unresolved(prov, gk, bn), call_callee_name(prov, e, n), member_method(prov, gk, n, _), - type_base_unresolved(prov, gk, _, bn, _), label = cat("external:", cat(bn, cat(".", n))). // The same for an UNQUALIFIED call, whose receiver is the implicit `this`: the @@ -337,10 +345,27 @@ external_target(prov, e, label) :- external_target(prov, e, label) :- call_is_unqualified(prov, e), expr_ultimate_type_group(prov, e, gk), - type_hierarchy_incomplete(prov, gk), + type_member_base_unresolved(prov, gk, bn), call_callee_name(prov, e, n), member_method(prov, gk, n, _), - type_base_unresolved(prov, gk, _, bn, _), + label = cat("external:", cat(bn, cat(".", n))). + +// ── `base.M()` THROUGH AN UNSTAGED BASE CLASS (#1450) ─────────────────────── +// `base.OnModelCreating(mb)` in a class deriving from an unstaged DbContext. The +// receiver is typed only through a RESOLVED base (callee-resolution.dl), so the site +// had neither a target nor a label, was ambiguous_unknown, and `impact` then offered +// it by name as a caller of every sibling context's OnModelCreating, which it can +// never call: `base.` binds to the base class's member, non-virtually. It is named +// here as the unstaged base's member. Position 0 of a class is the only place a base +// class can be written; a class whose declared base resolved is left to the staged +// lookup, and one with no heritage has nothing to name. +external_target(prov, e, label) :- + call_is_base(prov, e), + expr_ultimate_type_group(prov, e, gk), + !type_has_declared_base(prov, gk), + type_base_unresolved(prov, gk, "0", bn, _), + type_group(prov, t, gk), type_is_class(prov, t), + call_callee_name(prov, e, n), label = cat("external:", cat(bn, cat(".", n))). // ── A MEMBER THE STAGED LIBRARY DOES NOT CARRY IS STILL NAMED ─────────────── @@ -350,11 +375,16 @@ external_target(prov, e, label) :- // where the stub carries the one-argument overload and the source calls the // two-argument one, and `string.Any`, an extension method on no version of the // type. The type IS known, so the site is still named. +// +// NOT WHERE AN IN-SOURCE EXTENSION IS THE ANSWER (#1535). `Kit.Run().Describe()` with +// `Describe(this Report r)` in the project is that extension, and labelling it a +// member of the library's Report hid the call from the method it reaches. external_target(prov, e, label) :- call_recv_type(prov, e, gk), type_group("lib", _, gk), call_callee_name(prov, e, n), !member_method(prov, gk, n, _), + !extension_candidate_firm(prov, e, _), type_decl("lib", bn, _, _, _, t), type_group("lib", t, gk), label = cat("external:", cat(bn, cat(".", n))). @@ -363,6 +393,7 @@ external_target(prov, e, label) :- type_group("lib", _, gk), call_callee_name(prov, e, n), call_arity_no_member(prov, e), + !extension_candidate_firm(prov, e, _), type_decl("lib", bn, _, _, _, t), type_group("lib", t, gk), label = cat("external:", cat(bn, cat(".", n))). diff --git a/graph/csharp/engine/resolution/type-hierarchy.dl b/graph/csharp/engine/resolution/type-hierarchy.dl index a88dfd53..7c285be1 100644 --- a/graph/csharp/engine/resolution/type-hierarchy.dl +++ b/graph/csharp/engine/resolution/type-hierarchy.dl @@ -201,6 +201,24 @@ type_base_unresolved(prov, gk, pos, bn, ba) :- // instead of ambiguous_unknown. type_hierarchy_incomplete(prov, gk) :- type_base_unresolved(prov, gk, _, _, _). +// type_member_base_unresolved(Prov, GroupKey, BaseName) -- the unresolved entries +// MEMBER LOOKUP on a receiver of this type can reach (#1431). C# looks a member up on +// a class or struct receiver through the class chain only, never through the +// interfaces it implements, and a base class can only be written at position 0 of a +// class. So an unstaged entry after a class's first, and every entry of a struct, is +// an interface whose members the type must declare itself: a member found on the +// type is the answer, and `external:IComparable.Total` beside it names a target no +// lookup can reach. On an interface every entry is a base interface, and lookup does +// go through it. An unstaged entry AT position 0 of a class is either a base class or +// an interface, which the name alone cannot tell (an `I` prefix is a convention, not +// proof), so it keeps the boundary. The looser type_hierarchy_incomplete is kept for +// a MISS, which the boundary still explains when nothing is declared. +type_member_base_unresolved(prov, gk, bn) :- + type_base_unresolved(prov, gk, "0", bn, _), type_group(prov, t, gk), type_is_class(prov, t). +type_member_base_unresolved(prov, gk, bn) :- + type_base_unresolved(prov, gk, _, bn, _), type_group(prov, t, gk), type_is_interface(prov, t). +type_members_incomplete(prov, gk) :- type_member_base_unresolved(prov, gk, _). + // ── ANCESTORS ─────────────────────────────────────────────────────────────── // type_parent -- one relation over both kinds, for reachability. The two are kept // distinct above because dispatch needs them distinct; here they are unioned because diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index e36dafe8..f8bbbda2 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -260,6 +260,11 @@ .decl ctl_route_c(c0:symbol,c1:symbol,c2:symbol) .decl ctl_route_tok(c0:symbol,c1:symbol,c2:symbol) .decl ctor_accepts_argc(c0:symbol,c1:symbol,c2:symbol) +.decl delegate_arg_expr(c0:symbol,c1:symbol) +.decl delegate_group_demand(c0:symbol,c1:symbol) +.decl delegate_group_method(c0:symbol,c1:symbol,c2:symbol) +.decl delegate_handed_expr(c0:symbol,c1:symbol) +.decl delegate_handed_value(c0:symbol,c1:symbol) .decl delegate_invoke_node(c0:symbol,c1:symbol) .decl delegate_member_call(c0:symbol,c1:symbol,c2:symbol) .decl delegate_member_holds(c0:symbol,c1:symbol,c2:symbol) @@ -359,8 +364,11 @@ .decl extension_candidate(c0:symbol,c1:symbol,c2:symbol) .decl extension_candidate_count(c0:symbol,c1:symbol,c2:number) .decl extension_candidate_firm(c0:symbol,c1:symbol,c2:symbol) +.decl extension_candidate_matched(c0:symbol,c1:symbol) .decl extension_in_scope(c0:symbol,c1:symbol,c2:symbol) .decl extension_is_guess(c0:symbol,c1:symbol,c2:symbol) +.decl extension_lib_self_or_ancestor(c0:symbol,c1:symbol) +.decl extension_loose_match(c0:symbol,c1:symbol,c2:symbol) .decl extension_match(c0:symbol,c1:symbol,c2:symbol) .decl extension_method(c0:symbol,c1:symbol,c2:symbol) .decl extension_no_match(c0:symbol,c1:symbol,c2:symbol,c3:symbol) @@ -573,6 +581,7 @@ .decl method_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_dispatch_candidate(c0:symbol,c1:symbol,c2:symbol) .decl method_explicit_interface(c0:symbol,c1:symbol,c2:symbol) +.decl method_group_handoff(c0:symbol,c1:symbol,c2:symbol) .decl method_has_block(c0:symbol,c1:symbol) .decl method_has_body(c0:symbol,c1:symbol) .decl method_has_params_array(c0:symbol,c1:symbol) @@ -894,7 +903,9 @@ .decl type_is_static_entity(c0:symbol,c1:symbol) .decl type_is_struct(c0:symbol,c1:symbol) .decl type_is_top_level(c0:symbol,c1:symbol) +.decl type_member_base_unresolved(c0:symbol,c1:symbol,c2:symbol) .decl type_member_name(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl type_members_incomplete(c0:symbol,c1:symbol) .decl type_modifier(c0:symbol,c1:symbol,c2:symbol) .decl type_module(c0:symbol,c1:symbol,c2:symbol) .decl type_multiple_bases(c0:symbol,c1:symbol) diff --git a/graph/test/csharp/cases/11-delegate-fields/src/DelegateFields.cs b/graph/test/csharp/cases/11-delegate-fields/src/DelegateFields.cs index eeda82a3..8bb7300a 100644 --- a/graph/test/csharp/cases/11-delegate-fields/src/DelegateFields.cs +++ b/graph/test/csharp/cases/11-delegate-fields/src/DelegateFields.cs @@ -75,7 +75,7 @@ public void Configure() public string UsesOther(string url) => other(url); public string UsesMade(string url) => made(url); - // CONTROL: a local of delegate type, and a method group passed as an argument. + // CONTROL: a local of delegate type, and a method group passed as an argument (which gets a callback_registered hand-off edge, #1550, and no value node). public string ViaLocal(string url) { Func f = GetPath; return f(url); } public string Passed(string url) => Apply(GetPath, url); static string Apply(Func f, string url) => f(url); diff --git a/graph/test/csharp/engine-invariants.py b/graph/test/csharp/engine-invariants.py index e9f95a96..c2d54ff0 100755 --- a/graph/test/csharp/engine-invariants.py +++ b/graph/test/csharp/engine-invariants.py @@ -33,7 +33,7 @@ "known_edge", "multi_inferred", "fan_capped", "boundary_lib", "boundary_generated", "ambiguous_unknown", "ambiguous_dynamic", "known_implicit_ctor", "known_builtin_operator", "runtime_observed", - "event_dispatch", + "event_dispatch", "callback_registered", } # A predefined alias that denotes NO TYPE. Every other name in the engine's alias diff --git a/tests/cases/csharp/event-handler-is-followed/case.json b/tests/cases/csharp/event-handler-is-followed/case.json new file mode 100644 index 00000000..30f369f4 --- /dev/null +++ b/tests/cases/csharp/event-handler-is-followed/case.json @@ -0,0 +1,19 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a handler added with += to a field-like event is reached from the method that raises the event, as a delegate field's is (#1449)", + "run": ["path", "Ticker.Fire", "Listener.OnTicked"], + "want": ["→ [dispatch] Listener.OnTicked"], + "avoid": ["the two are independent"]}, + {"why": "raising the event by calling it directly reaches the handler too", + "run": ["path", "Ticker.Raise", "Listener.OnTicked"], + "want": ["→ [dispatch] Listener.OnTicked"]}, + {"why": "the handler has a user, so a change to it is not local (#1449)", + "run": ["impact", "Listener.OnTicked"], + "want": ["Ticker.Fire src/Ticker.cs:10"], + "avoid": ["the change is local"]}, + {"why": "control: the same shape on a delegate field is unchanged", + "run": ["path", "Ticker.FireField", "Listener.OnTocked"], + "want": ["→ [dispatch] Listener.OnTocked"]}, + {"why": "near-miss control: a handler on an event nothing raises is not reached from a raiser of another event", + "run": ["path", "Ticker.Fire", "Listener.OnSilent"], "expect_error": true, + "avoid": ["→ [dispatch] Listener.OnSilent"]}]} diff --git a/tests/cases/csharp/event-handler-is-followed/src/Ticker.cs b/tests/cases/csharp/event-handler-is-followed/src/Ticker.cs new file mode 100644 index 00000000..7ccef9fb --- /dev/null +++ b/tests/cases/csharp/event-handler-is-followed/src/Ticker.cs @@ -0,0 +1,26 @@ +using System; + +namespace App.Widgets; + +public class Ticker +{ + public event EventHandler? Ticked; + public EventHandler? Tocked; + public event EventHandler? Silent; + public void Fire() => Ticked?.Invoke(this, EventArgs.Empty); + public void FireField() => Tocked?.Invoke(this, EventArgs.Empty); + public void Raise() => Ticked(this, EventArgs.Empty); +} + +public class Listener +{ + public Listener(Ticker t) + { + t.Ticked += OnTicked; + t.Tocked += OnTocked; + t.Silent += OnSilent; + } + private void OnTicked(object? s, EventArgs e) { } + private void OnTocked(object? s, EventArgs e) { } + private void OnSilent(object? s, EventArgs e) { } +} diff --git a/tests/cases/csharp/event-handler-is-followed/src/Widgets.csproj b/tests/cases/csharp/event-handler-is-followed/src/Widgets.csproj new file mode 100644 index 00000000..a7a09e8a --- /dev/null +++ b/tests/cases/csharp/event-handler-is-followed/src/Widgets.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/csharp/extension-on-library-receiver/case.json b/tests/cases/csharp/extension-on-library-receiver/case.json new file mode 100644 index 00000000..80326fb8 --- /dev/null +++ b/tests/cases/csharp/extension-on-library-receiver/case.json @@ -0,0 +1,16 @@ +{"lang": "csharp", "src": "src", "library": "lib/Example.Kit", + "checks": [ + {"why": "with --library, an in-source extension on a library-typed receiver is the call's target, not a member of the library type (#1535)", + "run": ["path", "Program.Main", "OrderExtensions.Describe"], + "want": ["→ [known_edge · call @ src/app/App.cs:15] OrderExtensions.Describe"], + "avoid": ["the two are independent"]}, + {"why": "the extension's this type is a library base of the receiver's library type (#1535)", + "run": ["path", "Program.Main", "OrderExtensions.Check"], + "want": ["OrderExtensions.Check"], + "avoid": ["the two are independent"]}, + {"why": "control: an extension on an in-source type resolves as before", + "run": ["path", "Program.Main", "OrderExtensions.Label"], + "want": ["OrderExtensions.Label"]}, + {"why": "near-miss control: an extension on an unrelated in-source type is not matched to the library receiver", + "run": ["impact", "OrderExtensions.Tag"], + "avoid": ["Program.NotAnOrder src/app/App.cs:16 — calls it"]}]} diff --git a/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Example.Kit.csproj b/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Example.Kit.csproj new file mode 100644 index 00000000..d3b2f030 --- /dev/null +++ b/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Example.Kit.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Kit.cs b/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Kit.cs new file mode 100644 index 00000000..1eacd404 --- /dev/null +++ b/tests/cases/csharp/extension-on-library-receiver/lib/Example.Kit/Kit.cs @@ -0,0 +1,10 @@ +namespace Example.Kit; +public class Report { public int Count { get; set; } } +public interface IBuilder { } +public interface IBuilderInitial : IBuilder { } +internal class Builder : IBuilderInitial { } +public static class Kit +{ + public static Report Run() => new Report(); + public static IBuilderInitial For() => new Builder(); +} diff --git a/tests/cases/csharp/extension-on-library-receiver/src/app/App.cs b/tests/cases/csharp/extension-on-library-receiver/src/app/App.cs new file mode 100644 index 00000000..0cc332a1 --- /dev/null +++ b/tests/cases/csharp/extension-on-library-receiver/src/app/App.cs @@ -0,0 +1,17 @@ +using Example.Kit; +namespace App.Orders; +public class Order { public int Count { get; set; } } +public static class OrderExtensions +{ + public static string Describe(this Report r) => Format(r.Count); + public static void Check(this IBuilder b) => Audit(); + public static string Label(this Order o) => Format(o.Count); + public static string Tag(this Order o) => "o"; + static string Format(int n) => n.ToString(); + static void Audit() { } +} +public static class Program +{ + public static void Main() { Kit.Run().Describe(); Kit.For().Check(); new Order().Label(); } + public static void NotAnOrder() { Kit.Run().Tag(); } +} diff --git a/tests/cases/csharp/extension-on-library-receiver/src/app/App.csproj b/tests/cases/csharp/extension-on-library-receiver/src/app/App.csproj new file mode 100644 index 00000000..1a64a4bf --- /dev/null +++ b/tests/cases/csharp/extension-on-library-receiver/src/app/App.csproj @@ -0,0 +1,2 @@ +net8.0 + diff --git a/tests/cases/csharp/extension-on-unstaged-receiver/case.json b/tests/cases/csharp/extension-on-unstaged-receiver/case.json new file mode 100644 index 00000000..3c3c9499 --- /dev/null +++ b/tests/cases/csharp/extension-on-unstaged-receiver/case.json @@ -0,0 +1,14 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a this-IServiceCollection extension called on an unstaged ServiceCollection receiver is a candidate, one of a set, as it is through IServiceCollection (#1472)", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "want": ["[one of a set] Program.Concrete src/Program.cs:6"]}, + {"why": "control: the receiver typed as the this type itself", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "want": ["Program.Typed src/Program.cs:7"]}, + {"why": "an in-source class whose unstaged heritage names the this type matches it; one of a set, because an unstaged entry at position 0 may declare the member itself", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "want": ["Program.Derived src/Program.cs:8 — calls it"]}, + {"why": "near-miss control: an extension on an in-source this type never matches an unstaged receiver, which cannot derive from it", + "run": ["impact", "WidgetExtensions.AddGadgets"], + "avoid": ["Program.Unrelated src/Program.cs:9 — calls it", "[one of a set] Program.Unrelated", "[resolved] Program.Unrelated"]}]} diff --git a/tests/cases/csharp/extension-on-unstaged-receiver/src/App.csproj b/tests/cases/csharp/extension-on-unstaged-receiver/src/App.csproj new file mode 100644 index 00000000..6240b341 --- /dev/null +++ b/tests/cases/csharp/extension-on-unstaged-receiver/src/App.csproj @@ -0,0 +1,10 @@ + + + net8.0 + Exe + enable + + + + + diff --git a/tests/cases/csharp/extension-on-unstaged-receiver/src/Program.cs b/tests/cases/csharp/extension-on-unstaged-receiver/src/Program.cs new file mode 100644 index 00000000..a3ab463a --- /dev/null +++ b/tests/cases/csharp/extension-on-unstaged-receiver/src/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using App.Widgets; + +public static class Program +{ + public static void Concrete(ServiceCollection services) => services.AddWidgetRules(); + public static void Typed(IServiceCollection typed) => typed.AddWidgetRules(); + public static void Derived(AppServices mine) => mine.AddWidgetRules(); + public static void Unrelated(ServiceCollection services) => services.AddGadgets(); +} diff --git a/tests/cases/csharp/extension-on-unstaged-receiver/src/Widgets.cs b/tests/cases/csharp/extension-on-unstaged-receiver/src/Widgets.cs new file mode 100644 index 00000000..ae0572a1 --- /dev/null +++ b/tests/cases/csharp/extension-on-unstaged-receiver/src/Widgets.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +namespace App.Widgets; + +public interface IRule { bool Check(); } +public class SizeRule : IRule { public bool Check() => true; } +public class Gadget { } + +public static class WidgetExtensions +{ + public static IServiceCollection AddWidgetRules(this IServiceCollection s) + { + s.AddSingleton(); + return s; + } + public static Gadget AddGadgets(this Gadget g) => g; +} + +public abstract class AppServices : IServiceCollection { } diff --git a/tests/cases/csharp/method-group-handoff/case.json b/tests/cases/csharp/method-group-handoff/case.json new file mode 100644 index 00000000..3fc73191 --- /dev/null +++ b/tests/cases/csharp/method-group-handoff/case.json @@ -0,0 +1,24 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a method passed by name to a constructor is handed over by the method that passes it, so the change is not local (#1550)", + "run": ["impact", "WidgetSweeper.Sweep"], + "want": ["[registered] WidgetSweeper.Start src/Widgets.cs:11 — handed over as a value"], + "avoid": ["the change is local"]}, + {"why": "a method passed by name to a method is handed over the same way (#1550)", + "run": ["impact", "WidgetSweeper.Report"], + "want": ["[registered] WidgetSweeper.Start src/Widgets.cs:13 — handed over as a value"], + "avoid": ["the change is local"]}, + {"why": "a method group in a branch of ?: inside an argument is handed over too", + "run": ["impact", "WidgetSweeper.Fallback"], + "want": ["[registered] WidgetSweeper.Start src/Widgets.cs:15"]}, + {"why": "a method added with += to an event of an unstaged type is handed over (#1550)", + "run": ["impact", "WidgetSweeper.OnExit"], + "want": ["[registered] WidgetSweeper.Start src/Widgets.cs:12 — handed over as a value"]}, + {"why": "control: a method called inside a lambda argument is still a call from the lambda, not a hand-off", + "run": ["impact", "WidgetSweeper.Lambda"], + "want": ["[resolved] WidgetSweeper. src/Widgets.cs:14 — calls it"], + "avoid": ["[registered]"]}, + {"why": "control: a method nobody names or passes stays local", + "run": ["impact", "WidgetSweeper.Unused"], + "want": ["the change is local"], + "avoid": ["[registered]"]}]} diff --git a/tests/cases/csharp/method-group-handoff/src/Widgets.cs b/tests/cases/csharp/method-group-handoff/src/Widgets.cs new file mode 100644 index 00000000..cc9ce371 --- /dev/null +++ b/tests/cases/csharp/method-group-handoff/src/Widgets.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; + +namespace App.Widgets; + +public class WidgetSweeper +{ + private Timer? _timer; + public void Start() + { + _timer = new Timer(Sweep, null, 0, 1000); + AppDomain.CurrentDomain.ProcessExit += OnExit; + Run(Report); + Run(() => Lambda()); + Run(flag ? Report : Fallback); + } + private bool flag; + private void Sweep(object? state) { } + private void OnExit(object? s, EventArgs e) { } + private void Report() { } + private void Fallback() { } + private void Lambda() { } + private void Unused() { } + private static void Run(Action a) => a(); +} diff --git a/tests/cases/csharp/method-group-handoff/src/Widgets.csproj b/tests/cases/csharp/method-group-handoff/src/Widgets.csproj new file mode 100644 index 00000000..a7a09e8a --- /dev/null +++ b/tests/cases/csharp/method-group-handoff/src/Widgets.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/csharp/unstaged-base-member/case.json b/tests/cases/csharp/unstaged-base-member/case.json new file mode 100644 index 00000000..e7ab667c --- /dev/null +++ b/tests/cases/csharp/unstaged-base-member/case.json @@ -0,0 +1,22 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "base.M() through an unstaged base class calls the base's member, never a sibling class's override, so it is not a by-name caller of one (#1450)", + "run": ["impact", "WidgetContext.OnModelCreating"], + "avoid": ["[by name] AuditContext.OnModelCreating"]}, + {"why": "control: base.M() through an in-source base still binds to the base", + "run": ["impact", "LocalBase.Build"], + "want": ["[resolved] UserBuilder.Build src/Contexts.cs:16"]}, + {"why": "a class's unstaged interface after its base class declares nothing a class receiver can bind to, so the call is resolved (#1431)", + "run": ["impact", "Order.Total"], + "want": ["[resolved] Caller.Run src/Orders.cs:29"], + "avoid": ["[one of a set] Caller.Run"]}, + {"why": "a struct's unstaged interface likewise (#1431)", + "run": ["impact", "OrderKey.Hash"], + "want": ["[resolved] Caller.Run src/Orders.cs:29"], + "avoid": ["[one of a set] Caller.Run"]}, + {"why": "control: an in-source interface was always resolved", + "run": ["impact", "RankedOrder.Total"], + "want": ["[resolved] Caller.Run src/Orders.cs:29"]}, + {"why": "near-miss control: an unstaged entry at position 0 of a class may be the base class, so the call stays one of a set", + "run": ["impact", "Batch.Size"], + "want": ["[one of a set] Caller.Run src/Orders.cs:29"]}]} diff --git a/tests/cases/csharp/unstaged-base-member/src/Contexts.cs b/tests/cases/csharp/unstaged-base-member/src/Contexts.cs new file mode 100644 index 00000000..fa73d682 --- /dev/null +++ b/tests/cases/csharp/unstaged-base-member/src/Contexts.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace App.Widgets; + +public class WidgetContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder mb) { } +} +public class AuditContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder mb) { base.OnModelCreating(mb); } +} + +public class LocalBase { public virtual void Build() { } } +public class OrderBuilder : LocalBase { public override void Build() { } } +public class UserBuilder : LocalBase { public override void Build() { base.Build(); } } diff --git a/tests/cases/csharp/unstaged-base-member/src/Orders.cs b/tests/cases/csharp/unstaged-base-member/src/Orders.cs new file mode 100644 index 00000000..e52fb007 --- /dev/null +++ b/tests/cases/csharp/unstaged-base-member/src/Orders.cs @@ -0,0 +1,30 @@ +namespace App.Orders; + +public interface IRanked { int Rank(); } +public class OrderBase { } + +public class Order : OrderBase, IComparable +{ + public int Total() => 1; + public int CompareTo(Order? other) => 0; +} +public struct OrderKey : IEquatable +{ + public int Hash() => 2; + public bool Equals(OrderKey other) => true; +} +public class RankedOrder : OrderBase, IRanked +{ + public int Total() => 3; + public int Rank() => 0; +} +public class Batch : List +{ + public int Size() => 4; +} + +public class Caller +{ + public int Run(Order a, OrderKey k, RankedOrder r, Batch b) + => a.Total() + k.Hash() + r.Total() + b.Size(); +} diff --git a/tests/cases/csharp/unstaged-base-member/src/Widgets.csproj b/tests/cases/csharp/unstaged-base-member/src/Widgets.csproj new file mode 100644 index 00000000..9baf8d1d --- /dev/null +++ b/tests/cases/csharp/unstaged-base-member/src/Widgets.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + diff --git a/tests/run.py b/tests/run.py index dbc73363..75fcd456 100755 --- a/tests/run.py +++ b/tests/run.py @@ -46,7 +46,8 @@ for l, name, path in cases: print(f"… {l}/{name}", flush=True) spec = json.load(open(os.path.join(path, 'case.json'))) - build = ['bash', AX, 'index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) + build = ['bash', AX, 'index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) \ + + (['--library', os.path.join(path, spec['library'])] if spec.get('library') else []) # a staged dependency root, relative to the case r = subprocess.run(build, capture_output=True, text=True) if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); fail += 1; continue for stmt in spec.get('sql', []): # facts a framework extension would have written From 1f210732bb7827106e22afac3c96ff25446bb4ba Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:08:15 -0700 Subject: [PATCH 027/258] java: framework-registered entry points, and HTTP and gRPC destinations the join missed Fixes #1400, #1410, #1428, #1429, #1430, #1456, #1457, #1458, #1463, #1464, #1548 Entry points (config-resolution/entry-points.dl, knobs.dl (6b)): - @Bean(initMethod, destroyMethod) names lifecycle_init / lifecycle_destroy methods on the type the factory builds, as the XML init-method form already did (#1400). - A type annotated @WebServlet / @WebFilter / @WebListener is a registered type exactly as its web.xml twin, so its callbacks are web_servlet / web_filter / web_listener entry points (#1410). So is a class handed to ServletContext.addServlet / addFilter / addListener (class literal or new X()), and to Spring Boot's ServletRegistrationBean, FilterRegistrationBean and ServletListenerRegistrationBean. The receiver must be typed as a servlet registrar, so addListener on any other type registers nothing (#1457). - JPA: the seven entity callback annotations are orm_hook entry points on an entity, a mapped superclass or an @EntityListeners class; @EntityListeners(X.class), @Convert(converter = X.class) and a @Converter type register X, whose convertToDatabaseColumn / convertToEntityAttribute are entry points (#1463). Only those annotation arguments register a class: an arbitrary @Foo(Bar.class) still does not. - JAX-RS: a @Provider type is registered, and its @Override methods of an external supertype plus the provider interface methods by name are framework_hook entry points; the type of a @QueryParam / @PathParam / @HeaderParam / @FormParam / @MatrixParam / @CookieParam parameter has its valueOf(String), fromString(String) and String constructor as entry points (#1464). - gRPC with the generated Grpc class outside the tree (protoc at build time): the holder and nested stub or ImplBase names are read from the external type names, qualified per file through imports and package, so the service method is a grpc_service entry point and the stub call a remote edge (#1548). Destinations (framework-behavior/destinations.dl): - A class path and a method path are joined with exactly one '/', so @Path("/items") + @Path("{id}") serves /items/{id} and @RequestMapping("/api/") + @GetMapping("/x") serves /api/x (#1428). - A JAX-RS sub-resource locator (@Path, no verb) passes its route to the verb and @Path methods of its declared return type, one level deep; a returned class with no @Path of its own is no longer served at its bare method path (#1429). - The JAX-RS client chain client.target(url).path(p)...request(..)[.accept(..)].get(..) is a send, with its verb, when the root is typed as the library's Client / WebTarget or is ClientBuilder. A project class named Client with the same method names is not (#1430). - An HTTP send reads only its URL argument (argument 0 of every cfg_sends_http method), so a request body literal is neither a destination nor a URL fragment (#1456). - A web.xml mapped through , and @WebServlet(value / urlPatterns), is a served route for the servlet's do* methods, each for its verb and service() for any. "/x/*" becomes the template /x/{path} (joined by route shape only); "/*", "/" and "*.ext" name no route (#1458). The impact wording table gains web_servlet, lifecycle_init and lifecycle_destroy, which were printed as "a framework-called () method". Tests: graph/test/java cases 66 (gRPC with the generated class absent), 67 (registered entry points, with controls: an unregistered servlet, a @Bean sibling not named, addListener on a non-container receiver, a class named by an arbitrary annotation argument, converter method names on an unregistered class, a provider helper, a parameter type with no parameter annotation) and 68 (routes and sends, with controls: a method path with its own slash, a verb method returning a type, a project class named Client, a body passed as a parameter, a catch-all servlet). Case 23's golden now lists its servlet's /api/* mapping as a served route. tests/cases/java/framework-registered-entry-points checks the impact and path answers. Suites: graph/test/java/run-tests.sh --oracle --no-torture (JDK 23): 72 passed, 0 failed; tests/run.py --lang java: 195 of 195 checks in 54 cases; tests/hook_languages.py 7 of 7 and tests/enrich_lines.py 44 of 44. Smoke (an internal 530-file Java service with JPA entities, before vs after, fresh index): orm_hook entry points 0 -> 15 (13 entity callbacks and a converter's two methods); every other entry-point reason unchanged; remote edges 36 -> 36, identical set; unserved sends 4 -> 3 (a test's null argument no longer read as the URL "/null"). On a 26-file reproduction of all eleven issues, every expected entry point and remote edge is present, and no control gained one. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/config-resolution/entry-points.dl | 84 +++++++ graph/java/engine/config-resolution/knobs.dl | 99 ++++++++ .../engine/framework-behavior/destinations.dl | 228 ++++++++++++++++-- graph/java/souffle/decls_all.dl | 69 ++++++ .../src/app/inventory/InventoryClient.java | 7 + .../src/app/inventory/InventoryService.java | 19 ++ .../src/app/legacy/LegacyClient.java | 9 + .../src/app/legacy/LegacyOrderService.java | 13 + .../src/app/orders/NestedImportClient.java | 10 + .../src/app/orders/OrderClient.java | 28 +++ .../src/app/orders/OrderServiceImpl.java | 25 ++ .../src/app/widgets/WidgetClient.java | 9 + .../src/app/widgets/WidgetService.java | 13 + .../src/app/beans/AppConfig.java | 13 + .../src/app/beans/Plain.java | 6 + .../src/app/beans/Pool.java | 8 + .../src/app/init/AppInit.java | 19 ++ .../src/app/init/BootConfig.java | 13 + .../src/app/init/BootServlet.java | 9 + .../src/app/init/BusListener.java | 6 + .../src/app/init/EventBus.java | 5 + .../src/app/init/InitFilter.java | 10 + .../src/app/init/OrderServlet.java | 9 + .../src/app/init/StartupListener.java | 8 + .../src/app/jpa/Documented.java | 6 + .../src/app/jpa/LooseConverter.java | 6 + .../src/app/jpa/Price.java | 6 + .../src/app/jpa/PriceConverter.java | 10 + .../src/app/jpa/Unregistered.java | 7 + .../src/app/jpa/Widget.java | 19 ++ .../src/app/jpa/WidgetAudit.java | 7 + .../src/app/rs/AuthFilter.java | 13 + .../src/app/rs/Color.java | 6 + .../src/app/rs/MissingMapper.java | 11 + .../src/app/rs/Size.java | 7 + .../src/app/rs/Weight.java | 6 + .../src/app/rs/WidgetResource.java | 16 ++ .../src/app/web/AuditFilter.java | 13 + .../src/app/web/SessionCounter.java | 11 + .../src/app/web/UnmappedServlet.java | 11 + .../src/app/web/WidgetServlet.java | 12 + .../src/app/own/Client.java | 8 + .../src/app/own/OwnCaller.java | 6 + .../src/app/rs/Resources.java | 46 ++++ .../src/app/rs/RsClients.java | 24 ++ .../src/app/spring/OrderClient.java | 13 + .../src/app/spring/OrderController.java | 15 ++ .../src/app/web/AnnotatedServlet.java | 12 + .../src/app/web/CatchAllServlet.java | 11 + .../src/app/web/CountServlet.java | 12 + .../src/app/web/FileServlet.java | 10 + .../src/app/web/WebCaller.java | 13 + .../src/webapp/WEB-INF/web.xml | 27 +++ .../java/expected/23-config-xml-wiring.config | 4 +- .../66-grpc-generated-code-absent.config | 34 +++ .../66-grpc-generated-code-absent.edges | 19 ++ .../66-grpc-generated-code-absent.envelope | 1 + .../66-grpc-generated-code-absent.fields | 12 + .../66-grpc-generated-code-absent.remote | 9 + .../66-grpc-generated-code-absent.type-use | 58 +++++ ...7-framework-registered-entry-points.config | 52 ++++ ...67-framework-registered-entry-points.edges | 14 ++ ...7-framework-registered-entry-points.fields | 7 + ...framework-registered-entry-points.type-use | 80 ++++++ .../expected/68-http-routes-and-sends.config | 48 ++++ .../expected/68-http-routes-and-sends.edges | 24 ++ .../expected/68-http-routes-and-sends.fields | 19 ++ .../expected/68-http-routes-and-sends.remote | 13 + .../68-http-routes-and-sends.type-use | 74 ++++++ .../skills/axiomcode/scripts/ax_edges.py | 3 + .../case.json | 33 +++ .../src/app/AppConfig.java | 10 + .../src/app/AppInit.java | 12 + .../src/app/ItemsResource.java | 11 + .../src/app/OrderController.java | 11 + .../src/app/OrderServlet.java | 9 + .../src/app/Pool.java | 6 + .../src/app/PriceConverter.java | 10 + .../src/app/ShopClient.java | 10 + .../src/app/Store.java | 5 + .../src/app/UnmappedServlet.java | 10 + .../src/app/WidgetServlet.java | 12 + 82 files changed, 1677 insertions(+), 16 deletions(-) create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryClient.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryService.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyClient.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyOrderService.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/NestedImportClient.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderClient.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderServiceImpl.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetClient.java create mode 100644 graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetService.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/AppConfig.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Plain.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Pool.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/AppInit.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootConfig.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootServlet.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BusListener.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/EventBus.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/InitFilter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/OrderServlet.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/init/StartupListener.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Documented.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/LooseConverter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Price.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/PriceConverter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Unregistered.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Widget.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/WidgetAudit.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/AuthFilter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Color.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/MissingMapper.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Size.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Weight.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/WidgetResource.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/web/AuditFilter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/web/SessionCounter.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/web/UnmappedServlet.java create mode 100644 graph/test/java/cases/67-framework-registered-entry-points/src/app/web/WidgetServlet.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/own/Client.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/own/OwnCaller.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/rs/Resources.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/rs/RsClients.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderClient.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderController.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/web/AnnotatedServlet.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/web/CatchAllServlet.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/web/CountServlet.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/web/FileServlet.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/app/web/WebCaller.java create mode 100644 graph/test/java/cases/68-http-routes-and-sends/src/webapp/WEB-INF/web.xml create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.config create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.edges create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.envelope create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.fields create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.remote create mode 100644 graph/test/java/expected/66-grpc-generated-code-absent.type-use create mode 100644 graph/test/java/expected/67-framework-registered-entry-points.config create mode 100644 graph/test/java/expected/67-framework-registered-entry-points.edges create mode 100644 graph/test/java/expected/67-framework-registered-entry-points.fields create mode 100644 graph/test/java/expected/67-framework-registered-entry-points.type-use create mode 100644 graph/test/java/expected/68-http-routes-and-sends.config create mode 100644 graph/test/java/expected/68-http-routes-and-sends.edges create mode 100644 graph/test/java/expected/68-http-routes-and-sends.fields create mode 100644 graph/test/java/expected/68-http-routes-and-sends.remote create mode 100644 graph/test/java/expected/68-http-routes-and-sends.type-use create mode 100644 tests/cases/java/framework-registered-entry-points/case.json create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/AppConfig.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/AppInit.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/ItemsResource.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/OrderController.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/OrderServlet.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/Pool.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/PriceConverter.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/ShopClient.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/Store.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/UnmappedServlet.java create mode 100644 tests/cases/java/framework-registered-entry-points/src/app/WidgetServlet.java diff --git a/graph/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl index 98c0535e..8877c4ce 100644 --- a/graph/java/engine/config-resolution/entry-points.dl +++ b/graph/java/engine/config-resolution/entry-points.dl @@ -47,10 +47,57 @@ cfg_registered_type(t, kind) :- xml_container_class(kind, t, _). cfg_registered_type(t, "config_handler") :- config_class_ref(fmt, k, _, t, "client"), fmt != "annotation", cfg_key_expects_class(k). +// … a type an ANNOTATION registers: @WebServlet / @WebFilter / @WebListener, exactly as +// their web.xml twins; a JPA @Converter; a JAX-RS @Provider (knobs.dl (6b)). +cfg_registered_type(t, kind) :- ann_on_type("client", _, n, t), cfg_registering_type_ann(n, kind). +// … a class an annotation ARGUMENT names, for the arguments that mean "instantiate this": +// @EntityListeners(Audit.class), @Convert(converter = PriceConverter.class). Keyed on the +// annotation AND the argument, so an arbitrary @Foo(Bar.class) still registers nothing. +cfg_registered_type(t, kind) :- ann_arg(prov, a, arg, _, "CLASS_REFERENCE", _), + cfg_registering_class_arg(n, arg, kind), cfg_ann_name_of(prov, a, n), + cfg_ann_class_arg(prov, a, arg, t), type_decl("client", _, _, _, _, _, t). +cfg_ann_name_of(prov, a, n) :- ann_on_type(prov, a, n, _). +cfg_ann_name_of(prov, a, n) :- ann_on_field(prov, a, n, _, _). +cfg_ann_name_of(prov, a, n) :- ann_on_method(prov, a, n, _, _). +// … a class registered IN CODE with the servlet container, by a class literal or an +// instance: ctx.addServlet("orders", OrderServlet.class), ctx.addFilter("a", new F()), +// ctx.addListener(L.class). The receiver has to be typed as a registrar (knobs.dl (6b)). +cfg_registered_type(t, kind) :- cfg_servlet_registration(name, kind), call_site(call, name, recv), + servlet_registrar_recv(recv), call_arg(call, _, arg), registered_arg_type(arg, t). +// … and Spring Boot's new ServletRegistrationBean<>(new OrderServlet(), "/orders"). +cfg_registered_type(t, kind) :- cfg_servlet_registration_bean(bn, kind), + object_creation_named(e, bn), expr_child("client", e, "ARGUMENT", arg), + registered_arg_type(arg, t). +object_creation_named(e, bn) :- java_expression("OBJECT_CREATION", _, _, _, _, _, _, _, _, _, v, _, _, _, _, _, _, _, _, _, _, _, _, _, e), + cfg_servlet_registration_bean(bn, _), v = bn. +object_creation_named(e, bn) :- java_expression("OBJECT_CREATION", _, _, _, _, _, _, _, _, _, v, _, _, _, _, _, _, _, _, _, _, _, _, _, e), + cfg_servlet_registration_bean(bn, _), v = cat(bn, "<>"). +// the type an argument hands over: X.class, or new X(..) +registered_arg_type(arg, t) :- java_expression("CLASS_LITERAL", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, q, _, _, _, _, _, _, arg), + java_type(_, q, _, _, _, _, _, _, _, _, _, _, _, t). +registered_arg_type(arg, t) :- object_creation_type_resolves("client", arg, _, t). +// a receiver typed as ServletContext (or a Jetty handler), whether the type is staged as a +// library or known only by its written name +servlet_registrar_recv(recv) :- recv_type_simple(recv, n), cfg_servlet_registrar_type(n). +// recv_type_simple(Expr, SimpleName): an expression typed by a library or external type of +// that simple name, for the few framework names a rule asks about (typed_simple_demand). +typed_simple_demand(n) :- cfg_servlet_registrar_type(n). +typed_simple_demand(n) :- cfg_jaxrs_client_owner(n). +recv_type_simple(e, n) :- typed_simple_demand(n), lib_type(n, _, _, _, _, _, _, _, _, _, _, _, _, t), + expr_type("lib", e, t). +recv_type_simple(e, n) :- typed_simple_demand(n), x = cat("external:", n), expr_type("external", e, x). +recv_type_simple(e, n) :- typed_simple_demand(n), expr_type("external", e, x), + strlen(x) > strlen(n) + 1, + substr(x, max(0, strlen(x) - strlen(n) - 1), min(strlen(n) + 1, strlen(x))) = cat(".", n). + // (1) callbacks on a registered type — by name … config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), cfg_container_callback_name(name). +// … and by the per-kind name list (a JPA converter's convertTo*, a JAX-RS provider's filter). +config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + cfg_registered_callback_name(kind, name). config_entry_point(m, "spring_factories", t) :- cfg_registered_type(t, "spring_factories"), java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), cfg_factories_callback_name(name). @@ -71,8 +118,30 @@ config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), config_entry_point(m, kind, t) :- xml_lifecycle_method(t, mname, kind), method_name_in_type(mname, t, m), method_owner("client", _, m). +// (3b) the annotation twin of (3): @Bean(initMethod = "start", destroyMethod = "stop") +// names `start` and `stop` on the type the @Bean method builds. +config_entry_point(m, kind, fm) :- cfg_factory(prov, a, fm, _), ann_arg(prov, a, arg, mname, _, _), + cfg_bean_lifecycle_arg(arg, kind), mname != "", cfg_factory_type(fm, t), + method_name_in_type(mname, t, m), method_owner("client", _, m). + // (4) annotation-driven container callbacks that annotation_flow.dl does not cover. config_entry_point(m, "lifecycle", m) :- ann_on_method(_, _, n, m, _), cfg_lifecycle_ann(n). +// JPA entity callbacks, on an entity, a mapped superclass or an @EntityListeners class. +config_entry_point(m, "orm_hook", m) :- ann_on_method(_, _, n, m, _), cfg_jpa_callback_ann(n). +// A JAX-RS @QueryParam / @PathParam / … parameter of a client type T: the runtime builds +// it with T.valueOf(String), T.fromString(String) or new T(String). +jaxrs_param_type(t) :- ann_on_param(_, _, n, p, _), cfg_jaxrs_param_ann(n), + method_param_type_resolves("client", p, _, t). +config_entry_point(m, "framework_hook", t) :- jaxrs_param_type(t), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, _, "1", _, _, _, m), + cfg_jaxrs_param_factory(name), string_param_0(m). +config_entry_point(m, "framework_hook", t) :- jaxrs_param_type(t), + method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m), + java_method(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, "1", _, _, _, m), + string_param_0(m). +string_param_0(m) :- java_method_parameter(_, "0", m, _, tn, _, _, _, _, _, _, _, _), + jaxrs_string_type(tn). +jaxrs_string_type("String"). jaxrs_string_type("java.lang.String"). config_entry_point(m, "scheduled", m) :- ann_on_method(_, _, n, m, _), cfg_scheduled_ann(n). config_entry_point(m, "queue", m) :- ann_on_method(_, _, n, m, _), cfg_listener_ann(n). // … and an ApplicationListener implementation's onApplicationEvent, which Spring calls @@ -115,6 +184,21 @@ config_entry_point(m, kind, t) :- cfg_stub_base(base, kind), java_method(name, _, _, _, _, _, _, base, _, _, _, _, _, _, _, _, _, pc, _, _, _, _), name != "", java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", pc, _, _, _, m). +// ...and when protoc ran at build time, the ImplBase is an external type with no declared methods +// to test an override against. The service's own rpc-shaped methods are the handlers +// (framework-behavior/destinations.dl, grpc_ext_handler; #1548). +config_entry_point(m, "grpc_service", t) :- grpc_ext_handler(t, _, _), + java_method(_, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + grpc_rpc_shaped(m). + +// (6b) A REGISTERED type's method marked @Override whose supertype is known only by its +// written name (the framework API is not in the analysed library set): it overrides the +// framework's declaration, so the framework calls it. A JAX-RS ContainerRequestFilter's +// filter(..), an ExceptionMapper's toResponse(..). +config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), + type_parent(t, x), external_type(x), + ann_on_method(_, _, "Override", m, t), + method_kind("client", "INSTANCE_METHOD", _, m). // (7) A REGISTERED type's overrides, generalised. cfg_registered_type covers web.xml // and config-named handlers; clause (1) matched a knob name list or a LIBRARY supertype. diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index 2ec0de97..d708a89d 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -52,6 +52,66 @@ cfg_props_ann("ConfigurationProperties"). // ── (6) CONTAINER-INVOKED METHODS (entry points sourced from annotations that // carry a config VALUE — the name-only cases stay in annotation_flow.dl) ── cfg_lifecycle_ann("PostConstruct"). cfg_lifecycle_ann("PreDestroy"). +// @Bean(initMethod = "start", destroyMethod = "stop"): the annotation twin of the XML +// init-method / destroy-method attributes (8), naming a method on the bean's type. +// cfg_bean_lifecycle_arg(AnnotationArgument, EntryKind) +cfg_bean_lifecycle_arg("initMethod", "lifecycle_init"). +cfg_bean_lifecycle_arg("destroyMethod", "lifecycle_destroy"). +// JPA entity callbacks: the persistence provider calls them on the entity, on its mapped +// superclasses and on the classes its @EntityListeners names, with no call site anywhere. +cfg_jpa_callback_ann("PrePersist"). cfg_jpa_callback_ann("PostPersist"). +cfg_jpa_callback_ann("PreUpdate"). cfg_jpa_callback_ann("PostUpdate"). +cfg_jpa_callback_ann("PreRemove"). cfg_jpa_callback_ann("PostRemove"). +cfg_jpa_callback_ann("PostLoad"). + +// ── (6b) TYPES A FRAMEWORK INSTANTIATES BECAUSE AN ANNOTATION NAMES THEM ────── +// Only these. Lifting the "annotation" exclusion on cfg_registered_type wholesale would +// register every X.class written in any annotation argument. +// cfg_registering_type_ann(AnnotationOnTheType, EntryKind): the type itself carries it. +cfg_registering_type_ann("WebServlet", "web_servlet"). +cfg_registering_type_ann("WebFilter", "web_filter"). +cfg_registering_type_ann("WebListener", "web_listener"). +cfg_registering_type_ann("Converter", "orm_hook"). // JPA @Converter +cfg_registering_type_ann("Provider", "framework_hook"). // JAX-RS @Provider +// cfg_registering_class_arg(Annotation, Argument, EntryKind): a class named in an argument. +cfg_registering_class_arg("EntityListeners", "value", "orm_hook"). +cfg_registering_class_arg("Convert", "converter", "orm_hook"). +// Methods a framework calls on a type of that kind whose supertype is usually not in the +// analysed library set (so "overrides a library method" finds nothing), matched by name. +// cfg_registered_callback_name(EntryKind, MethodName) +cfg_registered_callback_name("orm_hook", "convertToDatabaseColumn"). +cfg_registered_callback_name("orm_hook", "convertToEntityAttribute"). +cfg_registered_callback_name("framework_hook", "filter"). // Container{Request,Response}Filter +cfg_registered_callback_name("framework_hook", "toResponse"). // ExceptionMapper +cfg_registered_callback_name("framework_hook", "readFrom"). // MessageBodyReader +cfg_registered_callback_name("framework_hook", "isReadable"). +cfg_registered_callback_name("framework_hook", "writeTo"). // MessageBodyWriter +cfg_registered_callback_name("framework_hook", "isWriteable"). +cfg_registered_callback_name("framework_hook", "getSize"). +cfg_registered_callback_name("framework_hook", "aroundReadFrom"). // ReaderInterceptor +cfg_registered_callback_name("framework_hook", "aroundWriteTo"). // WriterInterceptor +cfg_registered_callback_name("framework_hook", "getContext"). // ContextResolver +cfg_registered_callback_name("framework_hook", "getConverter"). // ParamConverterProvider +// A JAX-RS request parameter whose type is not a String is built by that type's static +// valueOf(String) / fromString(String) or its String constructor. +cfg_jaxrs_param_ann("QueryParam"). cfg_jaxrs_param_ann("PathParam"). +cfg_jaxrs_param_ann("HeaderParam"). cfg_jaxrs_param_ann("FormParam"). +cfg_jaxrs_param_ann("MatrixParam"). cfg_jaxrs_param_ann("CookieParam"). +cfg_jaxrs_param_factory("valueOf"). cfg_jaxrs_param_factory("fromString"). +// Registration in code: ServletContext.addServlet("name", X.class), addFilter(.., new F()), +// addListener(L.class), and Jetty's handler.addServlet(X.class, "/p"). The receiver's type +// decides, because addListener is a common method name. +// cfg_servlet_registration(MethodName, EntryKind) +cfg_servlet_registration("addServlet", "web_servlet"). +cfg_servlet_registration("addFilter", "web_filter"). +cfg_servlet_registration("addListener", "web_listener"). +cfg_servlet_registrar_type("ServletContext"). +cfg_servlet_registrar_type("ServletContextHandler"). +cfg_servlet_registrar_type("ServletHandler"). +// … and Spring Boot's registration beans, which take the instance as a constructor argument. +cfg_servlet_registration_bean("ServletRegistrationBean", "web_servlet"). +cfg_servlet_registration_bean("FilterRegistrationBean", "web_filter"). +cfg_servlet_registration_bean("ServletListenerRegistrationBean", "web_listener"). cfg_scheduled_ann("Scheduled"). cfg_listener_ann("EventListener"). cfg_listener_ann("KafkaListener"). cfg_listener_ann("TransactionalEventListener"). @@ -83,6 +143,10 @@ cfg_xml_lifecycle_attr("factory-method", "lifecycle_factory"). cfg_xml_container_elem("servlet-class", "web_servlet"). cfg_xml_container_elem("filter-class", "web_filter"). cfg_xml_container_elem("listener-class", "web_listener"). +// The that ties a to its . +cfg_xml_servlet_name_elem("servlet-name"). +cfg_xml_servlet_mapping_elem("servlet-mapping"). +cfg_xml_url_pattern_elem("url-pattern"). // ── (10) XML: wiring elements (a bean depends on another bean) ─────────────── // cfg_xml_wire_elem(Tag, WireKind) @@ -140,6 +204,15 @@ cfg_stub_base_suffix("Skeleton", "rpc_service"). // older RPC generators cfg_stub_client_suffix("BlockingStub"). cfg_stub_client_suffix("FutureStub"). cfg_stub_client_suffix("Stub"). +cfg_stub_client_suffix("BlockingV2Stub"). // grpc-java 1.73+, newBlockingV2Stub + +// With the generated class absent (#1548), the holder is known only by its written name, `Grpc`, +// and an rpc only by its response observer parameter. +.decl cfg_grpc_holder_suffix(c0:symbol) +cfg_grpc_holder_suffix("Grpc"). +.decl cfg_grpc_observer_type(c0:symbol) +cfg_grpc_observer_type("StreamObserver"). +cfg_grpc_observer_type("io.grpc.stub.StreamObserver"). // ── (8) DESTINATIONS: a name that identifies a place a message or request goes ── // A producer and the consumer that serves it live in different deployables and never @@ -234,6 +307,32 @@ cfg_sends_verb("delete", "DELETE"). cfg_sends_verb("patchForObject", "PAT cfg_sends_verb("get", "GET"). cfg_sends_verb("post", "POST"). cfg_sends_verb("patch", "PATCH"). +// The servlet method that serves each verb, for a servlet mapped to a URL pattern by +// web.xml or @WebServlet. `service` handles every verb. +cfg_servlet_verb_method("doGet", "GET"). cfg_servlet_verb_method("doPost", "POST"). +cfg_servlet_verb_method("doPut", "PUT"). cfg_servlet_verb_method("doDelete", "DELETE"). +cfg_servlet_verb_method("doHead", "HEAD"). cfg_servlet_verb_method("doOptions", "OPTIONS"). +cfg_servlet_verb_method("doPatch", "PATCH"). cfg_servlet_verb_method("service", "any"). +cfg_servlet_url_arg("value"). cfg_servlet_url_arg("urlPatterns"). +// a JAX-RS method with @Path and no verb is a sub-resource locator +cfg_subresource_locator_ann("Path"). + +// The JAX-RS client API: client.target(url).path(p).request(..).get(..). The chain has to +// start on one of these types, typed as the library's (never a client class of the same +// name), AND pass through target(..) and request(..). +cfg_jaxrs_client_owner("Client"). cfg_jaxrs_client_owner("WebTarget"). +cfg_jaxrs_client_factory("ClientBuilder"). // ClientBuilder.newClient().target(..) +cfg_jaxrs_invoke_verb("get", "GET"). cfg_jaxrs_invoke_verb("post", "POST"). +cfg_jaxrs_invoke_verb("put", "PUT"). cfg_jaxrs_invoke_verb("delete", "DELETE"). +cfg_jaxrs_invoke_verb("head", "HEAD"). cfg_jaxrs_invoke_verb("options", "OPTIONS"). +cfg_jaxrs_invoke_verb("method", "any"). +// builder steps between request(..) and the verb +cfg_jaxrs_builder_step("accept"). cfg_jaxrs_builder_step("acceptLanguage"). +cfg_jaxrs_builder_step("acceptEncoding"). cfg_jaxrs_builder_step("header"). +cfg_jaxrs_builder_step("headers"). cfg_jaxrs_builder_step("cookie"). +cfg_jaxrs_builder_step("cacheControl"). cfg_jaxrs_builder_step("property"). +cfg_jaxrs_builder_step("async"). cfg_jaxrs_builder_step("rx"). + // (i) an annotation that makes a TYPE a declarative HTTP CLIENT. Its methods carry the // same mapping annotations a controller does, so without this a Feign interface is // read as a handler and answers "who serves this route". diff --git a/graph/java/engine/framework-behavior/destinations.dl b/graph/java/engine/framework-behavior/destinations.dl index 3c21a940..a91ba983 100644 --- a/graph/java/engine/framework-behavior/destinations.dl +++ b/graph/java/engine/framework-behavior/destinations.dl @@ -117,13 +117,13 @@ route_tail_declared(a) :- ann_on_method(_, a, n, _, _), cfg_route_ann(n), cfg_ro // then answers "who serves this route" with somebody else's outbound call. route_type_is_client(t) :- ann_on_type(_, _, n, t), cfg_client_type_ann(n). -route_raw_of_method(m, path, "server") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), +route_raw_own(m, path, "server") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), cfg_route_ann(n), cfg_route_server_ann(n), !route_type_is_client(owner), - route_base(owner, base), path = cat(base, tail). -route_raw_of_method(m, path, "client") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), - cfg_route_ann(n), !cfg_route_server_ann(n), route_base(owner, base), path = cat(base, tail). -route_raw_of_method(m, path, "client") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), - cfg_route_ann(n), route_type_is_client(owner), route_base(owner, base), path = cat(base, tail). + route_base(owner, base), path_join(base, tail, path). +route_raw_own(m, path, "client") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), + cfg_route_ann(n), !cfg_route_server_ann(n), route_base(owner, base), path_join(base, tail, path). +route_raw_own(m, path, "client") :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), + cfg_route_ann(n), route_type_is_client(owner), route_base(owner, base), path_join(base, tail, path). // A handler that names only its VERB still has a path: its resource class's. The // verb annotation contributes no remainder of its own, so the route is the base and // nothing more, which is what JAX-RS means by @GET on a method under @Path("/orders"). @@ -131,13 +131,91 @@ route_raw_of_method(m, path, "client") :- route_tail(m, tail), ann_on_method(_, // its remainder from the @Path, and without the guard it would ALSO produce a second, // shorter route at the bare class path. method_route_ann(m) :- ann_on_method(_, _, n, m, _), cfg_route_ann(n). -route_raw_of_method(m, base, "server") :- ann_on_method(_, _, n, m, owner), +route_raw_own(m, base, "server") :- ann_on_method(_, _, n, m, owner), cfg_route_verb_ann(n, _), !method_route_ann(m), !route_type_is_client(owner), route_base(owner, base). // … and the same shape on a declarative client interface (MicroProfile @RegisterRestClient). -route_raw_of_method(m, base, "client") :- ann_on_method(_, _, n, m, owner), +route_raw_own(m, base, "client") :- ann_on_method(_, _, n, m, owner), cfg_route_verb_ann(n, _), !method_route_ann(m), route_type_is_client(owner), route_base(owner, base). +route_raw_of_method(m, p, side) :- route_raw_own(m, p, side), method_owner("client", owner, m), + !locator_only_resource(owner). + +// ── joining the two halves: exactly one '/' between them ──────────────────── +// JAX-RS and Spring both insert the separator when they concatenate a class path and a +// method path, and drop a doubled one: @Path("/items") + @Path("{id}") is /items/{id}, +// and @RequestMapping("/api/") + @GetMapping("/x") is /api/x. A bare cat() gave +// /items{id} and /api//x, neither of which any client writes. +path_join_demand(base, tail) :- route_tail(m, tail), ann_on_method(_, _, n, m, owner), + cfg_route_ann(n), route_base(owner, base). +path_join(b, t, t) :- path_join_demand(b, t), b = "". +path_join(b, t, b) :- path_join_demand(b, t), b != "", t = "". +path_join(b, t, j) :- path_join_demand(b, t), path_ends_slash(b), path_starts_slash(t), + j = cat(b, substr(t, 1, strlen(t) - 1)). +path_join(b, t, j) :- path_join_demand(b, t), path_ends_slash(b), path_starts_other(t), + j = cat(b, t). +path_join(b, t, j) :- path_join_demand(b, t), path_ends_other(b), path_starts_slash(t), + j = cat(b, t). +path_join(b, t, j) :- path_join_demand(b, t), path_ends_other(b), path_starts_other(t), + j = cat(b, cat("/", t)). +path_ends_slash(b) :- path_join_demand(b, _), strlen(b) > 0, substr(b, strlen(b) - 1, 1) = "/". +path_starts_slash(t) :- path_join_demand(_, t), strlen(t) > 0, substr(t, 0, 1) = "/". +// the negative halves are stated positively: the locator rules feed path_join_demand from +// a route that path_join itself produced, so a negation here would be a cycle through it +path_ends_other(b) :- path_join_demand(b, _), strlen(b) > 0, substr(b, strlen(b) - 1, 1) != "/". +path_starts_other(t) :- path_join_demand(_, t), strlen(t) > 0, substr(t, 0, 1) != "/". + +// ── JAX-RS SUB-RESOURCE LOCATORS ──────────────────────────────────────────── +// A method with @Path and no verb returns the object that serves the rest of the request: +// @Path("/widgets") class Widgets { @Path("/{id}") Widget find(..) { .. } } +// class Widget { @GET String details() { .. } } +// Widget.details serves GET /widgets/{id}. The locator's declared return type is the +// resource; its verb methods take the locator's route, and its own @Path methods extend +// it. One level only: a resource that returns its own type would otherwise grow the +// route without end. +jaxrs_locator(m, r) :- ann_on_method(_, _, n, m, owner), cfg_subresource_locator_ann(n), + !method_verb_declared(m), !route_type_is_client(owner), + method_return_type_resolves("client", m, _, r), r != owner. +locator_base(r, raw) :- jaxrs_locator(m, r), route_raw_own(m, raw, "server"). +// A class with no @Path of its own that a locator returns is not a root resource: its +// methods are served only below the locator, never at their own bare path. +locator_only_resource(r) :- jaxrs_locator(_, r), !route_base_declared(r). +route_raw_of_method(m, raw, "server") :- locator_base(r, raw), ann_on_method(_, _, n, m, r), + cfg_route_verb_ann(n, _), !method_route_ann(m). +route_raw_of_method(m, p, "server") :- locator_base(r, raw), route_tail(m, tail), + ann_on_method(_, _, n, m, r), cfg_route_ann(n), cfg_route_server_ann(n), path_join(raw, tail, p). +path_join_demand(raw, tail) :- locator_base(r, raw), route_tail(m, tail), ann_on_method(_, _, _, m, r). + +// ── SERVLETS MAPPED TO A URL PATTERN ──────────────────────────────────────── +// web.xml maps a servlet to its patterns through the both elements carry: +// wapp.W +// w/widgets +// and @WebServlet("/widgets") / urlPatterns = {..} says the same on the class. The servlet's +// do* methods serve the pattern, each for its own verb. +servlet_decl_name(t, name, fp) :- xml_container_class("web_servlet", t, elem), + xml_elem("client", _, _, _, parent, fp, elem), + xml_elem("client", nt, _, raw, parent, _, _), cfg_xml_servlet_name_elem(nt), + cfg_clean(raw, name), name != "". +servlet_mapped_pattern(name, pat, fp) :- xml_elem("client", mt, _, _, _, fp, mp), cfg_xml_servlet_mapping_elem(mt), + xml_elem("client", nt, _, rawn, mp, _, _), cfg_xml_servlet_name_elem(nt), cfg_clean(rawn, name), + xml_elem("client", pt, _, rawp, mp, _, _), cfg_xml_url_pattern_elem(pt), cfg_clean(rawp, pat). +servlet_pattern(t, pat) :- servlet_decl_name(t, name, fp), servlet_mapped_pattern(name, pat, fp). +servlet_pattern(t, pat) :- ann_on_type("client", a, "WebServlet", t), cfg_servlet_url_arg(arg), + ann_arg("client", a, arg, pat, "STRING_LITERAL", _). +// An exact pattern is the route. A path-prefix pattern "/widgets/*" serves every path +// below /widgets, which the join can only express as a template: /widgets/{path} meets a +// client that concatenates "/widgets/" + id by route shape, never exactly. The catch-all +// "/*", the default servlet "/" and an extension pattern "*.do" name no route. +servlet_route_raw(t, pat) :- servlet_pattern(t, pat), !contains("*", pat), pat != "/", pat != "". +servlet_route_raw(t, r) :- servlet_pattern(t, pat), strlen(pat) > 2, + substr(pat, strlen(pat) - 2, 2) = "/*", pre = substr(pat, 0, strlen(pat) - 2), + !contains("*", pre), r = cat(pre, "/{path}"). +servlet_handler(m, t, v) :- servlet_route_raw(t, _), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + cfg_servlet_verb_method(name, v). +route_declared(raw) :- servlet_route_raw(_, raw). +serves_destination(m, "http", u) :- servlet_handler(m, t, _), servlet_route_raw(t, raw), url_path(raw, u). + route_declared(raw) :- route_raw_of_method(_, raw, _). // A declared route goes through the same normalisation as a client's URL, so // @GetMapping("pets/visits") and a client's "…/pets/visits?petId={id}" meet. @@ -232,7 +310,13 @@ sends_destination_at(call, m, tr, d) :- sends_via(call, tr), call_site(call, nam // (d) an imperative HTTP call. The URL is routinely built by concatenation, so the // destination is any path-shaped string written anywhere inside the URL argument. -expr_descendant(a, a) :- sends_via(c, "http"), call_arg(c, _, a). +// Only the URL argument. A RestTemplate send takes (url, request body, ..): reading every +// argument made a body literal ("shipped") a destination of its own, which linked the +// call to whatever handler served /shipped and, as a second fragment, cost it the route +// it really calls. Every method in cfg_sends_http takes the URL first. +http_url_arg(c, a) :- sends_via(c, "http"), call_site(c, name, _), cfg_sends_http(name), + call_arg(c, "0", a). +expr_descendant(a, a) :- http_url_arg(_, a). expr_descendant(a, d) :- expr_descendant(a, c), expr_child("client", c, _, d). // Real code builds the URL into a local and passes the local: // String url = base + "/api/x/" + id + "/detail"; @@ -244,8 +328,7 @@ expr_descendant(a, d) :- expr_descendant(a, c), expr_reference_name(c, n), java_local_variable(n, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, lv), expr_owner("client", lv, _, d). -http_arg_literal(v) :- sends_via(call, "http"), call_site(call, name, _), cfg_sends_http(name), - call_arg(call, _, arg), expr_descendant(arg, d), expr_kind("client", "LITERAL", _, d), +http_arg_literal(v) :- http_url_arg(_, arg), expr_descendant(arg, d), expr_kind("client", "LITERAL", _, d), expr_literal("client", v, d). // A URL literal is a destination in its own right. Without this the only client paths // that resolved were the ones that happened to be spelled identically to a declared @@ -295,15 +378,43 @@ url_path(s, u) :- url_no_scheme(s, p), url_no_query(p, q), strlen(q) > 0, url_path(s, u) :- url_no_scheme(s, p), url_no_query(p, q), strlen(q) > 0, substr(q, 0, 1) != "/", u = cat("/", q). -sends_destination_at(call, m, "http", u) :- sends_via(call, "http"), call_site(call, name, _), - cfg_sends_http(name), call_arg(call, _, arg), expr_descendant(arg, d), +sends_destination_at(call, m, "http", u) :- http_url_arg(call, arg), expr_descendant(arg, d), arg_destination(d, p), url_path(p, u), expr_ultimate_method("client", call, m). sends_destination(m, tr, u) :- sends_destination_at(_, m, tr, u). +// (e) the JAX-RS client: client.target("http://shop/orders").path("{id}").request().get(..). +// The URL is built across the chain, so it is followed from target(..) through each +// path(..) to request(..) and the verb that sends it. Kept apart from sends_via: the +// verb methods (put, delete) share names with RestTemplate's URL-first sends, and +// reading put(Entity.json(x))'s argument as a URL is the defect (d) above avoids. +// The chain root is declared as the JAX-RS Client / WebTarget, and that name does not resolve +// to a class of the project (a project's own `Client` with a target(..) method is not the +// API), or is typed as the library's; or the chain starts on ClientBuilder itself. +jaxrs_client_root(root) :- recv_type_simple(root, n), cfg_jaxrs_client_owner(n). +jaxrs_client_root(root) :- recv_declared_simple(root, n), cfg_jaxrs_client_owner(n), + !expr_type("client", root, _). +jaxrs_client_root(root) :- expr_reference_name(root, n), cfg_jaxrs_client_factory(n), + !simple_name_declared(n). +jaxrs_target(call, d) :- call_site(call, "target", recv), recv_root(recv, root), + jaxrs_client_root(root), call_arg(call, "0", arg), arg_destination(arg, d). +jaxrs_target(call, j) :- call_site(call, "path", recv), jaxrs_target(recv, base), + call_arg(call, "0", arg), expr_kind("client", "LITERAL", _, arg), + expr_literal("client", v, arg), path_join(base, v, j). +path_join_demand(base, v) :- call_site(call, "path", recv), jaxrs_target(recv, base), + call_arg(call, "0", arg), expr_kind("client", "LITERAL", _, arg), expr_literal("client", v, arg). +dest_raw(v) :- call_site(call, "target", _), call_arg(call, "0", arg), + expr_kind("client", "LITERAL", _, arg), expr_literal("client", v, arg). +jaxrs_builder(call, d) :- call_site(call, "request", tgt), jaxrs_target(tgt, d). +jaxrs_builder(call, d) :- call_site(call, n, inner), cfg_jaxrs_builder_step(n), jaxrs_builder(inner, d). +jaxrs_send(call, m, d, v) :- call_site(call, n, b), cfg_jaxrs_invoke_verb(n, v), jaxrs_builder(b, d), + expr_ultimate_method("client", call, m). +path_candidate(d) :- jaxrs_send(_, _, d, _). +sends_destination_at(call, m, "http", u) :- jaxrs_send(call, m, d, _), url_path(d, u). + // EVERY path fragment this one call writes. A URL built by concatenation leaves more // than one — "/api/inventory/" + sku + "/reserved" leaves two — and a prefix rule that // looks at only the first will answer the route that the second one rules out. -send_fragment(call, f) :- sends_via(call, "http"), call_arg(call, _, arg), +send_fragment(call, f) :- http_url_arg(call, arg), expr_descendant(arg, d), expr_kind("client", "LITERAL", _, d), expr_literal("client", raw, d), url_path(raw, f). // NOTE: deliberately NOT gated on http_path_shaped. That guard wants two segments, @@ -321,7 +432,7 @@ route_contradicted(call, s) :- send_fragment(call, f), serves_destination(_, "ht // where they are written, the FIRST is the route's literal prefix and the LAST is its // tail, and the segment counts must add up — which is what keeps // /api/x/{id}/reserve away from /api/x/{id}/reservation. -send_frag_at(call, f, key) :- sends_via(call, "http"), call_arg(call, _, arg), +send_frag_at(call, f, key) :- http_url_arg(call, arg), expr_descendant(arg, d), expr_kind("client", "LITERAL", _, d), expr_literal("client", raw, d), url_path(raw, f), expr_src_pos("client", d, line, col), @@ -428,6 +539,8 @@ method_verb(m, v) :- ann_on_method(_, _, n, m, _), cfg_route_verb_ann(n, v). method_verb_declared(m) :- ann_on_method(_, _, n, m, _), cfg_route_verb_ann(n, _). method_verb(m, "any") :- ann_on_method(_, _, n, m, _), cfg_route_ann(n), !cfg_route_verb_known(n), !method_verb_declared(m). cfg_route_verb_known(n) :- cfg_route_verb(n, _). +// a servlet's doGet serves GET, its service(..) every verb +method_verb(m, v) :- servlet_handler(m, _, v). // … the client's from the call it made, or from its own exchange annotation. call_verb(call, v) :- call_site(call, name, _), cfg_sends_verb(name, v). // a fluent chain names the verb one call earlier: .get().uri(path) @@ -436,6 +549,8 @@ call_verb(call, v) :- call_site(call, _, recv), call_site(recv, name, _), cfg_se send_verb(m, v) :- sends_via(call, "http"), expr_ultimate_method("client", call, m), call_verb(call, v). send_verb(m, "any") :- sends_destination(m, "http", _), !send_verb_known(m). send_verb_known(m) :- sends_via(call, "http"), expr_ultimate_method("client", call, m), call_verb(call, _). +send_verb(m, v) :- jaxrs_send(_, m, _, v). +send_verb_known(m) :- jaxrs_send(_, m, _, _). send_verb(m, v) :- route_of_method(m, _, "client"), method_verb(m, v). // ── gRPC: the generated stub and the handler it serves (#1108) ────────────────────────────── @@ -484,6 +599,89 @@ remote_edge_via(call, from, to, "grpc", key, conf) :- grpc_sends_at(call, from, // the stub's type resolved, so the pairing rests on a type rather than a name alone conf = "exact". +// ── gRPC when the generated Grpc holder is NOT in the source tree (#1548) ──────────────── +// protoc usually runs at build time, into target/ or build/, so neither the ImplBase nor the stub +// has a java_type row and the rules above never fire. Both ends are still EXTERNAL types +// (resolution/external-types.dl) whose names carry the holder as written: `extends +// OrderServiceGrpc.OrderServiceImplBase` and a field of type `OrderServiceGrpc.OrderServiceBlockingStub`. +// These clauses feed grpc_serves and grpc_sends_at, so the edge rule above is shared. +// +// The shape is checked, not just the suffix: the holder is `Grpc` and the nested type is `` +// plus a server or client suffix, the same on both. An external `RequestStub` or `Foo.BarStub` +// is not a generated stub. +// +// The key is the QUALIFIED holder, read per file. The external node is shared by every file that +// wrote the same name, and one real 2,800-file project imports `MetricsServiceGrpc` from three +// packages (two versions of one API and an unrelated one): keyed on the simple name, each client +// would be paired with all three services. A written package qualifier, then the file's +// single-type import of the holder, then the file's own package when it has no on-demand import, +// which is the order javac resolves the name in. A holder only an on-demand import could supply +// is left unpaired. +grpc_ext_name_demand(x, w) :- external_type(x), strlen(x) > 9, + w = substr(x, 9, strlen(x) - 9), contains(".", w), + grpc_generated_suffix(k), strlen(w) > strlen(k), + substr(w, max(0, strlen(w) - strlen(k)), min(strlen(k), strlen(w))) = k. +grpc_generated_suffix(k) :- cfg_stub_client_suffix(k). +grpc_generated_suffix(k) :- cfg_stub_base_suffix(k, "grpc_service"), !cfg_grpc_holder_suffix(k). +grpc_ext_dot(w, i) :- grpc_ext_name_demand(_, w), i = range(0, strlen(w)), substr(w, i, 1) = ".". +grpc_ext_last_dot(w, l) :- grpc_ext_dot(w, _), l = max i : grpc_ext_dot(w, i). +grpc_ext_prev_dot(w, p) :- grpc_ext_last_dot(w, l), grpc_ext_dot(w, j), j < l, + p = max i : { grpc_ext_dot(w, i), i < l }. +grpc_ext_has_prev(w) :- grpc_ext_prev_dot(w, _). +// grpc_ext_parts(ExternalType, Qualifier, Holder, Nested): `a.b.FooGrpc.FooStub` -> a.b, FooGrpc, FooStub +grpc_ext_parts(x, pre, h, n) :- grpc_ext_name_demand(x, w), grpc_ext_prev_dot(w, p), grpc_ext_last_dot(w, l), + pre = substr(w, 0, p), h = substr(w, p + 1, l - p - 1), n = substr(w, l + 1, strlen(w) - l - 1). +grpc_ext_parts(x, "", h, n) :- grpc_ext_name_demand(x, w), !grpc_ext_has_prev(w), grpc_ext_last_dot(w, l), + h = substr(w, 0, l), n = substr(w, l + 1, strlen(w) - l - 1). +grpc_ext_service(x, s, n) :- grpc_ext_parts(x, _, h, n), cfg_grpc_holder_suffix(hs), + strlen(h) > strlen(hs), substr(h, max(0, strlen(h) - strlen(hs)), min(strlen(hs), strlen(h))) = hs, + s = substr(h, 0, max(0, strlen(h) - strlen(hs))). +grpc_ext_server(x) :- grpc_ext_service(x, s, n), cfg_stub_base_suffix(k, "grpc_service"), n = cat(s, k). +grpc_ext_client(x) :- grpc_ext_service(x, s, n), cfg_stub_client_suffix(k), n = cat(s, k). + +// the service class that extends the external ImplBase, and the file that wrote the extends +grpc_ext_impl_site(t, x, fp) :- + java_type_reference(_, "SUPER_TYPE", t, _, _, _, _, "0", _, _, _, _, _, _, _, _, _, ref), + type_ref_external(ref, x), grpc_ext_server(x), + java_type(_, _, _, _, _, _, _, fp, _, _, _, _, _, t). +// a call on a receiver typed as an external stub, and the file of the method that makes it +grpc_ext_send_site(call, from, x, fp, name) :- call_site(call, name, recv), name != "", + expr_type("external", recv, x), grpc_ext_client(x), + expr_ultimate_method("client", call, from), + java_method(_, _, _, _, fp, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, from). + +grpc_ext_site(x, fp) :- grpc_ext_impl_site(_, x, fp). +grpc_ext_site(x, fp) :- grpc_ext_send_site(_, _, x, fp, _). +grpc_file_on_demand(fp) :- grpc_ext_site(_, fp), java_import(_, _, _, _, fp, _, "false", "true", _, _, _). +grpc_file_package(fp, pkg) :- grpc_ext_site(_, fp), + java_type(n, q, _, _, _, _, "TOP_LEVEL_PLACEMENT", fp, _, _, _, _, _, _), + strlen(q) > strlen(n), pkg = substr(q, 0, max(0, strlen(q) - strlen(n) - 1)). +grpc_file_package(fp, "") :- grpc_ext_site(_, fp), + java_type(n, n, _, _, _, _, "TOP_LEVEL_PLACEMENT", fp, _, _, _, _, _, _). +// grpc_ext_holder_q(ExternalType, File, QualifiedHolder) +grpc_ext_holder_q(x, fp, q) :- grpc_ext_site(x, fp), grpc_ext_parts(x, pre, h, _), pre != "", + q = cat(pre, cat(".", h)). +grpc_ext_holder_q(x, fp, q) :- grpc_ext_site(x, fp), grpc_ext_parts(x, "", h, _), + java_import("SINGLE_TYPE", q, _, h, fp, _, "false", _, _, _, _). +grpc_ext_holder_q(x, fp, q) :- grpc_ext_site(x, fp), grpc_ext_parts(x, "", h, _), + !type_has_explicit_import(h, fp), !grpc_file_on_demand(fp), + grpc_file_package(fp, pkg), pkg != "", q = cat(pkg, cat(".", h)). +grpc_ext_holder_q(x, fp, h) :- grpc_ext_site(x, fp), grpc_ext_parts(x, "", h, _), + !type_has_explicit_import(h, fp), !grpc_file_on_demand(fp), grpc_file_package(fp, ""). + +// An rpc method takes the response StreamObserver: (request, observer) for unary and server +// streaming, (observer) for client and bidi streaming. With the ImplBase absent there is no +// declaration to test an override against, and a helper written beside the rpcs has no observer. +grpc_rpc_shaped(m) :- java_method_parameter(_, _, m, bt, _, _, _, _, _, _, _, _, _), cfg_grpc_observer_type(bt). + +grpc_ext_handler(t, x, fp) :- grpc_ext_impl_site(t, x, fp). +grpc_ext_handler(t, x, fp) :- grpc_ext_impl_site(t0, x, fp), type_ancestor(t, t0). +grpc_serves(m, key) :- grpc_ext_handler(t, x, fp), grpc_ext_holder_q(x, fp, q), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + name != "", grpc_rpc_shaped(m), key = cat(q, cat("/", name)). +grpc_sends_at(call, from, key) :- grpc_ext_send_site(call, from, x, fp, name), + grpc_ext_holder_q(x, fp, q), key = cat(q, cat("/", name)). + // (a) exact — a topic, a queue, or a route both ends spell the same way. remote_edge_via(call, from, to, tr, d, "exact") :- sends_destination_at(call, from, tr, d), serves_destination(to, tr, d), from != to, tr != "http". diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 0298ce49..04167a5a 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -754,6 +754,75 @@ .decl grpc_impl_holder(c0:symbol,c1:symbol) .decl grpc_serves(c0:symbol,c1:symbol) .decl grpc_sends_at(c0:symbol,c1:symbol,c2:symbol) +// gRPC with the generated holder outside the source tree (#1548): the same pairing from written names +.decl grpc_generated_suffix(c0:symbol) +.decl grpc_ext_name_demand(c0:symbol,c1:symbol) +.decl grpc_ext_dot(c0:symbol,c1:number) +.decl grpc_ext_last_dot(c0:symbol,c1:number) +.decl grpc_ext_prev_dot(c0:symbol,c1:number) +.decl grpc_ext_has_prev(c0:symbol) +.decl grpc_ext_parts(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl grpc_ext_service(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_ext_server(c0:symbol) +.decl grpc_ext_client(c0:symbol) +.decl grpc_ext_impl_site(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_ext_send_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl grpc_ext_site(c0:symbol,c1:symbol) +.decl grpc_file_on_demand(c0:symbol) +.decl grpc_file_package(c0:symbol,c1:symbol) +.decl grpc_ext_holder_q(c0:symbol,c1:symbol,c2:symbol) +.decl grpc_rpc_shaped(c0:symbol) +.decl grpc_ext_handler(c0:symbol,c1:symbol,c2:symbol) +// framework entry points and HTTP destinations (#1400 #1410 #1428-#1430 #1456-#1458 #1463 #1464) +.decl cfg_jpa_callback_ann(c0:symbol) +.decl cfg_jaxrs_param_ann(c0:symbol) +.decl cfg_jaxrs_param_factory(c0:symbol) +.decl cfg_servlet_registrar_type(c0:symbol) +.decl cfg_xml_servlet_name_elem(c0:symbol) +.decl cfg_xml_servlet_mapping_elem(c0:symbol) +.decl cfg_xml_url_pattern_elem(c0:symbol) +.decl cfg_servlet_url_arg(c0:symbol) +.decl cfg_jaxrs_client_owner(c0:symbol) +.decl cfg_jaxrs_builder_step(c0:symbol) +.decl cfg_subresource_locator_ann(c0:symbol) +.decl servlet_registrar_recv(c0:symbol) +.decl jaxrs_param_type(c0:symbol) +.decl string_param_0(c0:symbol) +.decl jaxrs_string_type(c0:symbol) +.decl path_ends_slash(c0:symbol) +.decl path_starts_slash(c0:symbol) +.decl path_ends_other(c0:symbol) +.decl path_starts_other(c0:symbol) +.decl cfg_bean_lifecycle_arg(c0:symbol,c1:symbol) +.decl cfg_registering_type_ann(c0:symbol,c1:symbol) +.decl cfg_registered_callback_name(c0:symbol,c1:symbol) +.decl cfg_servlet_registration(c0:symbol,c1:symbol) +.decl cfg_servlet_registration_bean(c0:symbol,c1:symbol) +.decl cfg_servlet_verb_method(c0:symbol,c1:symbol) +.decl cfg_jaxrs_invoke_verb(c0:symbol,c1:symbol) +.decl object_creation_named(c0:symbol,c1:symbol) +.decl registered_arg_type(c0:symbol,c1:symbol) +.decl path_join_demand(c0:symbol,c1:symbol) +.decl jaxrs_locator(c0:symbol,c1:symbol) +.decl locator_base(c0:symbol,c1:symbol) +.decl servlet_pattern(c0:symbol,c1:symbol) +.decl servlet_route_raw(c0:symbol,c1:symbol) +.decl jaxrs_target(c0:symbol,c1:symbol) +.decl jaxrs_builder(c0:symbol,c1:symbol) +.decl http_url_arg(c0:symbol,c1:symbol) +.decl cfg_registering_class_arg(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_ann_name_of(c0:symbol,c1:symbol,c2:symbol) +.decl route_raw_own(c0:symbol,c1:symbol,c2:symbol) +.decl path_join(c0:symbol,c1:symbol,c2:symbol) +.decl servlet_decl_name(c0:symbol,c1:symbol,c2:symbol) +.decl servlet_mapped_pattern(c0:symbol,c1:symbol,c2:symbol) +.decl servlet_handler(c0:symbol,c1:symbol,c2:symbol) +.decl jaxrs_send(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl typed_simple_demand(c0:symbol) +.decl recv_type_simple(c0:symbol,c1:symbol) +.decl jaxrs_client_root(c0:symbol) +.decl locator_only_resource(c0:symbol) +.decl cfg_jaxrs_client_factory(c0:symbol) .decl sends_destination(c0:symbol,c1:symbol,c2:symbol) .decl route_base(c0:symbol,c1:symbol) .decl route_base_declared(c0:symbol) diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryClient.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryClient.java new file mode 100644 index 00000000..9bcef237 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryClient.java @@ -0,0 +1,7 @@ +package app.inventory; + +public class InventoryClient { + private InventoryGrpc.InventoryBlockingStub stub; + + String reserve(String sku) { return stub.reserve(sku); } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryService.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryService.java new file mode 100644 index 00000000..d9b31e01 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/inventory/InventoryService.java @@ -0,0 +1,19 @@ +package app.inventory; + +import io.grpc.stub.StreamObserver; + +// the generated holder lives in this file's own package, so no import names it +public class InventoryService extends InventoryGrpc.InventoryImplBase { + @Override + public void reserve(String request, StreamObserver responseObserver) { + responseObserver.onNext(request); + } +} + +// a subclass of the service inherits the handler role for what it declares +class AuditedInventoryService extends InventoryService { + @Override + public void reserve(String request, StreamObserver responseObserver) { + super.reserve(request, responseObserver); + } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyClient.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyClient.java new file mode 100644 index 00000000..629e9628 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyClient.java @@ -0,0 +1,9 @@ +package app.legacy; + +import gen.v0.OrderServiceGrpc; + +public class LegacyClient { + private OrderServiceGrpc.OrderServiceBlockingStub stub; + + String place(String sku) { return stub.placeOrder(sku); } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyOrderService.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyOrderService.java new file mode 100644 index 00000000..ea49fb22 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/legacy/LegacyOrderService.java @@ -0,0 +1,13 @@ +package app.legacy; + +import gen.v0.OrderServiceGrpc; +import io.grpc.stub.StreamObserver; + +// CONTROL: the SAME written holder name from another package (an older API version). It must pair +// only with the v0 client, never with the v1 one. +public class LegacyOrderService extends OrderServiceGrpc.OrderServiceImplBase { + @Override + public void placeOrder(String request, StreamObserver responseObserver) { + responseObserver.onNext(request); + } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/NestedImportClient.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/NestedImportClient.java new file mode 100644 index 00000000..a4e3b4f5 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/NestedImportClient.java @@ -0,0 +1,10 @@ +package app.orders; + +import gen.v1.OrderServiceGrpc.OrderServiceBlockingStub; + +// the nested stub imported directly: the external name is then package-qualified +public class NestedImportClient { + private OrderServiceBlockingStub stub; + + String place(String sku) { return stub.placeOrder(sku); } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderClient.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderClient.java new file mode 100644 index 00000000..a2a4f2fd --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderClient.java @@ -0,0 +1,28 @@ +package app.orders; + +import gen.v1.OrderServiceGrpc; +import io.grpc.stub.StreamObserver; +import lib.RequestStub; +import lib.ShapeGrpc; + +public class OrderClient { + private OrderServiceGrpc.OrderServiceBlockingStub blockingStub; + private OrderServiceGrpc.OrderServiceStub asyncStub; + private OrderServiceGrpc.OrderServiceFutureStub futureStub; + private OrderServiceGrpc.OrderServiceBlockingV2Stub v2Stub; + private RequestStub requestStub; + private ShapeGrpc.OrderServiceBlockingStub mismatchedStub; + + String placeBlocking(String sku) { return blockingStub.placeOrder(sku); } + void placeAsync(String sku, StreamObserver obs) { asyncStub.placeOrder(sku, obs); } + Object placeFuture(String sku) { return futureStub.placeOrder(sku); } + String placeV2(String sku) { return v2Stub.placeOrder(sku); } + StreamObserver stream(StreamObserver obs) { return asyncStub.streamOrders(obs); } + + // CONTROL: a stub method that is not an rpc has no handler to pair with + Object configure() { return blockingStub.withDeadlineAfter(1, null); } + // CONTROL: the Stub suffix with no Grpc holder above it is not a generated stub + String decoyNoHolder(String sku) { return requestStub.placeOrder(sku); } + // CONTROL: a holder that does not spell the stub's service is not a generated stub + String decoyWrongHolder(String sku) { return mismatchedStub.placeOrder(sku); } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderServiceImpl.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderServiceImpl.java new file mode 100644 index 00000000..2adb1cb5 --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/orders/OrderServiceImpl.java @@ -0,0 +1,25 @@ +package app.orders; + +import gen.v1.OrderServiceGrpc; +import io.grpc.stub.StreamObserver; + +// protoc ran at build time: gen.v1.OrderServiceGrpc is NOT in this tree, so the base is external +public class OrderServiceImpl extends OrderServiceGrpc.OrderServiceImplBase { + // unary rpc: (request, response observer) + @Override + public void placeOrder(String request, StreamObserver responseObserver) { + responseObserver.onNext(audit(request)); + } + + // client-streaming rpc: (response observer) -> request observer + @Override + public StreamObserver streamOrders(StreamObserver responseObserver) { + return responseObserver; + } + + // CONTROL: a helper with the rpc's name but no observer is not the rpc + public String placeOrder(String sku) { return audit(sku); } + + // CONTROL: a plain helper is neither an entry point nor served + String audit(String sku) { return sku; } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetClient.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetClient.java new file mode 100644 index 00000000..e82cf43a --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetClient.java @@ -0,0 +1,9 @@ +package app.widgets; + +import gen.v2.*; + +public class WidgetClient { + private WidgetGrpc.WidgetBlockingStub stub; + + String build(String sku) { return stub.build(sku); } +} diff --git a/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetService.java b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetService.java new file mode 100644 index 00000000..f5f30c4f --- /dev/null +++ b/graph/test/java/cases/66-grpc-generated-code-absent/src/app/widgets/WidgetService.java @@ -0,0 +1,13 @@ +package app.widgets; + +import gen.v2.*; +import io.grpc.stub.StreamObserver; + +// CONTROL (declared limit): the holder is reachable only through an on-demand import, so its +// package is not known. The rpc is still an entry point; no remote edge is paired on a guess. +public class WidgetService extends WidgetGrpc.WidgetImplBase { + @Override + public void build(String request, StreamObserver responseObserver) { + responseObserver.onNext(request); + } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/AppConfig.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/AppConfig.java new file mode 100644 index 00000000..6ced7148 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/AppConfig.java @@ -0,0 +1,13 @@ +package app.beans; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + @Bean(initMethod = "start", destroyMethod = "stop") + public Pool pool() { return new Pool(); } + + @Bean + public Plain plain() { return new Plain(); } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Plain.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Plain.java new file mode 100644 index 00000000..e3699f71 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Plain.java @@ -0,0 +1,6 @@ +package app.beans; + +// CONTROL: built by a @Bean that names no init or destroy method +public class Plain { + void start() { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Pool.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Pool.java new file mode 100644 index 00000000..9e24a5ab --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/beans/Pool.java @@ -0,0 +1,8 @@ +package app.beans; + +public class Pool { + void start() { } + void stop() { } + // CONTROL: not named by the @Bean + void drain() { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/AppInit.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/AppInit.java new file mode 100644 index 00000000..3ffae604 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/AppInit.java @@ -0,0 +1,19 @@ +package app.init; + +import jakarta.servlet.ServletContainerInitializer; +import jakarta.servlet.ServletContext; +import java.util.Set; + +public class AppInit implements ServletContainerInitializer { + @Override + public void onStartup(Set> classes, ServletContext ctx) { + ctx.addServlet("orders", OrderServlet.class).addMapping("/orders"); + ctx.addFilter("audit", new InitFilter()); + ctx.addListener(StartupListener.class); + } + + // CONTROL: addListener on a receiver that is not a servlet container + void wire(EventBus bus) { + bus.addListener(BusListener.class); + } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootConfig.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootConfig.java new file mode 100644 index 00000000..6bd2e9b8 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootConfig.java @@ -0,0 +1,13 @@ +package app.init; + +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BootConfig { + @Bean + public ServletRegistrationBean bootServlet() { + return new ServletRegistrationBean<>(new BootServlet(), "/boot"); + } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootServlet.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootServlet.java new file mode 100644 index 00000000..993125f0 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BootServlet.java @@ -0,0 +1,9 @@ +package app.init; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class BootServlet extends HttpServlet { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BusListener.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BusListener.java new file mode 100644 index 00000000..78a36889 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/BusListener.java @@ -0,0 +1,6 @@ +package app.init; + +// CONTROL: handed to EventBus.addListener, which is not a servlet container +public class BusListener { + public void contextInitialized(Object e) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/EventBus.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/EventBus.java new file mode 100644 index 00000000..d1f3a34f --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/EventBus.java @@ -0,0 +1,5 @@ +package app.init; + +public class EventBus { + public void addListener(Class type) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/InitFilter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/InitFilter.java new file mode 100644 index 00000000..317208b5 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/InitFilter.java @@ -0,0 +1,10 @@ +package app.init; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +public class InitFilter implements Filter { + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/OrderServlet.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/OrderServlet.java new file mode 100644 index 00000000..fd61639d --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/OrderServlet.java @@ -0,0 +1,9 @@ +package app.init; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class OrderServlet extends HttpServlet { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/StartupListener.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/StartupListener.java new file mode 100644 index 00000000..c7184960 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/init/StartupListener.java @@ -0,0 +1,8 @@ +package app.init; + +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; + +public class StartupListener implements ServletContextListener { + public void contextInitialized(ServletContextEvent e) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Documented.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Documented.java new file mode 100644 index 00000000..f8b246e3 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Documented.java @@ -0,0 +1,6 @@ +package app.jpa; + +// an unrelated annotation whose argument is a class +public @interface Documented { + Class by(); +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/LooseConverter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/LooseConverter.java new file mode 100644 index 00000000..412edbb9 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/LooseConverter.java @@ -0,0 +1,6 @@ +package app.jpa; + +// CONTROL: the converter's method names on a class nothing registers +public class LooseConverter { + public Long convertToDatabaseColumn(Price p) { return p.cents; } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Price.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Price.java new file mode 100644 index 00000000..9fba8219 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Price.java @@ -0,0 +1,6 @@ +package app.jpa; + +public class Price { + final long cents; + public Price(long cents) { this.cents = cents; } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/PriceConverter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/PriceConverter.java new file mode 100644 index 00000000..42693e38 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/PriceConverter.java @@ -0,0 +1,10 @@ +package app.jpa; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class PriceConverter implements AttributeConverter { + public Long convertToDatabaseColumn(Price p) { return p.cents; } + public Price convertToEntityAttribute(Long v) { return new Price(v); } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Unregistered.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Unregistered.java new file mode 100644 index 00000000..0f0bfc3a --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Unregistered.java @@ -0,0 +1,7 @@ +package app.jpa; + +// CONTROL: named by an arbitrary annotation argument, so nothing instantiates it +public class Unregistered { + public Unregistered() { } + public void filter() { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Widget.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Widget.java new file mode 100644 index 00000000..17920fa5 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/Widget.java @@ -0,0 +1,19 @@ +package app.jpa; + +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; + +@Entity +@EntityListeners(WidgetAudit.class) +@Documented(by = Unregistered.class) +public class Widget { + @Id Long id; + @Convert(converter = PriceConverter.class) Price price; + + @PrePersist void stamp() { } + // CONTROL: no callback annotation + void touch() { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/WidgetAudit.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/WidgetAudit.java new file mode 100644 index 00000000..eb2400c3 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/jpa/WidgetAudit.java @@ -0,0 +1,7 @@ +package app.jpa; + +import jakarta.persistence.PostPersist; + +public class WidgetAudit { + @PostPersist void saved(Widget w) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/AuthFilter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/AuthFilter.java new file mode 100644 index 00000000..ec2bd5e1 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/AuthFilter.java @@ -0,0 +1,13 @@ +package app.rs; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class AuthFilter implements ContainerRequestFilter { + @Override + public void filter(ContainerRequestContext ctx) { } + // CONTROL: a helper the runtime does not call + void audit() { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Color.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Color.java new file mode 100644 index 00000000..b53e4b63 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Color.java @@ -0,0 +1,6 @@ +package app.rs; + +public class Color { + final String name; + public Color(String name) { this.name = name; } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/MissingMapper.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/MissingMapper.java new file mode 100644 index 00000000..f3b1bcc7 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/MissingMapper.java @@ -0,0 +1,11 @@ +package app.rs; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class MissingMapper implements ExceptionMapper { + @Override + public Response toResponse(RuntimeException e) { return null; } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Size.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Size.java new file mode 100644 index 00000000..193a2f1a --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Size.java @@ -0,0 +1,7 @@ +package app.rs; + +public class Size { + final int n; + Size(int n) { this.n = n; } + public static Size valueOf(String s) { return new Size(Integer.parseInt(s)); } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Weight.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Weight.java new file mode 100644 index 00000000..cc51d0cb --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/Weight.java @@ -0,0 +1,6 @@ +package app.rs; + +// CONTROL: a parameter type with no request-parameter annotation +public class Weight { + public static Weight valueOf(String s) { return new Weight(); } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/WidgetResource.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/WidgetResource.java new file mode 100644 index 00000000..719209fa --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/rs/WidgetResource.java @@ -0,0 +1,16 @@ +package app.rs; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.HeaderParam; + +@Path("widgets") +public class WidgetResource { + @GET + public String list(@QueryParam("size") Size size, @HeaderParam("color") Color color) { + return "n=" + size.n + color.name; + } + + public String weigh(Weight w) { return ""; } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/AuditFilter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/AuditFilter.java new file mode 100644 index 00000000..a00d2f33 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/AuditFilter.java @@ -0,0 +1,13 @@ +package app.web; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebFilter; + +@WebFilter("/widgets/*") +public class AuditFilter implements Filter { + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/SessionCounter.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/SessionCounter.java new file mode 100644 index 00000000..468f3774 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/SessionCounter.java @@ -0,0 +1,11 @@ +package app.web; + +import jakarta.servlet.annotation.WebListener; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; + +@WebListener +public class SessionCounter implements HttpSessionListener { + @Override + public void sessionCreated(HttpSessionEvent e) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/UnmappedServlet.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/UnmappedServlet.java new file mode 100644 index 00000000..3ff0e4f6 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/UnmappedServlet.java @@ -0,0 +1,11 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +// CONTROL: the same shape, registered nowhere +public class UnmappedServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/WidgetServlet.java b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/WidgetServlet.java new file mode 100644 index 00000000..2088e832 --- /dev/null +++ b/graph/test/java/cases/67-framework-registered-entry-points/src/app/web/WidgetServlet.java @@ -0,0 +1,12 @@ +package app.web; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/widgets") +public class WidgetServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/own/Client.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/own/Client.java new file mode 100644 index 00000000..db5074b7 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/own/Client.java @@ -0,0 +1,8 @@ +package app.own; + +// CONTROL: a project class that happens to be called Client, with the same method names +public class Client { + public Client target(String url) { return this; } + public Client request() { return this; } + public String get(Class type) { return ""; } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/own/OwnCaller.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/own/OwnCaller.java new file mode 100644 index 00000000..3b405230 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/own/OwnCaller.java @@ -0,0 +1,6 @@ +package app.own; + +public class OwnCaller { + Client client = new Client(); + String fetch() { return client.target("http://shop/orders").request().get(String.class); } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/Resources.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/Resources.java new file mode 100644 index 00000000..64e8230b --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/Resources.java @@ -0,0 +1,46 @@ +package app.rs; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +// a method path with no leading slash is joined with one +@Path("/items") +class ItemsResource { + @GET @Path("{id}") + String one(@PathParam("id") String id) { return id; } +} + +// CONTROL: the method path has its own slash +@Path("/orders") +class OrdersResource { + @GET @Path("/{id}") + String one(@PathParam("id") String id) { return id; } + @GET + String list() { return "[]"; } +} + +// a sub-resource locator: @Path and no verb, returning the resource that serves the rest +@Path("/widgets") +class WidgetsResource { + @Path("/{id}") + WidgetResource find(@PathParam("id") String id) { return new WidgetResource(id); } +} + +class WidgetResource { + String id; + WidgetResource(String id) { this.id = id; } + @GET String details() { return id; } + @GET @Path("parts") String parts() { return id; } +} + +// CONTROL: a verb method returning a type is a handler, not a locator +@Path("/gadgets") +class GadgetsResource { + @GET @Path("/{id}") + GadgetView details(@PathParam("id") String id) { return new GadgetView(); } +} + +class GadgetView { + @GET String render() { return ""; } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/RsClients.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/RsClients.java new file mode 100644 index 00000000..b069b294 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/rs/RsClients.java @@ -0,0 +1,24 @@ +package app.rs; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.MediaType; +import org.springframework.web.client.RestTemplate; + +class RsClients { + Client client = ClientBuilder.newClient(); + RestTemplate rest = new RestTemplate(); + + // the JAX-RS client API is a send + String orders() { return client.target("http://shop/orders").request().get(String.class); } + // … with the path built across the chain, and a builder step before the verb + String order(String id) { + return client.target("http://shop").path("orders").path("{id}").request(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).get(String.class); + } + String widget(String id) { return rest.getForObject("http://shop/widgets/{id}", String.class, id); } + String widgetParts(String id) { return rest.getForObject("http://shop/widgets/{id}/parts", String.class, id); } + String item(String id) { return rest.getForObject("http://shop/items/{id}", String.class, id); } + String gadget(String id) { return rest.getForObject("http://shop/gadgets/{id}/render", String.class, id); } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderClient.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderClient.java new file mode 100644 index 00000000..6cab9991 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderClient.java @@ -0,0 +1,13 @@ +package app.spring; + +import org.springframework.web.client.RestTemplate; + +public class OrderClient { + private final RestTemplate rest = new RestTemplate(); + + // the body literal is not a destination + public void markShipped(String id) { rest.postForObject("/api/orders/" + id, "shipped", String.class); } + // CONTROL: the same call with the body in a parameter + public void markPaid(String id, String status) { rest.postForObject("/api/orders/" + id, status, String.class); } + public String status() { return rest.getForObject("/api/status", String.class); } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderController.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderController.java new file mode 100644 index 00000000..cd48e2e5 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/spring/OrderController.java @@ -0,0 +1,15 @@ +package app.spring; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/") +public class OrderController { + @PostMapping("/orders/{id}") public String update(@PathVariable String id) { return id; } + @PostMapping("/shipped") public String shipped() { return ""; } + @GetMapping("status") public String status() { return ""; } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/web/AnnotatedServlet.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/AnnotatedServlet.java new file mode 100644 index 00000000..cc53e6fb --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/AnnotatedServlet.java @@ -0,0 +1,12 @@ +package app.web; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet(urlPatterns = {"/gauges"}) +public class AnnotatedServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CatchAllServlet.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CatchAllServlet.java new file mode 100644 index 00000000..b239fcfa --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CatchAllServlet.java @@ -0,0 +1,11 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +// CONTROL: mapped to "/*", which names no route +public class CatchAllServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CountServlet.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CountServlet.java new file mode 100644 index 00000000..a93372b0 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/CountServlet.java @@ -0,0 +1,12 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CountServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/web/FileServlet.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/FileServlet.java new file mode 100644 index 00000000..1034d0e9 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/FileServlet.java @@ -0,0 +1,10 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class FileServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/app/web/WebCaller.java b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/WebCaller.java new file mode 100644 index 00000000..82440785 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/app/web/WebCaller.java @@ -0,0 +1,13 @@ +package app.web; + +import org.springframework.web.client.RestTemplate; + +public class WebCaller { + RestTemplate rest = new RestTemplate(); + public String counts() { return rest.getForObject("http://localhost:8080/counts", String.class); } + public String addCount() { return rest.postForObject("http://localhost:8080/counts", null, String.class); } + public String file(String name) { return rest.getForObject("http://localhost:8080/files/" + name, String.class); } + public String gauges() { return rest.getForObject("http://localhost:8080/gauges", String.class); } + // CONTROL: nothing is mapped here, the catch-all servlet does not claim it + public String other() { return rest.getForObject("http://localhost:8080/elsewhere", String.class); } +} diff --git a/graph/test/java/cases/68-http-routes-and-sends/src/webapp/WEB-INF/web.xml b/graph/test/java/cases/68-http-routes-and-sends/src/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..9e90aab9 --- /dev/null +++ b/graph/test/java/cases/68-http-routes-and-sends/src/webapp/WEB-INF/web.xml @@ -0,0 +1,27 @@ + + + + counts + app.web.CountServlet + + + files + app.web.FileServlet + + + everything + app.web.CatchAllServlet + + + counts + /counts + + + files + /files/* + + + everything + /* + + diff --git a/graph/test/java/expected/23-config-xml-wiring.config b/graph/test/java/expected/23-config-xml-wiring.config index 12ebc43e..584bedac 100644 --- a/graph/test/java/expected/23-config-xml-wiring.config +++ b/graph/test/java/expected/23-config-xml-wiring.config @@ -43,7 +43,9 @@ unresolved_class xml beans.xml:19 @class "com.nope.Missing" ── remote_edge (0) ── ── remote_unserved [SENT, NO CONSUMER HERE] (0) ── -── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (2) ── + http /api/{path} testcases.config.MyServlet#doGet() + http /api/{path} testcases.config.MyServlet#doPost() ── remote_undetermined [DECLARED UNKNOWNS] (0) ── ── persistence_query (0) ── ── persistence_entity (0) ── diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.config b/graph/test/java/expected/66-grpc-generated-code-absent.config new file mode 100644 index 00000000..008b92a0 --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.config @@ -0,0 +1,34 @@ +── bean_def (0) ── +── bean_origin (0) ── +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (0) ── +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (6) ── + grpc_service app.inventory.AuditedInventoryService#reserve(String,StreamObserver) + grpc_service app.inventory.InventoryService#reserve(String,StreamObserver) + grpc_service app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) + grpc_service app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc_service app.orders.OrderServiceImpl#streamOrders(StreamObserver) + grpc_service app.widgets.WidgetService#build(String,StreamObserver) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (9) ── + grpc exact app.inventory.InventoryGrpc/reserve app.inventory.InventoryClient#reserve(String) -> app.inventory.AuditedInventoryService#reserve(String,StreamObserver) + grpc exact app.inventory.InventoryGrpc/reserve app.inventory.InventoryClient#reserve(String) -> app.inventory.InventoryService#reserve(String,StreamObserver) + grpc exact gen.v0.OrderServiceGrpc/placeOrder app.legacy.LegacyClient#place(String) -> app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.NestedImportClient#place(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeAsync(String,StreamObserver) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeBlocking(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeFuture(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeV2(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) + grpc exact gen.v1.OrderServiceGrpc/streamOrders app.orders.OrderClient#stream(StreamObserver) -> app.orders.OrderServiceImpl#streamOrders(StreamObserver) +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.edges b/graph/test/java/expected/66-grpc-generated-code-absent.edges new file mode 100644 index 00000000..a94f8a39 --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.edges @@ -0,0 +1,19 @@ +boundary_lib method app.inventory.InventoryClient#reserve(String) -> external:InventoryGrpc.InventoryBlockingStub.reserve +boundary_lib method app.inventory.InventoryService#reserve(String,StreamObserver) -> external:io.grpc.stub.StreamObserver.onNext +boundary_lib method app.legacy.LegacyClient#place(String) -> external:OrderServiceGrpc.OrderServiceBlockingStub.placeOrder +boundary_lib method app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) -> external:io.grpc.stub.StreamObserver.onNext +boundary_lib method app.orders.NestedImportClient#place(String) -> external:gen.v1.OrderServiceGrpc.OrderServiceBlockingStub.placeOrder +boundary_lib method app.orders.OrderClient#configure() -> external:OrderServiceGrpc.OrderServiceBlockingStub.withDeadlineAfter +boundary_lib method app.orders.OrderClient#decoyNoHolder(String) -> external:lib.RequestStub.placeOrder +boundary_lib method app.orders.OrderClient#decoyWrongHolder(String) -> external:ShapeGrpc.OrderServiceBlockingStub.placeOrder +boundary_lib method app.orders.OrderClient#placeAsync(String,StreamObserver) -> external:OrderServiceGrpc.OrderServiceStub.placeOrder +boundary_lib method app.orders.OrderClient#placeBlocking(String) -> external:OrderServiceGrpc.OrderServiceBlockingStub.placeOrder +boundary_lib method app.orders.OrderClient#placeFuture(String) -> external:OrderServiceGrpc.OrderServiceFutureStub.placeOrder +boundary_lib method app.orders.OrderClient#placeV2(String) -> external:OrderServiceGrpc.OrderServiceBlockingV2Stub.placeOrder +boundary_lib method app.orders.OrderClient#stream(StreamObserver) -> external:OrderServiceGrpc.OrderServiceStub.streamOrders +boundary_lib method app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) -> external:io.grpc.stub.StreamObserver.onNext +boundary_lib method app.widgets.WidgetClient#build(String) -> external:WidgetGrpc.WidgetBlockingStub.build +boundary_lib method app.widgets.WidgetService#build(String,StreamObserver) -> external:io.grpc.stub.StreamObserver.onNext +known_edge method app.inventory.AuditedInventoryService#reserve(String,StreamObserver) -> app.inventory.InventoryService#reserve(String,StreamObserver) +known_edge method app.orders.OrderServiceImpl#placeOrder(String) -> app.orders.OrderServiceImpl#audit(String) +known_edge method app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) -> app.orders.OrderServiceImpl#audit(String) diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.envelope b/graph/test/java/expected/66-grpc-generated-code-absent.envelope new file mode 100644 index 00000000..94e39309 --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.envelope @@ -0,0 +1 @@ +nominal app.inventory.InventoryService.reserve -> app.inventory.AuditedInventoryService.reserve diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.fields b/graph/test/java/expected/66-grpc-generated-code-absent.fields new file mode 100644 index 00000000..300c48d9 --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.fields @@ -0,0 +1,12 @@ +known_edge read app.inventory.InventoryClient#reserve(String) -> app.inventory.InventoryClient#stub +known_edge read app.legacy.LegacyClient#place(String) -> app.legacy.LegacyClient#stub +known_edge read app.orders.NestedImportClient#place(String) -> app.orders.NestedImportClient#stub +known_edge read app.orders.OrderClient#configure() -> app.orders.OrderClient#blockingStub +known_edge read app.orders.OrderClient#decoyNoHolder(String) -> app.orders.OrderClient#requestStub +known_edge read app.orders.OrderClient#decoyWrongHolder(String) -> app.orders.OrderClient#mismatchedStub +known_edge read app.orders.OrderClient#placeAsync(String,StreamObserver) -> app.orders.OrderClient#asyncStub +known_edge read app.orders.OrderClient#placeBlocking(String) -> app.orders.OrderClient#blockingStub +known_edge read app.orders.OrderClient#placeFuture(String) -> app.orders.OrderClient#futureStub +known_edge read app.orders.OrderClient#placeV2(String) -> app.orders.OrderClient#v2Stub +known_edge read app.orders.OrderClient#stream(StreamObserver) -> app.orders.OrderClient#asyncStub +known_edge read app.widgets.WidgetClient#build(String) -> app.widgets.WidgetClient#stub diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.remote b/graph/test/java/expected/66-grpc-generated-code-absent.remote new file mode 100644 index 00000000..1fee8e04 --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.remote @@ -0,0 +1,9 @@ +grpc exact app.inventory.InventoryGrpc/reserve app.inventory.InventoryClient#reserve(String) -> app.inventory.AuditedInventoryService#reserve(String,StreamObserver) +grpc exact app.inventory.InventoryGrpc/reserve app.inventory.InventoryClient#reserve(String) -> app.inventory.InventoryService#reserve(String,StreamObserver) +grpc exact gen.v0.OrderServiceGrpc/placeOrder app.legacy.LegacyClient#place(String) -> app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.NestedImportClient#place(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeAsync(String,StreamObserver) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeBlocking(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeFuture(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/placeOrder app.orders.OrderClient#placeV2(String) -> app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) +grpc exact gen.v1.OrderServiceGrpc/streamOrders app.orders.OrderClient#stream(StreamObserver) -> app.orders.OrderServiceImpl#streamOrders(StreamObserver) diff --git a/graph/test/java/expected/66-grpc-generated-code-absent.type-use b/graph/test/java/expected/66-grpc-generated-code-absent.type-use new file mode 100644 index 00000000..108668be --- /dev/null +++ b/graph/test/java/expected/66-grpc-generated-code-absent.type-use @@ -0,0 +1,58 @@ +ambiguous_unknown ANNOTATION_TYPE 0 app.inventory.AuditedInventoryService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.inventory.InventoryService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.legacy.LegacyOrderService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.orders.OrderServiceImpl [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.widgets.WidgetService [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 app.inventory.InventoryClient [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.legacy.LegacyClient [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.orders.NestedImportClient [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.orders.OrderClient [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.widgets.WidgetClient [FIELD] -> - +ambiguous_unknown METHOD_PARAM 0 app.inventory.AuditedInventoryService#reserve(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.inventory.InventoryClient#reserve(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.inventory.InventoryService#reserve(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.legacy.LegacyClient#place(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.NestedImportClient#place(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#decoyNoHolder(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#decoyWrongHolder(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#placeAsync(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#placeBlocking(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#placeFuture(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#placeV2(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderClient#stream(StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderServiceImpl#audit(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderServiceImpl#placeOrder(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.orders.OrderServiceImpl#streamOrders(StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.widgets.WidgetClient#build(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.widgets.WidgetService#build(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.inventory.AuditedInventoryService#reserve(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.inventory.InventoryService#reserve(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.legacy.LegacyOrderService#placeOrder(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.orders.OrderClient#placeAsync(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.orders.OrderClient#stream(StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.orders.OrderServiceImpl#placeOrder(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.orders.OrderServiceImpl#streamOrders(StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.widgets.WidgetService#build(String,StreamObserver) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 app.inventory.InventoryClient#reserve(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.legacy.LegacyClient#place(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.NestedImportClient#place(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#configure() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#decoyNoHolder(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#decoyWrongHolder(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#placeBlocking(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#placeFuture(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#placeV2(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderClient#stream(StreamObserver) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderServiceImpl#audit(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderServiceImpl#placeOrder(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.orders.OrderServiceImpl#streamOrders(StreamObserver) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.widgets.WidgetClient#build(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 1 app.orders.OrderClient#stream(StreamObserver) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 1 app.orders.OrderServiceImpl#streamOrders(StreamObserver) [METHOD] -> - +ambiguous_unknown SUPER_TYPE 0 app.inventory.InventoryService [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.legacy.LegacyOrderService [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.orders.OrderServiceImpl [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.widgets.WidgetService [TYPE] -> - +known_edge SUPER_TYPE 0 app.inventory.AuditedInventoryService [TYPE] -> app.inventory.InventoryService diff --git a/graph/test/java/expected/67-framework-registered-entry-points.config b/graph/test/java/expected/67-framework-registered-entry-points.config new file mode 100644 index 00000000..b819d1fa --- /dev/null +++ b/graph/test/java/expected/67-framework-registered-entry-points.config @@ -0,0 +1,52 @@ +── bean_def (4) ── + factory_method plain <- app.beans.Plain + factory_method pool <- app.beans.Pool + stereotype appConfig <- app.beans.AppConfig + stereotype bootConfig <- app.init.BootConfig +── bean_origin (4) ── + client appConfig <- app.beans.AppConfig + client bootConfig <- app.init.BootConfig + client plain <- app.beans.Plain + client pool <- app.beans.Pool +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (3) ── + annotation @Convert "PriceConverter" -> app.jpa.PriceConverter [client] + annotation @Documented "Unregistered" -> app.jpa.Unregistered [client] + annotation @EntityListeners "WidgetAudit" -> app.jpa.WidgetAudit [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (20) ── + factory app.beans.AppConfig#plain() + factory app.beans.AppConfig#pool() + factory app.init.BootConfig#bootServlet() + framework_hook app.rs.AuthFilter#filter(ContainerRequestContext) + framework_hook app.rs.Color#(String) + framework_hook app.rs.MissingMapper#toResponse(RuntimeException) + framework_hook app.rs.Size#valueOf(String) + lifecycle_destroy app.beans.Pool#stop() + lifecycle_init app.beans.Pool#start() + orm_hook app.jpa.PriceConverter#convertToDatabaseColumn(Price) + orm_hook app.jpa.PriceConverter#convertToEntityAttribute(Long) + orm_hook app.jpa.Widget#stamp() + orm_hook app.jpa.WidgetAudit#saved(Widget) + web_filter app.init.InitFilter#doFilter(ServletRequest,ServletResponse,FilterChain) + web_filter app.web.AuditFilter#doFilter(ServletRequest,ServletResponse,FilterChain) + web_listener app.init.StartupListener#contextInitialized(ServletContextEvent) + web_listener app.web.SessionCounter#sessionCreated(HttpSessionEvent) + web_servlet app.init.BootServlet#doPost(HttpServletRequest,HttpServletResponse) + web_servlet app.init.OrderServlet#doGet(HttpServletRequest,HttpServletResponse) + web_servlet app.web.WidgetServlet#doGet(HttpServletRequest,HttpServletResponse) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (2) ── + http /widgets app.rs.WidgetResource#list(Size,Color) + http /widgets app.web.WidgetServlet#doGet(HttpServletRequest,HttpServletResponse) +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/67-framework-registered-entry-points.edges b/graph/test/java/expected/67-framework-registered-entry-points.edges new file mode 100644 index 00000000..84af5fa4 --- /dev/null +++ b/graph/test/java/expected/67-framework-registered-entry-points.edges @@ -0,0 +1,14 @@ +ambiguous_unknown method app.init.AppInit#onStartup(Set,ServletContext) -> - +ambiguous_unknown method app.rs.Size#valueOf(String) -> - +ambiguous_unknown new app.init.BootConfig#bootServlet() -> - +boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addFilter +boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addListener +boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addServlet +known_edge method app.init.AppInit#wire(EventBus) -> app.init.EventBus#addListener(Class) +known_edge new app.beans.AppConfig#plain() -> app.beans.Plain#() +known_edge new app.beans.AppConfig#pool() -> app.beans.Pool#() +known_edge new app.init.AppInit#onStartup(Set,ServletContext) -> app.init.InitFilter#() +known_edge new app.init.BootConfig#bootServlet() -> app.init.BootServlet#() +known_edge new app.jpa.PriceConverter#convertToEntityAttribute(Long) -> app.jpa.Price#(long) +known_edge new app.rs.Size#valueOf(String) -> app.rs.Size#(int) +known_edge new app.rs.Weight#valueOf(String) -> app.rs.Weight#() diff --git a/graph/test/java/expected/67-framework-registered-entry-points.fields b/graph/test/java/expected/67-framework-registered-entry-points.fields new file mode 100644 index 00000000..a201c038 --- /dev/null +++ b/graph/test/java/expected/67-framework-registered-entry-points.fields @@ -0,0 +1,7 @@ +known_edge read app.jpa.LooseConverter#convertToDatabaseColumn(Price) -> app.jpa.Price#cents +known_edge read app.jpa.PriceConverter#convertToDatabaseColumn(Price) -> app.jpa.Price#cents +known_edge read app.rs.WidgetResource#list(Size,Color) -> app.rs.Color#name +known_edge read app.rs.WidgetResource#list(Size,Color) -> app.rs.Size#n +known_edge write app.jpa.Price#(long) -> app.jpa.Price#cents +known_edge write app.rs.Color#(String) -> app.rs.Color#name +known_edge write app.rs.Size#(int) -> app.rs.Size#n diff --git a/graph/test/java/expected/67-framework-registered-entry-points.type-use b/graph/test/java/expected/67-framework-registered-entry-points.type-use new file mode 100644 index 00000000..929e5c54 --- /dev/null +++ b/graph/test/java/expected/67-framework-registered-entry-points.type-use @@ -0,0 +1,80 @@ +ambiguous_unknown ANNOTATION_TYPE 0 app.beans.AppConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.init.AppInit [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.init.BootConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.jpa.PriceConverter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.jpa.Widget [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.jpa.WidgetAudit [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.AuthFilter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.MissingMapper [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.WidgetResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.AuditFilter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.SessionCounter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.UnmappedServlet [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.WidgetServlet [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 app.jpa.Widget [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.rs.Color [FIELD] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.init.AppInit [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.init.InitFilter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.init.StartupListener [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.jpa.PriceConverter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.rs.AuthFilter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.rs.MissingMapper [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.web.AuditFilter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.web.SessionCounter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.jpa.PriceConverter [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.rs.MissingMapper [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.AppInit#onStartup(Set,ServletContext) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.BootServlet#doPost(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.BusListener#contextInitialized(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.EventBus#addListener(Class) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.InitFilter#doFilter(ServletRequest,ServletResponse,FilterChain) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.OrderServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.init.StartupListener#contextInitialized(ServletContextEvent) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.jpa.PriceConverter#convertToEntityAttribute(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.AuthFilter#filter(ContainerRequestContext) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.Color#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.MissingMapper#toResponse(RuntimeException) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.Size#valueOf(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.Weight#valueOf(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.AuditFilter#doFilter(ServletRequest,ServletResponse,FilterChain) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.SessionCounter#sessionCreated(HttpSessionEvent) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.UnmappedServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.WidgetServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.init.AppInit#onStartup(Set,ServletContext) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 app.init.BootConfig#bootServlet() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.jpa.Documented#by() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.jpa.LooseConverter#convertToDatabaseColumn(Price) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.jpa.PriceConverter#convertToDatabaseColumn(Price) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.MissingMapper#toResponse(RuntimeException) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.WidgetResource#list(Size,Color) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.WidgetResource#weigh(Weight) [METHOD] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.init.BootConfig#bootServlet() [EXPRESSION] -> - +ambiguous_unknown SUPER_TYPE 0 app.init.BootServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.init.OrderServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.UnmappedServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.WidgetServlet [TYPE] -> - +known_edge ANNOTATION_PARAM 0 app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.Unregistered +known_edge ANNOTATION_PARAM 0 app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.WidgetAudit +known_edge ANNOTATION_TYPE 0 app.jpa.Widget [ANNOTATION] -> app.jpa.Documented +known_edge FIELD_TYPE 0 app.jpa.Widget [FIELD] -> app.jpa.Price +known_edge IMPLEMENTS_INTERFACE 1 app.jpa.PriceConverter [TYPE] -> app.jpa.Price +known_edge METHOD_PARAM 0 app.init.AppInit#wire(EventBus) [METHOD_PARAM] -> app.init.EventBus +known_edge METHOD_PARAM 0 app.jpa.LooseConverter#convertToDatabaseColumn(Price) [METHOD_PARAM] -> app.jpa.Price +known_edge METHOD_PARAM 0 app.jpa.PriceConverter#convertToDatabaseColumn(Price) [METHOD_PARAM] -> app.jpa.Price +known_edge METHOD_PARAM 0 app.jpa.WidgetAudit#saved(Widget) [METHOD_PARAM] -> app.jpa.Widget +known_edge METHOD_PARAM 0 app.rs.WidgetResource#list(Size,Color) [METHOD_PARAM] -> app.rs.Color +known_edge METHOD_PARAM 0 app.rs.WidgetResource#list(Size,Color) [METHOD_PARAM] -> app.rs.Size +known_edge METHOD_PARAM 0 app.rs.WidgetResource#weigh(Weight) [METHOD_PARAM] -> app.rs.Weight +known_edge METHOD_RETURN 0 app.beans.AppConfig#plain() [METHOD] -> app.beans.Plain +known_edge METHOD_RETURN 0 app.beans.AppConfig#pool() [METHOD] -> app.beans.Pool +known_edge METHOD_RETURN 0 app.jpa.PriceConverter#convertToEntityAttribute(Long) [METHOD] -> app.jpa.Price +known_edge METHOD_RETURN 0 app.rs.Size#valueOf(String) [METHOD] -> app.rs.Size +known_edge METHOD_RETURN 0 app.rs.Weight#valueOf(String) [METHOD] -> app.rs.Weight +known_edge METHOD_RETURN 1 app.init.BootConfig#bootServlet() [METHOD] -> app.init.BootServlet +known_edge OBJECT_CREATION_TYPE 0 app.beans.AppConfig#plain() [EXPRESSION] -> app.beans.Plain +known_edge OBJECT_CREATION_TYPE 0 app.beans.AppConfig#pool() [EXPRESSION] -> app.beans.Pool +known_edge OBJECT_CREATION_TYPE 0 app.init.AppInit#onStartup(Set,ServletContext) [EXPRESSION] -> app.init.InitFilter +known_edge OBJECT_CREATION_TYPE 0 app.init.BootConfig#bootServlet() [EXPRESSION] -> app.init.BootServlet +known_edge OBJECT_CREATION_TYPE 0 app.jpa.PriceConverter#convertToEntityAttribute(Long) [EXPRESSION] -> app.jpa.Price +known_edge OBJECT_CREATION_TYPE 0 app.rs.Size#valueOf(String) [EXPRESSION] -> app.rs.Size +known_edge OBJECT_CREATION_TYPE 0 app.rs.Weight#valueOf(String) [EXPRESSION] -> app.rs.Weight diff --git a/graph/test/java/expected/68-http-routes-and-sends.config b/graph/test/java/expected/68-http-routes-and-sends.config new file mode 100644 index 00000000..3226fca0 --- /dev/null +++ b/graph/test/java/expected/68-http-routes-and-sends.config @@ -0,0 +1,48 @@ +── bean_def (1) ── + stereotype orderController <- app.spring.OrderController +── bean_origin (1) ── + client orderController <- app.spring.OrderController +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (3) ── + xml web.xml:13 "app.web.CatchAllServlet" -> app.web.CatchAllServlet [client] + xml web.xml:5 "app.web.CountServlet" -> app.web.CountServlet [client] + xml web.xml:9 "app.web.FileServlet" -> app.web.FileServlet [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (5) ── + web_servlet app.web.AnnotatedServlet#doGet(HttpServletRequest,HttpServletResponse) + web_servlet app.web.CatchAllServlet#doGet(HttpServletRequest,HttpServletResponse) + web_servlet app.web.CountServlet#doGet(HttpServletRequest,HttpServletResponse) + web_servlet app.web.CountServlet#doPost(HttpServletRequest,HttpServletResponse) + web_servlet app.web.FileServlet#doGet(HttpServletRequest,HttpServletResponse) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (13) ── + http exact /api/status app.spring.OrderClient#status() -> app.spring.OrderController#status() + http exact /counts app.web.WebCaller#addCount() -> app.web.CountServlet#doPost(HttpServletRequest,HttpServletResponse) + http exact /counts app.web.WebCaller#counts() -> app.web.CountServlet#doGet(HttpServletRequest,HttpServletResponse) + http exact /gauges app.web.WebCaller#gauges() -> app.web.AnnotatedServlet#doGet(HttpServletRequest,HttpServletResponse) + http exact /items/{id} app.rs.RsClients#item(String) -> app.rs.ItemsResource#one(String) + http exact /orders app.rs.RsClients#orders() -> app.rs.OrdersResource#list() + http exact /orders/{id} app.rs.RsClients#order(String) -> app.rs.OrdersResource#one(String) + http exact /widgets/{id} app.rs.RsClients#widget(String) -> app.rs.WidgetResource#details() + http exact /widgets/{id} app.rs.RsClients#widget(String) -> app.rs.WidgetsResource#find(String) + http exact /widgets/{id}/parts app.rs.RsClients#widgetParts(String) -> app.rs.WidgetResource#parts() + http route_shape /api/orders/ app.spring.OrderClient#markPaid(String,String) -> app.spring.OrderController#update(String) + http route_shape /api/orders/ app.spring.OrderClient#markShipped(String) -> app.spring.OrderController#update(String) + http route_shape /files/ app.web.WebCaller#file(String) -> app.web.FileServlet#doGet(HttpServletRequest,HttpServletResponse) +── remote_unserved [SENT, NO CONSUMER HERE] (2) ── + http /elsewhere app.web.WebCaller#other() + http /gadgets/{id}/render app.rs.RsClients#gadget(String) +── remote_unsent [SERVED, NO PRODUCER HERE] (4) ── + http /api/orders/{id} app.spring.OrderController#update(String) + http /api/shipped app.spring.OrderController#shipped() + http /files/{path} app.web.FileServlet#doGet(HttpServletRequest,HttpServletResponse) + http /gadgets/{id} app.rs.GadgetsResource#details(String) +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/68-http-routes-and-sends.edges b/graph/test/java/expected/68-http-routes-and-sends.edges new file mode 100644 index 00000000..1e17c53b --- /dev/null +++ b/graph/test/java/expected/68-http-routes-and-sends.edges @@ -0,0 +1,24 @@ +ambiguous_unknown method app.rs.RsClients#() -> - +ambiguous_unknown method app.rs.RsClients#order(String) -> - +ambiguous_unknown method app.rs.RsClients#orders() -> - +ambiguous_unknown new app.rs.RsClients#() -> - +ambiguous_unknown new app.spring.OrderClient#() -> - +ambiguous_unknown new app.web.WebCaller#() -> - +boundary_lib method app.rs.RsClients#gadget(String) -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.rs.RsClients#item(String) -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.rs.RsClients#widget(String) -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.rs.RsClients#widgetParts(String) -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.spring.OrderClient#markPaid(String,String) -> external:org.springframework.web.client.RestTemplate.postForObject +boundary_lib method app.spring.OrderClient#markShipped(String) -> external:org.springframework.web.client.RestTemplate.postForObject +boundary_lib method app.spring.OrderClient#status() -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.web.WebCaller#addCount() -> external:org.springframework.web.client.RestTemplate.postForObject +boundary_lib method app.web.WebCaller#counts() -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.web.WebCaller#file(String) -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.web.WebCaller#gauges() -> external:org.springframework.web.client.RestTemplate.getForObject +boundary_lib method app.web.WebCaller#other() -> external:org.springframework.web.client.RestTemplate.getForObject +known_edge method app.own.OwnCaller#fetch() -> app.own.Client#get(Class) +known_edge method app.own.OwnCaller#fetch() -> app.own.Client#request() +known_edge method app.own.OwnCaller#fetch() -> app.own.Client#target(String) +known_edge new app.own.OwnCaller#() -> app.own.Client#() +known_edge new app.rs.GadgetsResource#details(String) -> app.rs.GadgetView#() +known_edge new app.rs.WidgetsResource#find(String) -> app.rs.WidgetResource#(String) diff --git a/graph/test/java/expected/68-http-routes-and-sends.fields b/graph/test/java/expected/68-http-routes-and-sends.fields new file mode 100644 index 00000000..86b9d44b --- /dev/null +++ b/graph/test/java/expected/68-http-routes-and-sends.fields @@ -0,0 +1,19 @@ +ambiguous_unknown read app.rs.RsClients#order(String) -> - +known_edge read app.own.OwnCaller#fetch() -> app.own.OwnCaller#client +known_edge read app.rs.RsClients#gadget(String) -> app.rs.RsClients#rest +known_edge read app.rs.RsClients#item(String) -> app.rs.RsClients#rest +known_edge read app.rs.RsClients#order(String) -> app.rs.RsClients#client +known_edge read app.rs.RsClients#orders() -> app.rs.RsClients#client +known_edge read app.rs.RsClients#widget(String) -> app.rs.RsClients#rest +known_edge read app.rs.RsClients#widgetParts(String) -> app.rs.RsClients#rest +known_edge read app.rs.WidgetResource#details() -> app.rs.WidgetResource#id +known_edge read app.rs.WidgetResource#parts() -> app.rs.WidgetResource#id +known_edge read app.spring.OrderClient#markPaid(String,String) -> app.spring.OrderClient#rest +known_edge read app.spring.OrderClient#markShipped(String) -> app.spring.OrderClient#rest +known_edge read app.spring.OrderClient#status() -> app.spring.OrderClient#rest +known_edge read app.web.WebCaller#addCount() -> app.web.WebCaller#rest +known_edge read app.web.WebCaller#counts() -> app.web.WebCaller#rest +known_edge read app.web.WebCaller#file(String) -> app.web.WebCaller#rest +known_edge read app.web.WebCaller#gauges() -> app.web.WebCaller#rest +known_edge read app.web.WebCaller#other() -> app.web.WebCaller#rest +known_edge write app.rs.WidgetResource#(String) -> app.rs.WidgetResource#id diff --git a/graph/test/java/expected/68-http-routes-and-sends.remote b/graph/test/java/expected/68-http-routes-and-sends.remote new file mode 100644 index 00000000..817ec6a2 --- /dev/null +++ b/graph/test/java/expected/68-http-routes-and-sends.remote @@ -0,0 +1,13 @@ +http exact /api/status app.spring.OrderClient#status() -> app.spring.OrderController#status() +http exact /counts app.web.WebCaller#addCount() -> app.web.CountServlet#doPost(HttpServletRequest,HttpServletResponse) +http exact /counts app.web.WebCaller#counts() -> app.web.CountServlet#doGet(HttpServletRequest,HttpServletResponse) +http exact /gauges app.web.WebCaller#gauges() -> app.web.AnnotatedServlet#doGet(HttpServletRequest,HttpServletResponse) +http exact /items/{id} app.rs.RsClients#item(String) -> app.rs.ItemsResource#one(String) +http exact /orders app.rs.RsClients#orders() -> app.rs.OrdersResource#list() +http exact /orders/{id} app.rs.RsClients#order(String) -> app.rs.OrdersResource#one(String) +http exact /widgets/{id} app.rs.RsClients#widget(String) -> app.rs.WidgetResource#details() +http exact /widgets/{id} app.rs.RsClients#widget(String) -> app.rs.WidgetsResource#find(String) +http exact /widgets/{id}/parts app.rs.RsClients#widgetParts(String) -> app.rs.WidgetResource#parts() +http route_shape /api/orders/ app.spring.OrderClient#markPaid(String,String) -> app.spring.OrderController#update(String) +http route_shape /api/orders/ app.spring.OrderClient#markShipped(String) -> app.spring.OrderController#update(String) +http route_shape /files/ app.web.WebCaller#file(String) -> app.web.FileServlet#doGet(HttpServletRequest,HttpServletResponse) diff --git a/graph/test/java/expected/68-http-routes-and-sends.type-use b/graph/test/java/expected/68-http-routes-and-sends.type-use new file mode 100644 index 00000000..8b2449bd --- /dev/null +++ b/graph/test/java/expected/68-http-routes-and-sends.type-use @@ -0,0 +1,74 @@ +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.GadgetView [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.GadgetsResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.ItemsResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.OrdersResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.WidgetResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.rs.WidgetsResource [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.spring.OrderController [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.AnnotatedServlet [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.CatchAllServlet [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.CountServlet [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.web.FileServlet [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 app.rs.RsClients [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.rs.WidgetResource [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.spring.OrderClient [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 app.web.WebCaller [FIELD] -> - +ambiguous_unknown METHOD_PARAM 0 app.own.Client#get(Class) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.own.Client#target(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.GadgetsResource#details(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.ItemsResource#one(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.OrdersResource#one(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.RsClients#gadget(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.RsClients#item(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.RsClients#order(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.RsClients#widget(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.RsClients#widgetParts(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.WidgetResource#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.rs.WidgetsResource#find(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.spring.OrderClient#markPaid(String,String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.spring.OrderClient#markShipped(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.spring.OrderController#update(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.AnnotatedServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.CatchAllServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.CountServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.CountServlet#doPost(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.FileServlet#doGet(HttpServletRequest,HttpServletResponse) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.web.WebCaller#file(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 app.own.Client#get(Class) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.own.OwnCaller#fetch() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.GadgetView#render() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.ItemsResource#one(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.OrdersResource#list() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.OrdersResource#one(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#gadget(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#item(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#order(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#orders() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#widget(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.RsClients#widgetParts(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.WidgetResource#details() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.rs.WidgetResource#parts() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.spring.OrderClient#status() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.spring.OrderController#shipped() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.spring.OrderController#status() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.spring.OrderController#update(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.web.WebCaller#addCount() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.web.WebCaller#counts() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.web.WebCaller#file(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.web.WebCaller#gauges() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.web.WebCaller#other() [METHOD] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.rs.RsClients [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.spring.OrderClient [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.web.WebCaller [EXPRESSION] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.AnnotatedServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.CatchAllServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.CountServlet [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 app.web.FileServlet [TYPE] -> - +known_edge FIELD_TYPE 0 app.own.OwnCaller [FIELD] -> app.own.Client +known_edge METHOD_RETURN 0 app.own.Client#request() [METHOD] -> app.own.Client +known_edge METHOD_RETURN 0 app.own.Client#target(String) [METHOD] -> app.own.Client +known_edge METHOD_RETURN 0 app.rs.GadgetsResource#details(String) [METHOD] -> app.rs.GadgetView +known_edge METHOD_RETURN 0 app.rs.WidgetsResource#find(String) [METHOD] -> app.rs.WidgetResource +known_edge OBJECT_CREATION_TYPE 0 app.own.OwnCaller [EXPRESSION] -> app.own.Client +known_edge OBJECT_CREATION_TYPE 0 app.rs.GadgetsResource#details(String) [EXPRESSION] -> app.rs.GadgetView +known_edge OBJECT_CREATION_TYPE 0 app.rs.WidgetsResource#find(String) [EXPRESSION] -> app.rs.WidgetResource diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index ac901e3e..9b86a8a3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -216,12 +216,15 @@ def via_base_why(base_kind): 'hub': ('a real-time hub method', 'outside'), 'cli': ('a command-line command', 'outside'), 'queue': ('a message consumer', 'outside'), 'task': ('a background task a queue runs', 'outside'), 'web_filter': ('a web request filter', 'outside'), + 'web_servlet': ('a servlet the web container dispatches requests to', 'outside'), 'package_export': ('an export of the package', 'outside'), 'exported_from_entry_module': ('an export of the entry module', 'outside'), 'unimported_module': ('a module run directly, which nothing imports', 'outside'), 'framework_hook': ('a framework hook', 'callback'), 'orm_hook': ('a model hook the ORM or validation library runs', 'callback'), 'lifecycle': ('a lifecycle callback', 'callback'), 'bean_ctor': ('a constructor the container runs to build a bean', 'callback'), 'factory': ('a factory method the container calls', 'callback'), 'fixture': ('a test fixture', 'callback'), + 'lifecycle_init': ('an init method the container calls on the bean it built', 'callback'), + 'lifecycle_destroy': ('a destroy method the container calls on the bean it built', 'callback'), 'service_loader': ('a provider a service loader instantiates', 'callback'), 'spring_factories': ('an auto-configuration class the container loads', 'callback'), 'di_provider': ('a dependency-injection provider', 'callback'), 'signal_receiver': ('a signal receiver', 'callback'), diff --git a/tests/cases/java/framework-registered-entry-points/case.json b/tests/cases/java/framework-registered-entry-points/case.json new file mode 100644 index 00000000..792ce358 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/case.json @@ -0,0 +1,33 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a servlet registered by @WebServlet is an entry point, as its web.xml twin is (#1410)", + "run": ["impact", "WidgetServlet.doGet"], + "want": ["web_servlet"], + "avoid": ["the change is local"]}, + {"why": "CONTROL: the same servlet registered nowhere is not an entry point", + "run": ["impact", "UnmappedServlet.doGet"], + "avoid": ["web_servlet"]}, + {"why": "a servlet registered by ServletContext.addServlet is an entry point (#1457)", + "run": ["impact", "OrderServlet.doGet"], + "want": ["web_servlet"]}, + {"why": "@Bean(initMethod) names a lifecycle method on the bean's type (#1400)", + "run": ["impact", "Pool.start"], + "want": ["an init method the container calls on the bean it built"], + "avoid": ["the change is local"]}, + {"why": "CONTROL: a sibling the @Bean does not name", + "run": ["impact", "Pool.drain"], + "avoid": ["an init method the container calls"]}, + {"why": "a JPA @Converter's methods are called by the persistence provider (#1463)", + "run": ["impact", "PriceConverter.convertToDatabaseColumn"], + "want": ["a model hook the ORM or validation library runs"]}, + {"why": "a JAX-RS method path with no leading slash is joined with one (#1428)", + "run": ["path", "ShopClient.item", "ItemsResource.one"], + "want": ["[http] at /items/{id} (exact)"], + "expect_error": true}, + {"why": "only the URL argument of a RestTemplate send is its destination (#1456)", + "run": ["impact", "OrderController.update"], + "want": ["[remote] ShopClient.markShipped"]}, + {"why": "CONTROL: the request body literal names no route", + "run": ["impact", "OrderController.shipped"], + "avoid": ["ShopClient.markShipped"]} + ]} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/AppConfig.java b/tests/cases/java/framework-registered-entry-points/src/app/AppConfig.java new file mode 100644 index 00000000..782ccb20 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/AppConfig.java @@ -0,0 +1,10 @@ +package app; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + @Bean(initMethod = "start") + public Pool pool() { return new Pool(); } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/AppInit.java b/tests/cases/java/framework-registered-entry-points/src/app/AppInit.java new file mode 100644 index 00000000..2fa9f9d3 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/AppInit.java @@ -0,0 +1,12 @@ +package app; + +import jakarta.servlet.ServletContainerInitializer; +import jakarta.servlet.ServletContext; +import java.util.Set; + +public class AppInit implements ServletContainerInitializer { + @Override + public void onStartup(Set> classes, ServletContext ctx) { + ctx.addServlet("orders", OrderServlet.class).addMapping("/orders"); + } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/ItemsResource.java b/tests/cases/java/framework-registered-entry-points/src/app/ItemsResource.java new file mode 100644 index 00000000..fd988f5d --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/ItemsResource.java @@ -0,0 +1,11 @@ +package app; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +@Path("/items") +public class ItemsResource { + @GET @Path("{id}") + public String one(@PathParam("id") String id) { return id; } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/OrderController.java b/tests/cases/java/framework-registered-entry-points/src/app/OrderController.java new file mode 100644 index 00000000..ed0af6d4 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/OrderController.java @@ -0,0 +1,11 @@ +package app; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OrderController { + @PostMapping("/orders/{id}") public String update(@PathVariable String id) { return id; } + @PostMapping("/shipped") public String shipped() { return ""; } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/OrderServlet.java b/tests/cases/java/framework-registered-entry-points/src/app/OrderServlet.java new file mode 100644 index 00000000..7486c081 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/OrderServlet.java @@ -0,0 +1,9 @@ +package app; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class OrderServlet extends HttpServlet { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/Pool.java b/tests/cases/java/framework-registered-entry-points/src/app/Pool.java new file mode 100644 index 00000000..b07f514a --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/Pool.java @@ -0,0 +1,6 @@ +package app; + +public class Pool { + void start() { } + void drain() { } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/PriceConverter.java b/tests/cases/java/framework-registered-entry-points/src/app/PriceConverter.java new file mode 100644 index 00000000..004d9821 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/PriceConverter.java @@ -0,0 +1,10 @@ +package app; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class PriceConverter implements AttributeConverter { + public String convertToDatabaseColumn(Long p) { return "" + p; } + public Long convertToEntityAttribute(String v) { return Long.valueOf(v); } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/ShopClient.java b/tests/cases/java/framework-registered-entry-points/src/app/ShopClient.java new file mode 100644 index 00000000..3e752b37 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/ShopClient.java @@ -0,0 +1,10 @@ +package app; + +import org.springframework.web.client.RestTemplate; + +public class ShopClient { + private final RestTemplate rest = new RestTemplate(); + + public String item(String id) { return rest.getForObject("http://shop/items/{id}", String.class, id); } + public void markShipped(String id) { rest.postForObject("/orders/" + id, "shipped", String.class); } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/Store.java b/tests/cases/java/framework-registered-entry-points/src/app/Store.java new file mode 100644 index 00000000..d2888559 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/Store.java @@ -0,0 +1,5 @@ +package app; + +public class Store { + static void list() { } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/UnmappedServlet.java b/tests/cases/java/framework-registered-entry-points/src/app/UnmappedServlet.java new file mode 100644 index 00000000..21fab23f --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/UnmappedServlet.java @@ -0,0 +1,10 @@ +package app; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class UnmappedServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { Store.list(); } +} diff --git a/tests/cases/java/framework-registered-entry-points/src/app/WidgetServlet.java b/tests/cases/java/framework-registered-entry-points/src/app/WidgetServlet.java new file mode 100644 index 00000000..9a6fee54 --- /dev/null +++ b/tests/cases/java/framework-registered-entry-points/src/app/WidgetServlet.java @@ -0,0 +1,12 @@ +package app; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/widgets") +public class WidgetServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { Store.list(); } +} From 5beb138d13eb969fb3c1ad8766636c22573ec1f3 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:33:51 -0700 Subject: [PATCH 028/258] java: Lombok builder, accessor shapes, logger type; generic bindings from an implicit receiver, a class literal and a deep self type Fixes #1404, #1405, #1406, #1407, #1408, #1409, #1412, #1547, #1478, #1452 What was wrong Generated members (resolution/generated-members.dl) named every accessor get/is/set plus the field name. A primitive boolean named isLocked got isIsLocked/setIsLocked (#1404); @Accessors(chain = true) setters were void and fluent = true declared getX/setX that do not exist (#1406); @Builder, @With, staticName factories and @Delegate declared nothing (#1405, (#1408). Generic bindings only reached a receiver whose type arguments were written on a declaration. A member inherited from `extends Base` and reached with no written receiver (`mapper.findOpen()`, `getMapper().findClosed()`) had no binding, and the field form minted an external type named after the variable, `external:M` (#1412). A generic method's own variable was never bound from its Class argument, so `Beans.get(X.class).m()` was unresolved (#1547). A self type fixed two supertypes down (`ChainStub extends Mid`, `Mid extends Base`) was bound on Mid only, so a call after `withTimeout` lost its type (#1478). The parser dropped the type references a FIELD annotation creates, so a class named only in `@JsonSerialize(using = X.class)` on a field had no user (#1452). impact listed every unresolved call named after a @Builder field as a writer, whatever chain it was on (#1409). The change - generated-members.dl: one relation (gen_acc) carries the field each generated member is built from. Names follow the processor: an isX boolean keeps isX and drops the is for setX/withX; @Accessors fluent/chain are read from the field, then the type, then lombok.config (lombok.accessors.chain / fluent, peeled the way the prefix key already was). A chain setter and a wither return the owner. @Builder synthesizes the builder type generated:. (or uses a partial builder the source writes), with a setter per field, build(), builder() and toBuilder(); builderClassName, builderMethodName and buildMethodName are read. staticName / staticConstructor declares the factory with the constructor's arity. @Delegate declares a forwarder per method the field's type declares, resolved by name in the owner's file (base facts only: reading the type-resolution fixpoint there did not stratify). The logger field gets the type its annotation fixes: a staged library type, a client type, or the external node. A generated getter's return type now takes the resolved type's own provenance, as a record accessor does. - generic-chain.dl: class_tvar_binding carries a concrete extends-clause argument up through supertypes that pass their variable on and down to every subtype; it feeds expr_arg_binding and new clauses for an unqualified call and a bare or this-qualified field typed as an inherited variable (client and library declarations). A method's own variable returned and written as Class is bound from a class literal argument at that position. - type-arg-binding.dl: super_tvar_rename also covers a client supertype. - external-types.dl: a reference whose name is a type variable in scope is never an external type. - field-extractor.ts: field annotations contribute their type references, as the method and type paths do. - impact (dl/impact.dl and the graph_sql fast path, in step): a generated one-argument member named like the field is its builder or fluent setter; a resolved call to it is a writer, and is no longer read as the fluent getter. Once such a setter is modelled, an unresolved same-named call is listed under reads/uses as a same-named call the engine did not place on this type's builder, not as a writer. The accessor facts carry withX, and setX / withX for an isX boolean. axiomcode-index gives a member of a synthesized builder a symbol row under `Owner.OwnerBuilder.name`, so the facts can see it. IMPACT_VERSION 42. Not covered: @SuperBuilder, @Singular, @Builder on a constructor or method, @Delegate types/excludes and the methods a delegate type inherits, and an edge from a forwarder to the method it forwards to (a generated member has no body, as before). Tests - graph/test/java: new cases 66-lombok-generated-shapes, 66-generic-bindings-implicit (with a stub library for the library forms) and 66-lombok-config-chain, each with near-miss controls (an `island` boolean, a boxed isOpen, a static field with no builder setter, a field-level @Accessors overriding the type's, a type @Accessors(chain = false) over lombok.config, a generic subclass that passes its variable on, a Class parameter). Goldens that moved: 49-lombok-generated-members (its pinned builder and logger gaps now resolve), and the type-use goldens of 22, 25, 27 and 52 (the field annotation type rows #1452 adds). Java suite: 72 passed, 0 failed; torture skipped (no platform IR here). - tests/run.py: new java case generated-builder-writer (#1409, #1404). java 188 of 188; python 183 of 184 (lambda-is-named-by-its-place, failing on the tip too); csharp 55 of 58 (the lambda-is-named-by-its-place and member-owner-is-its-type results the tip also has). tests/hook_languages.py 7 of 7, tests/enrich_lines.py 44 of 44. Smoke, fresh index of a real copy, installed 0.1.8 build before vs this change after: - a 1,360-file Java codebase: ambiguous_unknown 45,366 -> 43,968 (1,397 sites newly resolved: 948 builder chain calls, 340 logger calls now a library boundary, 99 other generated members, 4 withers, 6 hand-written calls chained after them); external type variable labels 4 -> 0; 1 site went from a boundary on external:K to unresolved (a type variable, not a type); 4 test sites gained a generated builder() as a second candidate beside an existing wrong external one. - a 116-file Spring app: ambiguous_unknown 1,390 -> 1,378 (13 builder chain calls); external:T.toString -> unresolved. - impact on a real @Builder field: 12 by-name writers before; after, the builder chain call is a writer through the resolved setter and same-named calls on other chains are listed as leads. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../java/engine/resolution/external-types.dl | 20 +- .../engine/resolution/generated-members.dl | 442 ++++++++++++++---- graph/java/engine/resolution/generic-chain.dl | 126 ++++- .../engine/resolution/type-arg-binding.dl | 11 + graph/java/souffle/decls_all.dl | 48 +- .../src/probe/Lombok.java | 8 +- .../lib-src/example/lib/LibLocator.java | 7 + .../lib-src/example/lib/LibSelfBase.java | 7 + .../lib-src/example/lib/LibSelfMid.java | 4 + .../lib-src/example/lib/LibService.java | 9 + .../src/probe/Base.java | 9 + .../src/probe/Beans.java | 16 + .../src/probe/Holder.java | 7 + .../src/probe/LibUses.java | 29 ++ .../src/probe/OrderJob.java | 17 + .../src/probe/OrderMapper.java | 13 + .../src/probe/OrderService.java | 23 + .../src/probe/OrderStore.java | 15 + .../src/probe/RawService.java | 8 + .../src/probe/SelfBase.java | 7 + .../src/probe/SelfMid.java | 4 + .../src/probe/Stubs.java | 22 + .../src/probe/SubService.java | 8 + .../66-lombok-config-chain/lombok.config | 3 + .../src/probe/Conf.java | 13 + .../src/probe/Plain.java | 15 + .../src/probe/Uses.java | 12 + .../src/probe/Account.java | 15 + .../src/probe/Cache.java | 13 + .../src/probe/Client.java | 59 +++ .../src/probe/FieldSer.java | 4 + .../src/probe/MemStore.java | 10 + .../src/probe/MethodSer.java | 4 + .../src/probe/Node.java | 20 + .../src/probe/Order.java | 14 + .../src/probe/Point.java | 14 + .../src/probe/Settings.java | 15 + .../src/probe/Store.java | 7 + .../src/probe/Temp.java | 15 + .../src/probe/Ticket.java | 20 + .../src/probe/TypeSer.java | 4 + .../src/probe/Widget.java | 15 + .../src/probe/Worker.java | 19 + .../22-config-annotation-args.type-use | 1 + .../java/expected/25-di-narrowing.type-use | 2 + .../expected/27-messaging-and-grpc.type-use | 3 + .../49-lombok-generated-members.edges | 7 +- .../expected/52-generated-override.type-use | 1 + .../66-generic-bindings-implicit.edges | 31 ++ .../66-generic-bindings-implicit.fields | 5 + .../66-generic-bindings-implicit.type-use | 32 ++ .../expected/66-lombok-config-chain.config | 20 + .../expected/66-lombok-config-chain.edges | 6 + .../expected/66-lombok-config-chain.type-use | 6 + .../66-lombok-generated-shapes.config | 22 + .../expected/66-lombok-generated-shapes.edges | 42 ++ .../66-lombok-generated-shapes.envelope | 2 + .../66-lombok-generated-shapes.fields | 7 + .../66-lombok-generated-shapes.type-use | 42 ++ .../java/extractors/field-extractor.ts | 6 + .../skills/axiomcode/scripts/axiomcode-impact | 6 +- .../skills/axiomcode/scripts/axiomcode-index | 11 + .../skills/axiomcode/scripts/dl/impact.dl | 20 +- .../skills/axiomcode/scripts/graph_sql.py | 45 +- .../java/generated-builder-writer/case.json | 10 + .../src/pkg/Account.java | 8 + .../src/pkg/Mailer.java | 5 + .../src/pkg/ProfileParam.java | 8 + .../src/pkg/Profiles.java | 22 + 69 files changed, 1403 insertions(+), 118 deletions(-) create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibLocator.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfBase.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfMid.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibService.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/Base.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/Beans.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/Holder.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/LibUses.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderJob.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderMapper.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderService.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderStore.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/RawService.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfBase.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfMid.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/Stubs.java create mode 100644 graph/test/java/cases/66-generic-bindings-implicit/src/probe/SubService.java create mode 100644 graph/test/java/cases/66-lombok-config-chain/lombok.config create mode 100644 graph/test/java/cases/66-lombok-config-chain/src/probe/Conf.java create mode 100644 graph/test/java/cases/66-lombok-config-chain/src/probe/Plain.java create mode 100644 graph/test/java/cases/66-lombok-config-chain/src/probe/Uses.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Account.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Cache.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Client.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/FieldSer.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/MemStore.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/MethodSer.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Node.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Order.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Point.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Settings.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Store.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Temp.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Ticket.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/TypeSer.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Widget.java create mode 100644 graph/test/java/cases/66-lombok-generated-shapes/src/probe/Worker.java create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.edges create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.fields create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.type-use create mode 100644 graph/test/java/expected/66-lombok-config-chain.config create mode 100644 graph/test/java/expected/66-lombok-config-chain.edges create mode 100644 graph/test/java/expected/66-lombok-config-chain.type-use create mode 100644 graph/test/java/expected/66-lombok-generated-shapes.config create mode 100644 graph/test/java/expected/66-lombok-generated-shapes.edges create mode 100644 graph/test/java/expected/66-lombok-generated-shapes.envelope create mode 100644 graph/test/java/expected/66-lombok-generated-shapes.fields create mode 100644 graph/test/java/expected/66-lombok-generated-shapes.type-use create mode 100644 tests/cases/java/generated-builder-writer/case.json create mode 100644 tests/cases/java/generated-builder-writer/src/pkg/Account.java create mode 100644 tests/cases/java/generated-builder-writer/src/pkg/Mailer.java create mode 100644 tests/cases/java/generated-builder-writer/src/pkg/ProfileParam.java create mode 100644 tests/cases/java/generated-builder-writer/src/pkg/Profiles.java diff --git a/graph/java/engine/resolution/external-types.dl b/graph/java/engine/resolution/external-types.dl index c60de8a6..77697803 100644 --- a/graph/java/engine/resolution/external-types.dl +++ b/graph/java/engine/resolution/external-types.dl @@ -78,6 +78,23 @@ name_resolves_in_file(tn, fp) :- ref_file(_, tn, fp), type_resolves_java_lang(_, // on an inferred local got an `external:var.` boundary beside its real target (3,842 of 85,025 // edges on a 1,360-file project). Its type is the initializer's: local-flow.dl carries a client // one, and the `var` clause of expr_type below an external one. #1401. +// +// A TYPE VARIABLE is not a type name either. A field written `protected M mapper` in `Base` +// arrives as a CLASS reference named `M`, which nothing declares, so a call through it became a +// library boundary on `external:M` (#1412). The variable is in scope from its declaring type, a +// type enclosing that, or the generic method whose signature or body writes it. +ref_names_tvar(ref) :- java_type_reference(_, _, encl, _, _, _, _, "0", tn, _, _, _, _, _, _, _, _, ref), + tn != "", java_type_parameter(tn, _, _, _, _, _, encl, _). +ref_names_tvar(ref) :- java_type_reference(_, _, encl, _, _, _, _, "0", tn, _, _, _, _, _, _, _, _, ref), + tn != "", type_in_type("client", encl, outer), java_type_parameter(tn, _, _, _, _, _, outer, _). +ref_names_tvar(ref) :- java_type_reference(_, _, _, _, _, _, _, "0", tn, _, _, _, _, _, _, m, "METHOD", ref), + tn != "", java_method_type_parameter(tn, _, _, _, _, _, _, m, _, _). +ref_names_tvar(ref) :- java_type_reference(_, _, _, _, _, _, _, "0", tn, _, _, _, _, _, _, p, "METHOD_PARAM", ref), + tn != "", java_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, p), + java_method_type_parameter(tn, _, _, _, _, _, _, m, _, _). +ref_names_tvar(ref) :- java_type_reference(_, _, _, _, _, _, _, "0", tn, _, _, _, _, _, _, l, "LOCAL_VARIABLE", ref), + tn != "", java_local_variable(_, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, l), + java_method_type_parameter(tn, _, _, _, _, _, _, m, _, _). candidate_ref(ref, tn, ctn, fp) :- java_type_reference(kind, ctx, _, _, _, _, _, "0", tn, ctn, _, _, _, _, _, _, _, ref), ext_ref_kind(kind), ext_ref_context(ctx), @@ -85,7 +102,8 @@ candidate_ref(ref, tn, ctn, fp) :- java_type_reference(kind, ctx, _, _, _, _, _, tn != "var", ref_file(ref, tn, fp), !name_resolves_in_file(tn, fp), - !simple_name_declared(tn). + !simple_name_declared(tn), + !ref_names_tvar(ref). // ── ref_external_name(Ref, QualifiedName) ─────────────────────────────────── ref_external_name(ref, q) :- candidate_ref(ref, tn, _, fp), diff --git a/graph/java/engine/resolution/generated-members.dl b/graph/java/engine/resolution/generated-members.dl index 4d9e9639..355592ea 100644 --- a/graph/java/engine/resolution/generated-members.dl +++ b/graph/java/engine/resolution/generated-members.dl @@ -13,12 +13,15 @@ // WHAT IT DOES NOT DO. It declares members, it does not give them bodies. A call // to a generated accessor is an edge to a member with no statements, which is the // truth: the accessor reads or writes a field and calls nothing further. Nothing -// here invents a call edge out of a generated member. +// here invents a call edge out of a generated member, and that includes a +// `@Delegate` forwarder: the call reaches the forwarder, not the delegate. // -// SCOPE: accessors, and the logger field a class-level annotation declares. -// `@Builder` needs a synthesized nested type as well as methods, and -// `@RequiredArgsConstructor` and friends need constructors; neither is here, and the -// case pins both as still unresolved so the gaps stay visible rather than silent. +// SCOPE: accessors (with the shapes `@Accessors` and lombok.config give them), +// `@With`, the logger field a class-level annotation declares and its type, +// constructors and their `staticName` factories, `@Builder` (a synthesized nested +// builder type with its setters, `build()`, `builder()` and `toBuilder()`), and +// `@Delegate` forwarders. `@SuperBuilder`, `@Singular` and a `@Builder` on a +// constructor or a static method are not modelled. // // THE LOGGER FIELD took two goes. Declaring it in `field_declared_in` did nothing, // because the site was never created to attach it to: field_access.dl:96 calls a @@ -35,11 +38,13 @@ // convention external-types.dl already uses for a type no IR declares. The bundle // synthesizes the row from the id, so the member is named in graph.sqlite and // carries provenance `generated`: a consumer can always tell a member read out of -// source from one inferred from an annotation. +// source from one inferred from an annotation. A synthesized builder TYPE is +// `generated:.`, and its members hang off that name. // // STRATIFICATION. `gen_method_live` and everything it is derived from read only -// PROJECTIONS OVER BASE RELATIONS (annotation_on, field_decl, field_modifier, -// type_decl), never the expr_type fixpoint. That is what lets it feed +// PROJECTIONS OVER BASE RELATIONS (annotation_on, ann_arg, field_decl, +// field_modifier, type_decl, and for `@Delegate` the declared field type and the +// type hierarchy), never the expr_type fixpoint. That is what lets it feed // `sig_declared_in`, which is read under the `!shadowed_in_type` negation in // method-lookup.dl and for that reason may only be built from base facts. The // `method_declared_in` clause DOES read `lookup_type`, which is seeded from @@ -47,7 +52,8 @@ // in method-lookup.dl do. // // COST. One pass over the annotations already staged, bounded by the number of -// annotated fields. It adds no join over the library method table. +// annotated fields. It adds no join over the library method table except for a +// `@Delegate` field, which reads the methods of that one field's type. // ============================================================================ // ───────────────────────────────────────────────────────────────────────────── @@ -61,10 +67,12 @@ // (a) on a FIELD: declares an accessor for that field alone. gen_field_getter_ann("Getter"). gen_field_setter_ann("Setter"). +gen_field_with_ann("With"). gen_field_with_ann("Wither"). // (b) on a TYPE: declares the accessor for every instance field of the type. gen_type_getter_ann("Getter"). gen_type_getter_ann("Data"). gen_type_setter_ann("Setter"). gen_type_setter_ann("Data"). +gen_type_with_ann("With"). gen_type_with_ann("Wither"). // `Value` is TWO different annotations sharing a simple name: the processor's, on // a TYPE, which declares a getter per field; and the framework's config injection, @@ -74,12 +82,30 @@ gen_type_setter_ann("Setter"). gen_type_setter_ann("Data"). // rather than one list consulted from both places. gen_type_getter_ann("Value"). -// (c) on a TYPE: declares a logger FIELD of the given name. +// (c) on a TYPE: declares a logger FIELD of the given name, of the given type. gen_logger_ann("Slf4j", "log"). gen_logger_ann("XSlf4j", "log"). gen_logger_ann("Log4j", "log"). gen_logger_ann("Log4j2", "log"). gen_logger_ann("CommonsLog", "log"). gen_logger_ann("JBossLog", "log"). gen_logger_ann("Flogger", "log"). gen_logger_ann("Log", "log"). +// The type is fixed by the annotation, so nothing is inferred (#1408). +gen_logger_type("Slf4j", "org.slf4j.Logger"). +gen_logger_type("XSlf4j", "org.slf4j.ext.XLogger"). +gen_logger_type("Log4j", "org.apache.log4j.Logger"). +gen_logger_type("Log4j2", "org.apache.logging.log4j.Logger"). +gen_logger_type("CommonsLog", "org.apache.commons.logging.Log"). +gen_logger_type("JBossLog", "org.jboss.logging.Logger"). +gen_logger_type("Flogger", "com.google.common.flogger.FluentLogger"). +gen_logger_type("Log", "java.util.logging.Logger"). + +// (d) the element that turns a constructor annotation into a static factory as well +// (`@AllArgsConstructor(staticName = "of")`, `@Data(staticConstructor = "of")`). +gen_static_arg("NoArgsConstructor", "staticName"). +gen_static_arg("AllArgsConstructor", "staticName"). +gen_static_arg("RequiredArgsConstructor", "staticName"). +gen_static_arg("Data", "staticConstructor"). +gen_static_arg("Value", "staticConstructor"). + // ───────────────────────────────────────────────────────────────────────────── // 1. WHICH FIELDS CARRY AN ACCESSOR @@ -91,46 +117,48 @@ gen_logger_ann("Flogger", "log"). gen_logger_ann("Log", "log"). // ───────────────────────────────────────────────────────────────────────────── gen_field_static(f) :- field_modifier(_, _, mod, f), contains("STATIC", mod). -// gen_accessor_demand(Prov, OwnerType, FieldName, FieldTypeName, Kind) -gen_accessor_demand(p, owner, fname, ftype, "get") :- +// gen_acc(Prov, OwnerType, FieldName, FieldTypeName, FieldHash, Kind): Kind is +// "get", "set" or "with". Every name, return type and field link below reads this +// one relation, so the field a member was built from is never lost on the way. +gen_acc(p, owner, fname, ftype, f, "get") :- annotation_on(p, ann, _, "FIELD_DECLARATION", f, _), gen_field_getter_ann(ann), field_decl(p, fname, ftype, owner, f). -gen_accessor_demand(p, owner, fname, ftype, "set") :- +gen_acc(p, owner, fname, ftype, f, "set") :- annotation_on(p, ann, _, "FIELD_DECLARATION", f, _), gen_field_setter_ann(ann), field_decl(p, fname, ftype, owner, f). -gen_accessor_demand(p, owner, fname, ftype, "get") :- +gen_acc(p, owner, fname, ftype, f, "with") :- + annotation_on(p, ann, _, "FIELD_DECLARATION", f, _), gen_field_with_ann(ann), + field_decl(p, fname, ftype, owner, f). +gen_acc(p, owner, fname, ftype, f, "get") :- annotation_on(p, ann, _, "TYPE_DECLARATION", owner, _), gen_type_getter_ann(ann), field_decl(p, fname, ftype, owner, f), !gen_field_static(f). -gen_accessor_demand(p, owner, fname, ftype, "set") :- +gen_acc(p, owner, fname, ftype, f, "set") :- annotation_on(p, ann, _, "TYPE_DECLARATION", owner, _), gen_type_setter_ann(ann), field_decl(p, fname, ftype, owner, f), !gen_field_static(f). +gen_acc(p, owner, fname, ftype, f, "with") :- + annotation_on(p, ann, _, "TYPE_DECLARATION", owner, _), gen_type_with_ann(ann), + field_decl(p, fname, ftype, owner, f), !gen_field_static(f). -// gen_accessor_src(Prov, OwnerType, FieldName, FieldHash, Kind) — the same four -// derivations as above, keeping the FIELD so section 7 can give the getter a return -// type. Carried separately rather than widening gen_accessor_demand, because that -// relation is joined on in four places and its shape is the reason the name rules -// below read cleanly. -gen_accessor_src(p, owner, fname, f, "get") :- - annotation_on(p, ann, _, "FIELD_DECLARATION", f, _), gen_field_getter_ann(ann), - field_decl(p, fname, _, owner, f). -gen_accessor_src(p, owner, fname, f, "get") :- - annotation_on(p, ann, _, "TYPE_DECLARATION", owner, _), gen_type_getter_ann(ann), - field_decl(p, fname, _, owner, f), !gen_field_static(f). +// gen_accessor_demand(Prov, OwnerType, FieldName, FieldTypeName, Kind): the +// projection the prefix rules below key on. +gen_accessor_demand(p, owner, fname, ftype, kind) :- gen_acc(p, owner, fname, ftype, _, kind). // ───────────────────────────────────────────────────────────────────────────── -// 1b. THE ACCESSOR PREFIX (lombok.config, #888) +// 1b. lombok.config (#888, #1406) // -// lombok.config decides the NAMES the processor generates, and reading only the field -// spelling gets them wrong wherever it is set. `lombok.accessors.prefix += m` on a -// field `mDepth` produces getDepth(), not getMDepth(). The wrong name is worse than no -// name, for the reason section 2 gives: it DECLARES a member the artefact does not -// have, and nothing downstream can tell it from a real one. +// lombok.config decides the NAMES and SHAPES the processor generates, and reading only +// the field spelling gets them wrong wherever it is set. `lombok.accessors.prefix += m` +// on a field `mDepth` produces getDepth(), not getMDepth(); `lombok.accessors.fluent = +// true` produces depth() and depth(v); `lombok.accessors.chain = true` makes every +// setter return `this`. The wrong name is worse than no name, for the reason section 2 +// gives: it DECLARES a member the artefact does not have, and nothing downstream can +// tell it from a real one. // // The file is in .properties format with no .properties extension, so the parser // matches it by name (FILE_EXTENSIONS.LOMBOK_CONFIG). // // SCOPE. A lombok.config applies to the directory tree below it. Rather than model -// that tree walk, the prefix is taken per PROVENANCE: one config per parsed root is +// that tree walk, a key is taken per PROVENANCE: one config per parsed root is // the shape every module in practice has, and a repository that sets a DIFFERENT // prefix in two subtrees of one module would need the walk. That case is declared, not // silently mis-named: gen_prefix_ambiguous drops both and section 2 falls back to the @@ -142,32 +170,36 @@ gen_accessor_src(p, owner, fname, f, "get") :- // Getting that wrong would rename every field that happens to start with the prefix // letter. // ───────────────────────────────────────────────────────────────────────────── -gen_prefix_key("lombok.accessors.prefix"). +gen_cfg_key("lombok.accessors.prefix"). +gen_cfg_key("lombok.accessors.chain"). +gen_cfg_key("lombok.accessors.fluent"). -// The value of the prefix key, from a lombok.config on either side. -gen_prefix_value(prov, v) :- gen_prefix_key(k), prop_key(prov, k, "true", f, h), +// The raw value of each key, from a lombok.config on either side. +gen_cfg_value(prov, k, v) :- gen_cfg_key(k), prop_key(prov, k, "true", f, h), contains("lombok.config", f), prop_segment(prov, h, v, _, _, _, _), v != "". // `+=` is the idiomatic spelling in this file and the properties scanner does not know -// it: the line splits on the first space, so the VALUE arrives as "+= m". Peeled here -// rather than taught to the scanner, because `+=` is a lombok.config-ism and a -// .properties file that happens to contain one must keep splitting the way it does -// today. Repeated single-character peeling, the same shape cfg_strip uses for quotes. -// A prefix is an identifier fragment, so it can never legitimately start with one of -// these three characters and nothing real is eaten. +// it: the line splits on the first space, so the VALUE arrives as "+= m" (and "= true" +// for a key written `key = true`). Peeled here rather than taught to the scanner, +// because `+=` is a lombok.config-ism and a .properties file that happens to contain one +// must keep splitting the way it does today. Repeated single-character peeling, the same +// shape cfg_strip uses for quotes. A value here is an identifier fragment or a boolean, +// so it can never legitimately start with one of these three characters and nothing +// real is eaten. gen_pfx_junk(s) :- gen_pfx_step(_, s), strlen(s) > 0, substr(s, 0, 1) = "+". gen_pfx_junk(s) :- gen_pfx_step(_, s), strlen(s) > 0, substr(s, 0, 1) = "=". gen_pfx_junk(s) :- gen_pfx_step(_, s), strlen(s) > 0, substr(s, 0, 1) = " ". -gen_pfx_step(v, v) :- gen_prefix_value(_, v). +gen_pfx_step(v, v) :- gen_cfg_value(_, _, v). gen_pfx_step(v, r) :- gen_pfx_step(v, s), gen_pfx_junk(s), strlen(s) > 1, r = substr(s, 1, strlen(s) - 1). -gen_prefix_raw(prov, c) :- gen_prefix_value(prov, v), gen_pfx_step(v, c), +gen_cfg_clean(prov, k, c) :- gen_cfg_value(prov, k, v), gen_pfx_step(v, c), !gen_pfx_junk(c), c != "". +gen_prefix_raw(prov, c) :- gen_cfg_clean(prov, "lombok.accessors.prefix", c). gen_prefix(prov, v) :- gen_prefix_raw(prov, v), !gen_prefix_ambiguous(prov). gen_prefix_ambiguous(prov) :- gen_prefix_raw(prov, a), gen_prefix_raw(prov, b), a != b. @@ -187,6 +219,36 @@ gen_effective_name(prov, fname, stripped) :- gen_prefix_applies(prov, fname, str gen_effective_name(prov, fname, fname) :- gen_accessor_demand(prov, _, fname, _, _), !gen_prefix_applies(prov, fname, _). +// ───────────────────────────────────────────────────────────────────────────── +// 1c. THE ACCESSOR SHAPE: @Accessors(fluent = true | chain = true) (#1406) +// +// `fluent = true` names the accessors after the field (`label()`, `label(v)`) instead of +// get/set, and implies `chain = true` unless chain is written false. `chain = true` makes +// a setter return the owner, so `s.setHost(h).setPort(1)` is one chain. The annotation on +// the FIELD decides; else the one on its TYPE; else the lombok.config key. Each option is +// decided on its own, which is how the processor merges them. +// ───────────────────────────────────────────────────────────────────────────── +gen_style_opt("chain"). +gen_style_opt("fluent"). + +gen_acc_ann_field(f, opt, v) :- annotation_on(_, "Accessors", _, "FIELD_DECLARATION", f, a), + ann_arg(_, a, opt, v, _, _), gen_style_opt(opt). +gen_acc_ann_type(owner, opt, v) :- annotation_on(_, "Accessors", _, "TYPE_DECLARATION", owner, a), + ann_arg(_, a, opt, v, _, _), gen_style_opt(opt). +gen_acc_field_has(f, opt) :- gen_acc_ann_field(f, opt, _). +gen_acc_type_has(owner, opt) :- gen_acc_ann_type(owner, opt, _). + +gen_style(f, opt, v) :- gen_acc(_, _, _, _, f, _), gen_acc_ann_field(f, opt, v). +gen_style(f, opt, v) :- gen_acc(_, owner, _, _, f, _), gen_acc_ann_type(owner, opt, v), + !gen_acc_field_has(f, opt). +gen_style(f, opt, v) :- gen_acc(p, owner, _, _, f, _), gen_style_opt(opt), + !gen_acc_field_has(f, opt), !gen_acc_type_has(owner, opt), + gen_cfg_clean(p, k, v), k = cat("lombok.accessors.", opt). + +gen_fluent(f) :- gen_style(f, "fluent", "true"). +gen_chain(f) :- gen_style(f, "chain", "true"). +gen_chain(f) :- gen_fluent(f), !gen_style(f, "chain", "false"). + // ───────────────────────────────────────────────────────────────────────────── // 2. THE NAME // @@ -196,42 +258,72 @@ gen_effective_name(prov, fname, fname) :- gen_accessor_demand(prov, _, fname, _, // declares a method the artefact does not have, and an invented member is worse // than a missing one, because nothing downstream can tell it from a real edge. // +// A primitive boolean ALREADY named `is` + an uppercase letter keeps its name for the +// getter and drops the `is` for the setter and the wither: `isLocked` -> `isLocked()`, +// `setLocked(v)`, `withLocked(v)` (#1404). `island` is not such a name, because `l` is +// lowercase: it is `isIsland()`, the ordinary rule. +// // cfg_upper is reused rather than re-tabled. Souffle sees one program: every .decl // is central in souffle/decls_all.dl and a rule may read a relation defined in any // phase folder, so the 26 facts stay in the one place that already holds them. // ───────────────────────────────────────────────────────────────────────────── -// Capitalize the EFFECTIVE name, which is the field name with any configured accessor -// prefix removed. gen_effective_name is the identity when no prefix is configured, so -// a project without a lombok.config derives exactly what it derived before. -gen_capitalized(n, c) :- gen_effective_name(_, _, n), strlen(n) > 0, +gen_is_stripped(p, f, s) :- gen_acc(p, _, fname, "boolean", f, _), + gen_effective_name(p, fname, eff), strlen(eff) > 2, + substr(eff, 0, 2) = "is", cfg_lower(substr(eff, 2, 1), _), + s = substr(eff, 2, strlen(eff) - 2). + +// gen_prop(Prov, FieldHash, PropertyName): the name the get/set/with prefix goes on. +gen_prop(p, f, s) :- gen_is_stripped(p, f, s). +gen_prop(p, f, eff) :- gen_acc(p, _, fname, _, f, _), + gen_effective_name(p, fname, eff), !gen_is_stripped(p, f, _). + +// Capitalize the property name, which is the field name with any configured accessor +// prefix (and a boolean's `is`) removed. +gen_cap_demand(n) :- gen_prop(_, _, n). +gen_capitalized(n, c) :- gen_cap_demand(n), strlen(n) > 0, cfg_upper(substr(n, 0, 1), up), c = cat(up, substr(n, 1, strlen(n) - 1)). -// ALREADY capitalized, which is what stripping an accessor prefix leaves behind: -// `mDepth` strips to `Depth`. cfg_upper maps lowercase to uppercase and has no key for -// an uppercase letter, so without this clause the stripped name capitalizes to nothing -// and the accessor is not declared at all, under either name. -gen_capitalized(n, n) :- gen_effective_name(_, _, n), strlen(n) > 0, +// ALREADY capitalized, which is what stripping a prefix leaves behind: `mDepth` strips +// to `Depth`. cfg_upper maps lowercase to uppercase and has no key for an uppercase +// letter, so without this clause the stripped name capitalizes to nothing and the +// accessor is not declared at all, under either name. +gen_capitalized(n, n) :- gen_cap_demand(n), strlen(n) > 0, cfg_lower(substr(n, 0, 1), _). -// gen_accessor(Prov, OwnerType, MethodName, Arity) +// gen_acc_named(Prov, OwnerType, FieldHash, Kind, MethodName, Arity) // Arity is a SYMBOL, not a number, because sig_declared_in carries parameterCount // as the symbol the IR wrote; a number here would not join with it. -gen_accessor(p, owner, cat("get", c), "0") :- - gen_accessor_demand(p, owner, fname, ftype, "get"), ftype != "boolean", - gen_effective_name(p, fname, eff), gen_capitalized(eff, c). -gen_accessor(p, owner, cat("is", c), "0") :- - gen_accessor_demand(p, owner, fname, "boolean", "get"), - gen_effective_name(p, fname, eff), gen_capitalized(eff, c). -gen_accessor(p, owner, cat("set", c), "1") :- - gen_accessor_demand(p, owner, fname, _, "set"), - gen_effective_name(p, fname, eff), gen_capitalized(eff, c). +gen_acc_named(p, owner, f, "get", cat("get", c), "0") :- gen_acc(p, owner, _, ftype, f, "get"), + !gen_fluent(f), ftype != "boolean", gen_prop(p, f, n), gen_capitalized(n, c). +gen_acc_named(p, owner, f, "get", cat("is", c), "0") :- gen_acc(p, owner, _, "boolean", f, "get"), + !gen_fluent(f), gen_prop(p, f, n), gen_capitalized(n, c). +gen_acc_named(p, owner, f, "set", cat("set", c), "1") :- gen_acc(p, owner, _, _, f, "set"), + !gen_fluent(f), gen_prop(p, f, n), gen_capitalized(n, c). +// fluent: the name is the field's, after the prefix is removed (a boolean keeps its `is`). +gen_acc_named(p, owner, f, "get", eff, "0") :- gen_acc(p, owner, fname, _, f, "get"), + gen_fluent(f), gen_effective_name(p, fname, eff). +gen_acc_named(p, owner, f, "set", eff, "1") :- gen_acc(p, owner, fname, _, f, "set"), + gen_fluent(f), gen_effective_name(p, fname, eff). +// @With is not affected by fluent: it is always `with` + the property name. +gen_acc_named(p, owner, f, "with", cat("with", c), "1") :- gen_acc(p, owner, _, _, f, "with"), + gen_prop(p, f, n), gen_capitalized(n, c). + +// gen_accessor(Prov, OwnerType, MethodName, Arity) +gen_accessor(p, owner, name, pc) :- gen_acc_named(p, owner, _, _, name, pc). // ───────────────────────────────────────────────────────────────────────────── // 3. THE MEMBERS, AND THEIR IDS +// +// gen_member(Prov, OwnerType, OwnerQualifiedName, Name, Arity) is the ONE list every +// kind of generated method joins: the accessors here, and the factories, builder members +// and forwarders of sections 6-9. The owner's qualified name is carried because a +// synthesized builder type is in no type_decl row to read it from. // ───────────────────────────────────────────────────────────────────────────── +gen_member(p, owner, q, name, pc) :- gen_accessor(p, owner, name, pc), + type_decl(p, _, q, _, _, _, owner). + // gen_method(OwnerType, Name, Arity, MethodId) -gen_method(owner, name, pc, id) :- gen_accessor(p, owner, name, pc), - type_decl(p, _, qname, _, _, _, owner), - id = cat("generated:", cat(qname, cat("#", cat(name, cat("/", pc))))). +gen_method(owner, name, pc, id) :- gen_member(_, owner, q, name, pc), + id = cat("generated:", cat(q, cat("#", cat(name, cat("/", pc))))). // ───────────────────────────────────────────────────────────────────────────── @@ -271,18 +363,18 @@ sig_concrete_in(t, name, pc) :- gen_method_live(t, name, pc, _). // method_owner, and projections/methods.dl builds that from java_method / // lib_method alone. Without this clause a generated member reached expr_pruned and // then produced nothing: it owns no row there. The provenance carried through from -// gen_accessor is what decides `known_edge` against `boundary_lib`, so a generated +// gen_member is what decides `known_edge` against `boundary_lib`, so a generated // member on a library type is a boundary edge exactly as a hand-written one is. method_owner(p, owner, id) :- gen_method_live(owner, name, pc, id), - gen_accessor(p, owner, name, pc). + gen_member(p, owner, _, name, pc). // ARITY. overload.dl counts parameters from java_method_parameter / // lib_method_parameter, so a generated method has no count and only the zero-arg // clause of arity_match can fire: every getter resolved and every SETTER was -// dropped at the arity gate. A setter takes exactly one argument, which the -// annotation determines without any parameter row, so state it directly. The -// getters need no clause: zero args against zero params is already a match. -method_paramc(m, 1) :- gen_method_live(_, _, "1", m). +// dropped at the arity gate. The annotation determines the count without any +// parameter row, so state it directly. Zero args against zero params is already a +// match, which is why the zero-arity members need no row. +method_paramc(m, n) :- gen_method_live(_, _, pc, m), pc != "0", n = to_number(pc). // gen_field(OwnerType, Name, FieldId) gen_field(owner, fname, id) :- @@ -313,6 +405,34 @@ recv_name_is_field(e) :- ident_field_recv(e, name), expr_ultimate_type("client", field_prov(p, id) :- gen_field_live(owner, fname, id), annotation_on(p, ann, _, "TYPE_DECLARATION", owner, _), gen_logger_ann(ann, fname). +// ── 4b. THE LOGGER FIELD'S TYPE (#1408) ──────────────────────────────────── +// The field was declared by name only, so `log.info(..)` had a receiver of no type and +// the call was unresolved, while the same call through a hand-declared Logger field is +// a library boundary. The annotation fixes the type: a staged library type when there +// is one, a client type of that name, and otherwise the external node external-types.dl +// mints for any type the client names and no IR declares. +gen_field_type_q(id, q) :- gen_field_live(owner, fname, id), + annotation_on(_, ann, _, "TYPE_DECLARATION", owner, _), gen_logger_ann(ann, fname), + gen_logger_type(ann, q). +gen_field_type(id, "lib", t) :- gen_field_type_q(id, q), + lib_type(_, q, _, _, _, _, _, _, _, _, _, _, _, t). +gen_field_type(id, "client", t) :- gen_field_type_q(id, q), + java_type(_, q, _, _, _, _, _, _, _, _, _, _, _, t). +gen_field_type(id, "external", t) :- gen_field_type_q(id, q), + !qualified_name_declared(q), t = cat("external:", q). +external_type(t) :- gen_field_type(_, "external", t). + +// The receiver: a bare name the parser tagged FIELD, in the declaring type, a subtype of +// it, or a type nested in it (a logger used from an inner or anonymous class). +gen_field_recv(e, id) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, e), + expr_ultimate_type("client", e, encl), gen_field_live(encl, name, id). +gen_field_recv(e, id) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, e), + expr_ultimate_type("client", e, encl), type_ancestor(encl, anc), gen_field_live(anc, name, id). +gen_field_recv(e, id) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, e), + expr_ultimate_type("client", e, encl), type_in_type("client", encl, outer), + gen_field_live(outer, name, id). +expr_type(prov, e, t) :- gen_field_recv(e, id), gen_field_type(id, prov, t). + // ───────────────────────────────────────────────────────────────────────────── // 5. OVERRIDE, which is a sixth relation built off the IR tables // @@ -462,6 +582,22 @@ ctor_of("client", owner, id) :- gen_ctor_live(owner, _, id). method_paramc(id, n) :- gen_ctor_live(_, n, id), n > 0. method_owner("client", owner, id) :- gen_ctor_live(owner, _, id). +// ── 6b. THE STATIC FACTORY (#1407) ───────────────────────────────────────── +// `@AllArgsConstructor(staticName = "of")` makes the constructor private and declares +// `static Point of(int x, int y)` beside it, with the constructor's arity. `Point.of(1, +// 2)` names no constructor, so without this the call and everything chained onto it +// were unresolved. +gen_ctor_static(p, owner, shape, v) :- annotation_on(p, ann, _, "TYPE_DECLARATION", owner, a), + gen_ctor_ann(ann, shape), gen_static_arg(ann, arg), ann_arg(_, a, arg, v, _, _), v != "". +gen_ctor_arity(owner, "none", 0) :- gen_ctor_demand(_, owner, "none"). +gen_ctor_arity(owner, "all", n) :- gen_ctor_all_count(owner, n). +gen_ctor_arity(owner, "required", n) :- gen_ctor_req_count(owner, n). + +gen_member(p, owner, q, v, to_string(n)) :- gen_ctor_static(p, owner, shape, v), + gen_ctor_arity(owner, shape, n), type_decl(p, _, q, _, _, _, owner). +gen_ret_self(id, owner) :- gen_ctor_static(_, owner, shape, v), + gen_ctor_arity(owner, shape, n), gen_method_live(owner, v, to_string(n), id). + // ───────────────────────────────────────────────────────────────────────────── // 7. THE RETURN TYPE (#885) @@ -477,9 +613,10 @@ method_owner("client", owner, id) :- gen_ctor_live(owner, _, id). // next door as field_type_resolves, so this is a join, not new machinery: the getter // returns exactly what the field holds. // -// Getters only. A setter returns void, and @Accessors(chain = true) making it return -// `this` is a different annotation with a different answer, deliberately not guessed -// here. +// A member that returns its OWNER (a chain setter, a wither, a static factory, every +// builder member) has no reference to reuse either, so it gets a synthetic one, +// `generated-ref:`. It names no IR node, so every consumer that walks a reference's +// type arguments finds none, which is the truth: the owner is written without any. // // gen_accessor_field(MethodId, FieldHash) — which field a generated getter reads. // @@ -491,25 +628,138 @@ method_owner("client", owner, id) :- gen_ctor_live(owner, _, id). // // Getters only. A setter WRITES the field, and cfg_field_read is a read relation; // modelling the write is a separate question with a separate consumer. -gen_accessor_field(id, f) :- gen_accessor_src(_, owner, fname, f, "get"), - gen_capitalized(fname, c), - gen_method_live(owner, cat("get", c), "0", id). -gen_accessor_field(id, f) :- gen_accessor_src(_, owner, fname, f, "get"), - gen_capitalized(fname, c), - gen_method_live(owner, cat("is", c), "0", id). - -// The REF column is the field's own type reference. Reusing it rather than minting a -// synthetic one keeps every downstream consumer that reads the reference (type -// arguments, generic binding) pointing at a node the IR actually has. // ───────────────────────────────────────────────────────────────────────────── -gen_return_type(id, ref, t) :- gen_accessor_src(p, owner, fname, f, "get"), - gen_capitalized(fname, c), - gen_method_live(owner, cat("get", c), "0", id), - field_type_resolves(p, f, ref, t). -gen_return_type(id, ref, t) :- gen_accessor_src(p, owner, fname, f, "get"), - gen_capitalized(fname, c), - gen_method_live(owner, cat("is", c), "0", id), - field_type_resolves(p, f, ref, t). - -method_return_type_resolves(p, id, ref, t) :- gen_return_type(id, ref, t), - gen_method_live(owner, _, _, id), type_decl(p, _, _, _, _, _, owner). +gen_accessor_field(id, f) :- gen_acc_named(_, owner, f, "get", name, "0"), + gen_method_live(owner, name, "0", id). + +// gen_return_type(MethodId, Ref, Type): the getter returns the field's own type, +// through the field's own reference. +gen_return_type(id, ref, t) :- gen_accessor_field(id, f), + field_type_resolves(_, f, ref, t). + +// A setter returns the owner when it chains (#1406), and a wither always does (#1407). +gen_ret_self(id, owner) :- gen_acc_named(_, owner, f, "set", name, "1"), gen_chain(f), + gen_method_live(owner, name, "1", id). +gen_ret_self(id, owner) :- gen_acc_named(_, owner, _, "with", name, "1"), + gen_method_live(owner, name, "1", id). +gen_return_type(id, ref, t) :- gen_ret_self(id, t), ref = cat("generated-ref:", id). + +// The provenance is the RESOLVED TYPE's, as for a record accessor (reference-types.dl): +// a client type returning a library type is the ordinary case. +method_return_type_resolves("client", id, ref, t) :- gen_return_type(id, ref, t), + type_prov(t, "client"). +method_return_type_resolves("lib", id, ref, t) :- gen_return_type(id, ref, t), + type_prov(t, "lib"). +method_return_type_resolves("client", id, ref, t) :- gen_return_type(id, ref, t), + gen_builder_type(_, _, t, _). + + +// ───────────────────────────────────────────────────────────────────────────── +// 8. @Builder (#1405) +// +// `@Builder` declares a nested `Builder` with one setter per field (named after +// the field, returning the builder), `build()` returning the owner, a static `builder()` +// on the owner, and, with `toBuilder = true`, an instance `toBuilder()`. None of it is in +// the source, so every call in `Order.builder().sku("a").build().ship()` was unresolved, +// including the hand-written `ship()` at its end. +// +// THE BUILDER TYPE. When the source writes a partial builder (`public static class +// OrderBuilder { ... }` inside Order, which the processor then fills), that type is the +// builder and its hand-written members win as they do anywhere else. Otherwise the type +// is synthesized: `generated:.`, a lookup type with no file, +// whose only members are the ones this section declares. +// +// builderClassName, builderMethodName and buildMethodName are read; toBuilder must be +// written `true`. A final field with an initialiser has no setter, as in the processor. +// ───────────────────────────────────────────────────────────────────────────── +gen_builder(p, owner, a) :- annotation_on(p, "Builder", _, "TYPE_DECLARATION", owner, a). + +gen_builder_opt(owner, opt, v) :- gen_builder(_, owner, a), ann_arg(_, a, opt, v, _, _), v != "". +gen_builder_opt_has(owner, opt) :- gen_builder_opt(owner, opt, _). + +// gen_builder_name(Owner, Which, Name) +gen_builder_name(owner, "class", v) :- gen_builder_opt(owner, "builderClassName", v). +gen_builder_name(owner, "class", cat(tn, "Builder")) :- gen_builder(p, owner, _), + !gen_builder_opt_has(owner, "builderClassName"), type_decl(p, tn, _, _, _, _, owner). +gen_builder_name(owner, "method", v) :- gen_builder_opt(owner, "builderMethodName", v). +gen_builder_name(owner, "method", "builder") :- gen_builder(_, owner, _), + !gen_builder_opt_has(owner, "builderMethodName"). +gen_builder_name(owner, "build", v) :- gen_builder_opt(owner, "buildMethodName", v). +gen_builder_name(owner, "build", "build") :- gen_builder(_, owner, _), + !gen_builder_opt_has(owner, "buildMethodName"). + +// gen_builder_type(Prov, Owner, BuilderType, BuilderQualifiedName) +gen_builder_written(owner, bt, bq) :- gen_builder_name(owner, "class", bn), + type_direct_parent("client", bt, owner), + java_type(bn, bq, _, _, _, _, _, _, _, _, _, _, _, bt). +gen_builder_has_written(owner) :- gen_builder_written(owner, _, _). +gen_builder_type(p, owner, bt, bq) :- gen_builder(p, owner, _), gen_builder_written(owner, bt, bq). +gen_builder_type(p, owner, bt, bq) :- gen_builder(p, owner, _), !gen_builder_has_written(owner), + gen_builder_name(owner, "class", bn), type_decl(p, _, q, _, _, _, owner), + bq = cat(q, cat(".", bn)), bt = cat("generated:", bq). + +lookup_type(bt) :- gen_builder_type(_, _, bt, _). + +// The fields with a setter: every instance field but a final one already initialised. +gen_builder_field_skip(f) :- field_modifier(_, _, mod, f), contains("FINAL", mod), + gen_field_initialized(f). +gen_builder_field(p, owner, fname) :- gen_builder(p, owner, _), + field_decl(p, fname, _, owner, f), !gen_field_static(f), !gen_builder_field_skip(f). + +// On the owner: builder() and toBuilder(), both returning the builder. +gen_member(p, owner, q, bm, "0") :- gen_builder_type(p, owner, _, _), + gen_builder_name(owner, "method", bm), type_decl(p, _, q, _, _, _, owner). +gen_member(p, owner, q, "toBuilder", "0") :- gen_builder_type(p, owner, _, _), + gen_builder_opt(owner, "toBuilder", "true"), type_decl(p, _, q, _, _, _, owner). +gen_ret_self(id, bt) :- gen_builder_type(_, owner, bt, _), gen_builder_name(owner, "method", bm), + gen_method_live(owner, bm, "0", id). +gen_ret_self(id, bt) :- gen_builder_type(_, owner, bt, _), gen_builder_opt(owner, "toBuilder", "true"), + gen_method_live(owner, "toBuilder", "0", id). + +// On the builder: a setter per field returning the builder, and build() returning the owner. +gen_member(p, bt, bq, fname, "1") :- gen_builder_type(p, owner, bt, bq), + gen_builder_field(p, owner, fname). +gen_member(p, bt, bq, b, "0") :- gen_builder_type(p, owner, bt, bq), + gen_builder_name(owner, "build", b). +gen_ret_self(id, bt) :- gen_builder_type(_, owner, bt, _), gen_builder_field(_, owner, fname), + gen_method_live(bt, fname, "1", id). +gen_ret_self(id, owner) :- gen_builder_type(_, owner, bt, _), gen_builder_name(owner, "build", b), + gen_method_live(bt, b, "0", id). + + +// ───────────────────────────────────────────────────────────────────────────── +// 9. @Delegate (#1407) +// +// `@Delegate private final Store inner` declares, on the owner, one forwarding method +// for each public instance method of the field's type. `new Cache().put("a")` names a +// method Cache does not write, so it was unresolved. The forwarder takes the delegated +// method's arity and return type. `types =` and `excludes =` are not read. +// +// BASE FACTS ONLY, and that decides the scope. A forwarder feeds sig_declared_in like any +// generated member, so it may not read the type-resolution fixpoint: the field's declared +// type is resolved by name in the owner's file (type_resolves_in_file, which reads imports +// and declarations), not through field_type_resolves, and only the methods the type +// DECLARES are forwarded, not the ones it inherits (type_ancestor is in that fixpoint too). +// A first draft read both and the program did not stratify. +// ───────────────────────────────────────────────────────────────────────────── +gen_delegate(p, owner, ft) :- annotation_on(p, "Delegate", _, "FIELD_DECLARATION", f, _), + field_decl(p, _, tn, owner, f), type_decl(p, _, _, fp, _, _, owner), + type_resolves_in_file(_, tn, fp, ft). + +gen_delegate_kind("INSTANCE_METHOD"). +gen_delegate_kind("ABSTRACT_METHOD"). + +// gen_delegate_target(FieldType, Name, Arity, DelegatedMethod) +gen_delegate_target(ft, name, pc, m) :- gen_delegate(_, _, ft), gen_delegate_kind(k), + java_method(name, _, _, _, _, _, _, ft, _, _, _, _, _, _, _, _, k, pc, _, _, _, m), name != "". +gen_delegate_target(ft, name, pc, m) :- gen_delegate(_, _, ft), gen_delegate_kind(k), + lib_method(name, _, _, _, _, _, _, ft, _, _, _, _, _, _, _, _, k, pc, _, _, _, m), name != "". + +gen_member(p, owner, q, name, pc) :- gen_delegate(p, owner, ft), + gen_delegate_target(ft, name, pc, _), type_decl(p, _, q, _, _, _, owner). + +gen_return_type(id, ref, t) :- gen_delegate(_, owner, ft), gen_delegate_target(ft, name, pc, m), + gen_method_live(owner, name, pc, id), method_return_type_resolves(_, m, ref, t). +gen_return_type(id, ref, t) :- gen_delegate(_, owner, ft), gen_delegate_target(ft, name, pc, m), + gen_method_live(owner, name, pc, id), lib_method_return_type(m, t), + ref = cat("generated-ref:", id). diff --git a/graph/java/engine/resolution/generic-chain.dl b/graph/java/engine/resolution/generic-chain.dl index 4c2ccdf3..5cf2ba5f 100644 --- a/graph/java/engine/resolution/generic-chain.dl +++ b/graph/java/engine/resolution/generic-chain.dl @@ -129,10 +129,134 @@ type_fixes_super(sub, super, superParam, argType) :- java_type_reference(k, _, _, _, _, sref, pos, "1", _, _, _, _, _, _, _, _, _, argRef), k != "TYPE_VARIABLE", type_ref_resolves(_, argRef, argType). +// class_tvar_binding(Type, Super, SuperParam, Arg): as seen from Type, Super's parameter is +// Arg. Seeded by the clause that fixes it, then carried two ways (#1478, #1412): +// UP, through a supertype that passes its own variable straight on. `ChainStub extends +// Mid`, `Mid extends Base`: Mid.S = ChainStub, so Base.S = ChainStub. +// Binding only the direct parent lost `withTimeout`'s S, declared on Base, and the call +// after it; one level (`DirectStub extends Base`) resolved. +// DOWN, to every subtype of the type that fixed it: a subclass of OrderService inherits +// Base.M = OrderMapper exactly as OrderService does. +// Bounded by the hierarchy (a finite DAG), and seeded only where a clause writes a concrete +// argument, so a type that passes a variable through adds nothing by itself. +class_tvar_binding(sub, super, superParam, argType) :- type_fixes_super(sub, super, superParam, argType). +class_tvar_binding(sub, super2, p2, argType) :- class_tvar_binding(sub, super1, p1, argType), + super_tvar_rename(super1, super2, p2, p1). +class_tvar_binding(sub2, super, p, argType) :- class_tvar_binding(sub, super, p, argType), + type_parent(sub2, sub), sub2 != sub. + // a receiver whose type fixes a supertype parameter carries that binding. expr_arg_binding(recv, super, superParam, argType) :- expr_type(_, recv, subType), - type_fixes_super(subType, super, superParam, argType). + class_tvar_binding(subType, super, superParam, argType). + +// ── THE RECEIVER IS IMPLICIT: an inherited member typed as the superclass's variable (#1412) ── +// `class OrderService extends Base` calling `getMapper().findClosed()` or +// `mapper.findOpen()`: the member is Base's, typed M, and the receiver is `this`, which no +// clause above sees because nothing is written at the call. The binding is the enclosing +// type's own, from its extends clause. The field form was worse than unresolved: M, read as +// a class name nothing declares, became an external type and the call a library boundary on +// `external:M` (external-types.dl now refuses a type variable's name). +expr_type(prov, call, argType) :- call_unqualified(call, _), + applicable_candidate(call, callee), + java_method_ret_tvar(callee, owner, tv), + expr_ultimate_type("client", call, encl), + class_tvar_binding(encl, owner, tv, argType), + type_prov(argType, prov). +expr_type(prov, call, argType) :- call_unqualified(call, _), + applicable_candidate(call, callee), + lib_method_ret_tvar(callee, owner, tv), + expr_ultimate_type("client", call, encl), + class_tvar_binding(encl, owner, tv, argType), + type_prov(argType, prov). + +// field_tvar(Field, OwnerType, TypeVariable): a field declared as its owner's type +// variable. The parser writes a field's variable as a CLASS reference carrying the name +// (the TYPE_VARIABLE kind appears in method signatures), so both encodings are read. +// java_type_reference (18): 0 kind 1 context 2 typeRegistryLinkHash 7 depth 8 typeName +// 10 typeVariableName 15 ownerHash 16 ownerKind +field_tvar(field, owner, tv) :- java_field(_, _, _, _, _, _, _, _, owner, _, _, _, _, field), + java_type_reference("CLASS", "FIELD_TYPE", _, _, _, _, _, "0", tv, _, _, _, _, _, _, field, "FIELD", _), + java_type_parameter(tv, _, _, _, _, _, owner, _). +field_tvar(field, owner, tv) :- java_field(_, _, _, _, _, _, _, _, owner, _, _, _, _, field), + java_type_reference("TYPE_VARIABLE", "FIELD_TYPE", _, _, _, _, _, "0", _, _, tv, _, _, _, _, field, "FIELD", _), + java_type_parameter(tv, _, _, _, _, _, owner, _). +field_tvar(field, owner, tv) :- lib_field(_, _, _, _, _, _, _, _, owner, _, _, _, _, field), + lib_type_reference("CLASS", "FIELD_TYPE", _, _, _, _, _, "0", tv, _, _, _, _, _, _, field, "FIELD", _), + lib_type_parameter(tv, _, _, _, _, _, owner, _). +field_tvar(field, owner, tv) :- lib_field(_, _, _, _, _, _, _, _, owner, _, _, _, _, field), + lib_type_reference("TYPE_VARIABLE", "FIELD_TYPE", _, _, _, _, _, "0", _, _, tv, _, _, _, _, field, "FIELD", _), + lib_type_parameter(tv, _, _, _, _, _, owner, _). + +// a bare inherited field: the enclosing type's binding for its declaring type's variable. +expr_type(prov, e, argType) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, e), + expr_ultimate_type("client", e, encl), + type_ancestor(encl, anc), + java_field(name, _, _, _, _, _, _, _, anc, _, _, _, _, field), + field_tvar(field, anc, tv), + class_tvar_binding(encl, anc, tv, argType), + type_prov(argType, prov). +expr_type(prov, e, argType) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, e), + expr_ultimate_type("client", e, encl), + type_ancestor(encl, anc), + lib_field(name, _, _, _, _, _, _, _, anc, _, _, _, _, field), + field_tvar(field, anc, tv), + class_tvar_binding(encl, anc, tv, argType), + type_prov(argType, prov). +// `this.mapper` / `svc.mapper`: the same, keyed on the qualifier's type. +expr_type(prov, e, argType) :- java_expression("FIELD_ACCESS", _, _, _, _, _, _, _, _, _, name, _, _, _, _, _, _, _, _, _, _, _, _, _, e), + java_expression(_, "QUALIFIER", _, _, _, _, e, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, q), + expr_type(_, q, qtype), + type_ancestor(qtype, anc), + java_field(name, _, _, _, _, _, _, _, anc, _, _, _, _, field), + field_tvar(field, anc, tv), + class_tvar_binding(qtype, anc, tv, argType), + type_prov(argType, prov). +expr_type(prov, e, argType) :- java_expression("FIELD_ACCESS", _, _, _, _, _, _, _, _, _, name, _, _, _, _, _, _, _, _, _, _, _, _, _, e), + java_expression(_, "QUALIFIER", _, _, _, _, e, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, q), + expr_type(_, q, qtype), + type_ancestor(qtype, anc), + lib_field(name, _, _, _, _, _, _, _, anc, _, _, _, _, field), + field_tvar(field, anc, tv), + class_tvar_binding(qtype, anc, tv, argType), + type_prov(argType, prov). + +// ── A GENERIC METHOD'S OWN VARIABLE, BOUND BY A CLASS LITERAL (#1547) ─────────────────────── +// `static T get(Class type)`, called `Beans.get(OrderStore.class).save("a")`: T is the +// METHOD's type parameter, and the only place it is fixed is the argument. Every clause above +// binds a CLASS's parameters from a receiver, so the call had no type and `save` was +// unresolved: the service-locator lookup (`getBean(X.class)`, `getMapper(X.class)`). The +// binding is sound where it applies: the argument IS `Class`, so T is X. +// Only a class literal written at the call binds; a Class-typed variable does not. +mtvar_ret(m, tv) :- java_type_reference("TYPE_VARIABLE", "METHOD_RETURN", _, _, _, _, _, "0", _, _, tv, _, _, _, _, m, "METHOD", _), + tv != "", java_method_type_parameter(tv, _, _, _, _, _, _, m, _, _). +mtvar_ret(m, tv) :- lib_type_reference("TYPE_VARIABLE", "METHOD_RETURN", _, _, _, _, _, "0", _, _, tv, _, _, _, _, m, "METHOD", _), + tv != "", lib_method_type_parameter(tv, _, _, _, _, _, _, m, _, _). + +// mtvar_class_param(Method, TypeVariable, Position): parameter Position is `Class`. +mtvar_class_param(m, tv, pos) :- java_method_parameter(_, pos, m, _, _, _, _, _, _, _, _, _, param), + java_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", "Class", _, _, _, _, _, _, param, "METHOD_PARAM", cref), + java_type_reference("TYPE_VARIABLE", _, _, _, _, cref, "0", "1", _, _, tv, _, _, _, _, _, _, _). +mtvar_class_param(m, tv, pos) :- lib_method_parameter(_, pos, m, _, _, _, _, _, _, _, _, _, param), + lib_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", "Class", _, _, _, _, _, _, param, "METHOD_PARAM", cref), + lib_type_reference("TYPE_VARIABLE", _, _, _, _, cref, "0", "1", _, _, tv, _, _, _, _, _, _, _). + +// class_literal_type(Expr, Prov, Type): the type a class literal names. +class_literal_type(e, "client", t) :- java_expression("CLASS_LITERAL", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, pqn, _, _, _, _, _, _, e), + pqn != "", java_type(_, pqn, _, _, _, _, _, _, _, _, _, _, _, t). +class_literal_type(e, "lib", t) :- java_expression("CLASS_LITERAL", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, pqn, _, _, _, _, _, _, e), + pqn != "", lib_type(_, pqn, _, _, _, _, _, _, _, _, _, _, _, t). +class_literal_type(e, prov, t) :- java_expression("CLASS_LITERAL", _, _, _, _, _, _, _, _, _, name, _, _, _, _, _, _, pqn, _, _, _, _, _, _, e), + name != "", !type_qname_known(pqn), + expr_ultimate_type("client", e, encl), + java_type(_, _, _, _, _, _, _, file, _, _, _, _, _, encl), + type_resolves_in_file(prov, name, file, t). + +expr_type(prov, call, t) :- java_expression("CLASS_LITERAL", "ARGUMENT", _, _, _, _, call, pos, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lit), + applicable_candidate(call, callee), + mtvar_ret(callee, tv), + mtvar_class_param(callee, tv, pos), + class_literal_type(lit, prov, t). // ── NESTED generics (c4): `List> l; l.get(0).get()`. type_arg_binding only binds // TOP-LEVEL (depth-1) args, so the inner Box.T=Dog is lost. arg_ref_at keys on the PARENT diff --git a/graph/java/engine/resolution/type-arg-binding.dl b/graph/java/engine/resolution/type-arg-binding.dl index da9f3a93..e9353f1d 100644 --- a/graph/java/engine/resolution/type-arg-binding.dl +++ b/graph/java/engine/resolution/type-arg-binding.dl @@ -93,6 +93,17 @@ super_tvar_rename(sub, super, superParam, subVar) :- type_super_ref_h(sub, super lib_type_parameter(superParam, pos, _, _, _, _, super, _), java_type_reference("TYPE_VARIABLE", _, _, _, _, sref, pos, "1", _, _, subVar, _, _, _, _, _, _, _). +// Positional rename, client subtype -> CLIENT supertype (`class Mid extends Base`, both +// in the source). The clauses above only reach a library supertype, because the substitution +// downstream once read only library methods; java_method_ret_tvar and the implicit-receiver +// clauses in generic-chain.dl read client ones too, and a binding that stopped one level up a +// client hierarchy lost every member declared higher (#1478). +super_tvar_rename(sub, super, superParam, subVar) :- type_super_ref_h(sub, superName, sref), + type_parent(sub, super), + java_type(superName, _, _, _, _, _, _, _, _, _, _, _, _, super), + java_type_parameter(superParam, pos, _, _, _, _, super, _), + java_type_reference("TYPE_VARIABLE", _, _, _, _, sref, pos, "1", _, _, subVar, _, _, _, _, _, _, _). + // Transitive climb: a binding on Sub carries to each Super via the positional rename, // closing over the whole ancestor chain (bounded — the type hierarchy is a finite DAG). type_arg_binding(ref, super, superParam, argType) :- type_arg_binding(ref, sub, subVar, argType), diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 04167a5a..9d269db3 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -489,6 +489,12 @@ .decl concrete_class_type(c0:symbol) .decl lib_method_ret_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl expr_arg_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl class_tvar_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (type, super, superParam, arg) as seen from type +.decl field_tvar(c0:symbol,c1:symbol,c2:symbol) // (field, ownerType, typeVariable) a field typed as its owner's variable +.decl mtvar_ret(c0:symbol,c1:symbol) // (method, its own type variable it returns) +.decl mtvar_class_param(c0:symbol,c1:symbol,c2:symbol) // (method, typeVariable, position of its Class parameter) +.decl class_literal_type(c0:symbol,c1:symbol,c2:symbol) // (classLiteralExpr, prov, type it names) +.decl ref_names_tvar(c0:symbol) // a reference whose name is a type variable in scope .decl type_fixes_super(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl arg_ref_at(c0:symbol,c1:symbol,c2:symbol,c3:symbol) @@ -962,12 +968,9 @@ .decl gen_prefix(c0:symbol,c1:symbol) // (prov,accessorPrefix) .decl gen_prefix_ambiguous(c0:symbol) .decl gen_prefix_applies(c0:symbol,c1:symbol,c2:symbol) -.decl gen_prefix_key(c0:symbol) .decl gen_prefix_raw(c0:symbol,c1:symbol) -.decl gen_prefix_value(c0:symbol,c1:symbol) .decl gen_pfx_junk(c0:symbol) .decl gen_pfx_step(c0:symbol,c1:symbol) -.decl gen_accessor_src(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) // (prov,owner,fieldName,fieldHash,kind) .decl gen_return_type(c0:symbol,c1:symbol,c2:symbol) // (generatedGetterId, typeRef, type) .decl gen_capitalized(c0:symbol,c1:symbol) // (name, Name) .decl gen_accessor(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (prov,owner,methodName,arity) @@ -982,6 +985,45 @@ .decl cfg_lib_bean_narrowing_enabled() .decl cfg_staged_lib_type(c0:symbol) .decl cfg_injectable_scope_type(c0:symbol) +.decl gen_field_with_ann(c0:symbol) // annotation on a FIELD declaring a wither +.decl gen_type_with_ann(c0:symbol) // annotation on a TYPE declaring a wither per field +.decl gen_logger_type(c0:symbol,c1:symbol) // (logger annotation, qualified type of the field) +.decl gen_static_arg(c0:symbol,c1:symbol) // (constructor annotation, element naming its static factory) +.decl gen_acc(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) // (prov,owner,fieldName,fieldType,fieldHash,kind) +.decl gen_cfg_key(c0:symbol) // a lombok.config key read here +.decl gen_cfg_value(c0:symbol,c1:symbol,c2:symbol) // (prov,key,rawValue) +.decl gen_cfg_clean(c0:symbol,c1:symbol,c2:symbol) // (prov,key,value with += / = peeled) +.decl gen_style_opt(c0:symbol) // an @Accessors element read here +.decl gen_acc_ann_field(c0:symbol,c1:symbol,c2:symbol) // (fieldHash,option,value) from @Accessors on the field +.decl gen_acc_ann_type(c0:symbol,c1:symbol,c2:symbol) // (ownerType,option,value) from @Accessors on the type +.decl gen_acc_field_has(c0:symbol,c1:symbol) +.decl gen_acc_type_has(c0:symbol,c1:symbol) +.decl gen_style(c0:symbol,c1:symbol,c2:symbol) // (fieldHash,option,value) as the processor decides it +.decl gen_fluent(c0:symbol) // fieldHash whose accessors are fluent +.decl gen_chain(c0:symbol) // fieldHash whose setter returns the owner +.decl gen_is_stripped(c0:symbol,c1:symbol,c2:symbol) // (prov,fieldHash,name) boolean isX field -> X +.decl gen_prop(c0:symbol,c1:symbol,c2:symbol) // (prov,fieldHash,propertyName) +.decl gen_cap_demand(c0:symbol) +.decl gen_acc_named(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) // (prov,owner,fieldHash,kind,methodName,arity) +.decl gen_member(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) // (prov,owner,ownerQualifiedName,name,arity) +.decl gen_field_type_q(c0:symbol,c1:symbol) // (generatedFieldId, qualified type name) +.decl gen_field_type(c0:symbol,c1:symbol,c2:symbol) // (generatedFieldId, prov, type) +.decl gen_field_recv(c0:symbol,c1:symbol) // (receiverExpr, generatedFieldId) +.decl gen_ctor_static(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (prov,owner,shape,factoryName) +.decl gen_ctor_arity(c0:symbol,c1:symbol,c2:number) // (owner,shape,arity) +.decl gen_ret_self(c0:symbol,c1:symbol) // (generatedMethodId, type it returns, no IR reference) +.decl gen_builder(c0:symbol,c1:symbol,c2:symbol) // (prov,owner,annotationHash) +.decl gen_builder_opt(c0:symbol,c1:symbol,c2:symbol) // (owner,element,value) +.decl gen_builder_opt_has(c0:symbol,c1:symbol) +.decl gen_builder_name(c0:symbol,c1:symbol,c2:symbol) // (owner, class|method|build, name) +.decl gen_builder_written(c0:symbol,c1:symbol,c2:symbol) // (owner, builderType, qname) the source writes the builder +.decl gen_builder_has_written(c0:symbol) +.decl gen_builder_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (prov,owner,builderType,builderQualifiedName) +.decl gen_builder_field_skip(c0:symbol) +.decl gen_builder_field(c0:symbol,c1:symbol,c2:symbol) // (prov,owner,fieldName) with a builder setter +.decl gen_delegate(c0:symbol,c1:symbol,c2:symbol) // (prov,owner,delegateFieldType) +.decl gen_delegate_kind(c0:symbol) +.decl gen_delegate_target(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (delegateFieldType,name,arity,method) .decl gen_accessor_field(c0:symbol,c1:symbol) // call-edge-generation/event_dispatch.dl + knobs.dl (20): Spring application events (#1391) .decl cfg_event_publisher(c0:symbol,c1:symbol) diff --git a/graph/test/java/cases/49-lombok-generated-members/src/probe/Lombok.java b/graph/test/java/cases/49-lombok-generated-members/src/probe/Lombok.java index 60ff7cde..067948da 100644 --- a/graph/test/java/cases/49-lombok-generated-members/src/probe/Lombok.java +++ b/graph/test/java/cases/49-lombok-generated-members/src/probe/Lombok.java @@ -67,7 +67,7 @@ public boolean ownBooleanAccessor() { return isReady(); } - /** SUBJECT C: the field the class annotation declares, and a call through it. */ + /** SUBJECT C: the field the class annotation declares, and a call through it, typed as the annotation fixes it (#1408). */ public void ownLogger() { log.info("count is {}", count); } @@ -98,9 +98,9 @@ public String libraryHandWritten(Catalog c) { } /** - * NOT COVERED, and pinned here so the gap stays visible rather than silent: the - * builder the class annotation declares needs a synthesized NESTED TYPE as well as - * methods. These sites are expected to stay unresolved. + * SUBJECT H: the builder the class annotation declares, on a library type. It needs + * a synthesized nested type as well as methods; every call in the chain is a + * boundary to a generated member (#1405). */ public Catalog libraryBuilder() { return Catalog.builder().name("z").size(3).build(); diff --git a/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibLocator.java b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibLocator.java new file mode 100644 index 00000000..e97ae92a --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibLocator.java @@ -0,0 +1,7 @@ +package example.lib; + +public final class LibLocator { + public static T getBean(Class type) { + return null; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfBase.java b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfBase.java new file mode 100644 index 00000000..f471e696 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfBase.java @@ -0,0 +1,7 @@ +package example.lib; + +public abstract class LibSelfBase> { + public final S withTimeout(long millis) { + return null; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfMid.java b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfMid.java new file mode 100644 index 00000000..3dd8c639 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibSelfMid.java @@ -0,0 +1,4 @@ +package example.lib; + +public abstract class LibSelfMid> extends LibSelfBase { +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibService.java b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibService.java new file mode 100644 index 00000000..87851729 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/lib-src/example/lib/LibService.java @@ -0,0 +1,9 @@ +package example.lib; + +public class LibService { + protected M baseMapper; + + public M getBaseMapper() { + return baseMapper; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Base.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Base.java new file mode 100644 index 00000000..078ade6f --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Base.java @@ -0,0 +1,9 @@ +package probe; + +public class Base { + protected M mapper; + + public M getMapper() { + return mapper; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Beans.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Beans.java new file mode 100644 index 00000000..29563c65 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Beans.java @@ -0,0 +1,16 @@ +package probe; + +public final class Beans { + public static T get(Class type) { + return null; + } + + public static T named(String name, Class type) { + return null; + } + + /** NEAR MISS: a Class parameter that is not Class binds nothing. */ + public static Object raw(Class type) { + return null; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Holder.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Holder.java new file mode 100644 index 00000000..e8773723 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Holder.java @@ -0,0 +1,7 @@ +package probe; + +public class Holder { + public M get() { + return null; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/LibUses.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/LibUses.java new file mode 100644 index 00000000..4e47bec5 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/LibUses.java @@ -0,0 +1,29 @@ +package probe; + +import example.lib.LibLocator; +import example.lib.LibSelfMid; +import example.lib.LibService; + +/** The same three shapes with the generic declared in a LIBRARY (#1478, #1412, #1547). */ +class LibChainStub extends LibSelfMid { + void send() { + } +} + +public class LibUses extends LibService { + void chained() { + new LibChainStub().withTimeout(5).send(); + } + + int field() { + return baseMapper.findOpen(); + } + + int getter() { + return getBaseMapper().findClosed(); + } + + void locate() { + LibLocator.getBean(OrderStore.class).save("x"); + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderJob.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderJob.java new file mode 100644 index 00000000..eef13628 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderJob.java @@ -0,0 +1,17 @@ +package probe; + +/** #1547: a generic method's own variable, fixed by the class literal argument. */ +public class OrderJob { + public void run() { + Beans.get(OrderStore.class).save("a"); + Beans.named("store", OrderStore.class).purge("b"); + OrderStore s = Beans.get(OrderStore.class); + s.touch("c"); + } + + /** NEAR MISS: raw(Class) returns Object whatever the literal; only the cast types keep(). */ + public void raw() { + ((OrderStore) Beans.raw(OrderStore.class)).keep("d"); + Beans.raw(OrderStore.class).hashCode(); + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderMapper.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderMapper.java new file mode 100644 index 00000000..7c54bd74 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderMapper.java @@ -0,0 +1,13 @@ +package probe; + +public interface OrderMapper { + int findOpen(); + + int findClosed(); + + int findLate(); + + int findThis(); + + int findDeep(); +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderService.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderService.java new file mode 100644 index 00000000..6889328f --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderService.java @@ -0,0 +1,23 @@ +package probe; + +/** #1412: members inherited from Base, reached with no written receiver. */ +public class OrderService extends Base { + private Holder holder; + + public int open() { + return mapper.findOpen(); + } + + public int closed() { + return getMapper().findClosed(); + } + + public int viaThis() { + return this.mapper.findThis(); + } + + /** CONTROL: the argument written on a declared reference. */ + public int late() { + return holder.get().findLate(); + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderStore.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderStore.java new file mode 100644 index 00000000..fbd8a702 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/OrderStore.java @@ -0,0 +1,15 @@ +package probe; + +public class OrderStore { + public void save(String id) { + } + + public void purge(String id) { + } + + public void touch(String id) { + } + + public void keep(String id) { + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/RawService.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/RawService.java new file mode 100644 index 00000000..b94ed7fc --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/RawService.java @@ -0,0 +1,8 @@ +package probe; + +/** NEAR MISS: a generic subclass passes its own variable on; nothing binds M here. */ +public class RawService extends Base { + public Object raw() { + return getMapper(); + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfBase.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfBase.java new file mode 100644 index 00000000..eb4963a9 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfBase.java @@ -0,0 +1,7 @@ +package probe; + +public abstract class SelfBase> { + public final S withTimeout(long millis) { + return null; + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfMid.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfMid.java new file mode 100644 index 00000000..0d8a4b91 --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SelfMid.java @@ -0,0 +1,4 @@ +package probe; + +public abstract class SelfMid> extends SelfBase { +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Stubs.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Stubs.java new file mode 100644 index 00000000..628cbb5a --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/Stubs.java @@ -0,0 +1,22 @@ +package probe; + +/** #1478: a self type fixed one level down (control) and two levels down. */ +class DirectStub extends SelfBase { + void send() { + } +} + +class ChainStub extends SelfMid { + void send() { + } +} + +public class Stubs { + void direct() { + new DirectStub().withTimeout(5).send(); + } + + void chained() { + new ChainStub().withTimeout(5).send(); + } +} diff --git a/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SubService.java b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SubService.java new file mode 100644 index 00000000..00728a1b --- /dev/null +++ b/graph/test/java/cases/66-generic-bindings-implicit/src/probe/SubService.java @@ -0,0 +1,8 @@ +package probe; + +/** #1412: a subclass of the type that fixed M inherits the binding. */ +public class SubService extends OrderService { + public int deep() { + return getMapper().findDeep(); + } +} diff --git a/graph/test/java/cases/66-lombok-config-chain/lombok.config b/graph/test/java/cases/66-lombok-config-chain/lombok.config new file mode 100644 index 00000000..fd7e7716 --- /dev/null +++ b/graph/test/java/cases/66-lombok-config-chain/lombok.config @@ -0,0 +1,3 @@ +# At the case root, as a module keeps it beside its build file: the processor finds it by +# bubbling up from the source tree. +lombok.accessors.chain = true diff --git a/graph/test/java/cases/66-lombok-config-chain/src/probe/Conf.java b/graph/test/java/cases/66-lombok-config-chain/src/probe/Conf.java new file mode 100644 index 00000000..d3fa0417 --- /dev/null +++ b/graph/test/java/cases/66-lombok-config-chain/src/probe/Conf.java @@ -0,0 +1,13 @@ +package probe; + +import lombok.Setter; + +/** #1406: lombok.config makes every setter chain. */ +@Setter +public class Conf { + private String host; + private int port; + + public void apply() { + } +} diff --git a/graph/test/java/cases/66-lombok-config-chain/src/probe/Plain.java b/graph/test/java/cases/66-lombok-config-chain/src/probe/Plain.java new file mode 100644 index 00000000..41cc1784 --- /dev/null +++ b/graph/test/java/cases/66-lombok-config-chain/src/probe/Plain.java @@ -0,0 +1,15 @@ +package probe; + +import lombok.Setter; +import lombok.experimental.Accessors; + +/** NEAR MISS: the type's @Accessors(chain = false) wins over lombok.config. */ +@Setter +@Accessors(chain = false) +public class Plain { + private String host; + private int port; + + public void apply() { + } +} diff --git a/graph/test/java/cases/66-lombok-config-chain/src/probe/Uses.java b/graph/test/java/cases/66-lombok-config-chain/src/probe/Uses.java new file mode 100644 index 00000000..dbdd3634 --- /dev/null +++ b/graph/test/java/cases/66-lombok-config-chain/src/probe/Uses.java @@ -0,0 +1,12 @@ +package probe; + +public class Uses { + public void conf(Conf c) { + c.setHost("h").setPort(1).apply(); + } + + public void plain(Plain p) { + p.setHost("h").setPort(1); + p.apply(); + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Account.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Account.java new file mode 100644 index 00000000..d48a8b63 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Account.java @@ -0,0 +1,15 @@ +package probe; + +import lombok.Data; + +/** #1404: a primitive boolean already named is + Uppercase keeps its name for the getter. */ +@Data +public class Account { + private boolean isLocked; + /** CONTROL: the ordinary rule. */ + private boolean active; + /** NEAR MISS: `is` followed by a lowercase letter is not the prefix; isIsland(). */ + private boolean island; + /** NEAR MISS: a boxed Boolean named isX takes get, and keeps the whole name. */ + private Boolean isOpen; +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Cache.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Cache.java new file mode 100644 index 00000000..a08bbaac --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Cache.java @@ -0,0 +1,13 @@ +package probe; + +import lombok.experimental.Delegate; + +/** #1407: @Delegate declares a forwarder per method of the field's type. */ +public class Cache { + @Delegate + private final Store inner = new MemStore(); + + /** Hand-written: wins over the forwarder of the same signature. */ + public void put(String key) { + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Client.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Client.java new file mode 100644 index 00000000..ee980eb3 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Client.java @@ -0,0 +1,59 @@ +package probe; + +public class Client { + public boolean locked(Account a) { + a.setLocked(true); + return a.isLocked(); + } + + /** CONTROL and near misses for #1404. */ + public boolean others(Account a) { + a.setActive(true); + a.setIsland(false); + a.setIsOpen(true); + return a.isActive() && a.isIsland() && a.getIsOpen(); + } + + public void order() { + Order o = Order.builder().sku("a").qty(2).build(); + o.ship(); + Order.builder().sku("b").build().ship(); + o.toBuilder().qty(3).build(); + } + + /** NEAR MISS: a static field has no builder setter. */ + public void orderStatic() { + Order.builder().created(1); + } + + public void ticket() { + Ticket.make().title("t").build().open(); + } + + public void settings(Settings s) { + s.setHost("h").setPort(1).apply(); + s.setPort(2); + s.apply(); + } + + public void node(Node n) { + n.label("x"); + n.next().visit(); + n.visit(); + n.setWeight(2); + n.getWeight(); + } + + public double temp(Temp t) { + return t.withDegrees(3.0).kelvin() + t.kelvin(); + } + + public int point() { + return Point.of(1, 2).sum(); + } + + public void cache(Cache c) { + c.put("a"); + c.child("b").put("c"); + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/FieldSer.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/FieldSer.java new file mode 100644 index 00000000..f3ec5a91 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/FieldSer.java @@ -0,0 +1,4 @@ +package probe; + +public class FieldSer { +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MemStore.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MemStore.java new file mode 100644 index 00000000..fa072560 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MemStore.java @@ -0,0 +1,10 @@ +package probe; + +public class MemStore implements Store { + public void put(String key) { + } + + public Store child(String name) { + return this; + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MethodSer.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MethodSer.java new file mode 100644 index 00000000..5aff5832 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/MethodSer.java @@ -0,0 +1,4 @@ +package probe; + +public class MethodSer { +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Node.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Node.java new file mode 100644 index 00000000..3fa60a71 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Node.java @@ -0,0 +1,20 @@ +package probe; + +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** #1406: fluent = true names the accessors after the field, and implies chain. */ +@Getter +@Setter +@Accessors(fluent = true) +public class Node { + private String label; + private Node next; + /** A field-level @Accessors overrides the type's: this one is not fluent. */ + @Accessors(fluent = false) + private int weight; + + public void visit() { + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Order.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Order.java new file mode 100644 index 00000000..a957440a --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Order.java @@ -0,0 +1,14 @@ +package probe; + +import lombok.Builder; + +/** #1405: builder(), the setters, build() and toBuilder() of a synthesized OrderBuilder. */ +@Builder(toBuilder = true) +public class Order { + private String sku; + private int qty; + private static int created; + + public void ship() { + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Point.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Point.java new file mode 100644 index 00000000..3ad18c45 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Point.java @@ -0,0 +1,14 @@ +package probe; + +import lombok.AllArgsConstructor; + +/** #1407: staticName declares a static factory with the constructor's arity. */ +@AllArgsConstructor(staticName = "of") +public class Point { + private int x; + private int y; + + public int sum() { + return x + y; + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Settings.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Settings.java new file mode 100644 index 00000000..21c18836 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Settings.java @@ -0,0 +1,15 @@ +package probe; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** #1406: chain = true setters return the owner. */ +@Data +@Accessors(chain = true) +public class Settings { + private String host; + private int port; + + public void apply() { + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Store.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Store.java new file mode 100644 index 00000000..2fbdebed --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Store.java @@ -0,0 +1,7 @@ +package probe; + +public interface Store { + void put(String key); + + Store child(String name); +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Temp.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Temp.java new file mode 100644 index 00000000..8d78be91 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Temp.java @@ -0,0 +1,15 @@ +package probe; + +import lombok.Value; +import lombok.With; + +/** #1407: @With declares withX(v) returning the owner. */ +@Value +@With +public class Temp { + double degrees; + + public double kelvin() { + return degrees + 273.15; + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Ticket.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Ticket.java new file mode 100644 index 00000000..6eac604b --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Ticket.java @@ -0,0 +1,20 @@ +package probe; + +import lombok.Builder; + +/** #1405: a PARTIAL builder written in the source is the builder; its written member wins. */ +@Builder(builderMethodName = "make") +public class Ticket { + private String title; + + public void open() { + } + + public static class TicketBuilder { + public TicketBuilder title(String t) { + this.title = t; + return this; + } + private String title; + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/TypeSer.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/TypeSer.java new file mode 100644 index 00000000..d6732d71 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/TypeSer.java @@ -0,0 +1,4 @@ +package probe; + +public class TypeSer { +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Widget.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Widget.java new file mode 100644 index 00000000..bc955d22 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Widget.java @@ -0,0 +1,15 @@ +package probe; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** #1452: a class named in a FIELD annotation is a type use, as on a type or a method. */ +@JsonSerialize(using = TypeSer.class) +public class Widget { + @JsonSerialize(using = FieldSer.class) + private long weight; + + @JsonSerialize(using = MethodSer.class) + public long getPrice() { + return 0L; + } +} diff --git a/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Worker.java b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Worker.java new file mode 100644 index 00000000..303b1f90 --- /dev/null +++ b/graph/test/java/cases/66-lombok-generated-shapes/src/probe/Worker.java @@ -0,0 +1,19 @@ +package probe; + +import lombok.extern.slf4j.Slf4j; + +/** #1408: the logger field has the type the annotation fixes. */ +@Slf4j +public class Worker { + private final org.slf4j.Logger audit = org.slf4j.LoggerFactory.getLogger("audit"); + + public void work() { + log.info("x"); + audit.info("x"); + new Runnable() { + public void run() { + log.debug("inner"); + } + }.run(); + } +} diff --git a/graph/test/java/expected/22-config-annotation-args.type-use b/graph/test/java/expected/22-config-annotation-args.type-use index bbd7910d..7fe16200 100644 --- a/graph/test/java/expected/22-config-annotation-args.type-use +++ b/graph/test/java/expected/22-config-annotation-args.type-use @@ -15,6 +15,7 @@ known_edge ANNOTATION_PARAM 0 testcases.config.Annotated [ANNOTATION_ARGUMENT] - known_edge ANNOTATION_PARAM 0 testcases.config.Annotated [ANNOTATION_ARGUMENT] -> testcases.config.IndexedBy known_edge ANNOTATION_TYPE 0 testcases.config.Annotated [ANNOTATION] -> testcases.config.Codec known_edge ANNOTATION_TYPE 0 testcases.config.Annotated [ANNOTATION] -> testcases.config.Idx +known_edge ANNOTATION_TYPE 0 testcases.config.Consumer [ANNOTATION] -> testcases.config.Value known_edge ANNOTATION_TYPE 0 testcases.config.SvcProps [ANNOTATION] -> testcases.config.ConfigurationProperties known_edge FIELD_TYPE 0 testcases.config.SvcProps [FIELD] -> testcases.config.Nested known_edge METHOD_RETURN 0 testcases.config.Codec#index() [METHOD] -> testcases.config.Idx diff --git a/graph/test/java/expected/25-di-narrowing.type-use b/graph/test/java/expected/25-di-narrowing.type-use index eadc970f..76e86e63 100644 --- a/graph/test/java/expected/25-di-narrowing.type-use +++ b/graph/test/java/expected/25-di-narrowing.type-use @@ -22,6 +22,8 @@ ambiguous_unknown METHOD_RETURN 0 testcases.config.Store#read(String) [METHOD] - ambiguous_unknown METHOD_RETURN 0 testcases.config.XmlCodec#encode(String) [METHOD] -> - known_edge ANNOTATION_TYPE 0 testcases.config.DbStore [ANNOTATION] -> testcases.config.Service known_edge ANNOTATION_TYPE 0 testcases.config.JsonCodec [ANNOTATION] -> testcases.config.Service +known_edge ANNOTATION_TYPE 0 testcases.config.Service1 [ANNOTATION] -> testcases.config.Autowired +known_edge ANNOTATION_TYPE 0 testcases.config.Service1 [ANNOTATION] -> testcases.config.Qualifier known_edge ANNOTATION_TYPE 0 testcases.config.Service1 [ANNOTATION] -> testcases.config.Service known_edge ANNOTATION_TYPE 0 testcases.config.Service2 [ANNOTATION] -> testcases.config.Service known_edge ANNOTATION_TYPE 0 testcases.config.XmlCodec [ANNOTATION] -> testcases.config.Service diff --git a/graph/test/java/expected/27-messaging-and-grpc.type-use b/graph/test/java/expected/27-messaging-and-grpc.type-use index 9e769668..27ae08cd 100644 --- a/graph/test/java/expected/27-messaging-and-grpc.type-use +++ b/graph/test/java/expected/27-messaging-and-grpc.type-use @@ -17,14 +17,17 @@ ambiguous_unknown METHOD_RETURN 0 testcases.config.Value#value() [METHOD] -> - known_edge ANNOTATION_TYPE 0 testcases.config.BatchConsumer [ANNOTATION] -> testcases.config.Component known_edge ANNOTATION_TYPE 0 testcases.config.BatchConsumer [ANNOTATION] -> testcases.config.KafkaHandler known_edge ANNOTATION_TYPE 0 testcases.config.BatchConsumer [ANNOTATION] -> testcases.config.KafkaListener +known_edge ANNOTATION_TYPE 0 testcases.config.GreeterService [ANNOTATION] -> testcases.config.Autowired known_edge ANNOTATION_TYPE 0 testcases.config.GreeterService [ANNOTATION] -> testcases.config.GrpcService known_edge ANNOTATION_TYPE 0 testcases.config.Handler [ANNOTATION] -> testcases.config.Component known_edge ANNOTATION_TYPE 0 testcases.config.MailConsumer [ANNOTATION] -> testcases.config.Component known_edge ANNOTATION_TYPE 0 testcases.config.MailConsumer [ANNOTATION] -> testcases.config.RabbitHandler known_edge ANNOTATION_TYPE 0 testcases.config.MailConsumer [ANNOTATION] -> testcases.config.RabbitListener +known_edge ANNOTATION_TYPE 0 testcases.config.OrderConsumer [ANNOTATION] -> testcases.config.Autowired known_edge ANNOTATION_TYPE 0 testcases.config.OrderConsumer [ANNOTATION] -> testcases.config.Component known_edge ANNOTATION_TYPE 0 testcases.config.OrderConsumer [ANNOTATION] -> testcases.config.KafkaListener known_edge ANNOTATION_TYPE 0 testcases.config.OrderProducer [ANNOTATION] -> testcases.config.Service +known_edge ANNOTATION_TYPE 0 testcases.config.OrderProducer [ANNOTATION] -> testcases.config.Value known_edge FIELD_TYPE 0 testcases.config.GreeterService [FIELD] -> testcases.config.Handler known_edge FIELD_TYPE 0 testcases.config.OrderConsumer [FIELD] -> testcases.config.Handler known_edge SUPER_TYPE 0 testcases.config.GreeterService [TYPE] -> testcases.config.GreeterImplBase diff --git a/graph/test/java/expected/49-lombok-generated-members.edges b/graph/test/java/expected/49-lombok-generated-members.edges index 6d913e1d..12ba4ee5 100644 --- a/graph/test/java/expected/49-lombok-generated-members.edges +++ b/graph/test/java/expected/49-lombok-generated-members.edges @@ -1,5 +1,3 @@ -ambiguous_unknown method probe.Lombok#libraryBuilder() -> - -ambiguous_unknown method probe.Lombok#ownLogger() -> - boundary_lib method probe.Lombok#chainControls(Shelf) -> dep.Catalog#describe() boundary_lib method probe.Lombok#chainControls(Shelf) -> dep.Shelf#current() boundary_lib method probe.Lombok#chainControls(Shelf) -> generated:dep.Shelf#getCatalog/0 @@ -11,8 +9,13 @@ boundary_lib method probe.Lombok#libraryAccessors(Catalog) -> generated:dep.Cata boundary_lib method probe.Lombok#libraryAccessors(Catalog) -> generated:dep.Catalog#getSize/0 boundary_lib method probe.Lombok#libraryAccessors(Catalog) -> generated:dep.Catalog#setName/1 boundary_lib method probe.Lombok#libraryBooleanAccessor(Catalog) -> generated:dep.Catalog#isArchived/0 +boundary_lib method probe.Lombok#libraryBuilder() -> generated:dep.Catalog#builder/0 +boundary_lib method probe.Lombok#libraryBuilder() -> generated:dep.Catalog.CatalogBuilder#build/0 +boundary_lib method probe.Lombok#libraryBuilder() -> generated:dep.Catalog.CatalogBuilder#name/1 +boundary_lib method probe.Lombok#libraryBuilder() -> generated:dep.Catalog.CatalogBuilder#size/1 boundary_lib method probe.Lombok#libraryHandWritten(Catalog) -> dep.Catalog#describe() boundary_lib method probe.Lombok#libraryShadowedAccessor(Catalog) -> dep.Catalog#getLabel() +boundary_lib method probe.Lombok#ownLogger() -> external:org.slf4j.Logger.info known_edge method probe.Lombok#main(String[]) -> probe.Lombok#handWritten() known_edge method probe.Lombok#main(String[]) -> probe.Lombok#libraryAccessors(Catalog) known_edge method probe.Lombok#main(String[]) -> probe.Lombok#libraryBooleanAccessor(Catalog) diff --git a/graph/test/java/expected/52-generated-override.type-use b/graph/test/java/expected/52-generated-override.type-use index 8031f721..50decd64 100644 --- a/graph/test/java/expected/52-generated-override.type-use +++ b/graph/test/java/expected/52-generated-override.type-use @@ -1,3 +1,4 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.GenOverride.Person [ANNOTATION] -> - ambiguous_unknown FIELD_TYPE 0 probe.GenOverride.Person [FIELD] -> - ambiguous_unknown METHOD_PARAM 0 probe.GenOverride#main(String[]) [METHOD_PARAM] -> - ambiguous_unknown METHOD_RETURN 0 probe.GenOverride#viaInterface(Named) [METHOD] -> - diff --git a/graph/test/java/expected/66-generic-bindings-implicit.edges b/graph/test/java/expected/66-generic-bindings-implicit.edges new file mode 100644 index 00000000..b25b5c0d --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.edges @@ -0,0 +1,31 @@ +boundary_lib method probe.LibUses#chained() -> example.lib.LibSelfBase#withTimeout(long) +boundary_lib method probe.LibUses#getter() -> example.lib.LibService#getBaseMapper() +boundary_lib method probe.LibUses#locate() -> example.lib.LibLocator#getBean(Class) +boundary_lib method probe.OrderJob#raw() -> external:Object.hashCode +known_edge method probe.LibUses#chained() -> probe.LibChainStub#send() +known_edge method probe.LibUses#field() -> probe.OrderMapper#findOpen() +known_edge method probe.LibUses#getter() -> probe.OrderMapper#findClosed() +known_edge method probe.LibUses#locate() -> probe.OrderStore#save(String) +known_edge method probe.OrderJob#raw() -> probe.Beans#raw(Class) +known_edge method probe.OrderJob#raw() -> probe.OrderStore#keep(String) +known_edge method probe.OrderJob#run() -> probe.Beans#get(Class) +known_edge method probe.OrderJob#run() -> probe.Beans#named(String,Class) +known_edge method probe.OrderJob#run() -> probe.OrderStore#purge(String) +known_edge method probe.OrderJob#run() -> probe.OrderStore#save(String) +known_edge method probe.OrderJob#run() -> probe.OrderStore#touch(String) +known_edge method probe.OrderService#closed() -> probe.Base#getMapper() +known_edge method probe.OrderService#closed() -> probe.OrderMapper#findClosed() +known_edge method probe.OrderService#late() -> probe.Holder#get() +known_edge method probe.OrderService#late() -> probe.OrderMapper#findLate() +known_edge method probe.OrderService#open() -> probe.OrderMapper#findOpen() +known_edge method probe.OrderService#viaThis() -> probe.OrderMapper#findThis() +known_edge method probe.RawService#raw() -> probe.Base#getMapper() +known_edge method probe.Stubs#chained() -> probe.ChainStub#send() +known_edge method probe.Stubs#chained() -> probe.SelfBase#withTimeout(long) +known_edge method probe.Stubs#direct() -> probe.DirectStub#send() +known_edge method probe.Stubs#direct() -> probe.SelfBase#withTimeout(long) +known_edge method probe.SubService#deep() -> probe.Base#getMapper() +known_edge method probe.SubService#deep() -> probe.OrderMapper#findDeep() +known_edge new probe.LibUses#chained() -> probe.LibChainStub#() +known_edge new probe.Stubs#chained() -> probe.ChainStub#() +known_edge new probe.Stubs#direct() -> probe.DirectStub#() diff --git a/graph/test/java/expected/66-generic-bindings-implicit.fields b/graph/test/java/expected/66-generic-bindings-implicit.fields new file mode 100644 index 00000000..65fda34f --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.fields @@ -0,0 +1,5 @@ +boundary_lib read probe.LibUses#field() -> example.lib.LibService#baseMapper +known_edge read probe.Base#getMapper() -> probe.Base#mapper +known_edge read probe.OrderService#late() -> probe.OrderService#holder +known_edge read probe.OrderService#open() -> probe.Base#mapper +known_edge read probe.OrderService#viaThis() -> probe.Base#mapper diff --git a/graph/test/java/expected/66-generic-bindings-implicit.type-use b/graph/test/java/expected/66-generic-bindings-implicit.type-use new file mode 100644 index 00000000..86a3d589 --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.type-use @@ -0,0 +1,32 @@ +ambiguous_unknown FIELD_TYPE 0 probe.Base [FIELD] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Beans#get(Class) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Beans#named(String,Class) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Beans#raw(Class) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderStore#keep(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderStore#purge(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderStore#save(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderStore#touch(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.Beans#raw(Class) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.RawService#raw() [METHOD] -> - +boundary_lib SUPER_TYPE 0 probe.LibChainStub [TYPE] -> example.lib.LibSelfMid +boundary_lib SUPER_TYPE 0 probe.LibUses [TYPE] -> example.lib.LibService +known_edge CAST_EXPRESSION 0 probe.OrderJob#raw() [EXPRESSION] -> probe.OrderStore +known_edge FIELD_TYPE 0 probe.OrderService [FIELD] -> probe.Holder +known_edge FIELD_TYPE 1 probe.OrderService [FIELD] -> probe.OrderMapper +known_edge LOCAL_VARIABLE 0 probe.OrderJob#run() [LOCAL_VARIABLE] -> probe.OrderStore +known_edge OBJECT_CREATION_TYPE 0 probe.LibUses#chained() [EXPRESSION] -> probe.LibChainStub +known_edge OBJECT_CREATION_TYPE 0 probe.Stubs#chained() [EXPRESSION] -> probe.ChainStub +known_edge OBJECT_CREATION_TYPE 0 probe.Stubs#direct() [EXPRESSION] -> probe.DirectStub +known_edge SUPER_TYPE 0 probe.ChainStub [TYPE] -> probe.SelfMid +known_edge SUPER_TYPE 0 probe.DirectStub [TYPE] -> probe.SelfBase +known_edge SUPER_TYPE 0 probe.OrderService [TYPE] -> probe.Base +known_edge SUPER_TYPE 0 probe.RawService [TYPE] -> probe.Base +known_edge SUPER_TYPE 0 probe.SelfMid [TYPE] -> probe.SelfBase +known_edge SUPER_TYPE 0 probe.SubService [TYPE] -> probe.OrderService +known_edge SUPER_TYPE 1 probe.ChainStub [TYPE] -> probe.ChainStub +known_edge SUPER_TYPE 1 probe.DirectStub [TYPE] -> probe.DirectStub +known_edge SUPER_TYPE 1 probe.LibChainStub [TYPE] -> probe.LibChainStub +known_edge SUPER_TYPE 1 probe.LibUses [TYPE] -> probe.OrderMapper +known_edge SUPER_TYPE 1 probe.OrderService [TYPE] -> probe.OrderMapper +known_edge TYPE_PARAM_BOUND 0 probe.SelfBase [TYPE] -> probe.SelfBase +known_edge TYPE_PARAM_BOUND 0 probe.SelfMid [TYPE] -> probe.SelfMid diff --git a/graph/test/java/expected/66-lombok-config-chain.config b/graph/test/java/expected/66-lombok-config-chain.config new file mode 100644 index 00000000..a3fb8784 --- /dev/null +++ b/graph/test/java/expected/66-lombok-config-chain.config @@ -0,0 +1,20 @@ +── bean_def (0) ── +── bean_origin (0) ── +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (0) ── +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (0) ── +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (1) ── + unbound_key binding lombok.accessors.chain +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/66-lombok-config-chain.edges b/graph/test/java/expected/66-lombok-config-chain.edges new file mode 100644 index 00000000..96bb9b86 --- /dev/null +++ b/graph/test/java/expected/66-lombok-config-chain.edges @@ -0,0 +1,6 @@ +ambiguous_unknown method probe.Uses#plain(Plain) -> - +known_edge method probe.Uses#conf(Conf) -> generated:probe.Conf#setHost/1 +known_edge method probe.Uses#conf(Conf) -> generated:probe.Conf#setPort/1 +known_edge method probe.Uses#conf(Conf) -> probe.Conf#apply() +known_edge method probe.Uses#plain(Plain) -> generated:probe.Plain#setHost/1 +known_edge method probe.Uses#plain(Plain) -> probe.Plain#apply() diff --git a/graph/test/java/expected/66-lombok-config-chain.type-use b/graph/test/java/expected/66-lombok-config-chain.type-use new file mode 100644 index 00000000..d3fe12b3 --- /dev/null +++ b/graph/test/java/expected/66-lombok-config-chain.type-use @@ -0,0 +1,6 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.Conf [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Plain [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Conf [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Plain [FIELD] -> - +known_edge METHOD_PARAM 0 probe.Uses#conf(Conf) [METHOD_PARAM] -> probe.Conf +known_edge METHOD_PARAM 0 probe.Uses#plain(Plain) [METHOD_PARAM] -> probe.Plain diff --git a/graph/test/java/expected/66-lombok-generated-shapes.config b/graph/test/java/expected/66-lombok-generated-shapes.config new file mode 100644 index 00000000..1f20071e --- /dev/null +++ b/graph/test/java/expected/66-lombok-generated-shapes.config @@ -0,0 +1,22 @@ +── bean_def (0) ── +── bean_origin (0) ── +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (3) ── + annotation @JsonSerialize "FieldSer" -> probe.FieldSer [client] + annotation @JsonSerialize "MethodSer" -> probe.MethodSer [client] + annotation @JsonSerialize "TypeSer" -> probe.TypeSer [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (0) ── +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/66-lombok-generated-shapes.edges b/graph/test/java/expected/66-lombok-generated-shapes.edges new file mode 100644 index 00000000..31f4457b --- /dev/null +++ b/graph/test/java/expected/66-lombok-generated-shapes.edges @@ -0,0 +1,42 @@ +ambiguous_anon anon_new probe.Worker#work() -> - +ambiguous_unknown method probe.Client#orderStatic() -> - +ambiguous_unknown method probe.Worker#() -> - +boundary_lib method probe.Worker#work() -> external:org.slf4j.Logger.info +boundary_lib method probe.Worker$anon:Runnable#run() -> external:org.slf4j.Logger.debug +known_edge method probe.Client#cache(Cache) -> generated:probe.Cache#child/1 +known_edge method probe.Client#cache(Cache) -> probe.Cache#put(String) +known_edge method probe.Client#locked(Account) -> generated:probe.Account#isLocked/0 +known_edge method probe.Client#locked(Account) -> generated:probe.Account#setLocked/1 +known_edge method probe.Client#node(Node) -> generated:probe.Node#getWeight/0 +known_edge method probe.Client#node(Node) -> generated:probe.Node#label/1 +known_edge method probe.Client#node(Node) -> generated:probe.Node#next/0 +known_edge method probe.Client#node(Node) -> generated:probe.Node#setWeight/1 +known_edge method probe.Client#node(Node) -> probe.Node#visit() +known_edge method probe.Client#order() -> generated:probe.Order#builder/0 +known_edge method probe.Client#order() -> generated:probe.Order#toBuilder/0 +known_edge method probe.Client#order() -> generated:probe.Order.OrderBuilder#build/0 +known_edge method probe.Client#order() -> generated:probe.Order.OrderBuilder#qty/1 +known_edge method probe.Client#order() -> generated:probe.Order.OrderBuilder#sku/1 +known_edge method probe.Client#order() -> probe.Order#ship() +known_edge method probe.Client#orderStatic() -> generated:probe.Order#builder/0 +known_edge method probe.Client#others(Account) -> generated:probe.Account#getIsOpen/0 +known_edge method probe.Client#others(Account) -> generated:probe.Account#isActive/0 +known_edge method probe.Client#others(Account) -> generated:probe.Account#isIsland/0 +known_edge method probe.Client#others(Account) -> generated:probe.Account#setActive/1 +known_edge method probe.Client#others(Account) -> generated:probe.Account#setIsOpen/1 +known_edge method probe.Client#others(Account) -> generated:probe.Account#setIsland/1 +known_edge method probe.Client#point() -> generated:probe.Point#of/2 +known_edge method probe.Client#point() -> probe.Point#sum() +known_edge method probe.Client#settings(Settings) -> generated:probe.Settings#setHost/1 +known_edge method probe.Client#settings(Settings) -> generated:probe.Settings#setPort/1 +known_edge method probe.Client#settings(Settings) -> probe.Settings#apply() +known_edge method probe.Client#temp(Temp) -> generated:probe.Temp#withDegrees/1 +known_edge method probe.Client#temp(Temp) -> probe.Temp#kelvin() +known_edge method probe.Client#ticket() -> generated:probe.Ticket#make/0 +known_edge method probe.Client#ticket() -> generated:probe.Ticket.TicketBuilder#build/0 +known_edge method probe.Client#ticket() -> probe.Ticket#open() +known_edge method probe.Client#ticket() -> probe.Ticket.TicketBuilder#title(String) +known_edge method probe.Worker#work() -> probe.Worker$anon:Runnable#run() +known_edge new probe.Cache#() -> probe.MemStore#() +multi_inferred method probe.Client#cache(Cache) -> probe.MemStore#put(String) +multi_inferred method probe.Client#cache(Cache) -> probe.Store#put(String) diff --git a/graph/test/java/expected/66-lombok-generated-shapes.envelope b/graph/test/java/expected/66-lombok-generated-shapes.envelope new file mode 100644 index 00000000..dda92455 --- /dev/null +++ b/graph/test/java/expected/66-lombok-generated-shapes.envelope @@ -0,0 +1,2 @@ +nominal probe.Store.child -> probe.MemStore.child +nominal probe.Store.put -> probe.MemStore.put diff --git a/graph/test/java/expected/66-lombok-generated-shapes.fields b/graph/test/java/expected/66-lombok-generated-shapes.fields new file mode 100644 index 00000000..c65196f9 --- /dev/null +++ b/graph/test/java/expected/66-lombok-generated-shapes.fields @@ -0,0 +1,7 @@ +ambiguous_unknown read probe.Worker#() -> - +known_edge read probe.Point#sum() -> probe.Point#x +known_edge read probe.Point#sum() -> probe.Point#y +known_edge read probe.Temp#kelvin() -> probe.Temp#degrees +known_edge read probe.Worker#work() -> generated:probe.Worker#log +known_edge read probe.Worker#work() -> probe.Worker#audit +known_edge write probe.Ticket.TicketBuilder#title(String) -> probe.Ticket.TicketBuilder#title diff --git a/graph/test/java/expected/66-lombok-generated-shapes.type-use b/graph/test/java/expected/66-lombok-generated-shapes.type-use new file mode 100644 index 00000000..89ca3e92 --- /dev/null +++ b/graph/test/java/expected/66-lombok-generated-shapes.type-use @@ -0,0 +1,42 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.Account [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Cache [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Node [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Order [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Point [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Settings [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Temp [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Ticket [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Widget [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Worker [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Account [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Node [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Order [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Settings [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Ticket [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Ticket.TicketBuilder [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Worker [FIELD] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Cache#put(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.MemStore#child(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.MemStore#put(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Store#child(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Store#put(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Ticket.TicketBuilder#title(String) [METHOD_PARAM] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 probe.Worker#work() [EXPRESSION] -> - +ambiguous_unknown SUPER_TYPE 0 probe.Worker$anon:Runnable [TYPE] -> - +known_edge ANNOTATION_PARAM 0 probe.Widget [ANNOTATION_ARGUMENT] -> probe.FieldSer +known_edge ANNOTATION_PARAM 0 probe.Widget [ANNOTATION_ARGUMENT] -> probe.MethodSer +known_edge ANNOTATION_PARAM 0 probe.Widget [ANNOTATION_ARGUMENT] -> probe.TypeSer +known_edge FIELD_TYPE 0 probe.Cache [FIELD] -> probe.Store +known_edge FIELD_TYPE 0 probe.Node [FIELD] -> probe.Node +known_edge IMPLEMENTS_INTERFACE 0 probe.MemStore [TYPE] -> probe.Store +known_edge LOCAL_VARIABLE 0 probe.Client#order() [LOCAL_VARIABLE] -> probe.Order +known_edge METHOD_PARAM 0 probe.Client#cache(Cache) [METHOD_PARAM] -> probe.Cache +known_edge METHOD_PARAM 0 probe.Client#locked(Account) [METHOD_PARAM] -> probe.Account +known_edge METHOD_PARAM 0 probe.Client#node(Node) [METHOD_PARAM] -> probe.Node +known_edge METHOD_PARAM 0 probe.Client#others(Account) [METHOD_PARAM] -> probe.Account +known_edge METHOD_PARAM 0 probe.Client#settings(Settings) [METHOD_PARAM] -> probe.Settings +known_edge METHOD_PARAM 0 probe.Client#temp(Temp) [METHOD_PARAM] -> probe.Temp +known_edge METHOD_RETURN 0 probe.MemStore#child(String) [METHOD] -> probe.Store +known_edge METHOD_RETURN 0 probe.Store#child(String) [METHOD] -> probe.Store +known_edge METHOD_RETURN 0 probe.Ticket.TicketBuilder#title(String) [METHOD] -> probe.Ticket.TicketBuilder +known_edge OBJECT_CREATION_TYPE 0 probe.Cache [EXPRESSION] -> probe.MemStore diff --git a/parser/src/parsers/java/extractors/field-extractor.ts b/parser/src/parsers/java/extractors/field-extractor.ts index 971fc74a..32dd5af9 100644 --- a/parser/src/parsers/java/extractors/field-extractor.ts +++ b/parser/src/parsers/java/extractors/field-extractor.ts @@ -686,6 +686,12 @@ export class FieldExtractor { // Collect annotation arguments const args = this.annotationExtractor.getExtractedArguments(); this.extractedAnnotationArguments.push(...args); + + // Collect the type references the annotations create (the annotation type itself, and a + // class literal argument such as `using = X.class`), as the method and type paths do. + // Dropped here, a class named only in a field annotation had no user (#1452). + const typeRefs = this.annotationExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...typeRefs); break; // extractFromFieldDeclaration handles all annotations } } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4919ce1e..675327bc 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -910,7 +910,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 = '45' # 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '46' # 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -996,6 +996,10 @@ class Impact: # the bean names, the fluent name (a record accessor, a plain-name getter), and the same name without a # leading underscore (a Python property over a private attribute) acc += [(fid, 'get' + cap, 'read'), (fid, 'is' + cap, 'read'), (fid, 'set' + cap, 'write'), (fid, f['name'], 'read')] + # the wither, and a boolean named `isX`, whose setter and wither drop the `is` (#1404) + acc.append((fid, 'with' + cap, 'write')) + if re.match(r'^is[A-Z]', f['name']): + acc += [(fid, 'set' + f['name'][2:], 'write'), (fid, 'with' + f['name'][2:], 'write')] # C# spells a generated accessor with an underscore -- get_ZipCode / set_ZipCode / init_City -- so the # bean names above match none of them and a property's readers are invisible even though the IR does # record both the backing field and the accessor. (An auto-property's field also has no owner in the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 3c627eb6..f59933ba 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -403,14 +403,25 @@ for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type by_qname = {} for t in types.values(): if t['qualified_name']: by_qname.setdefault(t['qualified_name'], t) +# A member of a type the engine SYNTHESISED as well (a Lombok builder, `generated:pkg.Order.OrderBuilder#sku/1`) has an +# owner no types row holds. It hangs off the nearest enclosing type that does, under the display the source would give +# it (`Order.OrderBuilder.sku`): the owner display then names no type, and every consumer walks it up to Order the way +# it walks up an enum constant's body, while `Order.sku`, the field, stays a different name (#1405, #1409). for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type_id, file_path, start_line, end_line FROM methods WHERE provenance='generated'"): t = types.get(m['owner_type_id']) if m['owner_type_id'] else None + inner = '' if t is None and (m['id'] or '').startswith('generated:'): body = m['id'].split(':', 1)[1] q = body.split('#')[0] if '#' in body else body.rsplit('.', 1)[0] t = by_qname.get(q) + up = q + while t is None and '#' in body and '.' in up: + up, seg = up.rsplit('.', 1) + inner = seg + ('.' + inner if inner else '') + t = by_qname.get(up) if t is None: continue od = tdisplay(t['id']); fp = rel(t['file_path']); nm = m['name'] or '' + if inner and od: od = od + '.' + inner if not nm: continue sym.append((m['id'], nm, (od + '.' if od else '') + nm, 'method', m['qualified_name'], m['signature'], fp, t['start_line'], t['end_line'], od, 1 if fp and TESTRE.search(fp) else 0, m['id'], None)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index a6256397..a35f492a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -566,14 +566,28 @@ shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, ff, _), name // claimed certainty the bundle does not record -- on a single-module library, 10 of 57 sampled accessor rows stood // on a call whose only tier was multi_inferred. The row itself is right either way (the caller does reach the field // through that accessor), so nothing is dropped; only the certainty stops being overstated. -direct(q, c, "reads", cat("reads it through ", an, "()"), "resolved", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "read"), member(t, a, an, _), calls(c, a, ti, f, l), ti != "multi_inferred". -direct(q, c, "reads", cat("reads it through ", an, "()"), "one of a set", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "read"), member(t, a, an, _), calls(c, a, "multi_inferred", f, l). +// A GENERATED one-argument member named like the field is its builder or fluent SETTER (`email(v)` on the synthesized +// builder, `label(v)` under @Accessors(fluent = true)), so the fluent name's read door must not claim it: the engine +// resolves these calls now (#1405, #1406), and each would otherwise be listed as a read. +.decl gen_setter(a:symbol) +gen_setter(a) :- member(_, a, _, _), match("generated:.*/1", a). +direct(q, c, "reads", cat("reads it through ", an, "()"), "resolved", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "read"), member(t, a, an, _), !gen_setter(a), calls(c, a, ti, f, l), ti != "multi_inferred". +direct(q, c, "reads", cat("reads it through ", an, "()"), "one of a set", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "read"), member(t, a, an, _), !gen_setter(a), calls(c, a, "multi_inferred", f, l). direct(q, c, "writes", cat("writes it through ", an, "()"), "resolved", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "write"), member(t, a, an, _), calls(c, a, ti, f, l), ti != "multi_inferred". direct(q, c, "writes", cat("writes it through ", an, "()"), "one of a set", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, "write"), member(t, a, an, _), calls(c, a, "multi_inferred", f, l). // a GENERATED accessor: the unresolved sites written with its name (the decoration says it exists) direct(q, c, "reads", cat("calls the generated getter ", an, "() — receiver not typed"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), gen(t, "get"), accessor(fl, an, "read"), named_site(c, an, k, f, l), !ctor_kind(k). direct(q, c, "writes", cat("calls the generated setter ", an, "() — receiver not typed"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), gen(t, "set"), accessor(fl, an, "write"), named_site(c, an, k, f, l), !ctor_kind(k). -direct(q, c, "writes", cat("sets it through the generated builder / fluent ", n, "()"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, w), (w = "builder" ; w = "fluent"), named_site(c, n, k, f, l), !ctor_kind(k). +// the builder / fluent setter the ENGINE resolved: the call is on this type's own builder chain or receiver (#1409) +direct(q, c, "writes", cat("sets it through the generated builder / fluent ", n, "()"), "resolved", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, w), (w = "builder" ; w = "fluent"), member(t, a, n, _), gen_setter(a), calls(c, a, ti, f, l), ti != "multi_inferred". +direct(q, c, "writes", cat("sets it through the generated builder / fluent ", n, "()"), "one of a set", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, w), (w = "builder" ; w = "fluent"), member(t, a, n, _), gen_setter(a), calls(c, a, "multi_inferred", f, l). +// ...and a same-named call it did NOT resolve. While the engine resolves none of them (a library type, an older engine) it +// is the only lead and stays a writer. Once it resolves one, the setter is modelled, and a same-named call left +// unresolved is on some other chain (`UserView.newBuilder().email(e)`): a lead, not a writer (#1409). +.decl gen_modelled(t:symbol, n:symbol) +gen_modelled(t, n) :- member(t, a, n, _), gen_setter(a). +direct(q, c, "writes", cat("sets it through the generated builder / fluent ", n, "()"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, w), (w = "builder" ; w = "fluent"), !gen_modelled(t, n), named_site(c, n, k, f, l), !ctor_kind(k). +direct(q, c, "uses", cat("calls a same-named ", n, "() that the engine did not place on this type's builder or accessors"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, w), (w = "builder" ; w = "fluent"), gen_modelled(t, n), named_site(c, n, k, f, l), !ctor_kind(k). direct(q, c, "reads", cat("reads it through the generated accessor ", n, "()"), "by name", f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), gen(t, "fluent_read"), named_site(c, n, k, f, l), !ctor_kind(k). direct(q, c, "writes", "passes it to the generated constructor", "by name", f, l) :- target(q, "field", fl, _), field(fl, t, _, _, _), gen(t, "ctor"), typ(t, tn, _), named_site(c, tn, k, f, l). // a string literal equal to its name: a serialized name, a map key, a request parameter diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 7723be82..8ad39a48 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -2635,10 +2635,13 @@ def declares_of(s_): ids_ = [a for (a, nm, _k) in by_tid.get(t, ()) if nm == an] if t else [] if not ids_: continue aph = ','.join('?' * len(ids_)) - for c, f_, l_ in q(f"""SELECT e.caller_id, s.file_path, s.start_line + # a generated builder / fluent setter is not a read door (gen_setter, impact.dl) + rids = [a for a in ids_ if not (role_ == 'read' and _gen_setter(a))] + rph = ','.join('?' * len(rids)) + for c, f_, l_ in (q(f"""SELECT e.caller_id, 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 ({aph}) AND e.callee_provenance='client' - ORDER BY s.start_line""", *ids_): + WHERE e.callee_method_id IN ({rph}) AND e.callee_provenance='client' + ORDER BY s.start_line""", *rids) if rids else ()): verb = 'reads' if role_ == 'read' else 'writes' rows.append((c, verb, f'{verb} it through {an}()', 'resolved', rel(f_) if f_ else '', l_ or 0)) de += [(c, a) for c, a in q(f"""SELECT DISTINCT caller_id, callee_method_id FROM call_edges @@ -2749,6 +2752,7 @@ def declares_of(s_): gen, _src = gen_of(q, owners, code) if owners else ({}, {}) if gen: sites = named_sites(q) + by_tid_ = _members(q)[4] tnames = dict(q("SELECT id, name FROM symbols WHERE id IN ({})".format(','.join('?' * len(owners))), *owners)) for f_ in fids: @@ -2766,10 +2770,27 @@ def declares_of(s_): if k == 'new': continue rows.append((c, role, why, 'by name', rel(sf) if sf else '', sl or 0)) if w & {'builder', 'fluent'}: + # the setter the engine resolved (#1409), and whether it is modelled at all (gen_modelled) + sids = [a for (a, nm, _k) in by_tid_.get(t_, ()) if nm == n_ and _gen_setter(a)] + if sids: + sph = ','.join('?' * len(sids)) + for c, f_, l_, ti in q(f"""SELECT e.caller_id, s.file_path, s.start_line, e.tier + FROM call_edges e LEFT JOIN call_sites s ON s.id=e.call_site_id + WHERE e.callee_method_id IN ({sph}) + AND e.callee_provenance='client' + ORDER BY s.start_line""", *sids): + rows.append((c, 'writes', f'sets it through the generated builder / fluent {n_}()', + 'one of a set' if ti == 'multi_inferred' else 'resolved', + rel(f_) if f_ else '', l_ or 0)) for c, k, sf, sl in sites.get(n_, ()): if k == 'new': continue - rows.append((c, 'writes', f'sets it through the generated builder / fluent {n_}()', - 'by name', rel(sf) if sf else '', sl or 0)) + if sids: + rows.append((c, 'uses', f'calls a same-named {n_}() that the engine did not place on ' + f"this type's builder or accessors", + 'by name', rel(sf) if sf else '', sl or 0)) + else: + rows.append((c, 'writes', f'sets it through the generated builder / fluent {n_}()', + 'by name', rel(sf) if sf else '', sl or 0)) if 'fluent_read' in w: for c, k, sf, sl in sites.get(n_, ()): if k == 'new': continue @@ -2848,13 +2869,23 @@ def field_typed(ffile, fname, fline): def _accessors(n): """`accessor(fl,an,role)` — the names a convention would give this field, exactly as the exporter derives - them: the bean pair, the fluent name, and the same name without a leading underscore.""" + them: the bean pair, the wither, the fluent name, the same name without a leading underscore, and for a + boolean named `isX` the setter and wither without the `is` (#1404).""" cap = n[:1].upper() + n[1:] - out = [('get' + cap, 'read'), ('is' + cap, 'read'), ('set' + cap, 'write'), (n, 'read')] + out = [('get' + cap, 'read'), ('is' + cap, 'read'), ('set' + cap, 'write'), ('with' + cap, 'write'), (n, 'read')] if n.startswith('_') and len(n) > 1: out.append((n.lstrip('_'), 'read')) + if _IS_PREFIXED.match(n): out += [('set' + n[2:], 'write'), ('with' + n[2:], 'write')] return out +_IS_PREFIXED = re.compile(r'^is[A-Z]') + + +def _gen_setter(a): + """`gen_setter(a)`: a GENERATED one-argument member: a builder or fluent setter when named like the field.""" + return bool(a) and a.startswith('generated:') and a.endswith('/1') + + def _qualifiers(code, f, l, n): """`qualifier(f,l,n,qn)` — what is written before `.n` on that line, from the blanked source.""" L = code(f) if f else [] diff --git a/tests/cases/java/generated-builder-writer/case.json b/tests/cases/java/generated-builder-writer/case.json new file mode 100644 index 00000000..5c24984e --- /dev/null +++ b/tests/cases/java/generated-builder-writer/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a @Builder setter the engine resolved is a writer of the field; a same-named call on another type's builder chain is not (#1409, #1405)", + "run": ["impact", "ProfileParam.email", "--kind", "field", "--limit", "50"], + "want": ["[resolved] Profiles.param", "sets it through the generated builder / fluent email()", + "calls a same-named email() that the engine did not place on this type's builder or accessors"], + "avoid": ["Profiles.notify", "[by name] Profiles.view src/pkg/Profiles.java:11 — sets it"]}, + {"why": "a boolean field named isX: the setter is setX and the getter isX, both resolved (#1404)", + "run": ["impact", "Account.isLocked", "--kind", "field", "--limit", "50"], + "want": ["writes it through setLocked()", "reads it through isLocked()", "Profiles.lock"]}]} diff --git a/tests/cases/java/generated-builder-writer/src/pkg/Account.java b/tests/cases/java/generated-builder-writer/src/pkg/Account.java new file mode 100644 index 00000000..152b79a6 --- /dev/null +++ b/tests/cases/java/generated-builder-writer/src/pkg/Account.java @@ -0,0 +1,8 @@ +package pkg; + +import lombok.Data; + +@Data +public class Account { + private boolean isLocked; +} diff --git a/tests/cases/java/generated-builder-writer/src/pkg/Mailer.java b/tests/cases/java/generated-builder-writer/src/pkg/Mailer.java new file mode 100644 index 00000000..a7d83c03 --- /dev/null +++ b/tests/cases/java/generated-builder-writer/src/pkg/Mailer.java @@ -0,0 +1,5 @@ +package pkg; + +public interface Mailer { + void email(String to); +} diff --git a/tests/cases/java/generated-builder-writer/src/pkg/ProfileParam.java b/tests/cases/java/generated-builder-writer/src/pkg/ProfileParam.java new file mode 100644 index 00000000..4488990d --- /dev/null +++ b/tests/cases/java/generated-builder-writer/src/pkg/ProfileParam.java @@ -0,0 +1,8 @@ +package pkg; + +import lombok.Builder; + +@Builder +public class ProfileParam { + private String email; +} diff --git a/tests/cases/java/generated-builder-writer/src/pkg/Profiles.java b/tests/cases/java/generated-builder-writer/src/pkg/Profiles.java new file mode 100644 index 00000000..99c3a02f --- /dev/null +++ b/tests/cases/java/generated-builder-writer/src/pkg/Profiles.java @@ -0,0 +1,22 @@ +package pkg; + +import app.proto.UserView; + +public class Profiles { + public ProfileParam param(String e) { + return ProfileParam.builder().email(e).build(); + } + + public UserView view(String e) { + return UserView.newBuilder().email(e).build(); + } + + public void notify(Mailer m, String e) { + m.email(e); + } + + public boolean lock(Account a) { + a.setLocked(true); + return a.isLocked(); + } +} From 95d66f43e0e1b88974d71456c9aa280fac1ca97f Mon Sep 17 00:00:00 2001 From: Swapnil Date: Mon, 28 Sep 2026 20:21:28 -0700 Subject: [PATCH 029/258] impact: tear-down is a fixture, non-registering decoration strings are no key, delete reads type evidence Fixes #1411, #1413, #1417, #1419, #1420, #1447, #1477, #1502 What was wrong - Test classification. The fixture table listed set-up only (@Before*, setUp), so a change reached only from @AfterEach or @AfterAll counted 0 tests (#1417). MSTest's [TestInitialize] and [TestCleanup] matched the @Test-shaped decoration rule because their names contain "Test", so they were counted as tests and the [TestMethod]s they run around got nothing (#1502). A JUnit 5 TestWatcher's testSuccessful / testFailed passed the test* naming rule because the class name holds "Test" (#1419). The SQL answers read the test* prefix with no owner condition at all, so the hooks and the rules disagreed on what a test is. - Registration keys. Every quoted string in a production decoration was read as the key the declaration is registered under: @SuppressWarnings("unchecked"), the method named by @SelectProvider(type = X.class, method = "m"), and a Python keyword such as mode="before" or methods=["GET"]. path printed "registered under" for each, and impact then followed the key, so a method that only returned the same word was listed as reaching the change (#1413). - impact --delete read decorations and literals from method targets only. A class registered by its own annotation (@WebServlet) said "no decoration", and a string naming the class in full ("app.OrdersServlet" handed to a servlet container) was never looked up (#1411). An annotation string naming a method as Class#method, which JUnit resolves by reflection for @DisabledIf, @EnabledIf and @MethodSource, is in no literals row, so the method read as named by nothing and next: called the change local (#1420). - A config key with an underscore was quoted on its [text] line in the canonical form the join matches on (app.orders.batchsize), a spelling no file holds (#1477). The change - graph_sql holds the one test classification (is_test_callable, is_fixture_callable) and the exporter in axiomcode-impact reads it. The fixture table adds tear-down: @After*, tearDown*, NUnit [TearDown] / [OneTimeTearDown], MSTest [TestInitialize] / [TestCleanup] / [ClassInitialize] / [ClassCleanup] / [AssemblyInitialize] / [AssemblyCleanup] and their Global forms. A decoration that is a fixture is never a test decoration. An @Override method is not a test by its name. The tests: line says "a fixture that runs before or after them". - ax_registration.decoration_key_strings takes the decoration's name and the graph's members: a suppression or compiler annotation (Suppress*, Deprecated, Obsolete, Generated) registers nothing; a keyword argument is a key only when the keyword names what is registered (path, name, topics, queues, command ...); a string naming a member of a type the same decoration names is a reference to that member. Both backends read it. - impact --delete reads a type target's own decorations (each with its line), a string equal to a type's qualified name (a binding: NOT SAFE, and next: names the line), and annotation strings naming a method as Class#method (a binding) or by its bare name (a lead). A config key row is quoted as written at its line. - #1447 was already answered on the tip (the outside-the-graph note without --library); the new C# case holds it with a control. - IMPACT_VERSION 46 (41 is on the tip, 42 to 45 are taken on other branches). reference/impact.md says what a fixture and a key are. Tests - New cases: java/teardown-is-a-fixture-and-a-callback-is-no-test, java/registration-key-is-what-registers, java/delete-reads-type-and-annotation-strings, csharp/mstest-initialize-is-a-fixture, python/decorator-keyword-is-no-key, each with near-miss controls. On the previous tip every fix check fails and every control passes. - Suites on the tree rebased onto 12d15748 (the tip since moved only the directive hook and its tests): tests/run.py --lang java: 204 of 204 check(s) passed in 56 case(s) tests/run.py --lang csharp: 60 of 63 passed, 1 pending, 2 failed (lambda-is-named-by-its-place, a known failure, and member-owner-is-its-type "pending but passes"; both fail the same way on the tip) tests/run.py --lang python: 194 of 195 passed, 1 failed (lambda-is-named-by-its-place, known) tests/hook_languages.py: 7 of 7 held; tests/enrich_lines.py: 44 of 44 held; tests/fastpath.py --lang python / java / csharp: 4 of 4 shapes ok each Smoke (two dev Java projects, fresh index, same graph read by the previous tip and by this change) - dev project A (a few hundred files): tests 799 -> 799; fixtures 1322 -> 1324 (two @After teardowns); decoration registration keys 548 -> 510, all 38 removed were @SuppressWarnings strings; routes 399 -> 399. - dev project B (over a thousand files): the SQL answers' test set 1851 -> 1845, now equal to the rule export's 1845 (the 6 dropped are 5 non-void test* helpers and one Predicate.test override); fixtures 1303 -> 1324 (21 @AfterEach teardowns); registration keys 377 -> 317 (34 @SuppressWarnings and 26 @Deprecated strings); routes 12 -> 12. impact --tests on three methods called from teardowns is unchanged (3, 10, 7 of 1845): a teardown declared in its tests' own class was already carried by the same-type helper rule, so the change shows where the teardown sits in a base class, as in the new case. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. Remaining: the Java parser drops keyword names from annotation text, so a keyword string on a Java annotation (groupId = "g1" beside topics = "orders") is still read as a key; that needs a parser change. --- .../skills/axiomcode/reference/impact.md | 11 +- .../axiomcode/scripts/ax_registration.py | 53 ++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 109 +++++++++++------- .../skills/axiomcode/scripts/graph_sql.py | 90 +++++++++++++-- skills/axiomcode/reference/impact.md | 11 +- .../mstest-initialize-is-a-fixture/case.json | 20 ++++ .../src/App/App.csproj | 4 + .../src/App/Handlers.cs | 14 +++ .../src/App/WidgetPricer.cs | 8 ++ .../tests/App.Tests/App.Tests.csproj | 4 + .../tests/App.Tests/PricerTests.cs | 23 ++++ .../case.json | 30 +++++ .../src/main/java/app/Init.java | 8 ++ .../src/main/java/app/OrderJob.java | 8 ++ .../src/main/java/app/OrdersServlet.java | 5 + .../src/main/java/app/web/PlainHelper.java | 4 + .../src/main/java/app/web/WidgetServlet.java | 13 +++ .../src/main/java/app/web/WidgetStore.java | 4 + .../src/main/resources/application.properties | 2 + .../src/test/java/app/Conditions.java | 7 ++ .../src/test/java/app/SyncTest.java | 18 +++ .../case.json | 17 +++ .../src/main/java/demo/dao/StockMapper.java | 13 +++ .../src/main/java/demo/dao/StockSql.java | 6 + .../src/main/java/demo/service/Audit.java | 4 + .../java/demo/service/ItemController.java | 8 ++ .../case.json | 20 ++++ .../src/main/java/app/Clock.java | 5 + .../src/main/java/app/Registry.java | 4 + .../src/main/java/app/Report.java | 6 + .../src/test/java/app/BaseTest.java | 16 +++ .../src/test/java/app/OrderTest.java | 10 ++ .../src/test/java/app/StockTest.java | 8 ++ .../src/test/java/app/TestResultLogger.java | 12 ++ .../app/__init__.py | 0 .../decorator-keyword-is-no-key/app/models.py | 10 ++ .../decorator-keyword-is-no-key/app/modes.py | 6 + .../decorator-keyword-is-no-key/app/views.py | 8 ++ .../decorator-keyword-is-no-key/case.json | 14 +++ 39 files changed, 550 insertions(+), 63 deletions(-) create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/case.json create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/App.csproj create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/Handlers.cs create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/WidgetPricer.cs create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/App.Tests.csproj create mode 100644 tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/PricerTests.cs create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/case.json create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/Init.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrderJob.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrdersServlet.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/PlainHelper.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetServlet.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetStore.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/main/resources/application.properties create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/Conditions.java create mode 100644 tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/SyncTest.java create mode 100644 tests/cases/java/registration-key-is-what-registers/case.json create mode 100644 tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockMapper.java create mode 100644 tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockSql.java create mode 100644 tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/Audit.java create mode 100644 tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/ItemController.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/case.json create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Clock.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Registry.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Report.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/BaseTest.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/OrderTest.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/StockTest.java create mode 100644 tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/TestResultLogger.java create mode 100644 tests/cases/python/decorator-keyword-is-no-key/app/__init__.py create mode 100644 tests/cases/python/decorator-keyword-is-no-key/app/models.py create mode 100644 tests/cases/python/decorator-keyword-is-no-key/app/modes.py create mode 100644 tests/cases/python/decorator-keyword-is-no-key/app/views.py create mode 100644 tests/cases/python/decorator-keyword-is-no-key/case.json diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 1b8552fa..14a5474a 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -174,8 +174,10 @@ line and names the constructor query to run; take that suggestion before acting file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change, and `--tests-in ` narrows the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs first does** (a constructor, a static initializer, `@Before*`, - `setUp` — a convention table, printed as such), **or it names the key the change is registered under** (below). + its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static + initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, + printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). + A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. `--in ` and `--depth N` bound it; `--json` is the same answer as data. - **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` @@ -185,7 +187,10 @@ line and names the constructor query to run; take that suggestion before acting in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most 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. + shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. Only what the decoration registers + under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring + keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member + of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. - **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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index da631575..c0d82607 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -640,18 +640,58 @@ def _has(q, t): # Every one of those is "the framework will dispatch to this declaration when someone writes this string", which is # exactly what the join needs. The kind is read from the key rather than from a list of decoration names: a key that # begins with `/` is a route, anything else is a key, and no framework is named anywhere in this function. -def decoration_key_strings(text): - """the strings a decoration's text registers its declaration under, sorted: every quoted string in it but prose. +# NOT EVERY QUOTED STRING IN A DECORATION IS WHAT IT REGISTERS THE DECLARATION UNDER (#1413). Three shapes carry a +# string that no caller will ever write to reach the declaration, and each was printed as "registered under" and then +# followed: a method that merely returned the same word was listed as reaching the change. +# 1. a decoration that names a WARNING or a status, and registers nothing: `@SuppressWarnings("unchecked")`, +# `[SuppressMessage(...)]`, `@Deprecated(since = "2")`, `[Obsolete("...")]`, `@Generated("tool")`. A language-level +# table (the compilers' own annotations and the analysers' suppressions), not a framework list. +_NOT_A_REGISTRAR = re.compile(r'^(Suppress\w*|Deprecated|Obsolete|Generated|SafeVarargs|FunctionalInterface)$') +# 2. a KEYWORD argument that configures the registration rather than naming it: `mode="before"`, `methods=["GET"]`, +# `tags=["orders"]`, `method = "byShelf"`. A positional string is the key (`@router.post("/orders")`, +# `@receiver("order_created")`); a keyword one is only when the keyword says it names the thing registered. +_KEY_KEYWORD = re.compile(r'^(value|values|path|paths|name|names|topics?|topic_?pattern|queues?|destinations?|channels?|' + r'subjects?|commands?|events?|signals?|routes?|patterns?|urls?|uri|endpoint|keys?|routing_?key|' + r'binding_?key|alias(es)?|rule|address|mapping)$', re.I) +# 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class, +# method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one. +# Only decided with the graph (`names_member(type, name)`); without it the string is kept. +_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') +_KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$') +_TYPE_ARG = re.compile(r'(? '' AND owner_id IS NOT NULL"""): if owner in tests: continue short = (name or '').split('.')[-1] - for key in decoration_key_strings(text): + for key in decoration_key_strings(text, name, members): kind = 'route' if key.startswith('/') else 'key' 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') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 675327bc..86f2642e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -142,24 +142,11 @@ def merge_reasons(why, others): LOCAL_KINDS = {'LOCAL_VARIABLE', 'PARAMETER', 'LAMBDA_PARAMETER', 'VARIABLE', 'PARAM'} # a ref the parser says is a local, not a member QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS', 'MEMBER_ACCESS'} # MEMBER_ACCESS: C# (#1445) MEMBER_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names -# the fixtures a test framework runs before a test, own or inherited: a language-neutral convention table, printed as such -FIXTURE_DECOR = re.compile(r'^(Before\w*|BeforeEach|BeforeAll|BeforeClass|fixture|setup\w*)$', re.I) -FIXTURE_NAMES = {'setUp', 'setUpClass', 'setup', 'setup_method', 'setup_class', 'setUpBeforeClass', 'beforeEach', 'beforeAll'} # `init` / `before` alone are ordinary helper names: they count as a fixture only with the decoration -TEST_DECOR = re.compile(r'(^|\.)(\w*Test\w*|Fact|Theory|it|test)$') -# The `test*` NAMING convention carries the condition that the class is a test class -- JUnit 3 reads -# it on a TestCase subclass, pytest only on a class matching python_classes (`Test*`). Read without -# that condition it takes in a `@Bean` method of a nested @Configuration class and a method of a -# Python class named anything at all, neither of which a runner ever invokes as a test (#1181). -# Matched with search, not fullmatch: a mixin or base that only CONCRETE subclasses run -- -# `RFC2616PolicyTestMixin`, `StorageTestMixin`, `TestBase` -- declares real tests, collected -# through a subclass named Test*. Anchoring the name dropped every one of them. -TEST_OWNER = re.compile(r'(Test|Spec|ITCase)') -# a decoration that says the method is a dependency the framework builds, not a test. A pytest `@fixture` is one -# whatever its name: `def test_image()` under it is built for the tests that request it, never collected (#1531). -NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration|fixture)$') -# a file the runner imports for its fixtures and hooks and never collects tests from -NON_TEST_FILE = re.compile(r'(^|/)conftest\.py$') -RET_TYPE = re.compile(r'\)\s*:\s*(.+)$') +# the fixtures a test framework runs before or after a test, own or inherited, and what a test is: ONE classification, +# graph_sql's, so the SQL answers and these facts cannot disagree (#1417 tear-down, #1419 an override is no named test, +# #1502 [TestInitialize] is a fixture). `init` / `before` alone are ordinary helper names: a fixture only with the decoration. +import graph_sql as _gs +FIXTURE_NAMES = _gs.FIXTURE_NAMES # a URL path as a router accepts it: path-absolute (RFC 3986 §3.3) with no whitespace and no quote in it. A path # parameter — `{id}`, ``, `:id`, `*` — is left exactly as written: its spelling is per framework, and the # join matches the two spellings against each other rather than normalising either into the other @@ -894,6 +881,18 @@ class Impact: m = re.match(r'\s*([A-Za-z0-9_.\-]+)\s*[:=]', line) if m and ckey(m.group(1)) in keys: out.append((ckey(m.group(1)), rel, i)) return out + def written_key(self, rel, line, canon): + """the key as the settings file writes it at `line`, when it is a spelling of `canon`; else `canon`""" + try: text = (self.lines(rel) or [])[int(line) - 1] + except (IndexError, ValueError, TypeError, OSError): return canon + m = re.match(r'\s*([A-Za-z0-9_.\-]+)\s*[:=]', text or '') + return m.group(1) if m and ckey(m.group(1)) == canon else canon + def spell_keys(self, out): + """each `defines or overrides the key` row of extbind carries the key as written at its line (written_key)""" + if out and out.get('extbind'): + out['extbind'] = [[f, l, self.written_key(f, l, n), how, *rest] if how == 'defines or overrides the key' else [f, l, n, how, *rest] + for f, l, n, how, *rest in out['extbind']] + return out def param_declared(self, mid, p): s = self.g.sym[mid]; L = self.lines(s['file']) head = ' '.join(L[s['line'] - 1: min(s['end_line'], s['line'] + 6)]).split('{')[0] @@ -910,7 +909,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 = '46' # 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '47' # 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1257,20 +1256,9 @@ class Impact: W('reexport_from', star) decs = collections.defaultdict(set) for r in (g.q("SELECT owner_id, name FROM decorations") if g.has('decorations') else []): decs[r[0]].add(r[1].split('.')[-1]) - def named_test(i, s): - # the naming convention, with the condition it actually carries: no owning type (a bare - # pytest function, a module-level `function testX()`), or a type that is a test class. - if not s['name'].startswith(('test', 'it')): return False - if any(NON_TEST_DECOR.match(d) for d in decs.get(i, ())): return False - if NON_TEST_FILE.search(s.get('file') or ''): return False - own = (s.get('owner') or '').split('.')[-1] - if own and not TEST_OWNER.search(own): return False - # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return - # type and it is not void is a helper the tests call -- `private Method[] testFoo()`. - # Languages whose signatures declare no return type are unaffected by this. - r = RET_TYPE.search(s.get('signature') or '') - return not r or r.group(1).strip() in ('void', 'Unit', 'None') - tm = [i for i, s in g.sym.items() if s['is_test'] and s.get('method_id') and s['kind'] in ('method', 'function') and (any(TEST_DECOR.search(d) for d in decs.get(i, ())) or named_test(i, s))] + # a test decoration, or the test* naming convention under the condition it carries (graph_sql.is_test_callable) + tm = [i for i, s in g.sym.items() if s['is_test'] and s.get('method_id') and s['kind'] in ('method', 'function') + and _gs.is_test_callable(s['name'], decs.get(i, ()), s.get('owner'), s.get('file'), s.get('signature'))] # a vitest / jest / mocha test is an ANONYMOUS callable handed to it(…) / test(…) / bench(…): on one TypeScript # project 6,661 of the 7,723 callables in test files are and exactly 2 carried a name the rule above # accepts, so the whole test layer was empty. The registrar on the declaration's own line names it as a test. @@ -1298,7 +1286,7 @@ class Impact: scripts = graph_sql.script_tests([(i, s['kind'], s.get('file')) for i, s in g.sym.items() if s['is_test'] and s.get('method_id') and s.get('file')], lambda f: '\n'.join(self.lines(f)), set(tm)) tm += sorted(scripts) - fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or s['name'] in FIXTURE_NAMES or any(FIXTURE_DECOR.match(d) for d in decs.get(i, ())))] + fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or _gs.is_fixture_callable(s['name'], decs.get(i, ())))] W('test_method', [(m,) for m in tm]); W('fixture', [(m,) for m in fx]) W('runs_before', sorted(self.wide_fixtures(tm, decs))) # ── which fixture runs before which test, when nothing calls it ──────────────────────────────────────── @@ -1664,7 +1652,7 @@ class Impact: d = os.environ['AXIOMCODE_DUMP_SOLVE']; os.makedirs(d, exist_ok=True) with open(os.path.join(d, re.sub(r'[^A-Za-z0-9_.-]', '_', str(QS)[:60]) + '.json'), 'w') as fh: json.dump(_sql, fh) - return _sql + return self.spell_keys(_sql) # AXIOMCODE_BACKEND=1 says which side answered, so a parity run can tell a real match from both arms # falling back to the same rules — a type target scored "identical" six times while SQL never ran. if os.environ.get('AXIOMCODE_BACKEND'): print('backend=datalog', file=sys.stderr) @@ -1689,6 +1677,10 @@ class Impact: if p[0] in QS: rows.append(p[1:] + [p[0]]) # … + the query id, so each row says which target it came from out[n] = rows self.solve_store(D, key, out) + # A KEY IS QUOTED AS IT IS WRITTEN AT THAT LINE (#1477). The join matches a settings line on the canonical form + # (ckey: `batch_size`, `batch-size` and `batchSize` are one property), so the row carries that form; printed + # as is, `app.orders.batch_size` was quoted as `app.orders.batchsize`, a spelling no file holds. + self.spell_keys(out) out['_targets'] = QS # AXIOMCODE_DUMP_SOLVE=: record what the rules derived, so the SQL port can be developed and diffed # against real output without re-running a 26 s query for every iteration. @@ -2734,7 +2726,7 @@ def main(argv): sure = [m for m, c in test_cert.items() if c == 'sound' and tests[m][0] <= 3] weak_tests = [m for m in tests if m not in sure_set] sys.stdout = _stdout - print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before them: constructor, static initializer, @Before*/setUp)" if tests else '')) + print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before or after them: constructor, static initializer, @Before*/@After*/setUp/tearDown)" if tests else '')) if out_src[1]: print(f" NOT SEEN: the graph was indexed with --src {out_src[0]}, and {len(out_src[1])} test file(s) lie outside it (" + ', '.join(out_src[1][:3]) + (f" … +{len(out_src[1]) - 3}" if len(out_src[1]) > 3 else '') @@ -2929,10 +2921,41 @@ def main(argv): prod_users = {c for c, role, why, cert, loc, n in D if cert in STRONG_DIRECT and not _is_t(c)} prod_reach = sorted((m for m in reached if m in sure_set and not _is_t(m)), key=lambda m: (reached[m], g.loc(m))) lone = not prod_users and not prod_reach + # WHAT NAMES THE DECLARATION BY A STRING, READ FOR A TYPE AS FOR A METHOD (#1411, #1420). Three kinds of evidence + # no call edge carries, each of which a delete breaks at RUN time and the compiler never sees: + # own_decs its own decorations, with their lines: `@WebServlet("/widgets")` on a class registers it as much as + # `@GetMapping` on a method does. Only a method's were read, so every annotated class said "no decoration". + # qual_lits a string equal to a type's QUALIFIED name: `ctx.addServlet("orders", "app.OrdersServlet")`, + # Class.forName, a reflective loader. A binding, not a lead: the name is written in full. + # ann_refs an annotation string naming a method, `Class#method` (JUnit's @MethodSource, @EnabledIf, @DisabledIf + # resolve it by reflection) or the bare name. Annotation arguments are in no `literals` row, so the + # method-body literal check could not see them. `Class#method` with this method's own class is a + # binding; a bare name may be a same-named other method, so it is a lead. + tgt_syms = [(k, t) for k, _l, pay in targets if k in ('method', 'type') for t in (pay if isinstance(pay, list) else []) if t in g.sym] + own_decs = sorted({(r[0].split('.')[-1], g.site_file(r[1]) if r[1] else '', r[2] or 0) for _k, t in tgt_syms + for r in (g.q("SELECT name, file, line FROM decorations WHERE owner_id = ?", t) if g.has('decorations') else [])}) + qual_lits, ann_refs, ann_bound = [], [], [] + for k, t in tgt_syms: + s_ = g.sym[t]; qn = (s_.get('qualified_name') or '').replace('$', '.') + if k == 'type' and qn and qn != s_['name'] and g.has('literals'): + qual_lits += [(g.site_file(r[0]), r[1]) for r in g.q("SELECT file, line FROM literals WHERE replace(value, '$', '.') = ?", qn)] + if k != 'method' or not g.has('decorations'): continue + n_ = s_['name']; own = (s_.get('owner') or '').split('.')[-1]; own_q = qn.rsplit('.', 1)[0] if '.' in qn else '' + for oid, txt, f_, l_ in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE ?", f'%{n_}%'): + if oid == t: continue + for v in re.findall(r'"([^"\s]{1,200})"', txt or ''): + cls, sep, mem = v.replace('$', '.').rpartition('#') + mem = mem.split('(')[0] + if mem != n_: continue + at = (g.site_file(f_) if f_ else '', l_ or 0) + if sep and cls and (cls in (own, own_q) or (own_q and cls.endswith('.' + own) and own_q.endswith(cls))): ann_bound.append(at) + elif not cls: ann_refs.append(at) + qual_lits = sorted(set(qual_lits)); ann_bound = sorted(set(ann_bound)); ann_refs = sorted(set(ann_refs) - set(ann_bound)) + bind_locs = [f"{f_}:{l_}" for f_, l_ in qual_lits + ann_bound if f_] # a green line over an empty set would be printed exactly where the answer is least trustworthy: say what was checked if want_delete: # is it safe to delete? every reason it is not, and — when there is none — what the graph cannot vouch for - names = {g.sym[t]['name'] for k, _, pay in targets if k == 'method' for t in (pay if isinstance(pay, list) else []) if t in g.sym} + names = {g.sym[t]['name'] for _k, t in tgt_syms} # THE MEMBERSHIP HERE IS EXACTLY WHAT IT WAS. Every row now labelled `registered` or `capped set` # was labelled `resolved` before, and a hand-off is a reason NOT to delete — something holds the # reference. Letting the new label fall out of this list would turn "something still uses this" into @@ -2941,7 +2964,7 @@ def main(argv): or x[3] in ('registered', 'capped set', 'framework')] # a task's producer, a signal's sender (#1509) weak = [x for x in D if x[3] in ('one of a set', 'by name', 'text', 'in scope')] lits = [r for n_ in names for r in g.q("SELECT file, line FROM literals WHERE value = ?", n_)] if g.has('literals') else [] - decs = sorted({r[0].split('.')[-1] for k, _, pay in targets if k == 'method' for t in (pay if isinstance(pay, list) else []) for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", t)}) + decs = [f"{n_} ({f_}:{l_})" if f_ and l_ else n_ for n_, f_, l_ in own_decs] # an override the engine put in dispatch_candidates is a caller too: the base's callers land here at run time ov = [r['d'] for k, _, pay in targets if k == 'method' for t in (pay if isinstance(pay, list) else []) if t in g.sym and g.sym[t].get('method_id') for r in (g.q("""SELECT DISTINCT b.display d FROM dispatch_candidates dc JOIN symbols b ON b.method_id = dc.base_method_id JOIN symbols c ON c.method_id = dc.candidate_method_id @@ -2992,6 +3015,8 @@ def main(argv): # A FILE THE INDEX DOES NOT READ AS SOURCE CAN STILL LOAD IT (#1388). A spring.factories entry, a services file, # a mapper XML: the rows were printed under "bound from outside the source" and then left out of the verdict, # which read "nothing" for a class Spring instantiates at startup only because such a file names it. + if qual_lits: why.append(f"{len(qual_lits)} string(s) name it in full, so something loads it by that name: " + ', '.join(f"{f_}:{l_}" for f_, l_ in qual_lits[:4]) + (' …' if len(qual_lits) > 4 else '')) + if ann_bound: why.append(f"{len(ann_bound)} annotation string(s) name it as Class#method, which a runner or framework resolves by reflection at RUN time: " + ', '.join(f"{f_}:{l_}" for f_, l_ in ann_bound[:4]) + (' …' if len(ann_bound) > 4 else '')) if ext_strong: why.append(f"{len(ext_strong)} site(s) in {len({f for f, *_ in ext_strong})} file(s) outside the source {'define' if all(x[3].startswith('defines') for x in ext_strong) else 'name'} it ([text] above), so deleting it breaks them at RUN time") # AN ENTRY POINT IS CALLED BY A FRAMEWORK, AND SO IS AN UNMODELLED ONE (#1446). The entry-point line above said # a framework invokes it, and this verdict then printed the one for a class nothing registers, word for word. @@ -3000,7 +3025,8 @@ def main(argv): fw_why.append(f"it is {' / '.join(ax_edges.entry_phrase(w) for w in epw[:3])} (above) — the framework calls it back, so no call site in the graph is expected") elif epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") if unmod: fw_why.append(f"NOT CHECKED: {unmod_sig} — a framework may call it and the graph does not model how; before deleting run: {unmod_grep}") - if why: print(" NOT SAFE — " + '; '.join(why) + ". They are listed above.") + # the string bindings (qual_lits, ann_bound) name their sites on the line itself; every other reason is a row above + if why: print(" NOT SAFE — " + '; '.join(why) + (". They are listed above." if calls or contract or weak or ov or ext_strong or abstract_over else ".")) elif fw_why: print(" NOT SAFE TO ASSUME — no dependent in this graph, but " + '; '.join(fw_why) + ". Check what else the graph cannot see:") elif exported: print(" NOT SAFE TO ASSUME — no dependent in this graph, but it is declared " + '/'.join(exported) + ": exported surface. For a library, no in-repo caller is the normal state of the public API," @@ -3014,12 +3040,13 @@ def main(argv): if outside and lone: bits.append("it extends / implements " + ', '.join(sorted({a for _o, a in outside})[:3]) + ", which the graph does not contain — that library or framework can instantiate or call it with no call site here") if ext_weak: bits.append(f"{len(ext_weak)} mention(s) of the name in {len({f for f, *_ in ext_weak})} file(s) outside the source ([text] above): " + ', '.join(f"{f}:{l}" for f, l, *_ in ext_weak[:4]) + (' …' if len(ext_weak) > 4 else '') + " — each may be the same word meaning something else") + if ann_refs: bits.append(f"{len(ann_refs)} annotation string(s) equal to the name (a method a runner or framework looks up by name? or a same-named other one): " + ', '.join(f"{f_}:{l_}" for f_, l_ in ann_refs[:4]) + (' …' if len(ann_refs) > 4 else '')) 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 bits and not calls and not contract and not weak and not ov and not ext and not fw_why and not qual_lits and not ann_bound: 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") # WHAT hops == 0 MEANS IS "NO ROW CLAIMS A CALL EDGE", NOT "NO ROW IS RESOLVED". The old parenthetical — # "every entry is by name, in scope, text, or one of a target set" — is a claim about the rows, and it is @@ -3111,7 +3138,7 @@ def main(argv): # method or type target seeds with itself and what it is bound to, which is where its reading starts here = ((sorted(set(x for x in field_locs if x))[:2] if field_locs else []) or sorted({g.loc(t) for t in seeds if t in g.sym})[:2]) - places = [x for x in dict.fromkeys(must + users) if x not in here] + places = [x for x in dict.fromkeys(must + users + bind_locs) if x not in here] ti = '; `test-impact` after the edit for the tests' if tests else '' lead = f"; the {weak_n} [by name]/[text] row(s) are leads — open one only if the change is to the name or the signature" if weak_n else '' # "LOCAL" IS A CLAIM ABOUT EVERYTHING ABOVE IT, NOT ABOUT `D` (#1388). Two things this answer prints are not rows of diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 8ad39a48..8ade9ac3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -555,9 +555,77 @@ def parent_up(edges, depth): import re as _re +# ONE TEST CLASSIFICATION, read by the exporter (axiomcode-impact) and by every SQL answer here, so the two cannot +# disagree on what a test is. TEST_DECOR = _re.compile(r'(^|\.)(\w*Test\w*|Fact|Theory|it|test)$') -FIXTURE_DECOR = _re.compile(r'^(Before\w*|BeforeEach|BeforeAll|BeforeClass|fixture|setup\w*)$', _re.I) -FIXTURE_NAMES = {'setUp', 'setUpClass', 'setup', 'setup_method', 'setup_class', 'setUpBeforeClass', 'beforeEach', 'beforeAll'} +# A FIXTURE runs around the tests of its scope, and TEAR-DOWN is one as much as set-up: an exception in JUnit's +# @AfterEach fails the test, one in @AfterAll fails the class, and MSTest's [TestCleanup] / [ClassCleanup] likewise. +# Only the set-up half was listed, so a change reached from a teardown counted 0 tests (#1417). The runners' names, +# as a language-neutral convention table: JUnit / TestNG @Before* / @After*, NUnit [SetUp] / [TearDown] / +# [OneTimeSetUp] / [OneTimeTearDown], MSTest [TestInitialize] / [TestCleanup] / [ClassInitialize] / [ClassCleanup] / +# [AssemblyInitialize] / [AssemblyCleanup] / [GlobalTestInitialize] / [GlobalTestCleanup], pytest @fixture. +FIXTURE_DECOR = _re.compile(r'^(Before\w*|After\w*|fixture|setup\w*|teardown\w*|OneTime(SetUp|TearDown)' + r'|(Global)?Test(Initialize|Cleanup)|(Class|Assembly)(Initialize|Cleanup))$', _re.I) +FIXTURE_NAMES = {'setUp', 'setUpClass', 'setup', 'setup_method', 'setup_class', 'setUpBeforeClass', 'beforeEach', 'beforeAll', + 'tearDown', 'tearDownClass', 'teardown', 'teardown_method', 'teardown_class', 'tearDownAfterClass', 'afterEach', 'afterAll'} +# The `test*` NAMING convention carries the condition that the class is a test class -- JUnit 3 reads +# it on a TestCase subclass, pytest only on a class matching python_classes (`Test*`). Read without +# that condition it takes in a `@Bean` method of a nested @Configuration class and a method of a +# Python class named anything at all, neither of which a runner ever invokes as a test (#1181). +# Matched with search, not fullmatch: a mixin or base that only CONCRETE subclasses run -- +# `RFC2616PolicyTestMixin`, `StorageTestMixin`, `TestBase` -- declares real tests, collected +# through a subclass named Test*. Anchoring the name dropped every one of them. +TEST_OWNER = _re.compile(r'(Test|Spec|ITCase)') +# a decoration that says the method is not a test the runner collects by its name: +# - a dependency the framework builds (`@Bean`, `@Provides`; a pytest `@fixture` whatever its name: `def test_image()` +# under it is built for the tests that request it, never collected, #1531); +# - an OVERRIDE (#1419): the method implements a supertype's, so whoever holds the supertype calls it -- a JUnit 5 +# TestWatcher's `testSuccessful` / `testFailed`, a listener's `testStarted`. The naming convention finds a test +# by its DECLARATION on a test class; a callback of an extension interface is named by that interface. +NON_TEST_DECOR = _re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration|fixture|Override)$') +# a file the runner imports for its fixtures and hooks and never collects tests from +NON_TEST_FILE = _re.compile(r'(^|/)conftest\.py$') +_RET_TYPE = _re.compile(r'\)\s*:\s*(.+)$') + + +def _short_decoration(d): + return (d or '').split('.')[-1] + + +def is_fixture_decoration(d): + return bool(FIXTURE_DECOR.match(_short_decoration(d))) + + +def is_test_decoration(d): + """a decoration that marks its method as a test: @Test, [TestMethod], [Fact]. A set-up or tear-down attribute that + happens to contain the word (MSTest's [TestInitialize], [TestCleanup]) is a fixture, not a test (#1502).""" + return bool(TEST_DECOR.search(d or '')) and not is_fixture_decoration(d) + + +def named_test(name, decs, owner, file, signature): + """the `test*` / `it*` naming convention, with the condition it actually carries: no owning type (a bare pytest + function, a module-level `function testX()`), or a type that is a test class; and no decoration that says the + method is something else (NON_TEST_DECOR).""" + if not (name or '').startswith(('test', 'it')): return False + if any(NON_TEST_DECOR.match(_short_decoration(d)) for d in decs or ()): return False + if NON_TEST_FILE.search(file or ''): return False + own = (owner or '').split('.')[-1] + if own and not TEST_OWNER.search(own): return False + # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return + # type and it is not void is a helper the tests call -- `private Method[] testFoo()`. + # Languages whose signatures declare no return type are unaffected by this. + r = _RET_TYPE.search(signature or '') + return not r or r.group(1).strip() in ('void', 'Unit', 'None') + + +def is_test_callable(name, decs, owner, file, signature): + """a test the runner collects: a test decoration, or the naming convention under its condition""" + return any(is_test_decoration(d) for d in decs or ()) or named_test(name, decs, owner, file, signature) + + +def is_fixture_callable(name, decs): + """a callable the runner runs before or after the tests of its scope, by its name or its decoration""" + return name in FIXTURE_NAMES or any(is_fixture_decoration(d) for d in decs or ()) TEST_REGISTRAR = re.compile(r'\b(it|test|bench)\s*(\.\w+)*\s*(\.\w+)?\s*[(<`]') @@ -620,7 +688,8 @@ def _test_sets(q, lines=None, rel=None): A helper in a test file (`_assertAsBigInteger`) is neither, so it is not a test: it is a CARRIER, and the tests it brings are the ones declared beside it. Counting every is_test callable as a test returned the helpers and lost the seven @Test methods they carry. - A FIXTURE is a test type, a constructor or module, a known setUp name, or a Before*/fixture/setup* decoration. + A FIXTURE is a test type, a constructor or module, a known setUp / tearDown name, or a set-up or tear-down + decoration (Before* / After* / fixture / setup* / TestInitialize ...: FIXTURE_DECOR). A jest / vitest / mocha test is an ANONYMOUS callable handed to it(…) / test(…) / bench(…), so the name test above it is the registrar's, not the callable's. Without that second leg the test layer of a JS or TS bundle @@ -631,13 +700,13 @@ def _test_sets(q, lines=None, rel=None): for oid, name in q("SELECT owner_id, name FROM decorations") if _has(q, 'decorations') else []: dec.setdefault(oid, []).append(name or '') tm, fx = set(), set() - for sid, name, kind, mid, tid in q("SELECT id, name, kind, method_id, type_id FROM symbols WHERE is_test=1"): + for sid, name, kind, mid, tid, owner, f, sig in q("SELECT id, name, kind, method_id, type_id, owner, file, signature FROM symbols WHERE is_test=1"): d = dec.get(sid, ()) - # a pytest fixture named test_* is built for the tests that request it and never collected (#1531) - if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))) \ - and not any((x or '').split('.')[-1] == 'fixture' for x in d): + # the exporter's own rule (is_test_callable): a test decoration, or the test* name under its condition. The + # name alone, read with no owner condition, counted a TestWatcher's testFailed callback as a test (#1419) + if mid and kind in ('method', 'function') and is_test_callable(name, d, owner, f, sig): tm.add(sid) - if (tid and not mid) or kind in ('constructor', 'module') or name in FIXTURE_NAMES or any(FIXTURE_DECOR.match((x or '').split('.')[-1]) for x in d): + if (tid and not mid) or kind in ('constructor', 'module') or is_fixture_callable(name, d): fx.add(sid) # …and the anonymous ones, named as tests by the registrar written on their own declaration line if lines is not None: @@ -1003,7 +1072,7 @@ def no_caller_reasons(q, mids): A wrapper (INERT_DECORATIONS, or a decorator this repository declares) is never a reason and never hides the by-name count. The type-level reasons (base, type decoration) are skipped for a private, static or constructor member, which is never entered through its type. A method with no reason gets [].""" - out = {} + out = {}; members = None has = {t: _has(q, t) for t in ('entry_points', 'decorations', 'methods', 'overrides', 'unresolved_sites', 'call_sites')} client_fn = {} def in_repo_decorator(name): @@ -1027,7 +1096,8 @@ def in_repo_decorator(name): if not sn or sn in INERT_DECORATIONS: continue shown = '@' + ((t or '').split('(')[0].strip().lstrip('@[') or (n or '')).rstrip(']') # the key a decoration registers it under, by the convention decoration_keys applies (none on a test) - keys = [] if s.get('is_test') else ax_registration.decoration_key_strings(t) + if not s.get('is_test') and members is None: members = ax_registration.member_names(q) + keys = [] if s.get('is_test') else ax_registration.decoration_key_strings(t, n, members) if keys: rs.append(('registered', f'{shown} "{keys[0]}"', loc(f, l))) elif not in_repo_decorator(sn): rs.append(('framework', shown, loc(f, l))) row = q("SELECT kind, visibility, owner_type_id FROM methods WHERE id = ?", mid) if has['methods'] else [] diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 1b8552fa..14a5474a 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -174,8 +174,10 @@ line and names the constructor query to run; take that suggestion before acting file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change, and `--tests-in ` narrows the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs first does** (a constructor, a static initializer, `@Before*`, - `setUp` — a convention table, printed as such), **or it names the key the change is registered under** (below). + its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static + initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, + printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). + A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. `--in ` and `--depth N` bound it; `--json` is the same answer as data. - **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` @@ -185,7 +187,10 @@ line and names the constructor query to run; take that suggestion before acting in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most 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. + shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. Only what the decoration registers + under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring + keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member + of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. - **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 diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/case.json b/tests/cases/csharp/mstest-initialize-is-a-fixture/case.json new file mode 100644 index 00000000..b6849d69 --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/case.json @@ -0,0 +1,20 @@ +{"lang": "csharp", + "checks": [ + {"why": "MSTest [TestInitialize] runs before each [TestMethod]: it is a fixture, not a test, so both tests reach what it calls and it is not counted (#1502)", + "run": ["impact", "WidgetPricer.Price", "--tests"], + "want": ["2 of 2 test method(s)", "PricerTests::Price_Doubles", "PricerTests::Price_Triples"], + "avoid": ["PricerTests::Init", "of 3 test method(s)", "of 4 test method(s)"]}, + {"why": "[TestCleanup] runs after each [TestMethod]: a fixture too (#1502)", + "run": ["impact", "WidgetPricer.Refund", "--tests"], + "want": ["2 of 2 test method(s)"], + "avoid": ["PricerTests::Done"]}, + {"why": "control: [ClassInitialize] was already a fixture and still carries both tests", + "run": ["impact", "WidgetPricer.Tax", "--tests"], + "want": ["via PricerTests.ClassInit", "PricerTests::Price_Doubles", "PricerTests::Price_Triples"]}, + {"why": "a method implementing a package interface the graph does not contain gets the outside-the-graph note, and --delete does not say nothing (#1447)", + "run": ["impact", "GetWidgetHandler.Handle", "--delete"], + "want": ["outside the graph: GetWidgetHandler extends / implements IJobHandler"], + "avoid": ["no by-name match, no literal, no decoration"]}, + {"why": "control: the same method on a class implementing nothing keeps the plain answer", + "run": ["impact", "GetOrderHelper.Handle", "--delete"], + "avoid": ["outside the graph"]}]} diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/App.csproj b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/App.csproj new file mode 100644 index 00000000..c895535c --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/App.csproj @@ -0,0 +1,4 @@ + + net8.0enable + + diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/Handlers.cs b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/Handlers.cs new file mode 100644 index 00000000..31978dca --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/Handlers.cs @@ -0,0 +1,14 @@ +using Example.Jobs; + +namespace App; + +public sealed class GetWidgetHandler : IJobHandler +{ + public Task Handle(CancellationToken ct) => Task.FromResult("widget"); +} + +// control: implements nothing +public sealed class GetOrderHelper +{ + public Task Handle(CancellationToken ct) => Task.FromResult("order"); +} diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/WidgetPricer.cs b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/WidgetPricer.cs new file mode 100644 index 00000000..6db9cb4d --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/src/App/WidgetPricer.cs @@ -0,0 +1,8 @@ +namespace App; + +public class WidgetPricer +{ + public int Price(int q) => q * 2; + public int Tax(int q) => q / 10; + public int Refund(int q) => q; +} diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/App.Tests.csproj b/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/App.Tests.csproj new file mode 100644 index 00000000..69a8bf39 --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/App.Tests.csproj @@ -0,0 +1,4 @@ + + net8.0enable + + diff --git a/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/PricerTests.cs b/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/PricerTests.cs new file mode 100644 index 00000000..b8782ea4 --- /dev/null +++ b/tests/cases/csharp/mstest-initialize-is-a-fixture/tests/App.Tests/PricerTests.cs @@ -0,0 +1,23 @@ +using App; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace App.Tests; + +[TestClass] +public class PricerTests +{ + [TestInitialize] + public void Init() => new WidgetPricer().Price(1); + + [TestCleanup] + public void Done() => new WidgetPricer().Refund(1); + + [ClassInitialize] + public static void ClassInit(TestContext c) => new WidgetPricer().Tax(1); + + [TestMethod] + public void Price_Doubles() => Assert.AreEqual(2, 2); + + [TestMethod] + public void Price_Triples() => Assert.AreEqual(3, 3); +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/case.json b/tests/cases/java/delete-reads-type-and-annotation-strings/case.json new file mode 100644 index 00000000..89cca6e5 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/case.json @@ -0,0 +1,30 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a class registered by its own class-level annotation says so in the delete check, with the line (#1411)", + "run": ["impact", "WidgetServlet", "--delete"], + "want": ["decorations on it: @WebServlet (src/main/java/app/web/WidgetServlet.java:7)"], + "avoid": ["no decoration", "the change is local"]}, + {"why": "a string that writes the class's qualified name loads it by that name: the delete check is NOT SAFE and next: names the line (#1411)", + "run": ["impact", "OrdersServlet", "--delete"], + "want": ["NOT SAFE", "1 string(s) name it in full", "src/main/java/app/Init.java:6"], + "avoid": ["no literal"]}, + {"why": "control: a class nothing names and nothing decorates keeps the plain answer", + "run": ["impact", "PlainHelper", "--delete"], + "avoid": ["name it in full", "decorations on it"]}, + {"why": "an annotation string Class#method (JUnit's @DisabledIf) binds to the method it names: the delete check is NOT SAFE and next: reads that line (#1420)", + "run": ["impact", "Conditions.onCi", "--delete"], + "want": ["annotation string(s) name it as Class#method", "src/test/java/app/SyncTest.java:9"], + "avoid": ["no literal", "the change is local"]}, + {"why": "near-miss control: Class#method naming another class's same-named method is not a binding to this one", + "run": ["impact", "Conditions.holiday", "--delete"], + "avoid": ["name it as Class#method", "SyncTest.java:16"]}, + {"why": "control: a literal in a method body is still read as before", + "run": ["impact", "Conditions.weekend", "--delete"], + "want": ["1 string literal(s) equal to the name", "src/test/java/app/SyncTest.java:13"]}, + {"why": "a configuration key is quoted as the settings file writes it, underscore and all (#1477)", + "run": ["impact", "app.orders.batch_size"], + "want": ["application.properties:1", "(app.orders.batch_size)"], + "avoid": ["(app.orders.batchsize)"]}, + {"why": "control: a key with no separator to lose reads as before", + "run": ["impact", "app.orders.timeout"], + "want": ["(app.orders.timeout)"]}]} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/Init.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/Init.java new file mode 100644 index 00000000..0403b8f3 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/Init.java @@ -0,0 +1,8 @@ +package app; +import jakarta.servlet.ServletContext; +public class Init { + public void onStartup(ServletContext ctx) { + String n = "reload"; + ctx.addServlet("orders", "app.OrdersServlet"); + } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrderJob.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrderJob.java new file mode 100644 index 00000000..873d2e00 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrderJob.java @@ -0,0 +1,8 @@ +package app; +import org.springframework.beans.factory.annotation.Value; +public class OrderJob { + @Value("${app.orders.batch_size}") + int batchSize; + @Value("${app.orders.timeout}") + int timeout; +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrdersServlet.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrdersServlet.java new file mode 100644 index 00000000..c7541cf9 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/OrdersServlet.java @@ -0,0 +1,5 @@ +package app; +import jakarta.servlet.http.HttpServlet; +public class OrdersServlet extends HttpServlet { + public void reload() { } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/PlainHelper.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/PlainHelper.java new file mode 100644 index 00000000..adad600d --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/PlainHelper.java @@ -0,0 +1,4 @@ +package app.web; +public class PlainHelper { + public static int twice(int x) { return x * 2; } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetServlet.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetServlet.java new file mode 100644 index 00000000..84882a4f --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetServlet.java @@ -0,0 +1,13 @@ +package app.web; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/widgets") +public class WidgetServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + WidgetStore.list(); + } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetStore.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetStore.java new file mode 100644 index 00000000..ffb177cd --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/java/app/web/WidgetStore.java @@ -0,0 +1,4 @@ +package app.web; +public class WidgetStore { + public static void list() { } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/resources/application.properties b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/resources/application.properties new file mode 100644 index 00000000..c6a18748 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/main/resources/application.properties @@ -0,0 +1,2 @@ +app.orders.batch_size=50 +app.orders.timeout=30 diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/Conditions.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/Conditions.java new file mode 100644 index 00000000..df65116a --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/Conditions.java @@ -0,0 +1,7 @@ +package app; + +public class Conditions { + public static boolean onCi() { return true; } + public static boolean weekend() { return false; } + public static boolean holiday() { return false; } +} diff --git a/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/SyncTest.java b/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/SyncTest.java new file mode 100644 index 00000000..4c1cab33 --- /dev/null +++ b/tests/cases/java/delete-reads-type-and-annotation-strings/src/test/java/app/SyncTest.java @@ -0,0 +1,18 @@ +package app; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.junit.jupiter.api.condition.EnabledIf; + +class SyncTest { + @Test + @DisabledIf("app.Conditions#onCi") + void syncs() { } + + @Test + void reflects() throws Exception { Conditions.class.getMethod("weekend"); } + + @Test + @EnabledIf("app.Weather#holiday") + void elsewhere() { } +} diff --git a/tests/cases/java/registration-key-is-what-registers/case.json b/tests/cases/java/registration-key-is-what-registers/case.json new file mode 100644 index 00000000..f9d75be0 --- /dev/null +++ b/tests/cases/java/registration-key-is-what-registers/case.json @@ -0,0 +1,17 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "@SuppressWarnings names a warning and registers nothing, so its string is no key (#1413)", + "expect_error": true, "run": ["path", "*", "StockSql.legacy"], + "avoid": ["registered under \"unchecked\""]}, + {"why": "a method that only returns the same word is not a caller of the suppressed method (#1413)", + "run": ["impact", "StockSql.legacy"], + "avoid": ["Audit.level"]}, + {"why": "@SelectProvider(type = StockSql.class, method = \"byShelf\") names a member of the type it names: a reference to StockSql.byShelf, not a key for the mapper method (#1413)", + "expect_error": true, "run": ["path", "*", "StockMapper.byShelf"], + "avoid": ["registered under \"byShelf\""]}, + {"why": "@Select SQL text is prose, no key", + "expect_error": true, "run": ["path", "*", "StockMapper.total"], + "avoid": ["registered under"]}, + {"why": "control: a route mapping is still the key its handler is registered under", + "expect_error": true, "run": ["path", "*", "ItemController.touch"], + "want": ["registered as a route \"/items/touch\" by @GetMapping"]}]} diff --git a/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockMapper.java b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockMapper.java new file mode 100644 index 00000000..9f274723 --- /dev/null +++ b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockMapper.java @@ -0,0 +1,13 @@ +package demo.dao; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.SelectProvider; +@Mapper +public interface StockMapper { + @Select("select qty from stock where id = #{id}") + int count(Long id); + @Select("select sum(qty) from stock") + int total(); + @SelectProvider(type = StockSql.class, method = "byShelf") + int byShelf(Long shelf); +} diff --git a/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockSql.java b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockSql.java new file mode 100644 index 00000000..f4aecf05 --- /dev/null +++ b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/dao/StockSql.java @@ -0,0 +1,6 @@ +package demo.dao; +public class StockSql { + public static String byShelf() { return "x"; } + @SuppressWarnings("unchecked") + public static String legacy() { return "y"; } +} diff --git a/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/Audit.java b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/Audit.java new file mode 100644 index 00000000..86a09bd6 --- /dev/null +++ b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/Audit.java @@ -0,0 +1,4 @@ +package demo.service; +public class Audit { + public String level() { return "unchecked"; } +} diff --git a/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/ItemController.java b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/ItemController.java new file mode 100644 index 00000000..eb94152d --- /dev/null +++ b/tests/cases/java/registration-key-is-what-registers/src/main/java/demo/service/ItemController.java @@ -0,0 +1,8 @@ +package demo.service; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +@RestController +public class ItemController { + @GetMapping("/items/touch") + public String touch() { return "ok"; } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/case.json b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/case.json new file mode 100644 index 00000000..e9dc66a5 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/case.json @@ -0,0 +1,20 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a change reached only from @AfterEach is carried to the tests that teardown runs after, as @BeforeEach is (#1417)", + "run": ["impact", "Clock.reset", "--tests"], + "want": ["StockTest::counts", "via BaseTest.rewind"], + "avoid": ["0 of 2 test method(s)"]}, + {"why": "a change reached only from a static @AfterAll is carried to the tests of its class and its subclasses (#1417)", + "run": ["impact", "Registry.shutdown", "--tests"], + "want": ["StockTest::counts", "via BaseTest.down"], + "avoid": ["0 of 2 test method(s)"]}, + {"why": "control: @BeforeEach was already a fixture and still is", + "run": ["impact", "Clock.start", "--tests"], + "want": ["StockTest::counts", "via BaseTest.wind"]}, + {"why": "a TestWatcher's testSuccessful overrides the extension interface: the runner calls it as a callback, so it is no test and the universe is the 2 real tests (#1419)", + "run": ["impact", "Report.pass", "--tests"], + "want": ["0 of 2 test method(s)"], + "avoid": ["TestResultLogger::testSuccessful", "of 4 test method(s)"]}, + {"why": "control: the @Test that calls Report.open is still a test", + "run": ["impact", "Report.open", "--tests"], + "want": ["OrderTest::opens"]}]} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Clock.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Clock.java new file mode 100644 index 00000000..87299a09 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Clock.java @@ -0,0 +1,5 @@ +package app; +public class Clock { + public static void start() { } + public static void reset() { } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Registry.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Registry.java new file mode 100644 index 00000000..0f763c78 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Registry.java @@ -0,0 +1,4 @@ +package app; +public class Registry { + public static void shutdown() { } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Report.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Report.java new file mode 100644 index 00000000..54d9bcd9 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/main/java/app/Report.java @@ -0,0 +1,6 @@ +package app; +public class Report { + public static void pass() { } + public static void fail() { } + public static void open() { } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/BaseTest.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/BaseTest.java new file mode 100644 index 00000000..84be270b --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/BaseTest.java @@ -0,0 +1,16 @@ +package app; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +abstract class BaseTest { + @BeforeEach + void wind() { Clock.start(); } + + @AfterEach + void rewind() { Clock.reset(); } + + @AfterAll + static void down() { Registry.shutdown(); } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/OrderTest.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/OrderTest.java new file mode 100644 index 00000000..cd3bb092 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/OrderTest.java @@ -0,0 +1,10 @@ +package app; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TestResultLogger.class) +class OrderTest { + @Test + void opens() { Report.open(); } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/StockTest.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/StockTest.java new file mode 100644 index 00000000..e46f7a43 --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/StockTest.java @@ -0,0 +1,8 @@ +package app; + +import org.junit.jupiter.api.Test; + +class StockTest extends BaseTest { + @Test + void counts() { } +} diff --git a/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/TestResultLogger.java b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/TestResultLogger.java new file mode 100644 index 00000000..95490e8c --- /dev/null +++ b/tests/cases/java/teardown-is-a-fixture-and-a-callback-is-no-test/src/test/java/app/TestResultLogger.java @@ -0,0 +1,12 @@ +package app; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestWatcher; + +class TestResultLogger implements TestWatcher { + @Override + public void testSuccessful(ExtensionContext c) { Report.pass(); } + + @Override + public void testFailed(ExtensionContext c, Throwable t) { Report.fail(); } +} diff --git a/tests/cases/python/decorator-keyword-is-no-key/app/__init__.py b/tests/cases/python/decorator-keyword-is-no-key/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/decorator-keyword-is-no-key/app/models.py b/tests/cases/python/decorator-keyword-is-no-key/app/models.py new file mode 100644 index 00000000..3af1c96c --- /dev/null +++ b/tests/cases/python/decorator-keyword-is-no-key/app/models.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, model_validator + + +class Widget(BaseModel): + size: int + + @model_validator(mode="before") + @classmethod + def fill(cls, data): + return data diff --git a/tests/cases/python/decorator-keyword-is-no-key/app/modes.py b/tests/cases/python/decorator-keyword-is-no-key/app/modes.py new file mode 100644 index 00000000..e315029a --- /dev/null +++ b/tests/cases/python/decorator-keyword-is-no-key/app/modes.py @@ -0,0 +1,6 @@ +def pick(): + return "before" + + +def verb(): + return "GET" diff --git a/tests/cases/python/decorator-keyword-is-no-key/app/views.py b/tests/cases/python/decorator-keyword-is-no-key/app/views.py new file mode 100644 index 00000000..e9ec00b8 --- /dev/null +++ b/tests/cases/python/decorator-keyword-is-no-key/app/views.py @@ -0,0 +1,8 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/widgets", methods=["GET"]) +def list_widgets(): + return "[]" diff --git a/tests/cases/python/decorator-keyword-is-no-key/case.json b/tests/cases/python/decorator-keyword-is-no-key/case.json new file mode 100644 index 00000000..f3a409b9 --- /dev/null +++ b/tests/cases/python/decorator-keyword-is-no-key/case.json @@ -0,0 +1,14 @@ +{"lang": "python", + "checks": [ + {"why": "a keyword argument that configures a decorator (mode=) is not the key it registers the function under (#1413)", + "expect_error": true, "run": ["path", "*", "Widget.fill"], + "avoid": ["registered under \"before\""]}, + {"why": "so a function that only returns the same word does not reach the validator (#1413)", + "run": ["impact", "Widget.fill"], + "avoid": ["pick"]}, + {"why": "control: the route's positional path is still its key", + "expect_error": true, "run": ["path", "*", "list_widgets"], + "want": ["registered as a route \"/widgets\""]}, + {"why": "the route's methods= list configures it and is no key: a function returning \"GET\" does not reach the handler (#1413)", + "expect_error": true, "run": ["path", "*", "list_widgets"], + "avoid": ["registered under \"GET\""]}]} From fed3ee2d1a8bd1c5158f12cc38531e3ac35ea502 Mon Sep 17 00:00:00 2001 From: swapnil Date: Mon, 28 Sep 2026 20:13:09 -0700 Subject: [PATCH 030/258] python: type class attributes, export async and generator defs, keep pydantic decorators Fixes #1518, #1525, #1534 Refs #1561 Three Python gaps, each where a value's identity was lost one step short. #1518. expr-type.dl typed `.x` from a construction-typed field only when the object had an INSTANCE type, so `Order.objects.open_orders()` was by name while `Order().objects.open_orders()` resolved. New clause (i2c): with a class object in the object position, a field assigned a construction in the CLASS BODY (origin CLASS_BODY_ASSIGN) types the attribute. An attribute written only through `self` does not exist on the class and stays untyped (the near miss in the new case). #1525. The parser's per-module export index, which `from m import name` is resolved against, admitted a module-level def only when its method kind was FUNCTION. `async def` is ASYNC_FUNCTION and a def with `yield` is GENERATOR or ASYNC_GENERATOR, so those imports resolved to a module variable instead of the function, and a value use of the name in the importing module was never counted by impact. The index now admits all four kinds at module level; a nested def of those kinds has an enclosing member and stays out. #1534. pydantic's @validate_call and @computed_field were unknown decorators, assumed to replace their target, so the def left lookup: the caller of a validated function fell to by-name and a computed field had no reader. Both are now builtin decorator kinds (VALIDATE_CALL, COMPUTED_FIELD) that are transparent with one elided hop, like @functools.cache and @cached_property, and a bare @computed_field over a plain def makes it a property getter, as pydantic does. A project-declared decorator still replaces its target. Not fixed: #1561 (a library return type variable bound from a class passed as an argument). The ORM signatures bind through type aliases of unions (`_EntityBindKey[_O]`), overloads, and a parameterized instance type (`Query[_O]` then `.first() -> _T`); the engine's expr_type carries a class only, with no per-instance type arguments, so this needs a new representation rather than one more rule. Tests: engine cases 35-class-attribute-instances and 36-call-through-model-decorators (with near-miss controls: an instance-only attribute read on the class, a class-body string, a project decorator that replaces its target); parser gate `function exports` (fails on the old code for the three kinds, control on a nested async def); plugin case typed-through-class-export-and-wrapper. Suites: graph/test/python/run-tests.sh passed 36 failed 0 (run on this change plus a pending fix the suite needs to get past its first gate); parser python-tests 23/23; tests/run.py --lang python 191 of 192 passed, the one failure is the known pre-existing lambda-is-named-by-its-place. Smoke, fresh index before vs after on two real projects: - a Django project: known_edge 2504 -> 2522, ambiguous_unknown 10156 -> 10138; all 18 moved sites are `Model.objects.()` or a call on its result, each read and correct. impact on one manager method: 10 by name -> 10 resolved. - a FastAPI project: call edges unchanged; METHOD references 2676 -> 2692, IMPORT 3282 -> 3276, GLOBAL_VARIABLE 12839 -> 12829 (imports of async functions now reach the function). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/expression-resolution/expr-type.dl | 13 +++ graph/python/engine/resolution/decorators.dl | 8 ++ .../35-class-attribute-instances/src/main.py | 6 ++ .../src/shop/__init__.py | 0 .../src/shop/models.py | 58 +++++++++++ .../src/app/__init__.py | 0 .../src/app/checkout.py | 21 ++++ .../src/app/orders.py | 61 ++++++++++++ .../35-class-attribute-instances.edges | 10 ++ .../35-class-attribute-instances.entries | 1 + .../35-class-attribute-instances.framework | 5 + .../35-class-attribute-instances.tiers | 25 +++++ .../36-call-through-model-decorators.edges | 17 ++++ .../36-call-through-model-decorators.entries | 1 + ...36-call-through-model-decorators.framework | 5 + .../36-call-through-model-decorators.tiers | 31 ++++++ .../decorators/PythonBuiltinDecoratorKind.ts | 11 +++ .../python-declaration-extractor.ts | 5 +- .../extractors/python-decorator-extractor.ts | 2 + .../extractors/python-resolution-linker.ts | 16 +++- parser/src/schema/python/schema.json | 2 + .../src/test/python-gates/function-exports.ts | 96 +++++++++++++++++++ parser/src/test/python-tests.ts | 3 + .../app/__init__.py | 0 .../app/checkout.py | 10 ++ .../app/jobs.py | 7 ++ .../app/models.py | 27 ++++++ .../app/orders.py | 18 ++++ .../app/registry.py | 3 + .../case.json | 21 ++++ 30 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 graph/test/python/cases/35-class-attribute-instances/src/main.py create mode 100644 graph/test/python/cases/35-class-attribute-instances/src/shop/__init__.py create mode 100644 graph/test/python/cases/35-class-attribute-instances/src/shop/models.py create mode 100644 graph/test/python/cases/36-call-through-model-decorators/src/app/__init__.py create mode 100644 graph/test/python/cases/36-call-through-model-decorators/src/app/checkout.py create mode 100644 graph/test/python/cases/36-call-through-model-decorators/src/app/orders.py create mode 100644 graph/test/python/expected/35-class-attribute-instances.edges create mode 100644 graph/test/python/expected/35-class-attribute-instances.entries create mode 100644 graph/test/python/expected/35-class-attribute-instances.framework create mode 100644 graph/test/python/expected/35-class-attribute-instances.tiers create mode 100644 graph/test/python/expected/36-call-through-model-decorators.edges create mode 100644 graph/test/python/expected/36-call-through-model-decorators.entries create mode 100644 graph/test/python/expected/36-call-through-model-decorators.framework create mode 100644 graph/test/python/expected/36-call-through-model-decorators.tiers create mode 100644 parser/src/test/python-gates/function-exports.ts create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/__init__.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/checkout.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/jobs.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/models.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/orders.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/app/registry.py create mode 100644 tests/cases/python/typed-through-class-export-and-wrapper/case.json diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index 350158a2..8385f783 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -172,6 +172,19 @@ expr_type(p, e, t) :- expr_type(p, obj, ot), type_attr_field(p, ot, n, f), field_type_from_construction(p, f, t). +// (i2c) the SAME hop with a CLASS OBJECT in the object position (#1518): +// `Order.objects.open_orders()` where the class body says `objects = OrderManager()`. +// The clause above joins expr_type, an INSTANCE type, so the class spelling had no +// clause while `Order().objects` resolved. Only a CLASS-BODY assignment is read here: +// an attribute written through `self` in a method does not exist on the class object, +// so typing `Cls.x` from it would name something the class never holds. +expr_type(p, e, t) :- + expr_node(p, "ATTRIBUTE_ACCESS", _, n, e), n != "", + expr_parent(p, e, "ATTRIBUTE_OBJECT", _, obj), + expr_type_class_object(p, obj, ot), + type_attr_field(p, ot, n, f), + field_decl(p, n, _, "CLASS_BODY_ASSIGN", f), + field_type_from_construction(p, f, t). // ── (i3) A PROPERTY READ IS A CALL, SO IT HAS A RETURN TYPE (issue #286) ───── // `attribute-lookup.dl` already argues that `obj.x` on a `@property` is a method call, and // `call_chain.dl` emits the PROPERTY_READ edge from it. Nothing gave the ATTRIBUTE diff --git a/graph/python/engine/resolution/decorators.dl b/graph/python/engine/resolution/decorators.dl index a346c4b7..b9899104 100644 --- a/graph/python/engine/resolution/decorators.dl +++ b/graph/python/engine/resolution/decorators.dl @@ -64,12 +64,20 @@ decorator_transparent("CACHED_PROPERTY"). decorator_transparent("CONTEXTMANAGER"). decorator_transparent("DATACLASS"). decorator_transparent("DEPRECATED"). +// pydantic's @validate_call validates, then calls the def; @computed_field marks a +// property for serialization and a read still runs the getter. Both call through, and +// as unknown decorators they removed the def from lookup: a caller of a validated +// function fell to by-name and a computed field had no reader (#1534). +decorator_transparent("VALIDATE_CALL"). +decorator_transparent("COMPUTED_FIELD"). // ── decorator_elides_hop(BuiltinKind) — transparent, but through a wrapper ─── decorator_elides_hop("LRU_CACHE"). decorator_elides_hop("CACHED_PROPERTY"). decorator_elides_hop("CONTEXTMANAGER"). decorator_elides_hop("DEPRECATED"). +decorator_elides_hop("VALIDATE_CALL"). +decorator_elides_hop("COMPUTED_FIELD"). // ── decorator_opaque(Prov, DecoratorHash) ──────────────────────────────────── // replacesTarget=true AND the builtinKind is not in the modelled set. `@audit("child")` diff --git a/graph/test/python/cases/35-class-attribute-instances/src/main.py b/graph/test/python/cases/35-class-attribute-instances/src/main.py new file mode 100644 index 00000000..10d621c4 --- /dev/null +++ b/graph/test/python/cases/35-class-attribute-instances/src/main.py @@ -0,0 +1,6 @@ +from shop.models import Order, list_via_class + + +def run(): + list_via_class() + return Order.objects.open_orders() diff --git a/graph/test/python/cases/35-class-attribute-instances/src/shop/__init__.py b/graph/test/python/cases/35-class-attribute-instances/src/shop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/35-class-attribute-instances/src/shop/models.py b/graph/test/python/cases/35-class-attribute-instances/src/shop/models.py new file mode 100644 index 00000000..605908b2 --- /dev/null +++ b/graph/test/python/cases/35-class-attribute-instances/src/shop/models.py @@ -0,0 +1,58 @@ +"""35 -- a class attribute holding an instance, reached on the CLASS (#1518). + +INTENT: `objects = OrderManager()` in a class body puts one OrderManager on the +class object, so `Order.objects.open_orders()` calls OrderManager.open_orders +exactly as `Order().objects.open_orders()` does. The instance spelling was typed +and the class spelling was not. + +Controls: + audit written through `self` in __init__: it exists on instances only, + so `Order.audit` names nothing the class holds and stays untyped + SpecialOrder inherits `objects`, so the class spelling on the subclass reaches + the same manager through the MRO + kind a class-body assignment of a string: no construction, no type +""" + + +class OrderManager: + def open_orders(self): + return [] + + +class AuditLog: + def entries(self): + return [] + + +class Order: + objects = OrderManager() + kind = "order" + + def __init__(self): + self.audit = AuditLog() + + +class SpecialOrder(Order): + pass + + +def list_via_class(): + return Order.objects.open_orders() + + +def list_via_instance(): + return Order().objects.open_orders() + + +def list_via_subclass(): + return SpecialOrder.objects.open_orders() + + +def audit_via_class(): + # Near miss: an instance-only attribute read on the class. + return Order.audit.entries() + + +def kind_via_class(): + # Near miss: a class-body value that is not a construction. + return Order.kind.upper() diff --git a/graph/test/python/cases/36-call-through-model-decorators/src/app/__init__.py b/graph/test/python/cases/36-call-through-model-decorators/src/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/36-call-through-model-decorators/src/app/checkout.py b/graph/test/python/cases/36-call-through-model-decorators/src/app/checkout.py new file mode 100644 index 00000000..82252183 --- /dev/null +++ b/graph/test/python/cases/36-call-through-model-decorators/src/app/checkout.py @@ -0,0 +1,21 @@ +from app.orders import Order, apply_coupon, apply_discount, apply_tax, replaced + + +def checkout(price: float): + return apply_discount(price) + + +def checkout_coupon(price: float): + return apply_coupon(price) + + +def checkout_taxed(price: float): + return apply_tax(price) + + +def checkout_replaced(price: float): + return replaced(price) + + +def show(order: Order): + return order.grand_total.hex(), order.tax_due, order.net_total diff --git a/graph/test/python/cases/36-call-through-model-decorators/src/app/orders.py b/graph/test/python/cases/36-call-through-model-decorators/src/app/orders.py new file mode 100644 index 00000000..388b9a84 --- /dev/null +++ b/graph/test/python/cases/36-call-through-model-decorators/src/app/orders.py @@ -0,0 +1,61 @@ +"""36 -- decorators that validate or mark, then call the decorated def (#1534). + +INTENT: pydantic's `@validate_call` validates the arguments and calls the +function; `@computed_field` marks a property for serialization and a read still +runs the getter. Both call through, like `@functools.cache`, so the name at the +call site still reaches the `def`. As unknown decorators they were assumed to +replace their target and the def left lookup: the caller fell to by-name and the +computed field had no reader. + +Controls: + apply_tax undecorated: resolved before and after + net_total plain @property: resolved before and after + replaced an unknown decorator still replaces its target, so its caller + stays unresolved +""" +from pydantic import BaseModel, computed_field, validate_call + +import pydantic + + +def audit(fn): + def wrapper(*args, **kwargs): + return fn(*args, **kwargs) + + return wrapper + + +@validate_call +def apply_discount(price: float) -> float: + return round(price * 0.9, 2) + + +@pydantic.validate_call(validate_return=True) +def apply_coupon(price: float) -> float: + return price - 1 + + +def apply_tax(price: float) -> float: + return round(price * 1.2, 2) + + +@audit +def replaced(price: float) -> float: + return price + + +class Order(BaseModel): + total: float + + @computed_field + @property + def grand_total(self) -> float: + return self.total * 1.2 + + @computed_field + def tax_due(self) -> float: + return self.total * 0.2 + + @property + def net_total(self) -> float: + return self.total diff --git a/graph/test/python/expected/35-class-attribute-instances.edges b/graph/test/python/expected/35-class-attribute-instances.edges new file mode 100644 index 00000000..e5453a7a --- /dev/null +++ b/graph/test/python/expected/35-class-attribute-instances.edges @@ -0,0 +1,10 @@ +ambiguous_unknown METHOD_CALL shop.models.audit_via_class -> - +ambiguous_unknown METHOD_CALL shop.models.kind_via_class -> - +boundary_lib SIMPLE_CALL shop.models.Order. -> builtin:object.__init__ +boundary_lib SIMPLE_CALL shop.models.Order.__init__ -> builtin:object.__init__ +known_edge METHOD_CALL main.run -> shop.models.OrderManager.open_orders +known_edge METHOD_CALL shop.models.list_via_class -> shop.models.OrderManager.open_orders +known_edge METHOD_CALL shop.models.list_via_instance -> shop.models.OrderManager.open_orders +known_edge METHOD_CALL shop.models.list_via_subclass -> shop.models.OrderManager.open_orders +known_edge SIMPLE_CALL main.run -> shop.models.list_via_class +known_edge SIMPLE_CALL shop.models.list_via_instance -> shop.models.Order.__init__ diff --git a/graph/test/python/expected/35-class-attribute-instances.entries b/graph/test/python/expected/35-class-attribute-instances.entries new file mode 100644 index 00000000..276e39fa --- /dev/null +++ b/graph/test/python/expected/35-class-attribute-instances.entries @@ -0,0 +1 @@ +── entry_point (0) ── diff --git a/graph/test/python/expected/35-class-attribute-instances.framework b/graph/test/python/expected/35-class-attribute-instances.framework new file mode 100644 index 00000000..b9cbca06 --- /dev/null +++ b/graph/test/python/expected/35-class-attribute-instances.framework @@ -0,0 +1,5 @@ +── framework_edge (0) ── +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/35-class-attribute-instances.tiers b/graph/test/python/expected/35-class-attribute-instances.tiers new file mode 100644 index 00000000..18e130a8 --- /dev/null +++ b/graph/test/python/expected/35-class-attribute-instances.tiers @@ -0,0 +1,25 @@ +distinct call sites emitted: 10 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 2 rows 2 sites ambiguous_unknown + 2 rows 2 sites boundary_lib + 6 rows 6 sites known_edge + +--- edge rows by call kind --- + 6 METHOD_CALL + 4 SIMPLE_CALL + +--- unresolved reasons --- + 2 no_rule + +--- the engine's own conservation ledger --- + 10 _total_sites + 2 ambiguous_unknown + 2 boundary_lib + 6 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 10 + minus extra rows from multi-target sites 0 + = tier/site pairs 10 + engine's conserved site total 10 diff --git a/graph/test/python/expected/36-call-through-model-decorators.edges b/graph/test/python/expected/36-call-through-model-decorators.edges new file mode 100644 index 00000000..266e2812 --- /dev/null +++ b/graph/test/python/expected/36-call-through-model-decorators.edges @@ -0,0 +1,17 @@ +ambiguous_unknown DECORATOR_APPLICATION app.orders. -> - +ambiguous_unknown DECORATOR_BARE app.orders. -> - +ambiguous_unknown DECORATOR_BARE app.orders.Order. -> - +boundary_lib DECORATOR_BARE app.orders.Order. -> builtin:property +boundary_lib DECORATOR_CALL app.orders. -> external:pydantic.validate_call +boundary_lib METHOD_CALL app.checkout.show -> builtin:float.hex +boundary_lib SIMPLE_CALL app.orders.apply_discount -> builtin:round +boundary_lib SIMPLE_CALL app.orders.apply_tax -> builtin:round +known_edge DECORATOR_BARE app.orders. -> app.orders.audit +known_edge PROPERTY_READ app.checkout.show -> app.orders.Order.grand_total +known_edge PROPERTY_READ app.checkout.show -> app.orders.Order.net_total +known_edge PROPERTY_READ app.checkout.show -> app.orders.Order.tax_due +known_edge SIMPLE_CALL app.checkout.checkout -> app.orders.apply_discount +known_edge SIMPLE_CALL app.checkout.checkout_coupon -> app.orders.apply_coupon +known_edge SIMPLE_CALL app.checkout.checkout_replaced -> app.orders.audit..wrapper +known_edge SIMPLE_CALL app.checkout.checkout_taxed -> app.orders.apply_tax +known_edge SIMPLE_CALL app.orders.audit..wrapper -> app.orders.replaced diff --git a/graph/test/python/expected/36-call-through-model-decorators.entries b/graph/test/python/expected/36-call-through-model-decorators.entries new file mode 100644 index 00000000..276e39fa --- /dev/null +++ b/graph/test/python/expected/36-call-through-model-decorators.entries @@ -0,0 +1 @@ +── entry_point (0) ── diff --git a/graph/test/python/expected/36-call-through-model-decorators.framework b/graph/test/python/expected/36-call-through-model-decorators.framework new file mode 100644 index 00000000..b9cbca06 --- /dev/null +++ b/graph/test/python/expected/36-call-through-model-decorators.framework @@ -0,0 +1,5 @@ +── framework_edge (0) ── +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/36-call-through-model-decorators.tiers b/graph/test/python/expected/36-call-through-model-decorators.tiers new file mode 100644 index 00000000..2e14b72c --- /dev/null +++ b/graph/test/python/expected/36-call-through-model-decorators.tiers @@ -0,0 +1,31 @@ +distinct call sites emitted: 19 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 4 rows 4 sites ambiguous_unknown + 6 rows 6 sites boundary_lib + 9 rows 9 sites known_edge + +--- edge rows by call kind --- + 1 DECORATOR_APPLICATION + 6 DECORATOR_BARE + 1 DECORATOR_CALL + 1 METHOD_CALL + 3 PROPERTY_READ + 7 SIMPLE_CALL + +--- unresolved reasons --- + 1 decorator_factory_result_untyped + 3 unmodelled_decorator + +--- the engine's own conservation ledger --- + 16 _total_sites + 4 ambiguous_unknown + 6 boundary_lib + 6 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 19 + minus extra rows from multi-target sites 0 + = tier/site pairs 19 + engine's conserved site total 16 + of which 3 are PROPERTY_READ, an edge with no call site (README decision 5) diff --git a/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts b/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts index 7757f8b6..06579620 100644 --- a/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts +++ b/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts @@ -29,6 +29,17 @@ export enum PythonBuiltinDecoratorKind { * wrapper), like LRU_CACHE. */ DEPRECATED = 'DEPRECATED', + /** + * pydantic `@validate_call`: validates the arguments, then calls the decorated + * function. A call-through wrapper like LRU_CACHE (#1534). + */ + VALIDATE_CALL = 'VALIDATE_CALL', + /** + * pydantic `@computed_field`: marks a property (or a bare def it wraps as one) for + * serialization; a read still runs the getter. A call-through wrapper like + * CACHED_PROPERTY (#1534). + */ + COMPUTED_FIELD = 'COMPUTED_FIELD', /** `typing.no_type_check`: sets an attribute on the function and returns it. Metadata only. */ NO_TYPE_CHECK = 'NO_TYPE_CHECK', /** Not a decorator the language defines. */ diff --git a/parser/src/parsers/python/extractors/python-declaration-extractor.ts b/parser/src/parsers/python/extractors/python-declaration-extractor.ts index e751e864..fc714bd4 100644 --- a/parser/src/parsers/python/extractors/python-declaration-extractor.ts +++ b/parser/src/parsers/python/extractors/python-declaration-extractor.ts @@ -1662,8 +1662,11 @@ export class PythonDeclarationExtractor { // modifier — it never suppresses the field. Checked both ways on a class that declares // the getter and also writes the attribute: the field keeps its row and its origin, and // the row is identical to the one the `@property` spelling produces. + // pydantic's `@computed_field` wraps a bare def as a property, so over a plain def it + // is a getter too; over `@property` the test above already matches (#1534). if (decoratorNames.some(d => d === 'property' || d.endsWith('.property') - || d === 'cached_property' || d.endsWith('.cached_property'))) { + || d === 'cached_property' || d.endsWith('.cached_property') + || d === 'computed_field' || d.endsWith('.computed_field'))) { return PythonMethodKind.PROPERTY_GETTER; } if (decoratorNames.some(d => d.endsWith('.setter'))) { diff --git a/parser/src/parsers/python/extractors/python-decorator-extractor.ts b/parser/src/parsers/python/extractors/python-decorator-extractor.ts index 339b4ca1..9f507bfd 100644 --- a/parser/src/parsers/python/extractors/python-decorator-extractor.ts +++ b/parser/src/parsers/python/extractors/python-decorator-extractor.ts @@ -61,6 +61,8 @@ const BUILTIN_DECORATORS: ReadonlyMap = PYTHON_BUILTIN_NAMES; +// The kinds a module-level `def` can take, whichever its body makes it. +const MODULE_FUNCTION_KINDS: ReadonlySet = new Set([ + PythonMethodKind.FUNCTION, + PythonMethodKind.ASYNC_FUNCTION, + PythonMethodKind.GENERATOR, + PythonMethodKind.ASYNC_GENERATOR, +]); + /** * NOTE: the HAS_GETATTR / HAS_SETATTR escape-hatch check was removed along with * `hasEscapeHatch`, which nothing called. The reasoning is worth keeping: a @@ -202,7 +210,13 @@ export class PythonResolutionLinker { for (const method of module.methods) { // Module-level functions only: a method belongs to its class, not to the // module namespace. - if (method.getPyTypeLinkHash() === '' && method.getMethodKind() === PythonMethodKind.FUNCTION) { + // An `async def`, a generator and an async generator at module level are module + // functions too: their kind names what a call returns, not where they live. Left + // out, `from m import job` got no entity, so a value use of `job` in another + // module was an IMPORT reference that impact never counted (#1525). A nested def + // of those kinds has an enclosing member and stays out. + if (method.getPyTypeLinkHash() === '' && method.getEnclosingMemberLinkHash() === '' + && MODULE_FUNCTION_KINDS.has(method.getMethodKind())) { add(method.getName(), method); } } diff --git a/parser/src/schema/python/schema.json b/parser/src/schema/python/schema.json index 3f7aadea..b7374690 100644 --- a/parser/src/schema/python/schema.json +++ b/parser/src/schema/python/schema.json @@ -636,6 +636,7 @@ "ABSTRACTMETHOD", "CACHED_PROPERTY", "CLASSMETHOD", + "COMPUTED_FIELD", "CONTEXTMANAGER", "DATACLASS", "DELETER", @@ -648,6 +649,7 @@ "PROPERTY", "SETTER", "STATICMETHOD", + "VALIDATE_CALL", "WRAPS" ] } diff --git a/parser/src/test/python-gates/function-exports.ts b/parser/src/test/python-gates/function-exports.ts new file mode 100644 index 00000000..899464a8 --- /dev/null +++ b/parser/src/test/python-gates/function-exports.ts @@ -0,0 +1,96 @@ +/** + * A module-level `async def`, generator or async generator is a module export (#1525). + * + * `from jobs import sync_orders` is resolved against a per-module export index. That + * index admitted a module-level function only when its method kind was FUNCTION, and + * the kind of a `def` names what a call RETURNS: `async def` is ASYNC_FUNCTION, one + * with `yield` is GENERATOR or ASYNC_GENERATOR. So the import of any of those got no + * entity, and a value use of the name in the importing module (a callback, a job list) + * was an IMPORT reference that impact never counted, while the same use of a plain + * `def` was a METHOD reference. + * + * Controls: a plain `def` resolves as before, and a nested `async def` is still not an + * export (it has an enclosing member, so importing it resolves to no function). + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { PythonProjectAnalyzer } from '@/workflows/python/python-project-analyzer'; + +const JOBS = [ + 'async def sync_orders(): return 1', + 'def stream_orders(): yield 1', + 'async def stream_users(): yield 1', + 'def count_orders(): return 1', + 'def outer():', + ' async def inner(): return 1', + ' return inner', + '', +].join('\n'); + +const REGISTRY = [ + 'from jobs import count_orders, inner, stream_orders, stream_users, sync_orders', + '', + 'JOBS = [sync_orders, stream_orders, stream_users, count_orders, inner]', + '', +].join('\n'); + +/** imported name -> [resolves to the module function of that name, what it proves] */ +const EXPECTED: ReadonlyArray = [ + ['sync_orders', true, 'an async def'], + ['stream_orders', true, 'a generator'], + ['stream_users', true, 'an async generator'], + ['count_orders', true, 'control: a plain def'], + ['inner', false, 'control: a nested async def is not a module export'], +]; + +export async function functionExports(): Promise { + const problems: string[] = []; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'py-fnexports-')); + const source = path.join(root, 'src'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'jobs.py'), JOBS); + fs.writeFileSync(path.join(source, 'registry.py'), REGISTRY); + + const outputDir = path.join(root, 'out'); + await new PythonProjectAnalyzer().analyze({ + rootDir: source, + outputDir, + baseMservPath: '/repo', + serviceVersionLinkHash: 'SERVICE_VERSION_' + '0'.repeat(32), + }); + + const tsv = (file: string): Record[] => { + const lines = fs.readFileSync(path.join(outputDir, file), 'utf-8').split('\n').filter(Boolean); + const header = lines[0]!.split('\t'); + return lines.slice(1).map((l) => { + const cells = l.split('\t'); + return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ''])); + }); + }; + const methods = new Map(tsv('all-python-methods.csv').map((m) => [m.pyMethodUniqueHash, m.name])); + const imports = tsv('all-python-imports.csv'); + let checked = 0; + for (const [name, exported, what] of EXPECTED) { + const row = imports.find((i) => i.originalName === name || i.simpleName === name); + if (!row) { + problems.push(`${name}: no import row`); + continue; + } + checked++; + const isFunction = row.resolvedTargetKind === 'FUNCTION' && methods.get(row.resolvedTargetHash) === name; + if (isFunction !== exported) { + problems.push( + `${name} (${what}): resolved to ${row.resolvedTargetKind} ${row.resolvedTargetHash}, ` + + `expected ${exported ? 'the FUNCTION of that name' : 'no module function'}` + ); + } + } + if (checked < EXPECTED.length) problems.push(`only ${checked} of ${EXPECTED.length} imports checked`); + + fs.rmSync(root, { recursive: true, force: true }); + console.log(` ${checked} imported names checked`); + for (const problem of problems) console.log(` FAIL ${problem}`); + return problems.length === 0 ? 0 : 1; +} diff --git a/parser/src/test/python-tests.ts b/parser/src/test/python-tests.ts index feaf252a..5215d0dc 100644 --- a/parser/src/test/python-tests.ts +++ b/parser/src/test/python-tests.ts @@ -38,6 +38,7 @@ import { splatCallee } from './python-gates/splat-callee'; import { cachedProperty } from './python-gates/cached-property'; import { fieldLines } from './python-gates/field-lines'; import { pep604Union } from './python-gates/pep604-union'; +import { functionExports } from './python-gates/function-exports'; const VERIFIED = 'src/test-data/python/verified'; const GOLDEN = path.join(VERIFIED, '_golden'); @@ -596,6 +597,8 @@ const CHECKS: Check[] = [ proves: 'async for and async with are distinguishable from their sync forms, so the right protocol edge can be chosen' }, { name: 'soft-keyword type call', run: softKeywordTypeCall, proves: 'type(obj).attr = v is an assignment through a call, and the subscript form is not' }, + { name: 'function exports', run: functionExports, + proves: 'a module-level async def, generator or async generator is importable by name, as a plain def is' }, { name: 'construct x position', run: constructPositions, proves: 'constructs hold in EVERY syntactic position, not just the one the corpus uses' }, ]; diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/__init__.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/checkout.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/checkout.py new file mode 100644 index 00000000..d9ff657e --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/app/checkout.py @@ -0,0 +1,10 @@ +from app.orders import Order, apply_discount, apply_tax + +def checkout(price: float): + return apply_discount(price) + +def checkout_taxed(price: float): + return apply_tax(price) + +def show(order: Order): + return order.grand_total, order.net_total diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/jobs.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/jobs.py new file mode 100644 index 00000000..3bbd7832 --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/app/jobs.py @@ -0,0 +1,7 @@ +async def sync_orders(): return 1 +def stream_orders(): yield 1 +async def stream_users(): yield 1 +def count_orders(): return 1 +def outer(): + async def inner(): return 1 + return inner diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/models.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/models.py new file mode 100644 index 00000000..63ea5464 --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/app/models.py @@ -0,0 +1,27 @@ +class OrderManager: + def open_orders(self): + return [] + + +class AuditLog: + def entries(self): + return [] + + +class Order: + objects = OrderManager() + + def __init__(self): + self.audit = AuditLog() + + +def list_via_class(): + return Order.objects.open_orders() + + +def list_via_instance(): + return Order().objects.open_orders() + + +def audit_via_class(): + return Order.audit.entries() diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/orders.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/orders.py new file mode 100644 index 00000000..14bc370d --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/app/orders.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, computed_field, validate_call + +@validate_call +def apply_discount(price: float) -> float: + return round(price * 0.9, 2) + +def apply_tax(price: float) -> float: + return round(price * 1.2, 2) + +class Order(BaseModel): + total: float + @computed_field + @property + def grand_total(self) -> float: + return self.total * 1.2 + @property + def net_total(self) -> float: + return self.total diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/app/registry.py b/tests/cases/python/typed-through-class-export-and-wrapper/app/registry.py new file mode 100644 index 00000000..654a3878 --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/app/registry.py @@ -0,0 +1,3 @@ +from .jobs import count_orders, stream_orders, stream_users, sync_orders + +JOBS = [sync_orders, stream_orders, stream_users, count_orders] diff --git a/tests/cases/python/typed-through-class-export-and-wrapper/case.json b/tests/cases/python/typed-through-class-export-and-wrapper/case.json new file mode 100644 index 00000000..964e1bab --- /dev/null +++ b/tests/cases/python/typed-through-class-export-and-wrapper/case.json @@ -0,0 +1,21 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a class attribute assigned a construction in the class body is typed on the CLASS spelling as on the instance one, so `Order.objects.open_orders()` resolves (#1518)", + "run": ["impact", "OrderManager.open_orders"], + "want": ["[resolved] list_via_class app/models.py:19", "[resolved] list_via_instance app/models.py:23"]}, + {"why": "near miss: an attribute written through self exists only on instances, so `Order.audit.entries()` stays a by-name lead (#1518)", + "run": ["impact", "AuditLog.entries"], + "want": ["[by name] audit_via_class"], + "avoid": ["[resolved] audit_via_class"]}, + {"why": "a module-level async def, generator and async generator are exports like a plain def, so a value use of each in another module is counted (#1525)", + "run": ["impact", "sync_orders"], + "want": ["registry. app/registry.py:3"]}, + {"why": "same for an async generator (#1525)", + "run": ["impact", "stream_users"], + "want": ["registry. app/registry.py:3"]}, + {"why": "@validate_call calls the decorated def, so its caller is resolved as for an undecorated function (#1534)", + "run": ["impact", "apply_discount"], + "want": ["[resolved] checkout app/checkout.py:4"]}, + {"why": "@computed_field over @property still reads through the getter, so the reader is resolved (#1534)", + "run": ["impact", "app/orders.py:14"], + "want": ["[resolved] show app/checkout.py:10"]}]} From f6987a2d4107492c40f8fff7e05614babdde2f58 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:31 -0700 Subject: [PATCH 031/258] schema doc: field_access kinds and tiers list csharp The C# field access change writes read, write and readwrite rows with the known_edge, multi_inferred and boundary_lib tiers, but SCHEMA.md was not regenerated, so the bundle test in the engine suites stops on a stale schema doc. --- graph/bundle/SCHEMA.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index ff013e58..b0222d9e 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -760,29 +760,30 @@ THE DATA GRAPH. One row per (site, resolved field), and the answer to "who reads | value | languages | meaning | |---|---|---| -| `read` | java, typescript | The value is used and not replaced. | -| `write` | java, typescript | The value is replaced without being read: a plain assignment `f = v`. | -| `readwrite` | java, typescript | The value is read and replaced at the one site: a compound assignment `f += v`, or `f++` / `--f`. One row, not two — a consumer asking "who writes f" and one asking "who reads f" must both match it. | +| `read` | java, typescript, csharp | The value is used and not replaced. | +| `write` | java, typescript, csharp | The value is replaced without being read: a plain assignment `f = v`. | +| `readwrite` | java, typescript, csharp | The value is read and replaced at the one site: a compound assignment `f += v`, or `f++` / `--f`. One row, not two — a consumer asking "who writes f" and one asking "who reads f" must both match it. | **`field_access.tier` values** | value | languages | meaning | |---|---|---| -| `known_edge` | java, typescript | Exactly one field resolved. Stronger than the call_edges tier of the same name: a field is not virtually dispatched, so this IS the storage location the access binds to. | -| `multi_inferred` | java, typescript | A sound SET: the receiver has more than one possible type, or two unrelated ancestors declare the name (which Java itself treats as ambiguous). Each member is one row. | -| `boundary_lib` | java, typescript | The field is declared in a staged library type. field_id is set and resolves in `fields` with provenance lib. | +| `known_edge` | java, typescript, csharp | Exactly one field resolved. Stronger than the call_edges tier of the same name: a field is not virtually dispatched, so this IS the storage location the access binds to. | +| `multi_inferred` | java, typescript, csharp | A sound SET: the receiver has more than one possible type, or two unrelated ancestors declare the name (which Java itself treats as ambiguous). Each member is one row. | +| `boundary_lib` | java, typescript, csharp | The field is declared in a staged library type. field_id is set and resolves in `fields` with provenance lib. | | `ambiguous_unknown` | java, typescript | Declared blind spot: the receiver could not be typed, or the name is not a member of the type it was typed to. field_id is NULL. Never dropped, and never replaced by a match on simple name. | **`field_access.field_provenance` values** | value | languages | meaning | |---|---|---| -| `client` | java, typescript | The field is declared in the analysed project. | -| `lib` | java, typescript | The field is declared in a staged library IR. | +| `client` | java, typescript, csharp | The field is declared in the analysed project. | +| `lib` | java, typescript, csharp | The field is declared in a staged library IR. | **Notes** -- **all** — JAVA AND TYPESCRIPT. The table is declared in every bundle and is EMPTY for Python, JavaScript and C# (C# fills `fields`, not `field_access`), so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field". +- **all** — JAVA, TYPESCRIPT AND C#. The table is declared in every bundle and is EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field". +- **csharp** — ONLY RESOLVED SITES ARE ROWS: there is no ambiguous_unknown tier for C#. A C# member access is a field, a property, an event or a method group until resolution says which, so an unresolved one is not known to be a field access (#1445). A PROPERTY is not here either: reading it is a call, in call_edges with kind property_read or property_write. Enum members are not rows, because `fields` does not list them. - **typescript** — AN ACCESSOR IS NOT HERE. `get url()` read as `c.url` is a CALL, and call_edges already carries it with kind PROPERTY_READ or PROPERTY_WRITE (#703). The two tables are disjoint by construction: this one holds properties, call_edges holds accessors. Ask both when you want every read of a member. - **typescript** — AN ELEMENT ACCESS IS NOT HERE either: `obj["x"]` with a literal key is a different node kind and is not yet a site. A known gap, not a silent one. - **typescript** — A METHOD IS NOT A SITE. The callee of `obj.m()` is a PROPERTY_ACCESS node (37% of them, measured on one TypeScript library), and `const f = obj.m` reads a method as a value; neither is a data edge, and admitting them would fill the ambiguous_unknown tier with sites the engine HAS resolved elsewhere. Both are excluded and counted in ext_field_site_excluded with reasons method_callee and method_value. From 92d8bc8f0970cf1f2ac2e1e84edaa0767fd76592 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:47:56 -0700 Subject: [PATCH 032/258] csharp: a const read the engine now resolves is a resolved reader What was wrong The control check of tests/cases/csharp/same-file-is-not-a-dependent wanted `Keys.Read` as the one reader of `Keys.Key` at the [in scope] tier. That tier came from a same-type name match, because the C# engine had no field_access rule. Since "csharp: resolve field reads and writes", the engine binds the read in `Read() => Key` (field_access, known_edge, line 20), so impact lists the same method, on the same line, as [resolved]. The reader was never lost: the check pinned the old, weaker tier and failed on the stronger one. The pipeline registration golden (graph/test/csharp/pipelines/expected.hops) had the same shape of staleness. The method group hand-off rule ("csharp: event handlers, method group hand-offs, ...") emits callback_registered from MapOrders to each endpoint handler it passes as a method group (Create, Get, Lines, Health), which is that rule's stated behaviour; the golden listed only the filter rows, so pipelines-test failed and stopped run-tests.sh before the remote-edge and target-typed tests ran. The change - same-file-is-not-a-dependent: the control wants "1 callable(s): 1 resolved" and the [resolved] Keys.Read row at src/Wiring.cs:20. The near-miss stays: the three same-file neighbours are [alongside] and the answer must not count 4 readers. - pipelines/expected.hops: the four registered rows added. The wraps controls (Health is wrapped by no filter) are unchanged. No engine or plugin source changes. Suites (on this commit, against origin/0.1.9 run the same way) - tests/run.py --lang csharp: 121/123. The two failures are unmodelled-entry-not-local's two #1446 checks, which fail on origin/0.1.9 too. origin/0.1.9 itself: 57/62 with 4 FAIL and 1 PEND. - graph/test/csharp/run-tests.sh with the Roslyn oracle: 18/18 cases, every tool test ok, remote-edge-test 7/7. origin/0.1.9: 18/18, all ok. No corpus run: only a test case and a golden changed, so engine output on real projects cannot move. Not yet measured on the full corpus or on held-out projects. --- graph/test/csharp/pipelines/expected.hops | 4 ++++ tests/cases/csharp/same-file-is-not-a-dependent/case.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/graph/test/csharp/pipelines/expected.hops b/graph/test/csharp/pipelines/expected.hops index 7b2de41a..d1a0f06a 100644 --- a/graph/test/csharp/pipelines/expected.hops +++ b/graph/test/csharp/pipelines/expected.hops @@ -4,6 +4,10 @@ registered Shop.DataSetup. Shop.EventsInterceptor.SavingChangesAsync registered Shop.DataSetup.AddData Shop.Seeder.ExecuteAsync registered Shop.OrderEndpoints.MapOrders Shop.IdempotencyFilter.InvokeAsync registered Shop.OrderEndpoints.MapOrders Shop.NonEmptyFilter.InvokeAsync +registered Shop.OrderEndpoints.MapOrders Shop.OrderEndpoints.Create +registered Shop.OrderEndpoints.MapOrders Shop.OrderEndpoints.Get +registered Shop.OrderEndpoints.MapOrders Shop.OrderEndpoints.Health +registered Shop.OrderEndpoints.MapOrders Shop.OrderEndpoints.Lines registered Shop.OrderEndpoints.MapOrders Shop.TenantFilter.InvokeAsync registered Shop.OrdersHost. Shop.ErrorInterceptor.UnaryServerHandler registered Shop.OrdersHost.Configure Shop.CorrelationInterceptor.AsyncUnaryCall diff --git a/tests/cases/csharp/same-file-is-not-a-dependent/case.json b/tests/cases/csharp/same-file-is-not-a-dependent/case.json index ccea1353..1193f78e 100644 --- a/tests/cases/csharp/same-file-is-not-a-dependent/case.json +++ b/tests/cases/csharp/same-file-is-not-a-dependent/case.json @@ -9,9 +9,9 @@ "stdout_json": true, "want": ["\"alongside\": [", "\"display\": \"Registrar.Wire\",\n \"role\": \"co-located\"", "\"display\": \"Caller.Run\",\n \"role\": \"uses\""], "avoid": ["\"display\": \"Registrar.Wire\",\n \"role\": \"uses\""]}, - {"why": "a const's same-file neighbours are not its readers; the method that reads it stays one (control)", + {"why": "a const's same-file neighbours are not its readers; the method that reads it stays one, resolved by the engine's field read (control)", "run": ["impact", "Keys.Key"], - "want": ["reads or uses it (1 callable(s): 1 in scope):", "Keys.Read src/Wiring.cs:20 — reads it", "[alongside] Note."], + "want": ["reads or uses it (1 callable(s): 1 resolved):", "[resolved] Keys.Read src/Wiring.cs:20 — reads it", "[alongside] Note."], "avoid": ["reads or uses it (4 callable(s)"]}, {"why": "control: with only alongside rows, nothing directly touches it, and the answer says so", "run": ["impact", "Registrar.Wire"], From 77fed9d111cd851d9a0b5c60f5d97856351e5144 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:08:31 -0700 Subject: [PATCH 033/258] java: a JPA callback is an entry point only on an entity, a mapped superclass or a named listener Two Java engine goldens failed once the batch was rebased onto 0.1.9. 66-entity-lifecycle-callbacks (config changed). The orm_hook entry point rule in graph/java/engine/config-resolution/entry-points.dl matched any method carrying @PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove or @PostLoad, whatever its class. Its comment said "on an entity, a mapped superclass or an @EntityListeners class", but the rule did not check the owner. The case added for entity lifecycle callbacks has a control for this: NoteStamps carries @PrePersist and no entity names it, so the provider never runs it. It came out as an orm_hook entry point, so impact called it framework-called when nothing runs it. The rule now requires the owner to be an @Entity or @MappedSuperclass type, or a class named in an @EntityListeners argument (jpa_callback_owner). The golden takes the 8 orm_hook rows that are correct for the case source: Item's two callbacks (an @Entity), Audited#touch (a @MappedSuperclass), and the callbacks of ItemStamps and ItemSearch (named by Item) and AuditTrail (named by Audited). NoteStamps#onCreate stays out. The entity_callback call edges from save, persist and delete do not change. 67-framework-registered-entry-points (type use changed). The golden was blessed with a parser build that did not have the batch's own field extractor change (#1452), which records the type references a field annotation creates. With the parser built from this tree the case gains: known_edge ANNOTATION_PARAM app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.PriceConverter from @Convert(converter = PriceConverter.class) on Widget.price. This row is correct for the source. It has the same shape as the two rows already in the golden for Widget's type annotations (WidgetAudit and Unregistered), so it is re-blessed. With the 0.1.9 parser the case passes unchanged, which confirms the cause. Suites, on the rebased tree: - graph/test/java/run-tests.sh (JDK 23, no torture): 76 passed, 0 failed. Base 0.1.9: 70 passed, 0 failed. - graph/test/csharp/run-tests.sh with a built oracle: base and candidate both exit 0 (cases 18 passed, 0 failed). The script now reads AXIOM_CS_ORACLE, the path of a built oracle. The oracle's build output is gitignored, so a fresh worktree has none and the suite skips with exit 77. - tests/run.py --lang java: 238 of 238 checks in 62 cases. No corpus smoke was run: the change narrows one entry-point rule and fixes two goldens. Not yet measured on the full corpus or on held-out projects; there are no corpus numbers for this change. --- graph/java/engine/config-resolution/entry-points.dl | 9 ++++++++- graph/java/souffle/decls_all.dl | 1 + graph/test/csharp/run-tests.sh | 6 ++++-- .../java/expected/66-entity-lifecycle-callbacks.config | 10 +++++++++- .../67-framework-registered-entry-points.type-use | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/graph/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl index 8877c4ce..0cad1c7b 100644 --- a/graph/java/engine/config-resolution/entry-points.dl +++ b/graph/java/engine/config-resolution/entry-points.dl @@ -127,7 +127,14 @@ config_entry_point(m, kind, fm) :- cfg_factory(prov, a, fm, _), ann_arg(prov, a, // (4) annotation-driven container callbacks that annotation_flow.dl does not cover. config_entry_point(m, "lifecycle", m) :- ann_on_method(_, _, n, m, _), cfg_lifecycle_ann(n). // JPA entity callbacks, on an entity, a mapped superclass or an @EntityListeners class. -config_entry_point(m, "orm_hook", m) :- ann_on_method(_, _, n, m, _), cfg_jpa_callback_ann(n). +// Only those: the provider never runs a @PrePersist on any other class, so a callback +// annotation on a class no entity names is not an entry point (it stays unreachable, and +// call-edge-generation/entity_lifecycle.dl gives it no caller either). +config_entry_point(m, "orm_hook", m) :- ann_on_method(_, _, n, m, _), cfg_jpa_callback_ann(n), + method_owner("client", t, m), jpa_callback_owner(t). +jpa_callback_owner(t) :- ann_on_type("client", _, n, t), cfg_entity_ann(n). +jpa_callback_owner(t) :- ann_arg(prov, a, arg, _, "CLASS_REFERENCE", _), cfg_ann_name_of(prov, a, n), + cfg_entity_listeners_ann(n), cfg_registering_class_arg(n, arg, _), cfg_ann_class_arg(prov, a, arg, t). // A JAX-RS @QueryParam / @PathParam / … parameter of a client type T: the runtime builds // it with T.valueOf(String), T.fromString(String) or new T(String). jaxrs_param_type(t) :- ann_on_param(_, _, n, p, _), cfg_jaxrs_param_ann(n), diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 9d269db3..91ab1f90 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -781,6 +781,7 @@ .decl grpc_ext_handler(c0:symbol,c1:symbol,c2:symbol) // framework entry points and HTTP destinations (#1400 #1410 #1428-#1430 #1456-#1458 #1463 #1464) .decl cfg_jpa_callback_ann(c0:symbol) +.decl jpa_callback_owner(c0:symbol) .decl cfg_jaxrs_param_ann(c0:symbol) .decl cfg_jaxrs_param_factory(c0:symbol) .decl cfg_servlet_registrar_type(c0:symbol) diff --git a/graph/test/csharp/run-tests.sh b/graph/test/csharp/run-tests.sh index fd635454..94ae3ab4 100755 --- a/graph/test/csharp/run-tests.sh +++ b/graph/test/csharp/run-tests.sh @@ -43,7 +43,9 @@ set -u HERE="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "$HERE/../../.." && pwd)" -ORACLE="$HERE/ground-truth/AxiomCsOracle/bin/Release/net8.0/axiom-cs-oracle" +# AXIOM_CS_ORACLE points at an oracle built elsewhere: the build output is gitignored, so a +# fresh worktree (a landing gate's, say) has none of its own and would skip with 77. +ORACLE="${AXIOM_CS_ORACLE:-$HERE/ground-truth/AxiomCsOracle/bin/Release/net8.0/axiom-cs-oracle}" WORK="${1:-}"; ONLY=""; VERBOSE=0 shift 2>/dev/null || true @@ -68,7 +70,7 @@ mkdir -p "$AXIOM_CS_DEV_CACHE" command -v souffle >/dev/null || { echo "souffle is not installed (brew install souffle)" >&2; exit 77; } [ -f "$REPO/parser/dist/index.js" ] || { echo "the parser is not built (npm run build)" >&2; exit 77; } [ -x "$ORACLE" ] || { - echo "the oracle is not built: dotnet build -c Release $HERE/ground-truth/AxiomCsOracle" >&2; exit 77; } + echo "the oracle is not built: dotnet build -c Release $HERE/ground-truth/AxiomCsOracle (or set AXIOM_CS_ORACLE to a built one)" >&2; exit 77; } # AND THE ORACLE ITSELF IS SCORED BEFORE THE SCORER, because a shape it emits NO row # for is invisible to everything below: it cannot be scored as agreement and it is not diff --git a/graph/test/java/expected/66-entity-lifecycle-callbacks.config b/graph/test/java/expected/66-entity-lifecycle-callbacks.config index fa335fc1..6c0e71cd 100644 --- a/graph/test/java/expected/66-entity-lifecycle-callbacks.config +++ b/graph/test/java/expected/66-entity-lifecycle-callbacks.config @@ -13,7 +13,15 @@ ── config_key_ref (0) ── ── config_binding (0) ── ── config_affects_method (0) ── -── config_entry_point (0) ── +── config_entry_point (8) ── + orm_hook probe.AuditTrail#recorded(Object) + orm_hook probe.Audited#touch() + orm_hook probe.Item#beforeInsert() + orm_hook probe.Item#gone() + orm_hook probe.ItemSearch#index(Object) + orm_hook probe.ItemStamps#onCreate(Item) + orm_hook probe.ItemStamps#onRemoved(Item) + orm_hook probe.ItemStamps#onUpdate(Item) ── bean_condition (0) ── ── config_unresolved [DECLARED UNKNOWNS] (0) ── ── remote_edge (0) ── diff --git a/graph/test/java/expected/67-framework-registered-entry-points.type-use b/graph/test/java/expected/67-framework-registered-entry-points.type-use index 929e5c54..12a02b8f 100644 --- a/graph/test/java/expected/67-framework-registered-entry-points.type-use +++ b/graph/test/java/expected/67-framework-registered-entry-points.type-use @@ -53,6 +53,7 @@ ambiguous_unknown SUPER_TYPE 0 app.init.BootServlet [TYPE] -> - ambiguous_unknown SUPER_TYPE 0 app.init.OrderServlet [TYPE] -> - ambiguous_unknown SUPER_TYPE 0 app.web.UnmappedServlet [TYPE] -> - ambiguous_unknown SUPER_TYPE 0 app.web.WidgetServlet [TYPE] -> - +known_edge ANNOTATION_PARAM 0 app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.PriceConverter known_edge ANNOTATION_PARAM 0 app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.Unregistered known_edge ANNOTATION_PARAM 0 app.jpa.Widget [ANNOTATION_ARGUMENT] -> app.jpa.WidgetAudit known_edge ANNOTATION_TYPE 0 app.jpa.Widget [ANNOTATION] -> app.jpa.Documented From aa9f346c44b9e338a2d34ecd5b8b27f6bb7cd0c0 Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 08:45:32 -0700 Subject: [PATCH 034/258] mcp: the verb dispatcher finds its folder when $0 mixes / and \ (Windows) On Windows every MCP tool call failed with "can't open file ...\code-graph\plugins\ax_grep.py" (or axiomcode-build, -context, -changed, -graph): bin/axiomcode exports AXIOMCODE_PLUGIN_ROOT from bash with '/', the MCP server joins the dispatcher's path onto it with os.path.join ('\'), and the dispatcher split $0 on '/' alone, landing on .../plugins. `dirname` split on either separator; the parameter expansion that replaced it for latency did not. The dispatcher now normalises '\' first, as bin/axiomcode already does. The CLI was unaffected. tests/mixed_separators.py builds the same mixed $0 off Windows and is red on the old dispatcher. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode | 5 +- tests/README.md | 2 + tests/mixed_separators.py | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/mixed_separators.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index d753d02e..770f54db 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -53,7 +53,10 @@ # --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 +# ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, +# C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and +# every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. +H="${0//\\//}"; case "$H" in */*) H="${H%/*}";; *) 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 # provide, and which on a desktop Windows is the Store placeholder. Started from node (the `axiomcode` command, the diff --git a/tests/README.md b/tests/README.md index 08713cfb..25889f3e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,8 @@ One check needs no graph and is its own script: directly, through an npm-style symlink to bin/axiomcode.js, on the SDK-free fallback, and from .mcp.json, .codex-plugin/mcp.json and .cursor-plugin as each host starts it + python3 tests/mixed_separators.py the verb dispatcher finds its own folder when $0 mixes / and \, as the MCP + server starts it on Windows (every MCP tool call failed there from 0.1.3) python3 tests/manifests.py every agent's manifest (Claude, Codex, Cursor, Gemini) names the same plugin and points at files that exist, the way that agent resolves them, and Gemini's skill and Cursor's rule are current copies diff --git a/tests/mixed_separators.py b/tests/mixed_separators.py new file mode 100644 index 00000000..8f9f494d --- /dev/null +++ b/tests/mixed_separators.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""tests/mixed_separators.py — the verb dispatcher finds its own folder when $0 mixes '/' and '\\'. + +On Windows the MCP server starts the dispatcher as os.path.join(AXIOMCODE_PLUGIN_ROOT, 'skills', 'axiomcode', +'scripts', 'axiomcode'), and bin/axiomcode exports that root from bash with forward slashes, so bash sees +C:/.../plugins/axiomcode\\skills\\axiomcode\\scripts\\axiomcode. Splitting it on '/' alone gave .../plugins, and every +MCP tool call ran a helper that is not there ("can't open file ...\\plugins\\ax_grep.py"). + +Off Windows a backslash is an ordinary file-name character, so the same $0 is built for real: a directory whose +plugins/axiomcode links to the plugin, and beside it a link literally named axiomcode\\skills\\axiomcode\\scripts\\axiomcode +pointing at the dispatcher. `help impact` reads the verb's script from the dispatcher's folder, so it answers only +when that folder is the scripts folder. The plain path is the control. + + python3 tests/mixed_separators.py +""" +import os, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGIN = os.path.join(ROOT, 'plugins', 'axiomcode') +SCRIPTS = os.path.join(PLUGIN, 'skills', 'axiomcode', 'scripts') +MIXED = 'axiomcode\\skills\\axiomcode\\scripts\\axiomcode' + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + bash = os.environ.get('AXIOMCODE_BASH') or 'bash' + def helps(dispatcher): + r = subprocess.run([bash, dispatcher, 'help', 'impact'], capture_output=True, text=True) + return r.returncode == 0 and 'impact' in r.stdout and 'no such verb' not in r.stderr, r.stdout[-300:] + r.stderr[-300:] + + ok, out = helps(os.path.join(SCRIPTS, 'axiomcode')) + check(ok, 'control: the dispatcher run by its own path finds its verbs', out) + + with tempfile.TemporaryDirectory(prefix='axiomcode-mixed-sep-') as t: + if os.name == 'nt': + dispatcher = PLUGIN.replace('\\', '/') + '\\' + MIXED.split('\\', 1)[1] + else: + os.makedirs(os.path.join(t, 'plugins')) + os.symlink(PLUGIN, os.path.join(t, 'plugins', 'axiomcode')) + os.symlink(os.path.join(SCRIPTS, 'axiomcode'), os.path.join(t, 'plugins', MIXED)) + dispatcher = os.path.join(t, 'plugins', MIXED) + ok, out = helps(dispatcher) + check(ok, 'a $0 that mixes / and \\ (how the MCP server starts it on Windows) finds the verbs', out) + + print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} failure(s)") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) From 58d093ff36126af30afa04a0f19c325c184848dd Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:59:16 -0700 Subject: [PATCH 035/258] ci: compare the alongside tier where the rules now put it; the java call oracle reads nested generic headers The release branch went red on three engine jobs after the last batch. Nothing in the engine regressed: two checks were stale against changes that landed beside them, and one ground-truth reader had a known blind spot that a new fixture reached. What was wrong 1. tests/fastpath.py (engine python and csharp; java never reached the step). The hook parity check counts the rules' `alongside` rows and fails when there are none, so the tier is never compared on nothing. It looked for them only in `direct`. impact now lists them apart, in their own `alongside` list, so the count was always 0 and the check failed with "the rules emitted no `alongside` row on any shape". 2. graph/test/java, --oracle on JDK 24, three cases (22-config-annotation-args, 25-di-narrowing, 27-messaging-and-grpc): "type-use score changed". The parser now records the types a field annotation names. The type-use goldens were updated with it, the scored reports were not, because they only run under --oracle. Each gains one resolved reference per field annotation (22: +4, 25: +4, 27: +3), all to the right annotation type, all in a context the bytecode does not carry, so precision and recall stay 1.0000. An improvement, re-blessed. 3. graph/test/java, 66-generic-bindings-implicit: "NEW missing edge probe.RawService#probe.SelfMid() -> probe.SelfBase#()". That caller exists nowhere. The bytecode oracle (tools/bytecode_oracle.py) matched class headers with `<[^>]*>`, which stops at the first `>`, so `SelfMid> extends SelfBase` never matched. The class was never opened: its empty implicit constructor was booked under the class printed before it and escaped the implicit-constructor skip, and `withTimeout`, declared on `SelfBase>`, had no declaration to resolve to, so both calls to it were dropped from the ground truth and the engine's two correct edges read as extras. field_oracle.py already carried the fix (strip_generics) and a note that the call reader had the same blind spot. The engine was right here. The change - tests/fastpath.py: alongside rows are read from both places (`direct` with that certainty, and the `alongside` list) on BOTH paths. The fast path has no rule for the tier, so any row it emits there is a disagreement and fails. A rules' alongside row listed as a direct use by either path fails. The hook check now also fails when either path's printed list names one of the rules' alongside rows for the edited target, whatever tier label the row carries. The check that the rules emitted at least one alongside row is kept. - bytecode_oracle.py: class headers are matched after strip_generics, now defined there and imported by field_oracle.py (type_use_oracle.py takes it through field_oracle as before). - Goldens: the three type-use-oracle reports; 66-generic-bindings-implicit gets the oracle goldens it never had (.oracle 27 of 27 agree, 0 extra; .boundary; .field-oracle 1.0000 / 1.0000; .type-use-oracle 1.0000 precision, 0.7143 recall, the misses being compiler-inserted casts and an erased self-type return). No other case moved when the oracle changed. Type-use scores, old -> new (precision and recall unchanged at 1.0000 / 1.0000) - 22-config-annotation-args: references 23 -> 27, resolved 9 -> 13 (39.1% -> 48.1%) - 25-di-narrowing: references 36 -> 40, resolved 14 -> 18 (38.9% -> 45.0%) - 27-messaging-and-grpc: references 34 -> 37, resolved 15 -> 18 (44.1% -> 48.6%) Near misses (each run, each failed as it should) - fastpath.py on the old reading of the tier: python and csharp 5 of 6, "the rules emitted no `alongside` row" (the CI failure). - the fast path emitting an alongside row of its own: python, java, csharp 2 of 6. - the fast path listing a rules' sibling as a resolved use: python, java, csharp 4 of 6. - hooks/changes.py listing the rules' alongside rows relabelled as resolved uses: python 5 of 6, "hook (rules): lists the rules' `alongside` rows as uses". - the oracle without strip_generics: 66-generic-bindings-implicit reports the bogus missing edge. Checks run, locally, as the engine job runs them (JDK 24.0.2, python3 3.12 with python3.10 on PATH, .NET 8 with the Roslyn oracle built) - npm install; parser/dist/index.js present. - .github/scripts/run-suite.sh python: passed 36, failed 0. - tests/fastpath.py --lang python: 6 of 6. - .github/scripts/run-suite.sh java --oracle --no-torture: passed 76, failed 0 (was 72 / 4). - tests/fastpath.py --lang java: 6 of 6. - .github/scripts/run-suite.sh csharp (Roslyn oracle built): exit 0, cases 18 of 18. - tests/fastpath.py --lang csharp: 6 of 6. - tests/run.py: java 238 of 238; python 233 of 234 and csharp 121 of 123. The three FAIL lines (python lambda-is-named-by-its-place, csharp unmodelled-entry-not-local twice) fail the same way on the release branch tip without this change, and CI does not run tests/run.py. No corpus smoke and no held-out run: the change is to test tooling and goldens only, and no engine rule or answer changed. --- .../22-config-annotation-args.type-use-oracle | 8 ++--- .../expected/25-di-narrowing.type-use-oracle | 8 ++--- .../27-messaging-and-grpc.type-use-oracle | 8 ++--- .../66-generic-bindings-implicit.boundary | 11 ++++++ .../66-generic-bindings-implicit.field-oracle | 7 ++++ .../66-generic-bindings-implicit.oracle | 1 + ...-generic-bindings-implicit.type-use-oracle | 15 ++++++++ graph/test/java/tools/bytecode_oracle.py | 28 ++++++++++++++- graph/test/java/tools/field_oracle.py | 28 +++------------ tests/fastpath.py | 34 +++++++++++++++---- 10 files changed, 104 insertions(+), 44 deletions(-) create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.boundary create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.field-oracle create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.oracle create mode 100644 graph/test/java/expected/66-generic-bindings-implicit.type-use-oracle diff --git a/graph/test/java/expected/22-config-annotation-args.type-use-oracle b/graph/test/java/expected/22-config-annotation-args.type-use-oracle index 3b3b23cc..136ac799 100644 --- a/graph/test/java/expected/22-config-annotation-args.type-use-oracle +++ b/graph/test/java/expected/22-config-annotation-args.type-use-oracle @@ -1,7 +1,7 @@ 22-config-annotation-args precision 1.0000 (2 correct, 0 wrong) recall 1.0000 (2 of 2 in the bytecode) - references 23 resolved 9 (39.1%) - tiers ambiguous_unknown=14 known_edge=9 - not scored: 7 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g - contexts ANNOTATION_PARAM=4 ANNOTATION_TYPE=3 FIELD_TYPE=7 METHOD_RETURN=9 + references 27 resolved 13 (48.1%) + tiers ambiguous_unknown=14 known_edge=13 + not scored: 11 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_PARAM=4 ANNOTATION_TYPE=7 FIELD_TYPE=7 METHOD_RETURN=9 diff --git a/graph/test/java/expected/25-di-narrowing.type-use-oracle b/graph/test/java/expected/25-di-narrowing.type-use-oracle index bf7f5243..75fa3b18 100644 --- a/graph/test/java/expected/25-di-narrowing.type-use-oracle +++ b/graph/test/java/expected/25-di-narrowing.type-use-oracle @@ -1,7 +1,7 @@ 25-di-narrowing precision 1.0000 (8 correct, 0 wrong) recall 1.0000 (8 of 8 in the bytecode) - references 36 resolved 14 (38.9%) - tiers ambiguous_unknown=22 known_edge=14 - not scored: 5 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g - contexts ANNOTATION_TYPE=5 FIELD_TYPE=4 IMPLEMENTS_INTERFACE=4 METHOD_PARAM=11 METHOD_RETURN=12 + references 40 resolved 18 (45.0%) + tiers ambiguous_unknown=22 known_edge=18 + not scored: 9 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_TYPE=9 FIELD_TYPE=4 IMPLEMENTS_INTERFACE=4 METHOD_PARAM=11 METHOD_RETURN=12 diff --git a/graph/test/java/expected/27-messaging-and-grpc.type-use-oracle b/graph/test/java/expected/27-messaging-and-grpc.type-use-oracle index add807f9..84be01d7 100644 --- a/graph/test/java/expected/27-messaging-and-grpc.type-use-oracle +++ b/graph/test/java/expected/27-messaging-and-grpc.type-use-oracle @@ -1,7 +1,7 @@ 27-messaging-and-grpc precision 1.0000 (3 correct, 0 wrong) recall 1.0000 (3 of 3 in the bytecode) - references 34 resolved 15 (44.1%) - tiers ambiguous_unknown=19 known_edge=15 - not scored: 12 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g - contexts ANNOTATION_TYPE=13 FIELD_TYPE=3 METHOD_PARAM=13 METHOD_RETURN=4 SUPER_TYPE=1 + references 37 resolved 18 (48.6%) + tiers ambiguous_unknown=19 known_edge=18 + not scored: 15 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_TYPE=16 FIELD_TYPE=3 METHOD_PARAM=13 METHOD_RETURN=4 SUPER_TYPE=1 diff --git a/graph/test/java/expected/66-generic-bindings-implicit.boundary b/graph/test/java/expected/66-generic-bindings-implicit.boundary new file mode 100644 index 00000000..c71e9e5a --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.boundary @@ -0,0 +1,11 @@ +staged library covers 50% of the library types this client calls (1 of 2; 1 call sites name an absent type) + ** LOW — the numbers below are bounded by what is staged, not by the rules. Stage the client's dependencies (tools/build-lib-ir.sh --coord) before reading them. +boundary sites: 2 (client callers, library callees matching example.,java.,javax.,jdk.) + EXACT 1 ( 50.0%) + LIB IR LACKS THE TYPE 1 ( 50.0%) + --- + correct METHOD named 50.0% (100.0% of the 1 the lib IR can answer) + wrong library method 0.0% + +ABSENT from the staged library, by call sites naming them + 1 java.lang.Object diff --git a/graph/test/java/expected/66-generic-bindings-implicit.field-oracle b/graph/test/java/expected/66-generic-bindings-implicit.field-oracle new file mode 100644 index 00000000..cabaf5af --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.field-oracle @@ -0,0 +1,7 @@ +66-generic-bindings-implicit + precision 1.0000 (4 correct, 0 wrong) + recall 1.0000 (4 of 4 in the bytecode) + sites 5 resolved 5 (100.0%) + tiers boundary_lib=1 known_edge=4 + access read=5 + not scored: 0 initializer-owned, 0 in a class the build did not compile, 0 oracle rows in a static initializer, 0 reads of an inlined constant diff --git a/graph/test/java/expected/66-generic-bindings-implicit.oracle b/graph/test/java/expected/66-generic-bindings-implicit.oracle new file mode 100644 index 00000000..3d7c878f --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.oracle @@ -0,0 +1 @@ +oracle=27 engine=27 agree=27 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/66-generic-bindings-implicit.type-use-oracle b/graph/test/java/expected/66-generic-bindings-implicit.type-use-oracle new file mode 100644 index 00000000..5a38d124 --- /dev/null +++ b/graph/test/java/expected/66-generic-bindings-implicit.type-use-oracle @@ -0,0 +1,15 @@ +66-generic-bindings-implicit + precision 1.0000 (20 correct, 0 wrong) + recall 0.7143 (20 of 28 in the bytecode) + references 33 resolved 22 (66.7%) + tiers ambiguous_unknown=11 boundary_lib=2 known_edge=20 + not scored: 0 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts CAST_EXPRESSION=1 FIELD_TYPE=3 LOCAL_VARIABLE=1 METHOD_PARAM=8 METHOD_RETURN=2 OBJECT_CREATION_TYPE=3 SUPER_TYPE=13 TYPE_PARAM_BOUND=2 + MISSING probe.LibUses CAST_EXPRESSION probe.LibChainStub + MISSING probe.LibUses CAST_EXPRESSION probe.OrderMapper + MISSING probe.LibUses CAST_EXPRESSION probe.OrderStore + MISSING probe.OrderService CAST_EXPRESSION probe.OrderMapper + MISSING probe.SelfBase METHOD_RETURN probe.SelfBase + MISSING probe.Stubs CAST_EXPRESSION probe.ChainStub + MISSING probe.Stubs CAST_EXPRESSION probe.DirectStub + MISSING probe.SubService CAST_EXPRESSION probe.OrderMapper diff --git a/graph/test/java/tools/bytecode_oracle.py b/graph/test/java/tools/bytecode_oracle.py index 453e32d1..38cf3015 100644 --- a/graph/test/java/tools/bytecode_oracle.py +++ b/graph/test/java/tools/bytecode_oracle.py @@ -62,6 +62,32 @@ def op(j): PRIM = {'B':'byte','C':'char','D':'double','F':'float','I':'int','J':'long','S':'short','Z':'boolean','V':'void'} +def strip_generics(s): + """Remove every balanced <...> group from a javap class-header line. + + CLASS_HDR matches the type-parameter list with `(?:<[^>]*>)?`, which stops at the FIRST `>`, so a + bound that is itself generic, `class SelfMid> extends SelfBase`, leaves a + stray `>` and the header does not match at all. The class is then never opened: its methods are + declared on whichever class javap printed before it, and its calls are made from there. On a + self-typed class that produced a caller that exists nowhere (`RawService#probe.SelfMid()`: the + constructor's rendered name, never renamed to because the class was not the one open), + so an empty implicit constructor escaped the skip below and its super() read as a written call; + and a call to a method such a class declares (`withTimeout` on `SelfBase>`) + found no declaration on its owner and was dropped from the ground truth. Stripping the groups + first makes the match independent of how deeply the bounds nest. The supertypes are read with + their arguments removed either way.""" + out, d = [], 0 + for ch in s: + if ch == '<': + d += 1 + elif ch == '>': + if d > 0: + d -= 1 + elif d == 0: + out.append(ch) + return ''.join(out) + + def desc_params(desc): inner = desc[desc.index('(')+1:desc.rindex(')')] out, i = [], 0 @@ -143,7 +169,7 @@ def parse(classes, names): in_bsm = None; bsm_idx = None; bsm_is_lambda = False for i, raw in enumerate(out): line = raw.rstrip(); s = line.strip() - m = CLASS_HDR.match(s) + m = CLASS_HDR.match(strip_generics(s)) if m and (m.group(2) in names or '.' in m.group(2)): cls = m.group(2).split('<')[0]; meth = None for g in (m.group(3), m.group(4)): diff --git a/graph/test/java/tools/field_oracle.py b/graph/test/java/tools/field_oracle.py index bc481be9..298f3669 100644 --- a/graph/test/java/tools/field_oracle.py +++ b/graph/test/java/tools/field_oracle.py @@ -63,30 +63,10 @@ import os, re, subprocess, sys, collections sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from bytecode_oracle import desc_params, compile_case, CLASS_HDR, INSTR, PRIM # noqa: E402 - - -def strip_generics(s): - """Remove every balanced <...> group from a javap class-header line. - - CLASS_HDR matches the type-parameter list with `(?:<[^>]*>)?`, which stops at the FIRST `>` - -- so a bound that is itself generic, `class Base> extends ...`, - leaves a stray `>` and the header does not match at all. The class is then never opened: every - field it declares is attributed to whichever class javap printed before it, and every access - to those fields is attributed to the wrong owner or dropped. Stripping the groups first makes - the match independent of how deeply nested the bounds are. (bytecode_oracle.py matches the - same headers with the same expression and has the same blind spot; fixing it there moves the - call-edge goldens, so it is left alone here.)""" - out, d = [], 0 - for ch in s: - if ch == '<': - d += 1 - elif ch == '>': - if d > 0: - d -= 1 - elif d == 0: - out.append(ch) - return ''.join(out) +from bytecode_oracle import desc_params, compile_case, CLASS_HDR, INSTR, PRIM, strip_generics # noqa: E402 +# strip_generics: see bytecode_oracle.py. It lived here first, when only this reader applied it; the +# call-edge reader had the same blind spot and now shares the one function. + # ` 9: putfield #13 // Field org/example/Foo.bar:I` — javap omits the owner when the # field is declared in the class being printed, so the owner is optional and defaults to it. diff --git a/tests/fastpath.py b/tests/fastpath.py index 69c002a1..d613fade 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -67,6 +67,15 @@ def _args(argv): # and a run where the rules never emitted one fails, because then this check would pass on nothing. ALONG = 'alongside' +# WHERE AN ALONGSIDE ROW LIVES. The rules used to carry them in `direct` with certainty `alongside`; since they are +# listed apart (never as dependents) they sit in their own `alongside` list, and `direct` holds dependents only. Read +# both places, on BOTH paths, so a move of the section cannot turn the tier into a comparison of nothing again: the +# counting below found no row after the move and would have passed silently had it not also required one. +def along_rows(d): + d = d or {} + return ({x['display'] for x in d.get('direct', []) if x.get('certainty') == ALONG} + | {x['display'] for x in d.get(ALONG, [])}) + def rels(d): d = d or {} return dict(contract=sorted({x['display'] for x in d.get('contract', [])}), @@ -104,7 +113,8 @@ def hook_rows(case, lang, path): for m in ROW.findall(l)] return rows, text -def check_hook(case, lang): +def check_hook(case, lang, siblings=frozenset()): + """`siblings`: the rules' alongside rows for the edit's target, `Owner.m(param)`; neither path may list them.""" if lang not in EDITS: return 0 got = {} for path in ('fast', 'rules'): @@ -117,6 +127,8 @@ def check_hook(case, lang): if any(c == ALONG for c, _ in rows): print(f"FAIL hook ({path}): lists `alongside` rows as uses: {sorted(d for c, d in rows if c == ALONG)}"); return 1 got[path] = sorted({d for _, d in rows}) + if set(got[path]) & set(siblings): + print(f"FAIL hook ({path}): lists the rules' `alongside` rows as uses: {sorted(set(got[path]) & set(siblings))}"); return 1 if got['fast'] != got['rules']: print(f"FAIL hook: the same edit lists different rows by path: fast={got['fast']} rules={got['rules']}"); return 1 print(f"ok hook on {EDITS[lang][0]}: both paths list {got['fast']}") @@ -129,7 +141,7 @@ def main(argv=None): if not built: r = subprocess.run(['bash', AX, 'index', CASE, '--lang', LANG], capture_output=True, text=True) if r.returncode: print("FAIL index: " + (r.stderr or r.stdout)[-400:]); return 1 - bad = 0; along = set() + bad = 0; along = set(); along_of = {} try: for target, must_answer in SHAPES: fast = graph_sql.impact_shaped(CASE, target) @@ -146,23 +158,31 @@ def main(argv=None): '--json', '--depth', '12'], capture_output=True, text=True) try: j = json.loads(cli.stdout) except Exception: print(f"FAIL {target!r}: the rules gave no JSON to compare against"); bad += 1; continue - along |= {x['display'] for x in j.get('direct', []) if x.get('certainty') == ALONG} - if any(x.get('certainty') == ALONG for x in fast.get('direct', [])): - print(f"FAIL {target!r}: the fast path emitted `alongside` rows, which it has no rule for"); bad += 1; continue + fa, ra = along_rows(fast), along_rows(j) + along |= ra; along_of[target] = ra + # the alongside tier, compared: the fast path has no rule for it, so any row it emits there (in either + # place) disagrees with the rules, whether or not the rules have the same row + if fa: + print(f"FAIL {target!r}: fast path and rules disagree on `alongside` rows, which the fast path has no " + f"rule for: fast={sorted(fa)} rules={sorted(ra)}"); bad += 1; continue a, b = rels(fast), rels(j) + # and neither path may list one of the rules' alongside rows as a dependent in a hook + listed = {p: sorted(ra & set(r['direct'])) for p, r in (('fast', a), ('rules', b))} + if any(listed.values()): + print(f"FAIL {target!r}: the rules' `alongside` rows are listed as direct uses: {listed}"); bad += 1; continue if a != b: print(f"FAIL {target!r}: fast path and rules disagree") for k in a: if a[k] != b[k]: print(f" {k}: fast={a[k]} rules={b[k]}") bad += 1 else: - al = sorted({x['display'] for x in j.get('alongside', [])}) + al = sorted(ra) print(f"ok {target!r}: {len(a['direct'])} direct, {a['reached']} reached — identical to the rules" + (f" (neither lists the rules' {len(al)} `alongside` row(s) as direct: {', '.join(al)})" if al else '')) if LANG in EDITS: if not along: print(f"FAIL the rules emitted no `alongside` row on any shape, so the tier was never compared"); bad += 1 - bad += check_hook(CASE, LANG) + bad += check_hook(CASE, LANG, along_of.get(SHAPES[2][0], set())) finally: if not keep: import shutil; shutil.rmtree(os.path.join(CASE, '.axiomcode'), ignore_errors=True) From 596cf1c7a204f3210a50249e662ebc45b0ca193d Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:29:41 -0700 Subject: [PATCH 036/258] mcp: a running server answers from the install that is current at each call A running MCP server kept answering from the build it was started from after the install moved to a newer one. Its rows carried the old IMPACT_VERSION and old-rule results that looked current, and only a footer hinted at it. What was wrong: server.py fixed the plugin directory once, at start. Two ways that directory went stale: - launch.js starts server.py by a path node has already resolved, so with no AXIOMCODE_PLUGIN_ROOT (a host that expands nothing) a link to the install was resolved to the build it pointed at when the server started. - A host that installs each version into its own directory (a versioned plugin cache) records the current one in an install record file; the server kept the directory it was started in. The in-process imports of ax_fresh and ax_contract (the refresh timer, the timeout note) were also pinned to the start-time scripts. The change: - server.py resolves the plugin directory on every call (plugin_root): the root as spelled, never resolved, so a link is followed afresh, or the directory the host's install record names for this plugin when it keeps one directory per version. The record is re-read only when its stat changes. ax_fresh and ax_contract are re-imported from the current scripts when those move. - The one thing that cannot be reloaded is server.py itself (the tool list and parameters). When the current install's server.py differs from the running one, the answer's first line says the server is older than the install and how to restart it; the rest of the answer is unchanged. - launch.js hands server.py the plugin directory as it was started, links kept, when the host names none. Hot path cost: a stat of the install record and one of the current server.py per call; a file read only when one of them changed. Test: tests/mcp.py check_install_move starts a server, moves the install under it, and asks again: once through a link retargeted at a newer build (same server.py, then a newer server.py that must add the warning line), once through an install record that names a newer version's directory, with a newer entry of another plugin as the near miss. HOME and the host's config directory variable point into the test's temp directory. Before the fix: 3 failures; after: ok. Suites, before the change -> after it (tests/mcp.py before is the suite without the new check): - tests/mcp.py: ok -> ok (the new check alone on the old server: 3 failures) - tests/mcp_docs.py, tests/surfaces.py, tests/graph_verb.py: ok -> ok - tests/fastpath.py --lang python, java, csharp: 6 of 6 -> 6 of 6 each Not yet measured on the full corpus or on held-out projects; no corpus numbers apply to this change. --- plugins/axiomcode/mcp/launch.js | 17 +++++ plugins/axiomcode/mcp/server.py | 118 +++++++++++++++++++++++++++++--- tests/mcp.py | 118 ++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/plugins/axiomcode/mcp/launch.js b/plugins/axiomcode/mcp/launch.js index b15557a4..e1eea239 100644 --- a/plugins/axiomcode/mcp/launch.js +++ b/plugins/axiomcode/mcp/launch.js @@ -67,6 +67,23 @@ function choose() { const py = findPython(); const env = py.exe ? withPython(process.env, py) : { ...process.env }; +// THE PLUGIN DIRECTORY AS IT WAS SPELLED, NOT RESOLVED. node resolves links in __dirname, so SERVER names the build a +// link pointed at when the server started, and server.py would take that build for the install for as long as it runs. +// The path this file was started by keeps the link, and server.py follows it again on every call; a host that names +// the directory itself (AXIOMCODE_PLUGIN_ROOT in its server entry) is left as it is. +if (!env.AXIOMCODE_PLUGIN_ROOT && require.main === module && process.argv[1]) { + const fs = require('fs'); + let started = path.resolve(process.argv[1]); + // a relative path (Codex runs `node mcp/launch.js` from the plugin) was made absolute from the resolved working + // directory; PWD, when it is the same directory, still has the links in it + const cwd = process.cwd(), pwd = process.env.PWD; + try { + if (pwd && pwd !== cwd && path.isAbsolute(pwd) && started.startsWith(cwd + path.sep) && fs.realpathSync(pwd) === cwd) + started = path.join(pwd, started.slice(cwd.length + 1)); + } catch (e) { /* PWD gone: the path as node gave it */ } + const root = path.dirname(path.dirname(started)); + if (fs.existsSync(path.join(root, 'mcp', 'server.py'))) env.AXIOMCODE_PLUGIN_ROOT = root; +} const { bash, error } = findBash(); if (bash) env.AXIOMCODE_BASH = bash; else process.stderr.write(`axiomcode mcp: ${error}\n The server starts, but every tool will say it cannot run the CLI.\n`); diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 5303efdf..9fc1e854 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -15,8 +15,103 @@ sys.stderr.write("axiomcode mcp: the Python MCP SDK is not installed for %s; " "serving with the built-in fallback. `pip install mcp` to use the SDK.\n" % sys.executable) +# THE INSTALL IS LOOKED UP ON EVERY CALL, NOT ONCE AT START. The server lives as long as the session, and the install +# can move under it: a link to the install retargeted at a newer build, or a host that installs each version into a +# directory of its own (a versioned plugin cache) and records which one is current. A path fixed at start kept +# running the old build's scripts and rules, and its rows carried the old IMPACT_VERSION looking current, with only a +# footer to say so. So each call resolves the plugin directory again (plugin_root) and runs the scripts found there; +# the only thing that cannot be reloaded is this file itself (the tools and their parameters), and when the current +# install's copy of it differs, the answer's FIRST line says the server is older than the install and how to restart it. +# The cost per call is a stat of the host's install record and of the current server.py. ROOT = os.environ.get('AXIOMCODE_PLUGIN_ROOT') or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -AX = os.path.join(ROOT, 'skills', 'axiomcode', 'scripts', 'axiomcode') +_SELF = os.path.realpath(os.path.abspath(__file__)) +try: _SELF_ST = os.stat(_SELF); _SELF_TEXT = open(_SELF, 'rb').read() +except OSError: _SELF_ST = None; _SELF_TEXT = None + +def _record(): + """the host's record of its installed plugins, under its config directory""" + cfg = os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude') + return os.path.join(cfg, 'plugins', 'installed_plugins.json') + +_RECORD = [None, None] # (the record's stat signature, the root it named), read again on a change + +def _recorded_root(root): + """the directory the host's install record names as current for this plugin, when the host keeps one directory per + version beside this one (///); None when there is no such record""" + p = _record() + try: st = os.stat(p) + except OSError: return None + sig = (p, st.st_mtime_ns, st.st_size, root) + if _RECORD[0] == sig: return _RECORD[1] + found = None + try: + import json + with open(p, encoding='utf-8') as f: plugins = json.load(f).get('plugins') or {} + here = os.path.normcase(os.path.normpath(root)); parent = os.path.dirname(here); cwd = os.getcwd() + best = None + for entries in plugins.values(): + for e in entries if isinstance(entries, list) else []: + ip = e.get('installPath') if isinstance(e, dict) else None + if not ip or os.path.dirname(os.path.normcase(os.path.normpath(ip))) != parent: continue + pp = e.get('projectPath') + if pp and not (cwd == pp or cwd.startswith(pp.rstrip(os.sep) + os.sep)): continue + if not os.path.isdir(os.path.join(ip, 'skills', 'axiomcode', 'scripts')): continue + key = (str(e.get('lastUpdated') or e.get('installedAt') or ''), os.path.normcase(os.path.normpath(ip)) == here) + if best is None or key > best[0]: best = (key, ip) + found = best[1] if best else None + except (OSError, ValueError, AttributeError, TypeError): + found = None + _RECORD[0], _RECORD[1] = sig, found + return found + +def plugin_root(): + """the plugin directory of the install that is current now. The root is kept as it was spelled, never resolved, so a + link on the way to it is followed afresh on every call""" + return _recorded_root(ROOT) or ROOT + +def scripts_dir(): + return os.path.join(plugin_root(), 'skills', 'axiomcode', 'scripts') + +_SEEN_SERVER = [None, False] # (stat signature of the current server.py, whether it differs from this one) + +def stale_note(root=None): + """one line when the install's server.py is not the one this process runs (so its tools and parameters are the old + ones), else ''""" + cur = os.path.join(root or plugin_root(), 'mcp', 'server.py') + try: st = os.stat(cur) + except OSError: return '' + if _SELF_ST is None: return '' + if (st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size) == (_SELF_ST.st_dev, _SELF_ST.st_ino, _SELF_ST.st_mtime_ns, _SELF_ST.st_size): return '' + sig = (cur, st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size) + if _SEEN_SERVER[0] != sig: + try: differs = open(cur, 'rb').read() != _SELF_TEXT + except OSError: differs = False + _SEEN_SERVER[0], _SEEN_SERVER[1] = sig, differs + if not _SEEN_SERVER[1]: return '' + return (f"WARNING: this axiomcode MCP server is older than the install ({os.path.dirname(os.path.dirname(_SELF))} is running, " + f"{os.path.realpath(root or plugin_root())} is installed); the answer below comes from the install's scripts, but the " + f"tools and their parameters are the old server's until it is restarted (reconnect the axiomcode MCP server in the " + f"client, or restart the agent session).") + +_LOADED = [None, set()] # (signature of the scripts the in-process modules came from, their dirs) + +def scripts_module(name): + """a module of the skill's scripts (ax_fresh, ax_contract) as the current install has it: when the install moved since + the last import, every module imported from a scripts directory is dropped and imported again from the current one""" + import importlib + d = scripts_dir() + try: st = os.stat(os.path.join(d, 'ax_fresh.py')); sig = (d, st.st_dev, st.st_ino, st.st_mtime_ns) + except OSError: sig = (d,) + if _LOADED[0] != sig: + old = {os.path.normcase(os.path.abspath(x)) for x in _LOADED[1] | {d}} + for k, m in list(sys.modules.items()): + f = getattr(m, '__file__', None) + if f and os.path.normcase(os.path.dirname(os.path.abspath(f))) in old: del sys.modules[k] + sys.path[:] = [x for x in sys.path if os.path.normcase(os.path.abspath(x or '.')) not in old or not x] + sys.path.insert(0, d) + importlib.invalidate_caches() + _LOADED[0] = sig; _LOADED[1].add(d) + return importlib.import_module(name) # THE CLI'S WORDS, SPELLED AS THIS SURFACE SPELLS THEM (#1567). The answers are the CLI's, so their hints name CLI # flags (`--in `, `--tests-only`, `--limit N`); an agent that sent those back as `in=`, `tests_only=` had them @@ -106,7 +201,8 @@ def _timer(interval): time.sleep(max(1.0, min(60.0, interval / 3))) for repo in list(SEEN): try: - if time.time() - ax_fresh.last_update(repo) >= interval: ax_fresh.kick(repo, trigger='the timer') + fresh = scripts_module('ax_fresh') + if time.time() - fresh.last_update(repo) >= interval: fresh.kick(repo, trigger='the timer') except Exception: pass def run(args, cwd=None, timeout=900): @@ -116,8 +212,12 @@ def run(args, cwd=None, timeout=900): # 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', AXIOMCODE_SURFACE='mcp') if args and args[0] != 'index' else None + root = plugin_root() + ax = os.path.join(root, 'skills', 'axiomcode', 'scripts', 'axiomcode') + note = stale_note(root) + head = (note + '\n') if note else '' try: - r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) + r = subprocess.run([BASH, ax, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) except OSError as e: return (f"axiomcode could not start bash ({BASH}): {e}. On Windows it needs the bash that comes with " "Git for Windows; install it, or set AXIOMCODE_BASH to its bin\\bash.exe.") @@ -126,16 +226,16 @@ def run(args, cwd=None, timeout=900): pos = [a for i, a in enumerate(args[1:], 1) if args[i - 1] not in VALUED] repo = next((a for a in reversed(pos) if os.path.isdir(a)), cwd or os.getcwd()) try: - sys.path.insert(0, os.path.dirname(AX)); import ax_contract, ax_fresh - if ax_fresh.building(os.path.realpath(repo)): return ax_contract.building_note(os.path.realpath(repo)) + ax_fresh = scripts_module('ax_fresh'); ax_contract = scripts_module('ax_contract') + if ax_fresh.building(os.path.realpath(repo)): return head + ax_contract.building_note(os.path.realpath(repo)) except Exception: pass - return (f"axiomcode {args[0] if args else ''} did not answer within {timeout} s. Nothing was changed; " - f"see {os.path.join(repo, '.axiomcode', 'build.log')} if a build was running, and ask again.") + return head + (f"axiomcode {args[0] if args else ''} did not answer within {timeout} s. Nothing was changed; " + f"see {os.path.join(repo, '.axiomcode', 'build.log')} if a build was running, and ask again.") out = (r.stdout or '') + (('\n' + r.stderr.strip()) if r.returncode and r.stderr.strip() else '') # an answer given from a graph that predates some edit says so, and names the files (#1305); one given from a graph a # fallback engine built, in place of the checkout's own, names that engine if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith(('graph refresh:', 'graph built by:'))) - return mcp_words(out.strip()) or f"(no output, exit {r.returncode})" + return head + (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]`, @@ -211,7 +311,7 @@ def axiomcode_graph(repo: str = ".", out: str = '') -> str: if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on try: - sys.path.insert(0, os.path.dirname(AX)); import ax_fresh; ax_fresh.kick(os.getcwd(), trigger='the MCP server starting') + scripts_module('ax_fresh').kick(os.getcwd(), trigger='the MCP server starting') if os.path.isdir(os.path.join(os.getcwd(), '.axiomcode')): SEEN.add(os.path.realpath(os.getcwd())) interval = float(os.environ.get('AXIOMCODE_REFRESH_INTERVAL') or 900) if interval > 0: diff --git a/tests/mcp.py b/tests/mcp.py index 9be19790..4e55ff52 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -17,6 +17,10 @@ .codex-plugin/mcp.json Codex's server entry, a relative path run from the plugin directory .cursor-plugin/plugin.json Cursor's own server entry, with ${CURSOR_PLUGIN_ROOT} replaced as Cursor does +and a server that is already running when the install moves under it answers the next call from the new install: +through a link retargeted at a newer build, and through a host's install record (in a config +directory of the test's own) that names a newer version's directory (check_install_move) + python3 tests/mcp.py """ import json, os, shutil, subprocess, sys, tempfile, threading @@ -226,6 +230,118 @@ def check_grep_default(): finally: server.run = real +class Session: + """one started server, called as many times as a test needs, as a client keeps it for a whole session""" + def __init__(self, cmd, cwd, env): + self.p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=cwd, env=env, text=True) + self.timer = threading.Timer(120, self.p.kill) + self.timer.start() + self.n = 0 + self.ask('initialize', {'protocolVersion': '2025-06-18', 'capabilities': {}, 'clientInfo': {'name': 'tests', 'version': '0'}}) + self.p.stdin.write(json.dumps({'jsonrpc': '2.0', 'method': 'notifications/initialized'}) + '\n') + self.p.stdin.flush() + + def ask(self, method, params): + self.n += 1 + self.p.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': self.n, 'method': method, 'params': params}) + '\n') + self.p.stdin.flush() + for line in self.p.stdout: + try: + m = json.loads(line) + except ValueError: + continue + if m.get('id') == self.n: + return m.get('result') or {} + return {} + + def text(self, name, arguments): + return ''.join(c.get('text', '') for c in self.ask('tools/call', {'name': name, 'arguments': arguments}).get('content', [])) + + def close(self): + self.timer.cancel() + self.p.stdin.close() + self.p.wait() + self.p.stdout.close() + self.p.stderr.close() + + +def check_install_move(work): + """The install moves while a server runs: the next call is answered by the new install's scripts, byte for byte, and + only when the new install's server.py differs from the running one does a warning come, on the answer's first line. + Each fake build's CLI prints which build it is; nothing here touches the real host config (HOME and the config + directory variable are the test's own).""" + bad = [] + top = os.path.join(work, 'install-move') + home = os.path.join(top, 'home') + os.makedirs(home) + + def build(dest, name): + shutil.copytree(os.path.join(ROOT, 'plugins', 'axiomcode'), dest, symlinks=True) + cli = os.path.join(dest, 'skills', 'axiomcode', 'scripts', 'axiomcode') + with open(cli, 'w') as f: + f.write(f'#!/usr/bin/env bash\necho "answer from {name}"\necho "IMPACT_VERSION {name}"\n') + os.chmod(cli, 0o755) + return f'answer from {name}\nIMPACT_VERSION {name}' + + base = {k: v for k, v in os.environ.items() if not k.endswith('PLUGIN_ROOT') and k != 'CLAUDE_CONFIG_DIR'} + base.update(HOME=home, CLAUDE_CONFIG_DIR=os.path.join(top, 'claude'), AXIOMCODE_REFRESH_INTERVAL='0') + repo = os.path.join(top, 'repo') + os.mkdir(repo) + ask = ('axiomcode_path', {'from_': 'a', 'to': 'b', 'repo': repo}) + warn = 'WARNING: this axiomcode MCP server is older than the install' + + def expect(label, got, want, warned=False): + first, _, rest = got.partition('\n') + if warned and not (first.startswith(warn) and rest == want): + bad.append(f"install move, {label}: want the warning on the first line and then {want!r}, got {got[:400]!r}") + elif not warned and got != want: + bad.append(f"install move, {label}: want exactly {want!r}, got {got[:400]!r}") + + # 1. A LINK TO THE INSTALL, retargeted at a newer build. The server is started through the link with no + # AXIOMCODE_PLUGIN_ROOT, as a host that expands nothing starts it; node resolves the link in the launcher's path. + a = build(os.path.join(top, 'builds', 'old', 'plugins', 'axiomcode'), 'the old build') + b = build(os.path.join(top, 'builds', 'new', 'plugins', 'axiomcode'), 'the new build') + link = os.path.join(top, 'install') + os.symlink(os.path.join(top, 'builds', 'old'), link) + s = Session(['node', os.path.join(link, 'plugins', 'axiomcode', 'mcp', 'launch.js')], repo, base) + try: + expect('link, before the move', s.text(*ask), a) + tmp = link + '.next' + os.symlink(os.path.join(top, 'builds', 'new'), tmp) + os.replace(tmp, link) + expect('link, after the move (same server.py)', s.text(*ask), b) + with open(os.path.join(top, 'builds', 'new', 'plugins', 'axiomcode', 'mcp', 'server.py'), 'a') as f: + f.write('\n# a newer server\n') + expect('link, after the move (newer server.py)', s.text(*ask), b, warned=True) + finally: + s.close() + + # 2. A HOST THAT INSTALLS EACH VERSION INTO ITS OWN DIRECTORY and records which one is current. The near miss: a + # newer entry of another plugin, in another directory, is not this one's install. + cache = os.path.join(top, 'claude', 'plugins', 'cache', 'mkt', 'axiomcode') + v1, v2 = os.path.join(cache, '0.0.1'), os.path.join(cache, '0.0.2') + other = os.path.join(top, 'claude', 'plugins', 'cache', 'mkt', 'another', '9.9.9') + a = build(v1, 'version 0.0.1') + b = build(v2, 'version 0.0.2') + build(other, 'another plugin') + record = os.path.join(top, 'claude', 'plugins', 'installed_plugins.json') + + def write_record(current): + with open(record, 'w') as f: + json.dump({'version': 2, 'plugins': { + 'axiomcode@mkt': [{'scope': 'user', 'installPath': current, 'lastUpdated': '2026-01-0%dT00:00:00.000Z' % (1 if current == v1 else 2)}], + 'another@mkt': [{'scope': 'user', 'installPath': other, 'lastUpdated': '2026-12-31T00:00:00.000Z'}]}}, f) + write_record(v1) + s = Session(['node', os.path.join(v1, 'mcp', 'launch.js')], repo, dict(base, AXIOMCODE_PLUGIN_ROOT=v1)) + try: + expect('recorded install, before the update', s.text(*ask), a) + write_record(v2) + expect('recorded install, after the update', s.text(*ask), b) + finally: + s.close() + return bad + def check(label, cmd, cwd, env=None, workdir=None, want_err=None): replies, err = exchange(cmd, cwd, env, workdir) @@ -264,6 +380,8 @@ def main(): bad += check_arguments('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo, lax=True) bad += check_words() bad += check_grep_default() + if os.name != 'nt': + bad += check_install_move(work) 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 11025334036e0723982ed3f1c0936e593cfca5d1 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:52:29 -0700 Subject: [PATCH 037/258] impact, path: a member's base types and type decorations come from its own declaring type, not every type of its display What was wrong: the type-level NOT CHECKED reasons (a library base the graph does not contain, a decoration a framework reads on the type) were joined from a member to its owner by the owner's display, which is the simple type name. Two classes of one simple name in different namespaces or modules then shared each other's bases: - graph_sql.no_caller_reasons took the first type symbol of that display (LIMIT 1), so a plain entity method was reported as "declared on a subtype of ViewComponent" because a view component in another namespace has the same name, and in the same graph the view component's own method could lose its base the same way; - the impact verdict's "outside the graph" list and the "it extends / implements X" bullet unioned the bases of every type of that display; - axiomcode-path unmodelled_entry unioned the decorations of every type of that display. A file:line target did not help, since the member was resolved correctly and only the owner join was by name. The same join is shared by Java and Python and failed there the same way (a Java model class inheriting a same-named servlet's HttpServlet, a Python model class inheriting a same-named view's View). The change: graph_sql.owner_type_ids resolves a member to the type that declares it, from the member's own row (methods.owner_type_id, else the type of that display whose span holds the member in its file), and graph_sql.type_parts adds the other parts of a C# partial class (same qualified name), so a base written on one part still counts for a method on the other. no_caller_reasons, unmodelled_entry and the impact verdict read bases and type decorations from those symbols only. When neither the owner id nor a span tells two types apart, every type of that display is still used, as before. Tests: a case per language, tests/cases/{csharp,java,python}/same-name-type-keeps-its-own-base: two classes of one simple name, one extending a framework base; impact --delete on the other one's method (by name and by file:line) must not carry that base or its NOT CHECKED reason; controls: the framework-derived one keeps its base, a single class of its name keeps its base, and (C#) a method on the base-less part of a partial class keeps the base the other part writes. Without the change the C# case fails 3 of its checks (including the control on the view component's own method), and the Java and Python cases fail their first check. Suites, rebased on 0.1.9 (before is the branch tip without this change): tests/run.py --lang csharp before: 57 of 62 check(s) passed in 14 case(s) - 1 PENDING - 4 FAILED after: 65 of 70 check(s) passed in 15 case(s) - 1 PENDING - 4 FAILED tests/run.py --lang java before: 192 of 192 check(s) passed in 54 case(s) after: 193 of 193 check(s) passed in 55 case(s) (two cases whose index failed under machine load in the full run passed when re-run alone) tests/run.py --lang python before: 206 of 207 check(s) passed in 44 case(s) - 1 FAILED after: 209 of 210 check(s) passed in 45 case(s) - 1 FAILED The failing checks are the same before and after (lambda-is-named-by-its-place in C# and Python, member-owner-is-its-type and unmodelled-entry-not-local in C#). tests/hook_languages.py 7 of 7 held, tests/enrich_lines.py 45 of 45 held. Smoke, one ASP.NET Core project, fresh index, impact --delete on every non-test method whose owner's simple name is shared by two or more types (268 targets), old scripts vs new on the same graph: - 7 methods of an entity class no longer say they are declared on a subtype of ViewComponent (one of them is now "the change is local"; the others have by-name callers); - 2 lambdas in production code now name their own library base, which the old join took from a same-named test class that has none; - 8 page model methods no longer carry @Authorize from another page model of the same name, and cite their own file for the base; - 121 methods of partial migration classes keep their base and now also name the class decorations written on the other part (@DbContext, @Migration). The other 130 answers are unchanged. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/axiomcode-impact | 21 ++- .../skills/axiomcode/scripts/axiomcode-path | 4 +- .../skills/axiomcode/scripts/graph_sql.py | 56 ++++++-- .../case.json | 121 ++++++++++++++++++ .../src/App/App.csproj | 4 + .../src/App/Components.cs | 21 +++ .../src/App/Entities.cs | 16 +++ .../src/App/Footer.Parts.cs | 7 + .../src/App/Program.cs | 2 + .../case.json | 23 ++++ .../src/main/java/app/model/Basket.java | 6 + .../src/main/java/app/web/Basket.java | 7 + .../src/main/java/app/web/Header.java | 7 + .../app/__init__.py | 0 .../app/model/__init__.py | 0 .../app/model/basket.py | 3 + .../app/web/__init__.py | 0 .../app/web/basket.py | 11 ++ .../case.json | 23 ++++ 19 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/case.json create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/App.csproj create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Components.cs create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Entities.cs create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Footer.Parts.cs create mode 100644 tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Program.cs create mode 100644 tests/cases/java/same-name-type-keeps-its-own-base/case.json create mode 100644 tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/model/Basket.java create mode 100644 tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Basket.java create mode 100644 tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Header.java create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/app/__init__.py create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/app/model/__init__.py create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/app/model/basket.py create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/app/web/__init__.py create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/app/web/basket.py create mode 100644 tests/cases/python/same-name-type-keeps-its-own-base/case.json diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 86f2642e..869fbd84 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2884,19 +2884,26 @@ def main(argv): if g.has('overrides') and g.q("SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id = ? AND b.provenance <> 'client' LIMIT 1", s['method_id']): return 'yes' if g.has('decorations') and g.q("SELECT 1 FROM decorations WHERE owner_id = ? AND name = 'Override' LIMIT 1", s['method_id']): return 'yes' return 'maybe' - how = {} + # keyed by the DECLARING type's symbol id, not its display: an entity Basket and a view component Basket share + # the display, and the entity's method was said to extend the component's library base + how = {}; own_name = {} for m in seeds: if m not in g.sym: continue - own = g.sym[m].get('owner') or (g.sym[m]['display'] if g.sym[m].get('type_id') and not g.sym[m].get('method_id') else None) + sm = g.sym[m] + is_type = sm.get('type_id') and not sm.get('method_id') + own = sm.get('owner') or (sm['display'] if is_type else None) v = via_super(m) if own else None - if v: how[own] = 'yes' if 'yes' in (how.get(own), v) else v + if not v: continue + for sid in (graph_sql.type_parts(g.q, m) if is_type else graph_sql.owner_type_ids(g.q, sm)): + how[sid] = 'yes' if 'yes' in (how.get(sid), v) else v; own_name[sid] = own outside = []; outside_how = {} - for own in sorted(how): + for sid in sorted(how, key=lambda t: (own_name[t], t)): # a library ancestor the engine resolved, or a written base no client type carries (Python and C# leave an # unresolvable library base out of type_ancestors, so a subclass of one read as having no outside caller) - for a in sorted({b for (sid,) in g.q("SELECT id FROM symbols WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL", own) - for b in P._library_bases(g, sid)}): - if a.split('.')[-1] not in MARKER: outside.append((own, a)); outside_how[a] = 'yes' if 'yes' in (outside_how.get(a), how[own]) else how[own] + own = own_name[sid] + for a in sorted(P._library_bases(g, sid)): + if a.split('.')[-1] not in MARKER and (own, a) not in outside: + outside.append((own, a)); outside_how[a] = 'yes' if 'yes' in (outside_how.get(a), how[sid]) else how[sid] dunder = sorted({g.sym[m]['name'] for m in seeds if m in g.sym and re.fullmatch(r'__\w+__', g.sym[m]['name'] or '')}) # A NAME IN A FILE THE INDEX DOES NOT READ AS SOURCE IS TWO KINDS OF EVIDENCE (#1388). A type written in full # (a qualified class name in spring.factories, a services file, a mapper XML, a settings file) or a key a settings diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 98036d05..17a14bfa 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1432,8 +1432,8 @@ def unmodelled_entry(g, ids): continue own = s.get('owner') if not own: continue - for r in g.q("SELECT id FROM symbols WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL", own): - for n in decs(r[0]): out.append((i, f"@{n} on its type {own}")) + for t in graph_sql.owner_type_ids(g.q, s): # the declaring type, not every type of that display + for n in decs(t): out.append((i, f"@{n} on its type {own}")) return list(dict.fromkeys(out)) def grep_roots(g): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 8ade9ac3..85009fde 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1054,6 +1054,42 @@ def _sym_row(q, i): return dict(zip(k, tuple(r[0]))) +def type_parts(q, sid): + """the symbol ids of every declaration of the type whose symbol is `sid`, that one first: the parts of a C# + `partial class` are separate type symbols with one qualified name, and a base written on one part is the base of + all of them. Two types that only share a display (an entity `Basket` and a view component `Basket` in another + namespace) have different qualified names and stay apart.""" + r = q("SELECT qualified_name FROM symbols WHERE id = ?", sid) + qn = r[0][0] if r else None + if not qn: return [sid] + return [sid] + [x[0] for x in q("""SELECT id FROM symbols WHERE qualified_name = ? AND method_id IS NULL + AND type_id IS NOT NULL AND id <> ? ORDER BY id""", qn, sid)] + + +def owner_type_ids(q, s): + """the symbol ids of the type that DECLARES member `s` (a symbols row as a dict), with its other partial parts, + never another type that only shares its display. Two classes of one simple name in different namespaces or + modules (an entity `Basket` and a view component `Basket`) have one display, so a join on the owner's display + gave the member every base and decoration of both. The owner comes from the member's own row: the method's + owner_type_id, else the type of that display whose span holds the member's line in the member's file. Only when + neither tells them apart is every type of that display returned, as before.""" + own = s.get('owner') if s else None + if not own: return [] + if s.get('method_id') and _has(q, 'methods'): + r = q("SELECT owner_type_id FROM methods WHERE id = ?", s['method_id']) + tid = r[0][0] if r else None + if tid: + ids = [x[0] for x in q("SELECT id FROM symbols WHERE type_id = ? AND method_id IS NULL ORDER BY id", tid)] + if ids: return list(dict.fromkeys(p for i in ids for p in type_parts(q, i))) + rows = [tuple(r) for r in q("""SELECT id, file, line, end_line FROM symbols + WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL ORDER BY id""", own)] + if len(rows) <= 1: return [r[0] for r in rows] + f, ln = s.get('file'), s.get('line') + inside = [(r[0], (r[3] or r[2]) - r[2]) for r in rows if f and ln and r[1] == f and r[2] and r[2] <= ln <= (r[3] or r[2])] + if inside: return type_parts(q, min(inside, key=lambda x: x[1])[0]) # the innermost span holding the member + return [r[0] for r in rows if f and r[1] == f] or [r[0] for r in rows] + + def no_caller_reasons(q, mids): """{method id: [(kind, label, evidence)]}: why nothing in the graph calls each of `mids`, strongest first, in the order of NO_CALLER_KINDS. `evidence` is `file:line` where there is a line to read, else ''. @@ -1114,16 +1150,18 @@ def in_repo_decorator(name): """SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id = ? AND b.provenance = 'client' LIMIT 1""", mid)): rs.append(('overrides', '', '')) - own = None - if s.get('owner'): - r = q("SELECT id FROM symbols WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL LIMIT 1", s['owner']) - own = _sym_row(q, r[0][0]) if r else None - if own is None and owner_tid: + # the declaring type and its partial parts, not every type that shares the owner's display + parts = owner_type_ids(q, s) if s.get('owner') else [] + if not parts and owner_tid: r = q("SELECT id FROM symbols WHERE type_id = ? AND method_id IS NULL LIMIT 1", owner_tid) - own = _sym_row(q, r[0][0]) if r else None - if own: - if not any(k == 'overrides' for k, *_ in rs): - for b in library_bases(q, own)[:2]: rs.append(('base', b, loc(own.get('file'), own.get('line')))) + parts = type_parts(q, r[0][0]) if r else [] + owners = [o for o in (_sym_row(q, i) for i in parts) if o] + if owners and not any(k == 'overrides' for k, *_ in rs): + first = {} # each base once, at the part that writes it + for o in owners: + for b in library_bases(q, o): first.setdefault(b, o) + for b, o in list(first.items())[:2]: rs.append(('base', b, loc(o.get('file'), o.get('line')))) + for own in owners: if has['decorations']: for n, t, f, l in q("SELECT name, text, file, line FROM decorations WHERE owner_id = ? ORDER BY line", own['id']): sn = decoration_name(n) diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/case.json b/tests/cases/csharp/same-name-type-keeps-its-own-base/case.json new file mode 100644 index 00000000..0e75823f --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/case.json @@ -0,0 +1,121 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a method of an entity is not on a subtype of ViewComponent because a view component in another namespace shares the entity's simple name", + "run": [ + "impact", + "Basket.Clear", + "--delete" + ], + "want": [ + "the change is local" + ], + "avoid": [ + "extends ViewComponent", + "subtype of ViewComponent", + "NOT CHECKED" + ] + }, + { + "why": "the same method targeted by file:line answers for its own declaring type only", + "run": [ + "impact", + "src/App/Entities.cs:8", + "--delete" + ], + "want": [ + "Basket.Clear (at src/App/Entities.cs:8)", + "the change is local" + ], + "avoid": [ + "extends ViewComponent", + "NOT CHECKED" + ] + }, + { + "why": "the entity type targeted by file:line does not inherit the other Basket's base", + "run": [ + "impact", + "src/App/Entities.cs:4", + "--delete" + ], + "want": [ + "class Basket (at src/App/Entities.cs:4)" + ], + "avoid": [ + "extends ViewComponent", + "NOT CHECKED" + ] + }, + { + "why": "control: the view component Basket, by file:line, keeps its base and its NOT CHECKED reason", + "run": [ + "impact", + "src/App/Components.cs:6", + "--delete" + ], + "want": [ + "NOT CHECKED: Basket extends ViewComponent", + "NOT SAFE TO ASSUME" + ], + "avoid": [ + "the change is local" + ] + }, + { + "why": "control: a method of the view component Basket keeps its base", + "run": [ + "impact", + "Basket.Invoke", + "--delete" + ], + "want": [ + "Basket extends ViewComponent" + ], + "avoid": [ + "the change is local" + ] + }, + { + "why": "control: the only class of its name still shows its base", + "run": [ + "impact", + "Header.Invoke", + "--delete" + ], + "want": [ + "NOT CHECKED: Header extends ViewComponent" + ], + "avoid": [ + "the change is local" + ] + }, + { + "why": "control: a method on the part of a partial class that writes no base still has the base the other part writes", + "run": [ + "impact", + "Footer.Render", + "--delete" + ], + "want": [ + "Footer extends ViewComponent" + ], + "avoid": [ + "the change is local" + ] + }, + { + "why": "control: path's empty-upstream note names the base only for the component's method", + "run": [ + "path", + "*", + "Basket.Clear" + ], + "expect_error": true, + "avoid": [ + "ViewComponent" + ] + } + ] +} diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/App.csproj b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/App.csproj new file mode 100644 index 00000000..f6487f71 --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/App.csproj @@ -0,0 +1,4 @@ + + net8.0enable + + diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Components.cs b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Components.cs new file mode 100644 index 00000000..d6e492be --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Components.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace App.Web.ViewComponents; + +// same simple name as App.Entities.Basket, extends a framework base +public class Basket : ViewComponent +{ + public IViewComponentResult Invoke() => Content("basket"); +} + +// the only class of its name, extends a framework base +public class Header : ViewComponent +{ + public IViewComponentResult Invoke() => Content("header"); +} + +// one class in two parts: the base is written on this part only +public partial class Footer : ViewComponent +{ + public IViewComponentResult Invoke() => Content("footer"); +} diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Entities.cs b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Entities.cs new file mode 100644 index 00000000..62190294 --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Entities.cs @@ -0,0 +1,16 @@ +namespace App.Entities; + +// an entity: no base, nothing registers it +public class Basket +{ + public int Count; + public void SetQuantities(int n) { Count = n; } + public void Clear() { Count = 0; } +} + +// a single class of its name +public class Wishlist +{ + public void Add(string item) { } + public void Clear() { } +} diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Footer.Parts.cs b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Footer.Parts.cs new file mode 100644 index 00000000..011b3cae --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Footer.Parts.cs @@ -0,0 +1,7 @@ +namespace App.Web.ViewComponents; + +// the other part of Footer: no base written here, but it is the same class +public partial class Footer +{ + public void Render() { } +} diff --git a/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Program.cs b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Program.cs new file mode 100644 index 00000000..88fb19c1 --- /dev/null +++ b/tests/cases/csharp/same-name-type-keeps-its-own-base/src/App/Program.cs @@ -0,0 +1,2 @@ +var b = new App.Entities.Basket(); +b.SetQuantities(2); diff --git a/tests/cases/java/same-name-type-keeps-its-own-base/case.json b/tests/cases/java/same-name-type-keeps-its-own-base/case.json new file mode 100644 index 00000000..9d694d3d --- /dev/null +++ b/tests/cases/java/same-name-type-keeps-its-own-base/case.json @@ -0,0 +1,23 @@ +{ + "lang": "java", + "checks": [ + { + "why": "a method of app.model.Basket is not on a subtype of HttpServlet because app.web.Basket shares its simple name", + "run": ["impact", "src/main/java/app/model/Basket.java:5", "--delete"], + "want": ["the change is local"], + "avoid": ["HttpServlet", "NOT CHECKED"] + }, + { + "why": "control: the servlet Basket's method keeps its base and its NOT CHECKED reason", + "run": ["impact", "src/main/java/app/web/Basket.java:6", "--delete"], + "want": ["NOT CHECKED: Basket extends HttpServlet"], + "avoid": ["the change is local"] + }, + { + "why": "control: the only class of its name still shows its base", + "run": ["impact", "src/main/java/app/web/Header.java:6", "--delete"], + "want": ["NOT CHECKED: Header extends HttpServlet"], + "avoid": ["the change is local"] + } + ] +} diff --git a/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/model/Basket.java b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/model/Basket.java new file mode 100644 index 00000000..c1edebfb --- /dev/null +++ b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/model/Basket.java @@ -0,0 +1,6 @@ +package app.model; + +public class Basket { + private int count; + public void clear() { count = 0; } +} diff --git a/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Basket.java b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Basket.java new file mode 100644 index 00000000..c5119d0d --- /dev/null +++ b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Basket.java @@ -0,0 +1,7 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; + +public class Basket extends HttpServlet { + public void clear() { } +} diff --git a/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Header.java b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Header.java new file mode 100644 index 00000000..0c45dbf2 --- /dev/null +++ b/tests/cases/java/same-name-type-keeps-its-own-base/src/main/java/app/web/Header.java @@ -0,0 +1,7 @@ +package app.web; + +import jakarta.servlet.http.HttpServlet; + +public class Header extends HttpServlet { + public void clear() { } +} diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/app/__init__.py b/tests/cases/python/same-name-type-keeps-its-own-base/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/app/model/__init__.py b/tests/cases/python/same-name-type-keeps-its-own-base/app/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/app/model/basket.py b/tests/cases/python/same-name-type-keeps-its-own-base/app/model/basket.py new file mode 100644 index 00000000..6bcb667a --- /dev/null +++ b/tests/cases/python/same-name-type-keeps-its-own-base/app/model/basket.py @@ -0,0 +1,3 @@ +class Basket: + def clear(self): + self.count = 0 diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/app/web/__init__.py b/tests/cases/python/same-name-type-keeps-its-own-base/app/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/app/web/basket.py b/tests/cases/python/same-name-type-keeps-its-own-base/app/web/basket.py new file mode 100644 index 00000000..26b1bf24 --- /dev/null +++ b/tests/cases/python/same-name-type-keeps-its-own-base/app/web/basket.py @@ -0,0 +1,11 @@ +from django.views import View + + +class Basket(View): + def clear(self): + pass + + +class Header(View): + def clear(self): + pass diff --git a/tests/cases/python/same-name-type-keeps-its-own-base/case.json b/tests/cases/python/same-name-type-keeps-its-own-base/case.json new file mode 100644 index 00000000..634caef6 --- /dev/null +++ b/tests/cases/python/same-name-type-keeps-its-own-base/case.json @@ -0,0 +1,23 @@ +{ + "lang": "python", + "checks": [ + { + "why": "a method of app.model.basket.Basket is not on a subtype of View because app.web.basket.Basket shares its simple name", + "run": ["impact", "app/model/basket.py:2", "--delete"], + "want": ["the change is local"], + "avoid": ["extends View", "subtype of View", "NOT CHECKED"] + }, + { + "why": "control: the view Basket's method keeps its base and its NOT CHECKED reason", + "run": ["impact", "app/web/basket.py:5", "--delete"], + "want": ["NOT CHECKED: Basket extends View"], + "avoid": ["the change is local"] + }, + { + "why": "control: the only class of its name still shows its base", + "run": ["impact", "app/web/basket.py:10", "--delete"], + "want": ["NOT CHECKED: Header extends View"], + "avoid": ["the change is local"] + } + ] +} From 09a3f9698b4fdffb6eedb163f9d12988b02b07f0 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:49:01 -0700 Subject: [PATCH 038/258] path, impact: entry points come from entry_points with their reason, and a base's dispatch hop is not a caller Fixes #1475, #1492 Refs #1381, #1421, #1426, #1469, #1474, #1508, #1480, #1418, #1460, #1497, #1544, #1403 What was wrong `path '*' X` and `impact X` built their "entry points" section from one test: a reached method is an entry point when it has no incoming edge in the path export, or is a test. The bundle's `entry_points` table was never read for that list, and every edge counted as a call, including the dispatch hop from a base method to its override. - C# (#1492): a minimal-API route lambda has its definer as an incoming `defines` edge, so it was dropped and the method that registers the route stood in for it. A `lifecycle` override that a subclass calls through `base.StartAsync` has an incoming edge, and the subclass override has the dispatch hop from that base, so both lifecycle overrides were left out. No row said why a method was an entry point. - Java (#1475): once the override's type is instantiated, the dispatch hop from the library base (`HttpServlet.doGet`) counts as a caller of `OrdersServlet.doGet`, so the servlet callback left the section. The same holds for any base nothing calls. The change One rule, `entry_points_among` in axiomcode-path, used by both verbs in every language: - a reached method with an `entry_points` row is an entry point, and the row prints its reason in words (`an HTTP route handler`, `a lifecycle callback`); - otherwise it is one when nothing resolved calls it, where a base's dispatch hop is followed to the base's own callers rather than counted, and an override's own call into its base is not a caller of it; - a method nothing calls that reaches the target only by defining a registered entry point (the route lambda), or only as the base of a listed override, is not listed in its place. Test rows, the ordering, the row layout and the JSON are unchanged. Nothing cached changes, so neither IMPACT_VERSION nor EXPORT_VERSION moves. Cases - tests/cases/csharp/entry-points-by-reason: the lambda, both lifecycle overrides and the method-group handler are listed with their reasons by path '*' and impact. Controls: the lambda's definer is not listed, an implementation called through its interface and a handler the lambda calls are not listed, and a caller-less method with no entry_points row still is. The first and third checks fail on the release branch tip. - tests/cases/java/override-of-uncalled-base-is-entry: an override of an in-graph base nothing calls, on an instantiated type, is listed by impact and path '*' and its base is not. Control: an implementation a resolved caller reaches stays out, and a caller-less method stays in. The first two checks fail on the tip. Suites (tests/run.py, tip vs this change, same machine) - csharp: 57 of 62 passed, 1 pending, 4 failed -> 61 of 66 passed, 1 pending, 4 failed (the 4 new checks pass) - java: 192 of 192 passed -> 195 of 195 passed (the 3 new checks pass) - python: 196 of 198 passed, 2 failed -> 206 of 207 passed, 1 failed Every failure after is one the tip already has: lambda-is-named-by-its-place, member-owner-is-its-type and unmodelled-entry-not-local in C#, lambda-is-named-by-its-place in Python. On the tip, Python's framework-hop-is-a-dependent also failed, with an index failure that did not recur; its 9 checks pass after. Smoke (one graph per project, tip scripts vs these scripts, path '*' and impact on 25 methods each, every page) - a 50-file Java web app: 333 entry rows before and after, same sets; 29 rows now carry a reason. - a 110-file C# layered web service: 546 -> 599 entry rows over 50 answers, 31 answers changed. 57 rows added, chiefly request-handler overrides whose library base nothing in the graph calls; 4 dropped, each an interface method nothing calls replaced by its one implementation. 54 rows now carry a reason. Not fixed here, left for their own change: the by-name group in path '*' (#1421), remote and framework hops as path edges for Java (#1469), a field as a path endpoint (#1474), composed and derived test annotations (#1418, #1497), query-method readers of an entity field (#1460) and generated constructors as impact seeds (#1403). #1426, #1508, #1480 and #1544 are already fixed on the release branch. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/axiomcode-impact | 13 ++-- .../skills/axiomcode/scripts/axiomcode-path | 59 +++++++++++++++++- .../csharp/entry-points-by-reason/case.json | 60 +++++++++++++++++++ .../entry-points-by-reason/src/App.csproj | 3 + .../entry-points-by-reason/src/Widgets.cs | 45 ++++++++++++++ .../case.json | 22 +++++++ .../pom.xml | 1 + .../src/main/java/app/Boot.java | 12 ++++ .../src/main/java/app/Handler.java | 5 ++ .../src/main/java/app/Lister.java | 5 ++ .../src/main/java/app/OrderLister.java | 8 +++ .../src/main/java/app/Orders.java | 5 ++ .../src/main/java/app/OrdersHandler.java | 8 +++ 13 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 tests/cases/csharp/entry-points-by-reason/case.json create mode 100644 tests/cases/csharp/entry-points-by-reason/src/App.csproj create mode 100644 tests/cases/csharp/entry-points-by-reason/src/Widgets.cs create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/case.json create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/pom.xml create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Boot.java create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Handler.java create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Lister.java create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrderLister.java create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Orders.java create mode 100644 tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrdersHandler.java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 869fbd84..0ab67a49 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2127,8 +2127,6 @@ def main(argv): if IN: reached = {m: d for m, d in reached.items() if g.under_in(g.sym[m]['file'])} parent = collections.defaultdict(list) for a, b, t, q_ in res['parent_up']: parent[a].append((b, t)) - callers_of = collections.defaultdict(set) - for a, b, _ in g.edges(): callers_of[b].add(a) prof('outputs read') tests = {} for m, d, q_ in res['test_near']: @@ -2176,8 +2174,11 @@ def main(argv): # 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] + # one rule with `path '*'` (P.entry_points_among): an entry_points row with its reason, or nothing resolved calls it, + # where a base's dispatch hop to its override is not a call (#1475, #1492). The walk up from the change starts at the + # direct users too: a field reader or a contract row has no edge to the seed, and is still where the change is seen + ent_why = P.entry_points_among(g, [m for m in {*direct_ids, *reached} if m not in aliases], {*seeds, *direct_ids}) + ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if m in ent_why] 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 @@ -2697,10 +2698,10 @@ def main(argv): # built in, which comes from a SET and is a function of hashing rather than of the answer. The location # makes it total, so the same graph prints the same list twice running. ent.sort(key=lambda x: (x[1], g.sym[x[0]]['display'], g.loc(x[0]), x[0])) - print(f" entry points ({len(ent)}: nothing resolved calls them — a framework, a runner, reflection — or a test; 0 hops = touches it directly), nearest first:") + print(f" entry points ({len(ent)}: a framework enters them, for the reason given, or nothing resolved calls them — a framework, a runner, reflection — or a test; 0 hops = touches it directly), nearest first:") for m, d in ent[:limit]: decs = [r[0].split('.')[-1] for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", m)] if g.has('decorations') else [] - print(f" {d:2} hop(s) {g.disp(m)}{' [test]' if g.sym[m]['is_test'] else ''}{(' @' + ','.join(decs[:3])) if decs else ''} {g.loc(m)}") + print(f" {d:2} hop(s) {g.disp(m)}{' [test]' if g.sym[m]['is_test'] else ''}{(' @' + ','.join(decs[:3])) if decs else ''} {g.loc(m)}{P.entry_why(ent_why.get(m))}") # the entry points' own overflow line, under their list: it sat inside the next section and printed only when # that section did if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 17a14bfa..b201217b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1146,6 +1146,58 @@ def why_no_caller(g, ids, shown=2, decls=3): return out +def entry_points_among(g, ids, targets=()): + """{symbol id: reason or None}: where execution enters, among `ids` (what reaches `targets`). One rule for `path '*'` + and `impact`, in every language (#1475, #1492): + - an `entry_points` row (a route, a lifecycle callback, a test) is an entry point WHATEVER calls it, with its reason. + A minimal-API route lambda has its definer as a "caller" in the graph, and a lifecycle override the subclass calls + through `base.` has a resolved caller, so "nothing calls it" alone dropped both. + - otherwise a method is one when nothing resolved calls it. A base's dispatch hop to its override is not a call: the + base's own callers are the override's callers. So an override reached only from a library base, or from a base + nothing resolved calls (a servlet's doGet, whose base the container runs), stays an entry point, and the override's + own `super.m()` / `base.M()` into that base does not count as calling itself. + - a method nothing calls that reaches the targets only by DEFINING an entry point (the method that registers a route + lambda) is not where execution enters for them: the framework runs the lambda, not its definer. Nor is a base + nothing calls that reaches them only through its override, when the override is listed: it is the same entry.""" + ids = [m for m in ids if m in g.sym] + if not ids: return {} + reason = {} + if g.has('entry_points'): + for k in range(0, len(ids), 900): + part = ids[k:k + 900] + by_mid = {g.sym[i].get('method_id'): i for i in part if g.sym[i].get('method_id')} + if not by_mid: continue + for r in g.q("SELECT method_id, reason FROM entry_points WHERE method_id IN (%s) ORDER BY reason" % ','.join('?' * len(by_mid)), *by_mid): + reason.setdefault(by_mid[r['method_id']], r['reason']) + callers = collections.defaultdict(set) + for a, b, t in g.edges(): callers[b].add((a, t)) + if not hasattr(g, '_dispatch_pairs'): + g._dispatch_pairs = {(r[0], r[1]) for r in g.q("SELECT DISTINCT base_method_id, candidate_method_id FROM dispatch_candidates" + " WHERE base_method_id <> candidate_method_id")} if g.has('dispatch_candidates') else set() + disp = g._dispatch_pairs + def called(m): + seen = {m}; stack = [m] + while stack: + x = stack.pop() + for a, _t in callers.get(x, ()): + if (a, x) in disp: # through a base: whoever calls the base calls m + if a not in seen: seen.add(a); stack.append(a) + elif a != m: return True # m's own call into its base is not a caller of m + return False + out = {m: reason.get(m) for m in ids if m in reason or g.sym[m]['is_test'] or not called(m)} + # what reaches the targets without stepping from a registered entry point up to the method that merely defines it + inside = set(ids) | set(targets); ahead = set(targets); fr = list(targets) + while fr: + x = fr.pop() + for a, t in callers.get(x, ()): + if a in inside and a not in ahead and not (t == 'defines' and x in reason) and not ((a, x) in disp and x in out): + ahead.add(a); fr.append(a) + return {m: r for m, r in out.items() if r or g.sym[m]['is_test'] or m in ahead} + +def entry_why(reason): + """the words a listed entry point carries after its location: why a framework enters it (a test says [test] already)""" + return f" — {ax_edges.entry_phrase(reason)}" if reason and reason != 'test' else '' + def closure(g, sel, upstream, limit=40, depth=40): g.export(); label, ids = g.resolve(sel, fragment=True) res = run(g, {'c': (ids if not upstream else [], ids if upstream else [])}) @@ -1295,14 +1347,15 @@ def closure(g, sel, upstream, limit=40, depth=40): decs = {} if g.has('decorations'): for r in g.q("SELECT owner_id, name FROM decorations WHERE owner_id IN (%s)" % ','.join('?' * len(rows)), *[m for m, _ in rows]): decs.setdefault(r['owner_id'], []).append(r['name'].split('.')[-1]) - ent = [(m, d) for m, d in rows if not adj.get(m) or g.sym[m]['is_test']] + why = entry_points_among(g, [m for m, _ in rows], ids) + ent = [(m, d) for m, d in rows if m in why] # two overloads share a display name, so (depth, display) is not a total order and a stable sort then keeps # whichever came first: add the id, which is unique, so the list does not depend on the row order. ent.sort(key=lambda x: (x[1], g.sym[x[0]]['display'], x[0])) - print(f" entry points among them ({len(ent)}: nothing resolved calls them — the caller is outside the graph — or a test), nearest first:") + print(f" entry points among them ({len(ent)}: a framework enters them, for the reason given, or nothing resolved calls them — the caller is outside the graph — or a test), nearest first:") for m, d in ent[:limit]: tag = (' [test]' if g.sym[m]['is_test'] else '') + (' @' + ','.join(decs[m][:3]) if m in decs else '') - print(f" {d:2} hop(s) {g.disp(m)}{tag} {g.loc(m)}") + print(f" {d:2} hop(s) {g.disp(m)}{tag} {g.loc(m)}{entry_why(why[m])}") if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") else: print(f" nearest first:") diff --git a/tests/cases/csharp/entry-points-by-reason/case.json b/tests/cases/csharp/entry-points-by-reason/case.json new file mode 100644 index 00000000..7e169ab9 --- /dev/null +++ b/tests/cases/csharp/entry-points-by-reason/case.json @@ -0,0 +1,60 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "path '*': a route lambda and a lifecycle override the subclass calls through base. are entry points, each with its reason from entry_points (#1492)", + "run": [ + "path", + "*", + "Store.List" + ], + "want": [ + "WidgetRoutes. src/Widgets.cs:9 — an HTTP route handler", + "Worker.StartAsync src/Widgets.cs:25 — a lifecycle callback", + "TimedWorker.StartAsync src/Widgets.cs:31 — a lifecycle callback", + "UserRoutes.Handle src/Widgets.cs:20 — an HTTP route handler" + ] + }, + { + "why": "control, path '*': the method that only registers the route lambda is not where execution enters, an implementation called through its interface or a handler called by the lambda is still not one, and a caller-less method with no entry_points row still is", + "run": [ + "path", + "*", + "Store.List" + ], + "avoid": [ + "hop(s) WidgetRoutes.AddRoutes src/Widgets.cs:7\n", + "hop(s) Audit.Record src/Widgets.cs:38\n", + "hop(s) WidgetRoutes.Handle src/Widgets.cs:11\n" + ], + "want": [ + "hop(s) Nightly.Run src/Widgets.cs:44\n" + ] + }, + { + "why": "impact lists the same entry points, with the same reasons (#1492)", + "run": [ + "impact", + "Store.List" + ], + "want": [ + "WidgetRoutes. src/Widgets.cs:9 — an HTTP route handler", + "Worker.StartAsync src/Widgets.cs:25 — a lifecycle callback", + "TimedWorker.StartAsync src/Widgets.cs:31 — a lifecycle callback", + "hop(s) Nightly.Run src/Widgets.cs:44" + ] + }, + { + "why": "control, impact: an implementation reached through its interface from a resolved caller is not an entry point, nor is the lambda's definer", + "run": [ + "impact", + "Store.List" + ], + "avoid": [ + "hop(s) WidgetRoutes.AddRoutes", + "hop(s) Audit.Record", + "hop(s) WidgetRoutes.Handle" + ] + } + ] +} diff --git a/tests/cases/csharp/entry-points-by-reason/src/App.csproj b/tests/cases/csharp/entry-points-by-reason/src/App.csproj new file mode 100644 index 00000000..9185da91 --- /dev/null +++ b/tests/cases/csharp/entry-points-by-reason/src/App.csproj @@ -0,0 +1,3 @@ + + net8.0enable + diff --git a/tests/cases/csharp/entry-points-by-reason/src/Widgets.cs b/tests/cases/csharp/entry-points-by-reason/src/Widgets.cs new file mode 100644 index 00000000..2ba4c849 --- /dev/null +++ b/tests/cases/csharp/entry-points-by-reason/src/Widgets.cs @@ -0,0 +1,45 @@ +namespace App; + +public sealed class Store { public void List() { } } + +public sealed class WidgetRoutes +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("api/widgets", (Store s) => Handle(s)); + } + public IResult Handle(Store s) { s.List(); return Results.Ok(); } +} + +public sealed class UserRoutes +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("api/users", Handle); + } + public static IResult Handle(Store s) { s.List(); return Results.Ok(); } +} + +public class Worker(Store s) : BackgroundService +{ + public override Task StartAsync(CancellationToken ct) { s.List(); return base.StartAsync(ct); } + protected override Task ExecuteAsync(CancellationToken ct) => Task.CompletedTask; +} + +public sealed class TimedWorker(Store s) : Worker(s) +{ + public override Task StartAsync(CancellationToken ct) => base.StartAsync(ct); +} + +public interface IAudit { void Record(Store s); } + +public sealed class Audit : IAudit +{ + public void Record(Store s) { s.List(); } +} + +public sealed class Nightly +{ + private readonly IAudit _audit = new Audit(); + public void Run(Store s) { _audit.Record(s); } +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/case.json b/tests/cases/java/override-of-uncalled-base-is-entry/case.json new file mode 100644 index 00000000..3cf8c0f8 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/case.json @@ -0,0 +1,22 @@ +{ + "lang": "java", + "checks": [ + { + "why": "impact: an override whose only caller is a base nothing resolved calls (the dispatch hop from a framework's base) is an entry point, and the base it stands for is not listed in its place (#1475)", + "run": ["impact", "Orders.list"], + "want": ["1 hop(s) OrdersHandler.handle @Override src/main/java/app/OrdersHandler.java:4\n"], + "avoid": ["hop(s) Handler.handle src/main/java/app/Handler.java:4"] + }, + { + "why": "path '*' lists the same override as an entry point (#1475)", + "run": ["path", "*", "Orders.list"], + "want": ["1 hop(s) OrdersHandler.handle @Override src/main/java/app/OrdersHandler.java:4\n"] + }, + { + "why": "control: an implementation a resolved caller reaches is not an entry point, and a method nothing calls still is", + "run": ["impact", "Orders.list"], + "want": ["2 hop(s) Boot.report src/main/java/app/Boot.java:8\n"], + "avoid": ["hop(s) OrderLister.listAll"] + } + ] +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/pom.xml b/tests/cases/java/override-of-uncalled-base-is-entry/pom.xml new file mode 100644 index 00000000..c86c96a0 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/pom.xml @@ -0,0 +1 @@ +4.0.0exorders1 diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Boot.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Boot.java new file mode 100644 index 00000000..ae501625 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Boot.java @@ -0,0 +1,12 @@ +package app; + +public class Boot { + public Handler handler() { + return new OrdersHandler(); + } + + public void report() { + Lister l = new OrderLister(); + l.listAll(); + } +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Handler.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Handler.java new file mode 100644 index 00000000..738c34a1 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Handler.java @@ -0,0 +1,5 @@ +package app; + +public abstract class Handler { + protected void handle(String req) { } +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Lister.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Lister.java new file mode 100644 index 00000000..ae1e3c4d --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Lister.java @@ -0,0 +1,5 @@ +package app; + +public interface Lister { + void listAll(); +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrderLister.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrderLister.java new file mode 100644 index 00000000..3ecbf254 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrderLister.java @@ -0,0 +1,8 @@ +package app; + +public class OrderLister implements Lister { + @Override + public void listAll() { + Orders.list(); + } +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Orders.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Orders.java new file mode 100644 index 00000000..6298e9fc --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/Orders.java @@ -0,0 +1,5 @@ +package app; + +public class Orders { + public static void list() { } +} diff --git a/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrdersHandler.java b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrdersHandler.java new file mode 100644 index 00000000..c6f23f88 --- /dev/null +++ b/tests/cases/java/override-of-uncalled-base-is-entry/src/main/java/app/OrdersHandler.java @@ -0,0 +1,8 @@ +package app; + +public class OrdersHandler extends Handler { + @Override + protected void handle(String req) { + Orders.list(); + } +} From cbf7d02e28690d0b58b4f601f993abffc6f3a889 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:45:55 -0700 Subject: [PATCH 039/258] java, changed, impact: derived queries, mapping-file queries and handed-over callbacks in the graph; signature uses resolved Fixes #1461, #1462, #1459, #1415, #1465, #1387, #1422, #1487, #1470 What was wrong - #1461: a Spring Data derived query method (findByColor, countByLabelStartingWith) had no persistence_query or persistence_field row. The only Spring Data rule read @Query text, and nothing read the method name, so renaming an entity property reached no repository method. - #1462: a named query declared in META-INF/orm.xml was reported undeclared_named_query. jpa_named_query was built from @NamedQuery annotations only, although the XML element and its text are already in the IR. - #1459: a named class instance handed to a library method (a matcher to argThat, a comparator to List.sort) had no caller. An override is followed only from a client call on a library-typed receiver, and the call that runs it is made inside the library. - #1415: every MyBatis #{...} parameter marker in a document was exported as a spel_expression blind spot. On a mapper-heavy project these were thousands of rows. - #1465: changed read `public class OrderService {` as the new header of the constructor that starts two lines below it, and reported an added parameter as a removed one plus a modifier change. - #1387: changed read a one-line method's whole line as its header, so `return 42;` to `return 43;` came back as a signature change. - #1422: impact on a type labelled every parameter, return and type-argument use as [text]. Java type_refs rows carry no line, so the typeref rule matched none of them, and the text grep labelled what type_use had already resolved. - #1487: impact's "also written as a string" hint named a bean qualifier and "the Java name" for every language. - #1470: verified on this build rather than changed here. changed and test-impact name the changed files outside the index and the commit they compare against, and context --in on a directory of unindexed files says it is not indexed (the earlier merge-base change and the text-scope change). The change - persistence.dl: a derived query is read from the method name of an abstract method on an interface whose extends clause names a repository base (the existing suffix knob). The entity is the base's first type argument. A property is matched where a criterion can start (after By, And, Or, OrderBy) and where a property name can end, and the longer of two properties at one place wins (labelText over label). A method with @Query, a default method, a name with no subject keyword, and an interface with no repository base are not derived. New knob cfg_derived_query_prefix. - persistence.dl: and elements in a mapping file join jpa_named_query with their name attribute and text. New knob cfg_orm_named_query_elem. - xml-wiring.dl: a #{...} value in a document whose root is a knob-listed element (cfg_xml_param_marker_root, "mapper") is not a spel_expression. A bean file's SpEL stays. - call-edge-generation/callback_dispatch.dl (new): a call that does not resolve to client code, with an argument that is a client object created for it (new C(), or a local initialised with one), reaches C's methods the library can call back: an override of a staged library method, or with no library staged, an @Override method under an external ancestor, or, in a class that writes no @Override and names a non-marker external supertype itself, a public instance method. Object members are never fanned. The edge is tier callback_registered, kind callback, beside the site's own row; the plugin already labels that tier "handed over as a value". A parameter or field passed along is not a hand-off. - axiomcode-changed: a constructor's new header skips a line that declares the type of the same name; a header is cut at the brace that opens its body. - impact (impact.dl, and the SQL port in graph_sql.py): a new fact sigtype from type_use gives a resolved row "names it in its signature (a parameter / the return type / a type argument of ...)", and the text row is dropped for a callable that has one. IMPACT_VERSION 44. - impact (both backends): a field target lists each method whose persistence query reads it (persistence_field: a derived name, @Query or named-query text, or a whole-entity select), so the derived and orm.xml queries above reach an agent's answer, not only the graph. New fact persist_field, in the same IMPACT_VERSION 44. - impact: the string hint is worded by the target's language (.java, .cs, .py, other). - graph/bundle/schema.ts still documents callback_registered for JavaScript and TypeScript only; the Java rows are recorded in the graph's vocabulary as undocumented until the schema doc is rebuilt. Tests - New Java engine cases, each with near-miss controls: 70-persistence-derived-and-mapping-file (controls: an @Query method, a default method, a name with no subject keyword, the same name on a non-repository interface, an undeclared named query, a SpEL value in a bean file), 71-object-handed-to-library-callback (no library; controls: a non-override helper, toString, a private method, a value passed along, an object handed to client code, a class under a Serializable base), 72-object-handed-to-staged-library (stub library; control: a method the library does not declare). - Re-blessed: 12-library-interface-override gains the callback edge for items.sort(new ByLength()), 53-repository-interface-bean gains its derived findByName query, oracle-agreement 49 to 50 cases. - New plugin cases: java/persistence-query-reads-the-property (#1461, #1462; control: the longer property wins), java/constructor-header-below-type (#1465, #1387; controls: a multi-line body edit, a return type edit on a one-line method), java/signature-use-resolved-by-type-use (#1422; control: the construction row), python/string-mention-worded-by-language (#1487; control: a name never written as a string). On the installed build the #1465, #1387, #1422 and #1487 checks fail and the controls pass. - Suites on the rebased tree: graph/test/java/run-tests.sh --oracle --no-torture (JDK 23): passed 72, failed 0. tests/run.py --lang java: 195 of 195. --lang python: 192 of 193, the one failure is lambda-is-named-by-its-place, which fails on the tip too. --lang csharp: 55 of 58, 1 pending; the 2 failures (lambda-is-named-by-its-place, and member-owner-is-its-type passing while marked pending) fail the same way on the tip. tests/tiers.py ok, tests/changed_range.py ok. Smoke (installed build vs this branch, fresh index of an rsync copy) - a 524-file Spring Boot project with MyBatis mappers and document-store repositories: spel_expression declared unknowns 4965 to 0 (every one was a #{...} in a document), config_unresolved 5147 to 182, persistence_query 0 to 11 derived methods, persistence_field 0 to 18, call edges unchanged (17246). - a 50-file web application: persistence_query 1 to 3 (2 derived methods), persistence_field 2 to 4, 3 callback_registered edges (a validator handed to a data binder: validate and supports; an application listener), call edges 1618 to 1621. `impact` on an entity field now lists the derived finder that reads it and, through it, the controller method that calls it. A first cut that read every public method of any class under an external ancestor gave 24 edges here, 20 of them an entity's accessors; requiring the class to name a non-marker external supertype itself removed those. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../call-edge-generation/callback_dispatch.dl | 93 ++++++++++++++++++ graph/java/engine/config-resolution/knobs.dl | 13 +++ .../engine/config-resolution/xml-wiring.dl | 9 +- .../engine/framework-behavior/persistence.dl | 85 ++++++++++++++++- graph/java/souffle/decls_all.dl | 30 ++++++ .../src/META-INF/orm.xml | 6 ++ .../src/beans.xml | 7 ++ .../src/mapper/InvoiceMapper.xml | 7 ++ .../src/testcases/derived/Invoice.java | 13 +++ .../src/testcases/derived/InvoiceDao.java | 24 +++++ .../src/testcases/derived/InvoiceMapper.java | 5 + .../src/testcases/derived/Widget.java | 13 +++ .../src/testcases/derived/WidgetFinder.java | 8 ++ .../testcases/derived/WidgetRepository.java | 34 +++++++ .../src/app/Callbacks.java | 94 +++++++++++++++++++ .../src/app/Rules.java | 12 +++ .../lib-src/dep/Checks.java | 6 ++ .../lib-src/dep/Matcher.java | 6 ++ .../src/app/Rules.java | 6 ++ .../src/app/Verifier.java | 21 +++++ .../12-library-interface-override.edges | 1 + .../12-library-interface-override.oracle | 3 +- .../53-repository-interface-bean.config | 7 +- ...ersistence-derived-and-mapping-file.config | 64 +++++++++++++ ...persistence-derived-and-mapping-file.edges | 7 ++ ...ersistence-derived-and-mapping-file.fields | 3 + ...sistence-derived-and-mapping-file.type-use | 36 +++++++ ...71-object-handed-to-library-callback.edges | 44 +++++++++ ...t-handed-to-library-callback.known-missing | 4 + ...1-object-handed-to-library-callback.oracle | 8 ++ ...object-handed-to-library-callback.type-use | 67 +++++++++++++ ...handed-to-library-callback.type-use-oracle | 8 ++ ...2-object-handed-to-staged-library.boundary | 11 +++ .../72-object-handed-to-staged-library.edges | 7 ++ ...2-object-handed-to-staged-library.envelope | 1 + .../72-object-handed-to-staged-library.oracle | 2 + ...2-object-handed-to-staged-library.type-use | 8 ++ ...t-handed-to-staged-library.type-use-oracle | 7 ++ graph/test/java/expected/oracle-agreement.txt | 2 +- .../axiomcode/scripts/axiomcode-changed | 23 ++++- .../skills/axiomcode/scripts/axiomcode-impact | 41 +++++++- .../skills/axiomcode/scripts/dl/impact.dl | 19 +++- .../skills/axiomcode/scripts/graph_sql.py | 37 +++++++- .../constructor-header-below-type/case.json | 17 ++++ .../new-ctor.java | 12 +++ .../new-multiline.java | 12 +++ .../new-oneline-ret.java | 12 +++ .../new-oneline.java | 12 +++ .../constructor-header-below-type/old.java | 12 +++ .../src/pkg/OrderService.java | 12 +++ .../src/pkg/Repo.java | 2 + .../case.json | 13 +++ .../src/main/java/app/Order.java | 9 ++ .../src/main/java/app/OrderDao.java | 8 ++ .../src/main/java/app/Widget.java | 9 ++ .../src/main/java/app/WidgetRepository.java | 16 ++++ .../src/main/resources/META-INF/orm.xml | 6 ++ .../case.json | 12 +++ .../src/main/java/app/Customer.java | 7 ++ .../src/main/java/app/CustomerDesk.java | 12 +++ .../src/main/java/app/CustomerLabel.java | 7 ++ .../src/main/java/app/CustomerStore.java | 11 +++ .../case.json | 9 ++ .../src/app/__init__.py | 0 .../src/app/signals.py | 12 +++ 65 files changed, 1109 insertions(+), 15 deletions(-) create mode 100644 graph/java/engine/call-edge-generation/callback_dispatch.dl create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/META-INF/orm.xml create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/beans.xml create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/mapper/InvoiceMapper.xml create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Invoice.java create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceDao.java create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceMapper.java create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Widget.java create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetFinder.java create mode 100644 graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetRepository.java create mode 100644 graph/test/java/cases/71-object-handed-to-library-callback/src/app/Callbacks.java create mode 100644 graph/test/java/cases/71-object-handed-to-library-callback/src/app/Rules.java create mode 100644 graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Checks.java create mode 100644 graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Matcher.java create mode 100644 graph/test/java/cases/72-object-handed-to-staged-library/src/app/Rules.java create mode 100644 graph/test/java/cases/72-object-handed-to-staged-library/src/app/Verifier.java create mode 100644 graph/test/java/expected/70-persistence-derived-and-mapping-file.config create mode 100644 graph/test/java/expected/70-persistence-derived-and-mapping-file.edges create mode 100644 graph/test/java/expected/70-persistence-derived-and-mapping-file.fields create mode 100644 graph/test/java/expected/70-persistence-derived-and-mapping-file.type-use create mode 100644 graph/test/java/expected/71-object-handed-to-library-callback.edges create mode 100644 graph/test/java/expected/71-object-handed-to-library-callback.known-missing create mode 100644 graph/test/java/expected/71-object-handed-to-library-callback.oracle create mode 100644 graph/test/java/expected/71-object-handed-to-library-callback.type-use create mode 100644 graph/test/java/expected/71-object-handed-to-library-callback.type-use-oracle create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.boundary create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.edges create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.envelope create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.oracle create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.type-use create mode 100644 graph/test/java/expected/72-object-handed-to-staged-library.type-use-oracle create mode 100644 tests/cases/java/constructor-header-below-type/case.json create mode 100644 tests/cases/java/constructor-header-below-type/new-ctor.java create mode 100644 tests/cases/java/constructor-header-below-type/new-multiline.java create mode 100644 tests/cases/java/constructor-header-below-type/new-oneline-ret.java create mode 100644 tests/cases/java/constructor-header-below-type/new-oneline.java create mode 100644 tests/cases/java/constructor-header-below-type/old.java create mode 100644 tests/cases/java/constructor-header-below-type/src/pkg/OrderService.java create mode 100644 tests/cases/java/constructor-header-below-type/src/pkg/Repo.java create mode 100644 tests/cases/java/persistence-query-reads-the-property/case.json create mode 100644 tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Order.java create mode 100644 tests/cases/java/persistence-query-reads-the-property/src/main/java/app/OrderDao.java create mode 100644 tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Widget.java create mode 100644 tests/cases/java/persistence-query-reads-the-property/src/main/java/app/WidgetRepository.java create mode 100644 tests/cases/java/persistence-query-reads-the-property/src/main/resources/META-INF/orm.xml create mode 100644 tests/cases/java/signature-use-resolved-by-type-use/case.json create mode 100644 tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/Customer.java create mode 100644 tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerDesk.java create mode 100644 tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerLabel.java create mode 100644 tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerStore.java create mode 100644 tests/cases/python/string-mention-worded-by-language/case.json create mode 100644 tests/cases/python/string-mention-worded-by-language/src/app/__init__.py create mode 100644 tests/cases/python/string-mention-worded-by-language/src/app/signals.py diff --git a/graph/java/engine/call-edge-generation/callback_dispatch.dl b/graph/java/engine/call-edge-generation/callback_dispatch.dl new file mode 100644 index 00000000..27a2abb7 --- /dev/null +++ b/graph/java/engine/call-edge-generation/callback_dispatch.dl @@ -0,0 +1,93 @@ +// ============================================================================ +// Call-edge generation · A CLIENT OBJECT HANDED TO A LIBRARY, WHICH CALLS IT BACK +// +// verify(repo).save(argThat(new IsValid())); // IsValid implements ArgumentMatcher +// orders.sort(new ByTotal()); // ByTotal implements Comparator +// +// The library runs IsValid.matches / ByTotal.compare, and no call site in client code names +// either method: the call is made inside the library. An override is followed from a call +// on a receiver of the library type (virtual-dispatch.dl), and there is no such receiver +// here, so the named class's methods had no caller at all. The same body written as an +// anonymous class was reached, only because it is lexically inside the caller (`defines`). +// +// THE EDGE. A call site that does NOT resolve to client code, with an argument that is a client +// object created for it (`new C(..)`, or a local initialised with one), reaches each method of C +// that the library can call back: +// (a) an override of a library method (virtual_override from a lib base), when the +// library is staged; or +// (b) with the library absent, a method carrying @Override in a type that has an EXTERNAL +// ancestor (external-types.dl): the annotation says a supertype declares it, and the +// external ancestor is where that supertype can be. +// (c) with the library absent and no @Override written anywhere in C, a public method of a C +// that itself names an external supertype other than a marker interface. +// A helper of a class that marks its overrides is not reached. java.lang.Object's members are +// never fanned (equals / hashCode / toString over every argument is the classic blow-up; +// the same exclusion client_overridden_lib_type makes). +// +// TIER callback_registered, kind "callback": the site HANDS the object over, and the library +// may call it, now or later, or never. It is the tier JavaScript and TypeScript already use +// for `xs.forEach(f)`, labelled so it is never read as a resolved call. The site keeps its +// own row (boundary_lib / ambiguous_unknown) beside it. +// A call that resolves to CLIENT code is left to that code: its body holds the parameter and +// the call on it, and dispatch follows that. An anonymous class is reached as before, by its +// `defines` hop: it is lexically inside the method that hands it over. +// ============================================================================ + +cb_object_member("equals"). cb_object_member("hashCode"). cb_object_member("toString"). +cb_object_member("clone"). cb_object_member("finalize"). + +// an argument of a call that leaves the client, and the client type it holds +cb_arg_type(call, t) :- invocation_site(call, kind), cb_site_kind(kind), + expr_child("client", call, "ARGUMENT", arg), + object_creation_type_resolves("client", arg, _, t). +// … or a local the method created with `new` for it (`ByLength cmp = new ByLength(); xs.sort(cmp)`). +// Only an object CREATED to be handed over: a parameter or a field passed along (`c.accept(w)`) +// is a value flowing through, and the library method it reaches is often not one that calls +// any of its callbacks (a Consumer does not close the AutoCloseable it is given). +cb_arg_type(call, t) :- invocation_site(call, kind), cb_site_kind(kind), + expr_child("client", call, "ARGUMENT", arg), + java_expression(_, "ARGUMENT", _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, arg), + expr_ultimate_method("client", arg, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + local_creation_init(local), + expr_type("client", arg, t). +cb_site_kind("method"). cb_site_kind("new"). + +cb_client_site(call) :- client_calls_client("client", call, "client", _). +cb_handoff(call, t) :- cb_arg_type(call, t), !cb_client_site(call), + java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, t). + +// the methods of t, declared in it or inherited from a client ancestor +cb_member(t, name, m) :- cb_handoff(_, t), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m). +cb_member(t, name, m) :- cb_handoff(_, t), type_ancestor(t, sup), + java_method(name, _, _, _, _, _, _, sup, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m). + +// (a) an override of a library method +cb_callback(t, m) :- cb_member(t, name, m), !cb_object_member(name), + virtual_override(base, m), method_owner("lib", _, base). +// (b) @Override, under an external ancestor +cb_external_ancestor(t) :- cb_handoff(_, t), type_ancestor(t, x), external_type(x). +cb_callback(t, m) :- cb_member(t, name, m), !cb_object_member(name), cb_external_ancestor(t), + ann_on_method("client", _, "Override", m, _). +// (c) with the library absent and a class that writes no @Override at all, the annotation says +// nothing either way: every PUBLIC instance method it declares is a candidate (a library calls +// an implementation through its interface, and an interface method is public). A class that +// does write @Override has said which methods are overrides, and (b) reads that instead. +cb_writes_override(t) :- cb_handoff(_, t), + java_method(_, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, _, _, _, _, _, m), + ann_on_method("client", _, "Override", m, _). +// Only for a class that names the external supertype ITSELF (`class ByLength implements +// Comparator`), and not a marker interface, which declares nothing to call: an entity +// under a client base that is Serializable would otherwise offer every getter and setter. +// Measured on a 50-file web application before this guard: 20 of 24 such edges were an entity's +// accessors, reached from a test that put the entity in a collection. +cb_marker_type("external:java.io.Serializable"). cb_marker_type("external:Serializable"). +cb_marker_type("external:java.lang.Cloneable"). cb_marker_type("external:Cloneable"). +cb_direct_external(t) :- cb_handoff(_, t), type_parent(t, x), external_type(x), !cb_marker_type(x). +cb_callback(t, m) :- cb_handoff(_, t), cb_direct_external(t), !cb_writes_override(t), + java_method(name, _, _, _, _, _, _, t, _, _, "PUBLIC", _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + !cb_object_member(name). + +call_chain_edge(call, caller, "-", m, "client", "callback_registered", "callback") :- + cb_handoff(call, t), cb_callback(t, m), call_from(call, caller). diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index d708a89d..b86654c0 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -130,6 +130,9 @@ cfg_xml_class_elem("listener-class"). cfg_xml_class_elem("resource-class"). cfg_xml_class_elem("provider-class"). cfg_xml_class_elem("handler-class"). cfg_xml_class_elem("ejb-class"). cfg_xml_class_elem("mapper-class"). // (c) the element that DEFINES a bean (its class comes from cfg_xml_class_attr) +// a document root under which `#{...}` is a bound statement parameter, not a Spring +// expression (MyBatis mapper XML; xml-wiring.dl) +cfg_xml_param_marker_root("mapper"). cfg_xml_bean_elem("bean"). cfg_xml_bean_elem("alias"). // ── (8) XML: attributes naming a METHOD the container invokes ──────────────── @@ -342,6 +345,9 @@ cfg_client_type_ann("RegisterRestClient"). // MicroProfile // ── (10) PERSISTENCE: a query is a reference to a schema ──────────────────── cfg_entity_ann("Entity"). cfg_entity_ann("MappedSuperclass"). cfg_query_ann("NamedQuery"). cfg_query_ann("NamedNativeQuery"). +// the same declarations in a mapping file (META-INF/orm.xml): the element carries the name +// attribute and a child holding the text. +cfg_orm_named_query_elem("named-query"). cfg_orm_named_query_elem("named-native-query"). // the EntityManager-like receivers that make a call a query, for the same reason a // broker send is gated on its template: `createQuery` is not a rare method name. @@ -356,6 +362,13 @@ cfg_query_method("createNativeQuery", "inline"). // and there is no call site anywhere (Spring Data, and Micronaut Data's @Query). cfg_method_query_ann("Query"). cfg_method_query_ann("NativeQuery"). cfg_query_arg("value"). cfg_query_arg("query"). +// the subject keywords a Spring Data DERIVED query method name opens with; the criteria +// follow the first `By` after it (framework-behavior/persistence.dl). +cfg_derived_query_prefix("find"). cfg_derived_query_prefix("read"). +cfg_derived_query_prefix("get"). cfg_derived_query_prefix("query"). +cfg_derived_query_prefix("search"). cfg_derived_query_prefix("stream"). +cfg_derived_query_prefix("count"). cfg_derived_query_prefix("exists"). +cfg_derived_query_prefix("delete"). cfg_derived_query_prefix("remove"). // ── (15) CONDITIONS: an annotation that decides whether a bean EXISTS ──────── // A bean the container did not create has no methods that run, so a condition is // the difference between a live filter chain and a dead one. Three families, kept diff --git a/graph/java/engine/config-resolution/xml-wiring.dl b/graph/java/engine/config-resolution/xml-wiring.dl index b24da4a8..53223a2b 100644 --- a/graph/java/engine/config-resolution/xml-wiring.dl +++ b/graph/java/engine/config-resolution/xml-wiring.dl @@ -103,8 +103,15 @@ xml_prop_value_key(ownerElem, propName, key, default) :- cfg_placeholder(v, key, default). // A SpEL expression in XML is a declared blind spot: `#{...}` can name anything. +// Not in a document whose ROOT element says `#{...}` means something else: in a MyBatis +// , `#{id}` is a bound parameter, a property of the statement's parameter object, +// and nothing Spring evaluates (cfg_xml_param_marker_root). The parser classifies every +// `#{...}` as SPEL_EXPRESSION whatever the document, and on a mapper-heavy project those +// rows outnumbered every real unknown. +xml_param_marker_file(f) :- xml_elem("client", tag, _, _, "", f, _), cfg_xml_param_marker_root(tag). config_unresolved("xml", vh, expr, "spel_expression") :- - xml_vref("client", expr, "SPEL_EXPRESSION", _, _, _, vh). + xml_vref("client", expr, "SPEL_EXPRESSION", _, owner, _, vh), + xml_elem("client", _, _, _, _, f, owner), !xml_param_marker_file(f). // ───────────────────────────────────────────────────────────────────────────── // 4. CONTAINER REGISTRATIONS (web.xml and friends) diff --git a/graph/java/engine/framework-behavior/persistence.dl b/graph/java/engine/framework-behavior/persistence.dl index ae33a78d..6ffd4dd1 100644 --- a/graph/java/engine/framework-behavior/persistence.dl +++ b/graph/java/engine/framework-behavior/persistence.dl @@ -47,6 +47,17 @@ jpa_entity_field(t, fname, f) :- jpa_entity(t), type_ancestor(t, sup), // type with its own arguments, so no special handling of the container is needed. jpa_named_query(name, text, t) :- ann_on_type("client", a, n, t), cfg_query_ann(n), ann_arg("client", a, "name", name, _, _), ann_arg("client", a, "query", text, _, _). +// … and in a mapping file: `…` in +// META-INF/orm.xml (or any mapping file the unit lists). The parser already holds the +// element, its name attribute and the child's text; the declaring "type" is the element, +// since a mapping-file query belongs to the persistence unit rather than to a class. The +// JPQL is the child's text exactly as it is for the annotation's `query` argument, so the +// entity and property rules below read both the same way. A is SQL, +// which names tables rather than entities, and is left to report no_entity_in_query. +orm_named_query(name, text, nq) :- xml_elem("client", tag, _, _, _, _, nq), cfg_orm_named_query_elem(tag), + xml_attr("client", "name", raw, nq, _), cfg_clean(raw, name), name != "", + xml_elem("client", "query", _, text, nq, _, _), text != "". +jpa_named_query(name, text, nq) :- orm_named_query(name, text, nq). // ── uses: em.createNamedQuery("…") / em.createQuery("…") ──────────────────── // Gated on the receiver's declared type, for the same reason a broker send is: the @@ -99,6 +110,75 @@ persistence_query(m, "inline", "-", text) :- query_call(_, m, "inline", text). persistence_query(m, "spring_data", "-", text) :- ann_on_method("client", a, n, m, _), cfg_method_query_ann(n), cfg_query_arg(arg), ann_arg("client", a, arg, text, _, _). +// ── Spring Data: a DERIVED query, where the method NAME is the query ──────── +// `List findByColorAndLabelStartingWith(String c, String p)` on an interface +// extending a repository base carries no text at all: the framework parses the name at +// start-up, and a property the name spells that the entity does not have fails there. +// So renaming Widget.color breaks findByColor exactly as it breaks an @Query naming +// `w.color`, and it is recorded the same way: persistence_query (kind "derived", the +// method name as its text), persistence_entity, and one persistence_field per property. +// +// The entity is the repository base's FIRST type argument (`JpaRepository`), +// read from the written extends clause because the base is normally not staged; the +// base is recognised by the same suffix knob that makes the interface a proxy bean +// (cfg_repository_base_suffix, di.dl). The entity need not carry @Entity: a document +// store's repository derives its queries the same way. +// +// The NAME: a subject prefix (cfg_derived_query_prefix: find, read, get, query, search, +// stream, count, exists, delete, remove), anything up to the first `By` after it +// (`Distinct`, `Top10`, `All`), then the criteria. A property is matched where a +// criterion can start (right after that `By`, an `And`, an `Or` or an `OrderBy`) as +// the capitalised property name, followed by the end of the name, `_` or another +// capital (an operator such as `StartingWith`, the next `And`, a nested path). Where +// two properties match at one place the LONGER one is the property (`labelText` over +// `label`), as Spring's own parser resolves it. A method carrying @Query is not +// derived: the annotation's text wins, and the rule above already reads it. +repo_entity(repo, e) :- type_super_ref_h(repo, sn, sref), cfg_repo_name_matches(sn), + type_decl(_, _, _, _, "INTERFACE_TYPE", _, repo), + java_type_reference(_, _, _, _, _, sref, "0", "1", _, _, _, _, _, _, _, _, _, argRef), + type_ref_resolves(_, argRef, e), java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, e). +repo_entity_field(e, fname, f) :- repo_entity(_, e), + java_field(fname, _, _, _, _, _, _, _, e, _, _, _, _, f). +repo_entity_field(e, fname, f) :- repo_entity(_, e), type_ancestor(e, sup), + java_field(fname, _, _, _, _, _, _, _, sup, _, _, _, _, f). +// the capitalised property name, as it is spelled inside a method name +repo_field_cap(e, cap, f) :- repo_entity_field(e, fname, f), strlen(fname) > 0, + cfg_upper(substr(fname, 0, 1), up), cap = cat(up, substr(fname, 1, strlen(fname) - 1)). +repo_field_cap(e, fname, f) :- repo_entity_field(e, fname, f), strlen(fname) > 0, + cfg_is_upper_char(substr(fname, 0, 1)). + +has_method_query_ann(m) :- ann_on_method("client", _, n, m, _), cfg_method_query_ann(n). +derived_candidate(m, name, e, p) :- repo_entity(repo, e), + java_method(name, _, _, _, _, _, _, repo, _, _, _, _, _, _, _, _, "ABSTRACT_METHOD", _, _, _, _, m), + cfg_derived_query_prefix(p), strlen(name) > strlen(p) + 2, + substr(name, 0, strlen(p)) = p, !has_method_query_ann(m). +derived_by_at(m, i) :- derived_candidate(m, name, _, p), + i = range(strlen(p), strlen(name) - 1), substr(name, i, 2) = "By". +derived_criteria(m, e, crit) :- derived_candidate(m, name, e, _), + b = min i : derived_by_at(m, i), b + 2 < strlen(name), + crit = substr(name, b + 2, strlen(name) - b - 2). +// where a criterion may start: the beginning, or after And / Or / By (OrderBy), at a capital +derived_start(m, 0) :- derived_criteria(m, _, _). +derived_start(m, j) :- derived_criteria(m, _, crit), derived_joiner(w), + j = range(1, strlen(crit)), j >= strlen(w), substr(crit, j - strlen(w), strlen(w)) = w, + cfg_is_upper_char(substr(crit, j, 1)). +derived_joiner("And"). derived_joiner("Or"). derived_joiner("By"). +// a property spelled at a start, and ending where a property name can end +derived_match(m, j, n, f) :- derived_start(m, j), derived_criteria(m, e, crit), + repo_field_cap(e, cap, f), n = strlen(cap), j + n <= strlen(crit), + substr(crit, j, n) = cap, derived_ends(crit, j + n). +derived_ends(crit, k) :- derived_criteria(_, _, crit), k = strlen(crit). +derived_ends(crit, k) :- derived_criteria(_, _, crit), k = range(1, strlen(crit)), + cfg_is_upper_char(substr(crit, k, 1)). +derived_ends(crit, k) :- derived_criteria(_, _, crit), k = range(1, strlen(crit)), + substr(crit, k, 1) = "_". +derived_longest(m, j, n) :- derived_match(m, j, _, _), n = max l : derived_match(m, j, l, _). + +persistence_query(m, "derived", "-", name) :- derived_criteria(m, _, _), + derived_candidate(m, name, _, _). +persistence_entity(m, e) :- derived_criteria(m, e, _). +pf_derived(m, f) :- derived_longest(m, j, n), derived_match(m, j, n, f). + // ── what the query text names ─────────────────────────────────────────────── // No JPQL parser: an entity is recognised where a query can introduce one, and the // candidate set is only the @Entity types, so the test is both cheap and specific. @@ -108,7 +188,7 @@ persistence_query(m, "spring_data", "-", text) :- ann_on_method("client", a, n, // start, in the middle and at the end. Both delimiters are required: measured on a real // codebase, matching " FROM Pet" without the trailing space also matched // " FROM PetType", and the blast radius of PetType.name then included Pet.name. -query_padded(text, p) :- persistence_query(_, _, _, text), p = cat(" ", cat(text, " ")). +query_padded(text, p) :- persistence_query(_, kind, _, text), kind != "derived", p = cat(" ", cat(text, " ")). query_names_type(text, name) :- query_padded(text, p), jpa_entity(t), java_type(name, _, _, _, _, _, _, _, _, _, _, _, _, t), jpa_intro(kw), contains(cat(" ", cat(kw, cat(name, " "))), p). @@ -117,7 +197,7 @@ jpa_intro("JOIN "). jpa_intro("join "). jpa_intro("Join "). jpa_intro("UPDATE "). jpa_intro("update "). jpa_intro("Update "). jpa_intro("INTO "). jpa_intro("into "). -persistence_entity(m, t) :- persistence_query(m, _, _, text), query_names_type(text, name), +persistence_entity(m, t) :- persistence_query(m, kind, _, text), kind != "derived", query_names_type(text, name), java_type(name, _, _, _, _, _, _, _, _, _, _, _, _, t), jpa_entity(t). // A property is named with a dot after its alias — `o.customerId`. The dot is what @@ -141,6 +221,7 @@ pf_nested(m, f) :- persistence_entity(m, t), persistence_query(m, _, _, text), mid != fname, jpa_after(d), contains(cat(".", cat(mid, cat(".", cat(fname, d)))), p). // A property read directly SOMEWHERE is direct, even if it also appears nested. +pf_any(m, f) :- pf_derived(m, f). pf_direct(m, f) :- pf_any(m, f), !pf_nested(m, f). persistence_field(m, f, "direct") :- pf_direct(m, f). persistence_field(m, f, "nested") :- pf_nested(m, f), !pf_direct(m, f). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 91ab1f90..961d8a16 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -926,6 +926,24 @@ .decl pf_nested(c0:symbol,c1:symbol) .decl pf_direct(c0:symbol,c1:symbol) .decl persistence_unresolved(c0:symbol,c1:symbol) +.decl cfg_derived_query_prefix(c0:symbol) +.decl repo_entity(c0:symbol,c1:symbol) +.decl repo_entity_field(c0:symbol,c1:symbol,c2:symbol) +.decl repo_field_cap(c0:symbol,c1:symbol,c2:symbol) +.decl has_method_query_ann(c0:symbol) +.decl derived_candidate(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl derived_by_at(c0:symbol,c1:number) +.decl derived_criteria(c0:symbol,c1:symbol,c2:symbol) +.decl derived_start(c0:symbol,c1:number) +.decl derived_joiner(c0:symbol) +.decl derived_match(c0:symbol,c1:number,c2:number,c3:symbol) +.decl derived_ends(c0:symbol,c1:number) +.decl derived_longest(c0:symbol,c1:number,c2:number) +.decl pf_derived(c0:symbol,c1:symbol) +.decl orm_named_query(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_orm_named_query_elem(c0:symbol) +.decl cfg_xml_param_marker_root(c0:symbol) +.decl xml_param_marker_file(c0:symbol) .decl cfg_method_query_ann(c0:symbol) .decl cfg_query_arg(c0:symbol) .decl query_padded(c0:symbol,c1:symbol) @@ -1079,3 +1097,15 @@ .decl twr_close_decl(c0:symbol,c1:symbol) .decl twr_close(c0:symbol,c1:symbol) .decl twr_close_count(c0:symbol,c1:number) +// call-edge-generation/callback_dispatch.dl +.decl cb_object_member(c0:symbol) +.decl cb_arg_type(c0:symbol,c1:symbol) +.decl cb_site_kind(c0:symbol) +.decl cb_client_site(c0:symbol) +.decl cb_handoff(c0:symbol,c1:symbol) +.decl cb_member(c0:symbol,c1:symbol,c2:symbol) +.decl cb_callback(c0:symbol,c1:symbol) +.decl cb_external_ancestor(c0:symbol) +.decl cb_writes_override(c0:symbol) +.decl cb_marker_type(c0:symbol) +.decl cb_direct_external(c0:symbol) diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/META-INF/orm.xml b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/META-INF/orm.xml new file mode 100644 index 00000000..d021610d --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/META-INF/orm.xml @@ -0,0 +1,6 @@ + + + + SELECT i FROM Invoice i WHERE i.status = :status + + diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/beans.xml b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/beans.xml new file mode 100644 index 00000000..32ae1c71 --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/beans.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/mapper/InvoiceMapper.xml b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/mapper/InvoiceMapper.xml new file mode 100644 index 00000000..faf928ea --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/mapper/InvoiceMapper.xml @@ -0,0 +1,7 @@ + + + + + update invoice set status = #{status,jdbcType=VARCHAR} where id = #{id} + + diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Invoice.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Invoice.java new file mode 100644 index 00000000..86be64eb --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Invoice.java @@ -0,0 +1,13 @@ +package testcases.derived; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.NamedQuery; + +@Entity +@NamedQuery(name = "Invoice.byTotal", query = "SELECT i FROM Invoice i WHERE i.total > :min") +public class Invoice { + @Id Long id; + String status; + long total; +} diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceDao.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceDao.java new file mode 100644 index 00000000..ea9acef3 --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceDao.java @@ -0,0 +1,24 @@ +package testcases.derived; + +import jakarta.persistence.EntityManager; + +import java.util.List; + +public class InvoiceDao { + EntityManager em; + + // declared in META-INF/orm.xml + List byStatus(String s) { + return em.createNamedQuery("Invoice.byStatus", Invoice.class).setParameter("status", s).getResultList(); + } + + // control: declared by @NamedQuery on the entity + List byTotal(long min) { + return em.createNamedQuery("Invoice.byTotal", Invoice.class).setParameter("min", min).getResultList(); + } + + // control: declared nowhere + List byNothing() { + return em.createNamedQuery("Invoice.byNothing", Invoice.class).getResultList(); + } +} diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceMapper.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceMapper.java new file mode 100644 index 00000000..c901cdc2 --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/InvoiceMapper.java @@ -0,0 +1,5 @@ +package testcases.derived; + +public interface InvoiceMapper { + int touch(Long id); +} diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Widget.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Widget.java new file mode 100644 index 00000000..7c33a61c --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/Widget.java @@ -0,0 +1,13 @@ +package testcases.derived; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +@Entity +public class Widget { + @Id Long id; + String color; + String label; + String labelText; + int weight; +} diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetFinder.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetFinder.java new file mode 100644 index 00000000..448c75f3 --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetFinder.java @@ -0,0 +1,8 @@ +package testcases.derived; + +import java.util.List; + +/** Control: the same method name on an interface that extends no repository base. */ +public interface WidgetFinder { + List findByColor(String color); +} diff --git a/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetRepository.java b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetRepository.java new file mode 100644 index 00000000..4e50e78f --- /dev/null +++ b/graph/test/java/cases/70-persistence-derived-and-mapping-file/src/testcases/derived/WidgetRepository.java @@ -0,0 +1,34 @@ +package testcases.derived; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +/** + * Spring Data derives a query from each method NAME: the properties it spells are the + * ones a rename breaks at start-up. + */ +public interface WidgetRepository extends JpaRepository { + + List findByColor(String color); + + long countByLabelStartingWith(String prefix); + + // the longer property wins where two start at one place: labelText, not label + List findDistinctByColorAndLabelTextOrderByIdDesc(String color, String text); + + boolean existsByWeightGreaterThan(int weight); + + // control: @Query carries the text, and the name is not parsed + @Query("SELECT w FROM Widget w WHERE w.label = :label") + List findByNothingAtAll(String label); + + // control: no subject keyword, so the name is not a derived query + List byColor(String color); + + // control: a default method has a body, and Spring runs that body + default List findByColorTwice(String color) { + return findByColor(color); + } +} diff --git a/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Callbacks.java b/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Callbacks.java new file mode 100644 index 00000000..ba260ec8 --- /dev/null +++ b/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Callbacks.java @@ -0,0 +1,94 @@ +package app; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * A named class handed to a library method is called back by the library: no client call + * site names its methods. No library is staged, so the supertypes are external and the + * @Override annotation is what says a supertype declares the method. + */ +class Callbacks { + static class ByLength implements Comparator { + @Override + public int compare(String a, String b) { return Rules.shorter(a, b); } + + // control: not an override, the library cannot call it + public int other(String a) { return Rules.helper(a); } + + // control: an Object member is never fanned + @Override + public String toString() { return Rules.shown("by length"); } + } + + static class NonEmpty implements Predicate { + @Override + public boolean test(String s) { return Rules.valid(s); } + } + + // a class that writes no @Override: its public methods are the candidates, a private one is not + static class Shouty implements Function { + public String apply(String s) { return Rules.loud(s); } + + private String quiet(String s) { return Rules.soft(s); } + } + + // control: a value class under a Serializable base writes no @Override either, and a marker + // interface declares nothing a library could call back + static class Base implements Serializable { + public String code() { return Rules.shown("base"); } + } + + static class Item extends Base { + public String label() { return Rules.shown("item"); } + } + + // control: a class with no external ancestor + static class Plain { + public boolean check(String s) { return Rules.local(s); } + } + + void sortNamed(List xs) { + xs.sort(new ByLength()); + } + + void sortHeld(List xs) { + ByLength cmp = new ByLength(); + xs.sort(cmp); + } + + boolean filterNamed(List xs) { + return xs.stream().anyMatch(new NonEmpty()); + } + + String mapNamed(List xs) { + return xs.stream().map(new Shouty()).findFirst().orElse(""); + } + + // control: a value passed along, not created for the call, is not a hand-off + void passAlong(List xs, ByLength given) { + xs.sort(given); + } + + // control: the anonymous form is reached by containment, as before + void sortAnonymous(List xs) { + xs.sort(new Comparator() { + @Override + public int compare(String a, String b) { return Rules.known(a) ? 1 : 0; } + }); + } + + // control: handed to CLIENT code, which holds the call on it + void keepLocal(List xs) { + consume(new ByLength()); + new ArrayList().add(new Plain()); + new ArrayList().add(new Item()); + new ArrayList().add(new Base()); + } + + void consume(Comparator c) { c.compare("a", "b"); } +} diff --git a/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Rules.java b/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Rules.java new file mode 100644 index 00000000..7ddb97b3 --- /dev/null +++ b/graph/test/java/cases/71-object-handed-to-library-callback/src/app/Rules.java @@ -0,0 +1,12 @@ +package app; + +class Rules { + static boolean valid(String s) { return !s.isEmpty(); } + static boolean known(String s) { return s.length() > 1; } + static int shorter(String a, String b) { return a.length() - b.length(); } + static int helper(String a) { return a.length(); } + static String shown(String a) { return a; } + static boolean local(String a) { return a.isEmpty(); } + static String loud(String a) { return a.toUpperCase(); } + static String soft(String a) { return a.toLowerCase(); } +} diff --git a/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Checks.java b/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Checks.java new file mode 100644 index 00000000..01e46708 --- /dev/null +++ b/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Checks.java @@ -0,0 +1,6 @@ +package dep; + +/** A dependency method that takes a callback and calls it inside the dependency. */ +public final class Checks { + public static T argThat(Matcher m) { return null; } +} diff --git a/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Matcher.java b/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Matcher.java new file mode 100644 index 00000000..9bfd882c --- /dev/null +++ b/graph/test/java/cases/72-object-handed-to-staged-library/lib-src/dep/Matcher.java @@ -0,0 +1,6 @@ +package dep; + +/** A dependency-declared callback interface: the dependency calls matches(). */ +public interface Matcher { + boolean matches(T value); +} diff --git a/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Rules.java b/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Rules.java new file mode 100644 index 00000000..058473d7 --- /dev/null +++ b/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Rules.java @@ -0,0 +1,6 @@ +package app; + +class Rules { + static boolean valid(String s) { return !s.isEmpty(); } + static int helper(String s) { return s.length(); } +} diff --git a/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Verifier.java b/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Verifier.java new file mode 100644 index 00000000..99b87911 --- /dev/null +++ b/graph/test/java/cases/72-object-handed-to-staged-library/src/app/Verifier.java @@ -0,0 +1,21 @@ +package app; + +import dep.Checks; +import dep.Matcher; + +/** + * The dependency is staged, so the override is known from its declaration: no @Override + * is needed, and a method it does not declare is not a callback. + */ +class Verifier { + static class IsValid implements Matcher { + public boolean matches(String s) { return Rules.valid(s); } + + // control: the dependency does not declare it + public int size(String s) { return Rules.helper(s); } + } + + String check() { + return Checks.argThat(new IsValid()); + } +} diff --git a/graph/test/java/expected/12-library-interface-override.edges b/graph/test/java/expected/12-library-interface-override.edges index 8211d9cd..a75f030e 100644 --- a/graph/test/java/expected/12-library-interface-override.edges +++ b/graph/test/java/expected/12-library-interface-override.edges @@ -5,6 +5,7 @@ boundary_lib method LibIfaceOverride#viaFunction(Function,String) -> external:ja boundary_lib method LibIfaceOverride#viaLibrarySort(List) -> external:java.util.List.sort boundary_lib method LibIfaceOverride.ByLength#probe(String) -> external:String.length boundary_lib method LibIfaceOverride.Shouty#mark(String) -> external:String.toUpperCase +callback_registered callback LibIfaceOverride#viaLibrarySort(List) -> LibIfaceOverride.ByLength#compare(String,String) known_edge method LibIfaceOverride#main(String[]) -> LibIfaceOverride#viaComparator(Comparator,String,String) known_edge method LibIfaceOverride#main(String[]) -> LibIfaceOverride#viaFunction(Function,String) known_edge method LibIfaceOverride#main(String[]) -> LibIfaceOverride#viaLibrarySort(List) diff --git a/graph/test/java/expected/12-library-interface-override.oracle b/graph/test/java/expected/12-library-interface-override.oracle index 74f2494b..54ad7f87 100644 --- a/graph/test/java/expected/12-library-interface-override.oracle +++ b/graph/test/java/expected/12-library-interface-override.oracle @@ -1,3 +1,4 @@ -oracle=9 engine=11 agree=9 missing=0 (known 0, NEW 0) extra=2 +oracle=9 engine=12 agree=9 missing=0 (known 0, NEW 0) extra=3 extra LibIfaceOverride#viaComparator -> LibIfaceOverride.ByLength#compare(String,String) extra LibIfaceOverride#viaFunction -> LibIfaceOverride.Shouty#apply(String) + extra LibIfaceOverride#viaLibrarySort -> LibIfaceOverride.ByLength#compare(String,String) diff --git a/graph/test/java/expected/53-repository-interface-bean.config b/graph/test/java/expected/53-repository-interface-bean.config index 54cb2627..fc3e9405 100644 --- a/graph/test/java/expected/53-repository-interface-bean.config +++ b/graph/test/java/expected/53-repository-interface-bean.config @@ -27,7 +27,10 @@ ── remote_unserved [SENT, NO CONSUMER HERE] (0) ── ── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── ── remote_undetermined [DECLARED UNKNOWNS] (0) ── -── persistence_query (0) ── -── persistence_entity (0) ── +── persistence_query (1) ── + derived probe.OrderRepository#findByName(String) + "findByName" +── persistence_entity (1) ── + probe.Order probe.OrderRepository#findByName(String) ── persistence_field (0) ── ── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/70-persistence-derived-and-mapping-file.config b/graph/test/java/expected/70-persistence-derived-and-mapping-file.config new file mode 100644 index 00000000..46de6715 --- /dev/null +++ b/graph/test/java/expected/70-persistence-derived-and-mapping-file.config @@ -0,0 +1,64 @@ +── bean_def (2) ── + repository_proxy widgetRepository <- testcases.derived.WidgetRepository + xml invoiceDao <- testcases.derived.InvoiceDao +── bean_origin (2) ── + client invoiceDao <- testcases.derived.InvoiceDao + client widgetRepository <- testcases.derived.WidgetRepository +── inject_point (0) ── +── di_edge (0) ── +── config_class_ref (1) ── + xml beans.xml:4 @class "testcases.derived.InvoiceDao" -> testcases.derived.InvoiceDao [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (0) ── +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (1) ── + spel_expression xml XML_VALUE_REFERENCE_dc1d72626b3bf150d11939c7baf98c85 "systemProperties['user.timezone']" +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (7) ── + derived testcases.derived.WidgetRepository#countByLabelStartingWith(String) + "countByLabelStartingWith" + derived testcases.derived.WidgetRepository#existsByWeightGreaterThan(int) + "existsByWeightGreaterThan" + derived testcases.derived.WidgetRepository#findByColor(String) + "findByColor" + derived testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) + "findDistinctByColorAndLabelTextOrderByIdDesc" + named Invoice.byStatus testcases.derived.InvoiceDao#byStatus(String) + "SELECT i FROM Invoice i WHERE i.status = :status" + named Invoice.byTotal testcases.derived.InvoiceDao#byTotal(long) + "SELECT i FROM Invoice i WHERE i.total > :min" + spring_data testcases.derived.WidgetRepository#findByNothingAtAll(String) + "SELECT w FROM Widget w WHERE w.label = :label" +── persistence_entity (7) ── + testcases.derived.Invoice testcases.derived.InvoiceDao#byStatus(String) + testcases.derived.Invoice testcases.derived.InvoiceDao#byTotal(long) + testcases.derived.Widget testcases.derived.WidgetRepository#countByLabelStartingWith(String) + testcases.derived.Widget testcases.derived.WidgetRepository#existsByWeightGreaterThan(int) + testcases.derived.Widget testcases.derived.WidgetRepository#findByColor(String) + testcases.derived.Widget testcases.derived.WidgetRepository#findByNothingAtAll(String) + testcases.derived.Widget testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) +── persistence_field (17) ── + direct testcases.derived.Invoice#status testcases.derived.InvoiceDao#byStatus(String) + direct testcases.derived.Invoice#total testcases.derived.InvoiceDao#byTotal(long) + direct testcases.derived.Widget#color testcases.derived.WidgetRepository#findByColor(String) + direct testcases.derived.Widget#color testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) + direct testcases.derived.Widget#id testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) + direct testcases.derived.Widget#label testcases.derived.WidgetRepository#countByLabelStartingWith(String) + direct testcases.derived.Widget#label testcases.derived.WidgetRepository#findByNothingAtAll(String) + direct testcases.derived.Widget#labelText testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) + direct testcases.derived.Widget#weight testcases.derived.WidgetRepository#existsByWeightGreaterThan(int) + projection testcases.derived.Invoice#id testcases.derived.InvoiceDao#byStatus(String) + projection testcases.derived.Invoice#id testcases.derived.InvoiceDao#byTotal(long) + projection testcases.derived.Invoice#status testcases.derived.InvoiceDao#byTotal(long) + projection testcases.derived.Invoice#total testcases.derived.InvoiceDao#byStatus(String) + projection testcases.derived.Widget#color testcases.derived.WidgetRepository#findByNothingAtAll(String) + projection testcases.derived.Widget#id testcases.derived.WidgetRepository#findByNothingAtAll(String) + projection testcases.derived.Widget#labelText testcases.derived.WidgetRepository#findByNothingAtAll(String) + projection testcases.derived.Widget#weight testcases.derived.WidgetRepository#findByNothingAtAll(String) +── persistence_unresolved [DECLARED UNKNOWNS] (1) ── + undeclared_named_query testcases.derived.InvoiceDao#byNothing() diff --git a/graph/test/java/expected/70-persistence-derived-and-mapping-file.edges b/graph/test/java/expected/70-persistence-derived-and-mapping-file.edges new file mode 100644 index 00000000..3765a9be --- /dev/null +++ b/graph/test/java/expected/70-persistence-derived-and-mapping-file.edges @@ -0,0 +1,7 @@ +ambiguous_unknown method testcases.derived.InvoiceDao#byNothing() -> - +ambiguous_unknown method testcases.derived.InvoiceDao#byStatus(String) -> - +ambiguous_unknown method testcases.derived.InvoiceDao#byTotal(long) -> - +boundary_lib method testcases.derived.InvoiceDao#byNothing() -> external:jakarta.persistence.EntityManager.createNamedQuery +boundary_lib method testcases.derived.InvoiceDao#byStatus(String) -> external:jakarta.persistence.EntityManager.createNamedQuery +boundary_lib method testcases.derived.InvoiceDao#byTotal(long) -> external:jakarta.persistence.EntityManager.createNamedQuery +known_edge method testcases.derived.WidgetRepository#findByColorTwice(String) -> testcases.derived.WidgetRepository#findByColor(String) diff --git a/graph/test/java/expected/70-persistence-derived-and-mapping-file.fields b/graph/test/java/expected/70-persistence-derived-and-mapping-file.fields new file mode 100644 index 00000000..51d4a960 --- /dev/null +++ b/graph/test/java/expected/70-persistence-derived-and-mapping-file.fields @@ -0,0 +1,3 @@ +known_edge read testcases.derived.InvoiceDao#byNothing() -> testcases.derived.InvoiceDao#em +known_edge read testcases.derived.InvoiceDao#byStatus(String) -> testcases.derived.InvoiceDao#em +known_edge read testcases.derived.InvoiceDao#byTotal(long) -> testcases.derived.InvoiceDao#em diff --git a/graph/test/java/expected/70-persistence-derived-and-mapping-file.type-use b/graph/test/java/expected/70-persistence-derived-and-mapping-file.type-use new file mode 100644 index 00000000..e470d346 --- /dev/null +++ b/graph/test/java/expected/70-persistence-derived-and-mapping-file.type-use @@ -0,0 +1,36 @@ +ambiguous_unknown ANNOTATION_TYPE 0 testcases.derived.Invoice [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 testcases.derived.Widget [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 testcases.derived.WidgetRepository [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 testcases.derived.Invoice [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 testcases.derived.InvoiceDao [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 testcases.derived.Widget [FIELD] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.InvoiceDao#byStatus(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.InvoiceMapper#touch(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetFinder#findByColor(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#byColor(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#countByLabelStartingWith(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#findByColor(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#findByColorTwice(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#findByNothingAtAll(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.InvoiceDao#byNothing() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.InvoiceDao#byStatus(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.InvoiceDao#byTotal(long) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetFinder#findByColor(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetRepository#byColor(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetRepository#findByColor(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetRepository#findByColorTwice(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetRepository#findByNothingAtAll(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) [METHOD] -> - +ambiguous_unknown SUPER_TYPE 0 testcases.derived.WidgetRepository [TYPE] -> - +ambiguous_unknown SUPER_TYPE 1 testcases.derived.WidgetRepository [TYPE] -> - +known_edge METHOD_RETURN 1 testcases.derived.InvoiceDao#byNothing() [METHOD] -> testcases.derived.Invoice +known_edge METHOD_RETURN 1 testcases.derived.InvoiceDao#byStatus(String) [METHOD] -> testcases.derived.Invoice +known_edge METHOD_RETURN 1 testcases.derived.InvoiceDao#byTotal(long) [METHOD] -> testcases.derived.Invoice +known_edge METHOD_RETURN 1 testcases.derived.WidgetFinder#findByColor(String) [METHOD] -> testcases.derived.Widget +known_edge METHOD_RETURN 1 testcases.derived.WidgetRepository#byColor(String) [METHOD] -> testcases.derived.Widget +known_edge METHOD_RETURN 1 testcases.derived.WidgetRepository#findByColor(String) [METHOD] -> testcases.derived.Widget +known_edge METHOD_RETURN 1 testcases.derived.WidgetRepository#findByColorTwice(String) [METHOD] -> testcases.derived.Widget +known_edge METHOD_RETURN 1 testcases.derived.WidgetRepository#findByNothingAtAll(String) [METHOD] -> testcases.derived.Widget +known_edge METHOD_RETURN 1 testcases.derived.WidgetRepository#findDistinctByColorAndLabelTextOrderByIdDesc(String,String) [METHOD] -> testcases.derived.Widget +known_edge SUPER_TYPE 1 testcases.derived.WidgetRepository [TYPE] -> testcases.derived.Widget diff --git a/graph/test/java/expected/71-object-handed-to-library-callback.edges b/graph/test/java/expected/71-object-handed-to-library-callback.edges new file mode 100644 index 00000000..dacc2a0b --- /dev/null +++ b/graph/test/java/expected/71-object-handed-to-library-callback.edges @@ -0,0 +1,44 @@ +ambiguous_anon anon_new app.Callbacks#sortAnonymous(List) -> - +ambiguous_unknown method app.Callbacks#filterNamed(List) -> - +ambiguous_unknown method app.Callbacks#keepLocal(List) -> - +ambiguous_unknown method app.Callbacks#mapNamed(List) -> - +ambiguous_unknown new app.Callbacks#keepLocal(List) -> - +boundary_lib method app.Callbacks#consume(Comparator) -> external:java.util.Comparator.compare +boundary_lib method app.Callbacks#filterNamed(List) -> external:java.util.List.stream +boundary_lib method app.Callbacks#mapNamed(List) -> external:java.util.List.stream +boundary_lib method app.Callbacks#passAlong(List,ByLength) -> external:java.util.List.sort +boundary_lib method app.Callbacks#sortAnonymous(List) -> external:java.util.List.sort +boundary_lib method app.Callbacks#sortHeld(List) -> external:java.util.List.sort +boundary_lib method app.Callbacks#sortNamed(List) -> external:java.util.List.sort +boundary_lib method app.Rules#helper(String) -> external:String.length +boundary_lib method app.Rules#known(String) -> external:String.length +boundary_lib method app.Rules#local(String) -> external:String.isEmpty +boundary_lib method app.Rules#loud(String) -> external:String.toUpperCase +boundary_lib method app.Rules#shorter(String,String) -> external:String.length +boundary_lib method app.Rules#soft(String) -> external:String.toLowerCase +boundary_lib method app.Rules#valid(String) -> external:String.isEmpty +callback_registered callback app.Callbacks#filterNamed(List) -> app.Callbacks.NonEmpty#test(String) +callback_registered callback app.Callbacks#mapNamed(List) -> app.Callbacks.Shouty#apply(String) +callback_registered callback app.Callbacks#sortHeld(List) -> app.Callbacks.ByLength#compare(String,String) +callback_registered callback app.Callbacks#sortNamed(List) -> app.Callbacks.ByLength#compare(String,String) +known_edge method app.Callbacks#keepLocal(List) -> app.Callbacks#consume(Comparator) +known_edge method app.Callbacks$anon:Comparator#compare(String,String) -> app.Rules#known(String) +known_edge method app.Callbacks.Base#code() -> app.Rules#shown(String) +known_edge method app.Callbacks.ByLength#compare(String,String) -> app.Rules#shorter(String,String) +known_edge method app.Callbacks.ByLength#other(String) -> app.Rules#helper(String) +known_edge method app.Callbacks.ByLength#toString() -> app.Rules#shown(String) +known_edge method app.Callbacks.Item#label() -> app.Rules#shown(String) +known_edge method app.Callbacks.NonEmpty#test(String) -> app.Rules#valid(String) +known_edge method app.Callbacks.Plain#check(String) -> app.Rules#local(String) +known_edge method app.Callbacks.Shouty#apply(String) -> app.Rules#loud(String) +known_edge method app.Callbacks.Shouty#quiet(String) -> app.Rules#soft(String) +known_edge new app.Callbacks#filterNamed(List) -> app.Callbacks.NonEmpty#() +known_edge new app.Callbacks#keepLocal(List) -> app.Callbacks.Base#() +known_edge new app.Callbacks#keepLocal(List) -> app.Callbacks.ByLength#() +known_edge new app.Callbacks#keepLocal(List) -> app.Callbacks.Item#() +known_edge new app.Callbacks#keepLocal(List) -> app.Callbacks.Plain#() +known_edge new app.Callbacks#mapNamed(List) -> app.Callbacks.Shouty#() +known_edge new app.Callbacks#sortHeld(List) -> app.Callbacks.ByLength#() +known_edge new app.Callbacks#sortNamed(List) -> app.Callbacks.ByLength#() +multi_inferred method app.Callbacks#consume(Comparator) -> app.Callbacks$anon:Comparator#compare(String,String) +multi_inferred method app.Callbacks#consume(Comparator) -> app.Callbacks.ByLength#compare(String,String) diff --git a/graph/test/java/expected/71-object-handed-to-library-callback.known-missing b/graph/test/java/expected/71-object-handed-to-library-callback.known-missing new file mode 100644 index 00000000..bb3bd9e5 --- /dev/null +++ b/graph/test/java/expected/71-object-handed-to-library-callback.known-missing @@ -0,0 +1,4 @@ +# Accepted gap, the same as 02-anonymous-sam: an ANONYMOUS CLASS has no constructor entity for +# `new Comparator(){...}` to point at (JLS 15.9.5.1), while bytecode names one. The +# control that uses it (sortAnonymous) is here for its `defines` hop, not for the constructor. +app.Callbacks#sortAnonymous -> app.Callbacks$anon:Comparator# diff --git a/graph/test/java/expected/71-object-handed-to-library-callback.oracle b/graph/test/java/expected/71-object-handed-to-library-callback.oracle new file mode 100644 index 00000000..e6269362 --- /dev/null +++ b/graph/test/java/expected/71-object-handed-to-library-callback.oracle @@ -0,0 +1,8 @@ +oracle=20 engine=25 agree=19 missing=1 (known 1, NEW 0) extra=6 + known-missing app.Callbacks#sortAnonymous -> app.Callbacks$anon:Comparator# + extra app.Callbacks#consume -> app.Callbacks$anon:Comparator#compare(String,String) + extra app.Callbacks#consume -> app.Callbacks.ByLength#compare(String,String) + extra app.Callbacks#filterNamed -> app.Callbacks.NonEmpty#test(String) + extra app.Callbacks#mapNamed -> app.Callbacks.Shouty#apply(String) + extra app.Callbacks#sortHeld -> app.Callbacks.ByLength#compare(String,String) + extra app.Callbacks#sortNamed -> app.Callbacks.ByLength#compare(String,String) diff --git a/graph/test/java/expected/71-object-handed-to-library-callback.type-use b/graph/test/java/expected/71-object-handed-to-library-callback.type-use new file mode 100644 index 00000000..b8e64d27 --- /dev/null +++ b/graph/test/java/expected/71-object-handed-to-library-callback.type-use @@ -0,0 +1,67 @@ +ambiguous_unknown ANNOTATION_TYPE 0 app.Callbacks$anon:Comparator [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.Callbacks.ByLength [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 app.Callbacks.NonEmpty [ANNOTATION] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.Callbacks.Base [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.Callbacks.ByLength [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.Callbacks.NonEmpty [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 app.Callbacks.Shouty [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.Callbacks.ByLength [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.Callbacks.NonEmpty [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.Callbacks.Shouty [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#consume(Comparator) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#filterNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#keepLocal(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#mapNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#passAlong(List,ByLength) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#sortAnonymous(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#sortHeld(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks#sortNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks$anon:Comparator#compare(String,String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.ByLength#compare(String,String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.ByLength#other(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.NonEmpty#test(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.Plain#check(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.Shouty#apply(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Callbacks.Shouty#quiet(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#helper(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#known(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#local(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#loud(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#shorter(String,String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#shown(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#soft(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#valid(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#consume(Comparator) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#filterNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#keepLocal(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#mapNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#passAlong(List,ByLength) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#sortAnonymous(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#sortHeld(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 app.Callbacks#sortNamed(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks#mapNamed(List) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks.Base#code() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks.ByLength#toString() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks.Item#label() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks.Shouty#apply(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Callbacks.Shouty#quiet(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Rules#loud(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Rules#shown(String) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 app.Rules#soft(String) [METHOD] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.Callbacks#keepLocal(List) [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 app.Callbacks#sortAnonymous(List) [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 1 app.Callbacks#keepLocal(List) [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 1 app.Callbacks#sortAnonymous(List) [EXPRESSION] -> - +ambiguous_unknown SUPER_TYPE 0 app.Callbacks$anon:Comparator [TYPE] -> - +ambiguous_unknown SUPER_TYPE 1 app.Callbacks$anon:Comparator [TYPE] -> - +known_edge LOCAL_VARIABLE 0 app.Callbacks#sortHeld(List) [LOCAL_VARIABLE] -> app.Callbacks.ByLength +known_edge METHOD_PARAM 0 app.Callbacks#passAlong(List,ByLength) [METHOD_PARAM] -> app.Callbacks.ByLength +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#filterNamed(List) [EXPRESSION] -> app.Callbacks.NonEmpty +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#keepLocal(List) [EXPRESSION] -> app.Callbacks.Base +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#keepLocal(List) [EXPRESSION] -> app.Callbacks.ByLength +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#keepLocal(List) [EXPRESSION] -> app.Callbacks.Item +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#keepLocal(List) [EXPRESSION] -> app.Callbacks.Plain +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#mapNamed(List) [EXPRESSION] -> app.Callbacks.Shouty +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#sortHeld(List) [EXPRESSION] -> app.Callbacks.ByLength +known_edge OBJECT_CREATION_TYPE 0 app.Callbacks#sortNamed(List) [EXPRESSION] -> app.Callbacks.ByLength +known_edge SUPER_TYPE 0 app.Callbacks.Item [TYPE] -> app.Callbacks.Base diff --git a/graph/test/java/expected/71-object-handed-to-library-callback.type-use-oracle b/graph/test/java/expected/71-object-handed-to-library-callback.type-use-oracle new file mode 100644 index 00000000..ef2cde5b --- /dev/null +++ b/graph/test/java/expected/71-object-handed-to-library-callback.type-use-oracle @@ -0,0 +1,8 @@ +71-object-handed-to-library-callback + precision 1.0000 (9 correct, 0 wrong) + recall 0.9000 (9 of 10 in the bytecode) + references 76 resolved 11 (14.5%) + tiers ambiguous_unknown=65 known_edge=11 + not scored: 0 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_TYPE=4 IMPLEMENTS_INTERFACE=8 LOCAL_VARIABLE=1 METHOD_PARAM=35 METHOD_RETURN=9 OBJECT_CREATION_TYPE=16 SUPER_TYPE=3 + MISSING app.Callbacks$anon:Comparator METHOD_PARAM app.Callbacks diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.boundary b/graph/test/java/expected/72-object-handed-to-staged-library.boundary new file mode 100644 index 00000000..7d157a51 --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.boundary @@ -0,0 +1,11 @@ +staged library covers 50% of the library types this client calls (1 of 2; 2 call sites name an absent type) + ** LOW — the numbers below are bounded by what is staged, not by the rules. Stage the client's dependencies (tools/build-lib-ir.sh --coord) before reading them. +boundary sites: 3 (client callers, library callees matching dep.,java.,javax.,jdk.) + EXACT 1 ( 33.3%) + LIB IR LACKS THE TYPE 2 ( 66.7%) + --- + correct METHOD named 33.3% (100.0% of the 1 the lib IR can answer) + wrong library method 0.0% + +ABSENT from the staged library, by call sites naming them + 2 java.lang.String diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.edges b/graph/test/java/expected/72-object-handed-to-staged-library.edges new file mode 100644 index 00000000..e46d5356 --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.edges @@ -0,0 +1,7 @@ +boundary_lib method app.Rules#helper(String) -> external:String.length +boundary_lib method app.Rules#valid(String) -> external:String.isEmpty +boundary_lib method app.Verifier#check() -> dep.Checks#argThat(Matcher) +callback_registered callback app.Verifier#check() -> app.Verifier.IsValid#matches(String) +known_edge method app.Verifier.IsValid#matches(String) -> app.Rules#valid(String) +known_edge method app.Verifier.IsValid#size(String) -> app.Rules#helper(String) +known_edge new app.Verifier#check() -> app.Verifier.IsValid#() diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.envelope b/graph/test/java/expected/72-object-handed-to-staged-library.envelope new file mode 100644 index 00000000..885d6018 --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.envelope @@ -0,0 +1 @@ +nominal lib:dep.Matcher.matches -> app.Verifier.IsValid.matches diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.oracle b/graph/test/java/expected/72-object-handed-to-staged-library.oracle new file mode 100644 index 00000000..bbbe3f85 --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.oracle @@ -0,0 +1,2 @@ +oracle=3 engine=4 agree=3 missing=0 (known 0, NEW 0) extra=1 + extra app.Verifier#check -> app.Verifier.IsValid#matches(String) diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.type-use b/graph/test/java/expected/72-object-handed-to-staged-library.type-use new file mode 100644 index 00000000..6a1b730c --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.type-use @@ -0,0 +1,8 @@ +ambiguous_unknown IMPLEMENTS_INTERFACE 1 app.Verifier.IsValid [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#helper(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Rules#valid(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Verifier.IsValid#matches(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 app.Verifier.IsValid#size(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 app.Verifier#check() [METHOD] -> - +boundary_lib IMPLEMENTS_INTERFACE 0 app.Verifier.IsValid [TYPE] -> dep.Matcher +known_edge OBJECT_CREATION_TYPE 0 app.Verifier#check() [EXPRESSION] -> app.Verifier.IsValid diff --git a/graph/test/java/expected/72-object-handed-to-staged-library.type-use-oracle b/graph/test/java/expected/72-object-handed-to-staged-library.type-use-oracle new file mode 100644 index 00000000..0700b56a --- /dev/null +++ b/graph/test/java/expected/72-object-handed-to-staged-library.type-use-oracle @@ -0,0 +1,7 @@ +72-object-handed-to-staged-library + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 in the bytecode) + references 8 resolved 2 (25.0%) + tiers ambiguous_unknown=6 boundary_lib=1 known_edge=1 + not scored: 0 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts IMPLEMENTS_INTERFACE=2 METHOD_PARAM=4 METHOD_RETURN=1 OBJECT_CREATION_TYPE=1 diff --git a/graph/test/java/expected/oracle-agreement.txt b/graph/test/java/expected/oracle-agreement.txt index 4bb656ba..35a6fa7d 100644 --- a/graph/test/java/expected/oracle-agreement.txt +++ b/graph/test/java/expected/oracle-agreement.txt @@ -1,4 +1,4 @@ -cases compared 49 agreeing 49 +cases compared 50 agreeing 50 CONSTRUCTOR-RULE disagreements: 0 cases, 0 rows OTHER disagreements: 0 rows naming no method: 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index a06d034c..b7e162ad 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -293,6 +293,17 @@ class Changed: elif ch == '=' and h[k + 1:k + 2] == '>' and depth == 0 and seen: return h[:k].rstrip() return h @staticmethod + def before_block_body(h): + """a header's text up to the `{` that opens its body: the first brace at bracket depth 0 after the parameter list + opened. Read on the text with strings blanked (same length), so a brace in an annotation's route template is not + it; a header with no parameter list (a type, a property) is returned whole""" + code = strip_code(h); depth = 0; seen = False + for k, ch in enumerate(code): + if ch in '([': depth += 1; seen = seen or ch == '(' + elif ch in ')]': depth = max(0, depth - 1) + elif ch == '{' 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""" @@ -447,12 +458,22 @@ class Changed: # 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): # 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)): + code = strip_code(NL[s - 1], strings=False, hash_comments=is_py) + # a constructor's name is its type's: the type's own header (`class OrderService {`) two lines + # above is not the constructor's new header (#1465) + if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(n)}\b', code): continue + if re.search(rf'\b{re.escape(n)}\b', code): 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) + # A BLOCK BODY ON THE HEADER'S LINE IS NOT HEADER EITHER. `public int total() { return 42; }` is one line, + # and read to its line the whole body was header text: `return 42;` to `return 43;` came back as a + # signature change (#1387) + if not is_py: + body_brace = self.before_block_body + oh, oh_raw, nh, nh_raw = body_brace(oh), body_brace(oh_raw), body_brace(nh), body_brace(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() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 0ab67a49..46eb0949 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -88,7 +88,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', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', '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 @@ -909,7 +909,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 = '47' # 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '48' # 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1161,6 +1161,18 @@ class Impact: 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)) + # a signature position the engine RESOLVED to a type (#1422): Java writes no line into type_refs, so typeref above + # holds none of its parameter / return uses, and the textuse grep then called each one `text` + sig = set() + if g.has('type_use'): + for r in g.q("""SELECT DISTINCT owner_method_id, type_id, context, depth FROM type_use + WHERE owner_method_id IS NOT NULL AND type_id IS NOT NULL + AND context IN ('METHOD_PARAM', 'METHOD_RETURN')"""): + sig.add((r['owner_method_id'], r['type_id'], r['context'], int(r['depth'] or 0))) + W('sigtype', sorted(sig)) + # the properties each persistence query reads (framework-behavior/persistence.dl): a derived repository method, + # an @Query or named query's text, a whole-entity select (#1461, #1462) + W('persist_field', [tuple(r) for r in g.q("SELECT DISTINCT c0, c1, c2 FROM ext_persistence_field")] if g.has('ext_persistence_field') else []) # an identifier-shaped literal is a name in some other namespace (a serialized field, a map key, a bean # qualifier). A PATH-shaped one is the other half of a registration: the URL a test asks for is a string in # the test and a string in the handler's decoration, and nothing else in the graph connects the two. It is @@ -1902,6 +1914,27 @@ def tests_outside_src(g, cap=20000): STRING_TEXT = [] # (repo, target, --in, why): targets answered by text too (ax_text.py) +# WHAT A NAME WRITTEN AS A STRING BINDS, in the target's own language (#1487): a Python function's name in a string is +# a key or a registration, never a bean qualifier, and "not by the Java name" misread every non-Java answer. +STRING_BINDS = { + '.java': 'a topic, a route, a key, a bean qualifier bind by string, not by the Java name', + '.cs': 'a route, a key, a handler or a service name bind by string, not by the C# name', + '.py': 'a key, a route, a signal, a registration or a getattr bind by string, not by the Python name', + '': 'a topic, a route, a key or a registration binds by string, not by the name in the code', +} + + +def target_ext(g, pay): + """the source extension of a target's first declaration ('' for a language the table does not word). A method or + type target carries symbol ids, a field target the symbol rows themselves""" + for t in (pay if isinstance(pay, list) else [pay]): + f = t.get('file') if isinstance(t, dict) else (g.sym.get(t) or {}).get('file') if isinstance(t, str) else None + if f: + ext = os.path.splitext(f)[1] + return '.py' if ext == '.pyi' else ext if ext in STRING_BINDS else '' + return '' + + def main(argv): args = list(argv); depth = 40; limit = 25; IN = None; as_json = '--json' in args; kind = None # --tests lists the tests grouped by rung and file; --tests-only prints that section and nothing else; @@ -2416,12 +2449,12 @@ def main(argv): # and the verified / bound lines that qualify it, are printed _stdout = sys.stdout if tests_only: sys.stdout = io.StringIO() - for k, lab, _ in targets: + for k, lab, pay in targets: if k in ('method', 'field', 'type'): nm = lab.split()[-1].split('(')[0].split('.')[-1] n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) if g.has('literals') else 0 d = len(g.q("SELECT 1 FROM decorations WHERE text LIKE ?", f'%"{nm}"%')) if g.has('decorations') else 0 - if n + d > 1: print(f" the name {nm} is also written as a string in {n + d} place(s) (a topic, a route, a key, a bean qualifier bind by string, not by the Java name): ask for it quoted, `impact '\"{nm}\"'`, to get those") + if n + d > 1: print(f" the name {nm} is also written as a string in {n + d} place(s) ({STRING_BINDS.get(target_ext(g, pay), STRING_BINDS[''])}): ask for it quoted, `impact '\"{nm}\"'`, to get those") break if contract: nm = sum(1 for _, why in contract if 'name match' in why) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index a35f492a..3eb20d45 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -119,6 +119,8 @@ .decl field_type(fl:symbol, n:symbol) .input field_type .decl target(q:symbol, k:symbol, s:symbol, x:symbol) .input target .decl textuse(c:symbol, n:symbol, f:symbol, l:number) .input textuse +.decl sigtype(c:symbol, t:symbol, ctx:symbol, d:number) .input sigtype +.decl persist_field(m:symbol, fl:symbol, how:symbol) .input persist_field .decl importuse(c:symbol, n:symbol, f:symbol, l:number) .input importuse .decl inside_target(q:symbol, c:symbol) .input inside_target // config(key, m, why) a configuration key the engine bound to a method (@Value, @ConfigurationProperties, a .yml/.properties key) @@ -627,7 +629,22 @@ direct(q, c, "uses", cat("renders <", tag, ">, whose attributes are checked agai // 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). +// a persistence query that names the property (#1461, #1462): a derived repository method name, @Query / named-query +// text, or a whole-entity select that loads it. No call or reference connects them; the rename fails when it is parsed. +.decl persist_words(how:symbol, w:symbol) +persist_words("direct", "its persistence query names the property: a rename breaks the query when it is parsed, not the compile"). +persist_words("nested", "its persistence query reads it through an association path"). +persist_words("projection", "its persistence query selects the whole entity, which loads this column"). +direct(q, m, "reads", w, "resolved", "", 0) :- target(q, "field", fl, _), persist_field(m, fl, how), persist_words(how, w), !inside_target(q, m). +// a signature the engine RESOLVED to the type (#1422): a parameter, a return, or a type argument of either. Java's +// type_refs carry no line, so no typeref row held these, and the textuse grep below labelled each one `text` +.decl sig_words(ctx:symbol, w:symbol) +sig_words("METHOD_PARAM", "a parameter"). sig_words("METHOD_RETURN", "the return type"). +.decl sig_resolved(q:symbol, c:symbol) +sig_resolved(q, c) :- target(q, "type", t, _), sigtype(c, t, _, _), !inside_target(q, c). +direct(q, c, "uses", cat("names it in its signature (", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, 0), sig_words(ctx, w), !inside_target(q, c). +direct(q, c, "uses", cat("names it in its signature (a type argument of ", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, d), d > 0, sig_words(ctx, w), !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), !sig_resolved(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). direct(q, c, "writes", cat("calls the generated setter ", an, "()"), "by name", f, l) :- target(q, "type", t, _), gen(t, "set"), field(fl, t, _, _, _), accessor(fl, an, "write"), 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 85009fde..2e361511 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -2285,6 +2285,8 @@ def keyed_literals(q, code, at, values): return sorted(set(rows)) +SIG_CTX_WORDS = {'METHOD_PARAM': 'a parameter', 'METHOD_RETURN': 'the return type'} + 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. @@ -2305,7 +2307,8 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): 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 + over, todo = set(), [] + sig_resolved = set() # callables whose signature type_use resolves to it (273b) # 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) @@ -2432,9 +2435,23 @@ def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE li 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) + # 273b — a signature the engine RESOLVED to this type (#1422): `type_use` holds each parameter, return and + # type-argument position with the type it names. Java writes no line into type_refs, so rule 273 matched none of + # them and 274 below grepped the same signature and called it `text`, "may be a same-named other thing". + # direct(q,c,"uses",cat("names it in its signature (",ctx,")"),"resolved","",0) + # :- target(q,"type",t,_), type_use(_,t,ctx,depth,_,_,c,_,_,_), sig_ctx(ctx), !inside_target(q,c) + if _has(q, 'type_use'): + for c, ctx, depth in q("""SELECT DISTINCT owner_method_id, context, depth FROM type_use + WHERE type_id = ? AND owner_method_id IS NOT NULL + AND context IN ('METHOD_PARAM', 'METHOD_RETURN')""", t): + if not c or c in inside: continue + sig_resolved.add(c) + what = SIG_CTX_WORDS.get(ctx, ctx.lower()) + if depth and int(depth) > 0: what = f"a type argument of {what}" + rows.append((c, 'uses', f'names it in its signature ({what})', 'resolved', '', 0)) # 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: + if nm == n and c not in inside and c not in sig_resolved: 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 @@ -2599,6 +2616,12 @@ class that shares a display: one measured Java server has two test classes of on return out +PERSIST_WORDS = { + 'direct': 'its persistence query names the property: a rename breaks the query when it is parsed, not the compile', + 'nested': 'its persistence query reads it through an association path', + 'projection': 'its persistence query selects the whole entity, which loads this column', +} + def direct_for_field(q, fids, at, code, lines, inside, rel): """`direct` for a FIELD target — 33 rules, the largest kind. A field has no call edges of its own, so almost everything here is a reference judged by WHERE it is and HOW it is written. @@ -2908,6 +2931,16 @@ def declares_of(s_): for c, k, sf, sl in sites.get(tnames.get(t_) or '', ()): rows.append((c, 'writes', 'passes it to the generated constructor', 'by name', rel(sf) if sf else '', sl or 0)) + # a persistence query that names the property (#1461, #1462): a repository method whose derived name or query + # text reads it, or a whole-entity select that loads it. Renaming the property breaks the query when it is parsed, + # not the compile, and no call or reference connects them. + # direct(q,m,"reads",w,"resolved","",0) :- target(q,"field",fl,_), persist_field(m,fl,how), persist_words(how,w), + # !inside_target(q,m) + if _has(q, 'ext_persistence_field'): + fph = ','.join('?' * len(fids)) + for m, how in q(f"SELECT DISTINCT c0, c2 FROM ext_persistence_field WHERE c1 IN ({fph})", *fids): + if m in inside or how not in PERSIST_WORDS: continue + rows.append((m, 'reads', PERSIST_WORDS[how], 'resolved', '', 0)) # a barrel that re-exports the field's name (rule 397) fnames = {r[1] for r in (field_rec(q, f_) for f_ in fids) if r and r[1]} rows += reexport_rows(q, fnames, code, rel) diff --git a/tests/cases/java/constructor-header-below-type/case.json b/tests/cases/java/constructor-header-below-type/case.json new file mode 100644 index 00000000..a6b52942 --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/case.json @@ -0,0 +1,17 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a constructor starting two lines under its type's header: its new header is its own line, not `class OrderService {`, so an added parameter reads as added (#1465)", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-ctor.java", "--file", "src/pkg/OrderService.java"], + "want": ["signature OrderService.OrderService", "+zone"], + "avoid": ["-repo", "return type / modifiers", "OrderService(repo)"]}, + {"why": "a body edit inside a method written on one line is a body change, not a header change (#1387)", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-oneline.java", "--file", "src/pkg/OrderService.java"], + "want": ["body OrderService.total"], + "avoid": ["signature OrderService.total", "header text changed"]}, + {"why": "control: the same edit in a method whose body spans lines is a body change too", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-multiline.java", "--file", "src/pkg/OrderService.java"], + "want": ["body OrderService.count"], + "avoid": ["signature OrderService.count"]}, + {"why": "control: a return type edited on a one-line method is still a signature change", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-oneline-ret.java", "--file", "src/pkg/OrderService.java"], + "want": ["signature OrderService.total", "int → public long"]}]} diff --git a/tests/cases/java/constructor-header-below-type/new-ctor.java b/tests/cases/java/constructor-header-below-type/new-ctor.java new file mode 100644 index 00000000..cbe935db --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/new-ctor.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo, String zone) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public int total() { return 42; } + public int count() { + return 7; + } +} diff --git a/tests/cases/java/constructor-header-below-type/new-multiline.java b/tests/cases/java/constructor-header-below-type/new-multiline.java new file mode 100644 index 00000000..c4040d72 --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/new-multiline.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public int total() { return 42; } + public int count() { + return 8; + } +} diff --git a/tests/cases/java/constructor-header-below-type/new-oneline-ret.java b/tests/cases/java/constructor-header-below-type/new-oneline-ret.java new file mode 100644 index 00000000..a81cd158 --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/new-oneline-ret.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public long total() { return 42; } + public int count() { + return 7; + } +} diff --git a/tests/cases/java/constructor-header-below-type/new-oneline.java b/tests/cases/java/constructor-header-below-type/new-oneline.java new file mode 100644 index 00000000..f47fa476 --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/new-oneline.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public int total() { return 43; } + public int count() { + return 7; + } +} diff --git a/tests/cases/java/constructor-header-below-type/old.java b/tests/cases/java/constructor-header-below-type/old.java new file mode 100644 index 00000000..f28b510c --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/old.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public int total() { return 42; } + public int count() { + return 7; + } +} diff --git a/tests/cases/java/constructor-header-below-type/src/pkg/OrderService.java b/tests/cases/java/constructor-header-below-type/src/pkg/OrderService.java new file mode 100644 index 00000000..f28b510c --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/src/pkg/OrderService.java @@ -0,0 +1,12 @@ +package pkg; +public class OrderService { + private final Repo repo; + public OrderService(Repo repo) { + this.repo = repo; + } + public String load(String id) { return repo.find(id); } + public int total() { return 42; } + public int count() { + return 7; + } +} diff --git a/tests/cases/java/constructor-header-below-type/src/pkg/Repo.java b/tests/cases/java/constructor-header-below-type/src/pkg/Repo.java new file mode 100644 index 00000000..29a57f04 --- /dev/null +++ b/tests/cases/java/constructor-header-below-type/src/pkg/Repo.java @@ -0,0 +1,2 @@ +package pkg; +public interface Repo { String find(String id); } diff --git a/tests/cases/java/persistence-query-reads-the-property/case.json b/tests/cases/java/persistence-query-reads-the-property/case.json new file mode 100644 index 00000000..c30b4f1e --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/case.json @@ -0,0 +1,13 @@ +{"lang": "java", "src": ".", + "checks": [ + {"why": "a derived repository method is a reader of the property its name spells (#1461)", + "run": ["impact", "Widget.color", "--kind", "field"], + "want": ["WidgetRepository.findByColor", "its persistence query names the property", "WidgetRepository.findDistinctByColorAndLabelTextOrderByIdDesc"], + "avoid": ["WidgetRepository.countByLabelStartingWith", "WidgetRepository.byColorName"]}, + {"why": "control: the longer property wins, so label is not read by the method that spells labelText", + "run": ["impact", "Widget.label", "--kind", "field"], + "want": ["WidgetRepository.countByLabelStartingWith", "WidgetRepository.byLabel"], + "avoid": ["findDistinctByColorAndLabelTextOrderByIdDesc "]}, + {"why": "a named query declared in orm.xml reads the property its JPQL names (#1462)", + "run": ["impact", "Order.status", "--kind", "field"], + "want": ["OrderDao.byStatus", "its persistence query names the property"]}]} diff --git a/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Order.java b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Order.java new file mode 100644 index 00000000..19d21a3c --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Order.java @@ -0,0 +1,9 @@ +package app; +import jakarta.persistence.*; +@Entity +@NamedQuery(name = "Order.byTotal", query = "SELECT o FROM Order o WHERE o.total > :min") +public class Order { + @Id Long id; + String status; + long total; +} diff --git a/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/OrderDao.java b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/OrderDao.java new file mode 100644 index 00000000..75368c6b --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/OrderDao.java @@ -0,0 +1,8 @@ +package app; +import java.util.List; +import jakarta.persistence.EntityManager; +public class OrderDao { + EntityManager em; + List byStatus(String s) { return em.createNamedQuery("Order.byStatus", Order.class).setParameter("status", s).getResultList(); } + List byTotal(long min) { return em.createNamedQuery("Order.byTotal", Order.class).setParameter("min", min).getResultList(); } +} diff --git a/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Widget.java b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Widget.java new file mode 100644 index 00000000..15bd3b01 --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/Widget.java @@ -0,0 +1,9 @@ +package app; +import jakarta.persistence.*; +@Entity +public class Widget { + @Id Long id; + String color; + String label; + String labelText; +} diff --git a/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/WidgetRepository.java b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/WidgetRepository.java new file mode 100644 index 00000000..0c27cc95 --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/src/main/java/app/WidgetRepository.java @@ -0,0 +1,16 @@ +package app; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +public interface WidgetRepository extends JpaRepository { + List findByColor(String color); + + long countByLabelStartingWith(String prefix); + + List findDistinctByColorAndLabelTextOrderByIdDesc(String c, String t); + + @Query("SELECT w FROM Widget w WHERE w.label = :label") + List byLabel(String label); + + List byColorName(String color); +} diff --git a/tests/cases/java/persistence-query-reads-the-property/src/main/resources/META-INF/orm.xml b/tests/cases/java/persistence-query-reads-the-property/src/main/resources/META-INF/orm.xml new file mode 100644 index 00000000..4ec203d2 --- /dev/null +++ b/tests/cases/java/persistence-query-reads-the-property/src/main/resources/META-INF/orm.xml @@ -0,0 +1,6 @@ + + + + SELECT o FROM Order o WHERE o.status = :status + + diff --git a/tests/cases/java/signature-use-resolved-by-type-use/case.json b/tests/cases/java/signature-use-resolved-by-type-use/case.json new file mode 100644 index 00000000..fea6de43 --- /dev/null +++ b/tests/cases/java/signature-use-resolved-by-type-use/case.json @@ -0,0 +1,12 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a parameter, return or type-argument use that type_use resolves to the type is listed resolved, not as [text] (#1422)", + "run": ["impact", "Customer", "--kind", "type"], + "want": ["[resolved] CustomerStore.findById", "names it in its signature (the return type)", + "[resolved] CustomerStore.findAll", "a type argument of the return type", + "[resolved] CustomerStore.save", "names it in its signature (a parameter)", + "[resolved] CustomerLabel.label"], + "avoid": ["[text] CustomerStore", "[text] CustomerLabel.label", "[text] CustomerDesk.open"]}, + {"why": "control: the construction stays a resolved instantiation", + "run": ["impact", "Customer", "--kind", "type"], + "want": ["instantiates it"]}]} diff --git a/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/Customer.java b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/Customer.java new file mode 100644 index 00000000..377fd638 --- /dev/null +++ b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/Customer.java @@ -0,0 +1,7 @@ +package app; + +public class Customer { + private Long id; + + public Long getId() { return id; } +} diff --git a/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerDesk.java b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerDesk.java new file mode 100644 index 00000000..f00d1b5b --- /dev/null +++ b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerDesk.java @@ -0,0 +1,12 @@ +package app; + +public class CustomerDesk { + public Customer open() { + return new Customer(); + } + + // control: the name in a comment and a string is text, not a signature: Customer + public String note() { + return "Customer"; + } +} diff --git a/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerLabel.java b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerLabel.java new file mode 100644 index 00000000..9e9badfe --- /dev/null +++ b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerLabel.java @@ -0,0 +1,7 @@ +package app; + +public class CustomerLabel { + public String label(Customer c) { + return "#" + c.getId(); + } +} diff --git a/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerStore.java b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerStore.java new file mode 100644 index 00000000..47054bc4 --- /dev/null +++ b/tests/cases/java/signature-use-resolved-by-type-use/src/main/java/app/CustomerStore.java @@ -0,0 +1,11 @@ +package app; + +import java.util.List; + +public interface CustomerStore { + Customer findById(Long id); + + List findAll(); + + void save(Customer customer); +} diff --git a/tests/cases/python/string-mention-worded-by-language/case.json b/tests/cases/python/string-mention-worded-by-language/case.json new file mode 100644 index 00000000..e73af13e --- /dev/null +++ b/tests/cases/python/string-mention-worded-by-language/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a Python function's name written as a string is explained in Python terms, not as a Java bean qualifier (#1487)", + "run": ["impact", "on_saved"], + "want": ["also written as a string in 2 place(s)", "not by the Python name"], + "avoid": ["bean qualifier", "Java name"]}, + {"why": "control: a name written as a string nowhere gets no string hint at all", + "run": ["impact", "on_deleted"], + "avoid": ["also written as a string"]}]} diff --git a/tests/cases/python/string-mention-worded-by-language/src/app/__init__.py b/tests/cases/python/string-mention-worded-by-language/src/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/string-mention-worded-by-language/src/app/signals.py b/tests/cases/python/string-mention-worded-by-language/src/app/signals.py new file mode 100644 index 00000000..f0d1b984 --- /dev/null +++ b/tests/cases/python/string-mention-worded-by-language/src/app/signals.py @@ -0,0 +1,12 @@ +def on_saved(order): + return order + + +def on_deleted(order): + return order + + +def connect(bus): + bus.subscribe("order_saved", on_saved, uid="on_saved") + bus.subscribe("order_saved", on_saved, name="on_saved") + bus.subscribe("order_deleted", on_deleted) From 1a65a0a708ae5e5f9ef0f4ded0ee02b0aaaeef5c Mon Sep 17 00:00:00 2001 From: Swapnil Date: Mon, 28 Sep 2026 22:50:35 -0700 Subject: [PATCH 040/258] cli: axiomcode diff compares two graphs of one tree by name and line Every engine change has been measured before and after with hand SQL over call_edges joined to sites and methods, because no verb compared two graphs, and a join on ids does not work: ids hash the index directory, so two indexes of one tree share none. The change: a new verb, `axiomcode diff ` (and the MCP tool axiomcode_diff). Each side is a graph.sqlite or an indexed directory, whose language graphs are paired by language. Rows are matched by file, line, column, qualified name and callee as written, never by id; absolute paths (Java methods and call sites) are made relative to the tree each graph was built from. Per language it prints a summary of counts per kind and the call edges per tier (A -> B), then: - call edges added, removed, retiered (same callee, another tier or kind) and re-targeted (a site whose callees changed, old set then new set) - entry points with their reason, and methods reachable from one - remote edges, Python framework edges, Java config bindings - symbols added or removed, and a declaration whose signature changed --file scopes the rows and counts to a path fragment, --limit caps the rows per section, --json prints every row. Neither graph is rebuilt or refreshed. Documented in SKILL.md and a new reference/diff.md (plugin and skills/ copies, rebuilt by packaging/copies.py, which also brings the stale skills/ copy of schema.md up to date), AGENTS.md and the Cursor rule. Tests: tests/diff_verb.py indexes one small tree three times per language (Python, Java, C#): the same tree at two paths diffs to nothing while both graphs hold call edges (near-miss control), one call added on an existing line is exactly one row and no other kind moves, --file scopes it, the graph.sqlite paths answer as the directories do. Passes 28/28. Also run: tests/surfaces.py, tests/manifests.py, tests/mcp.py, tests/mcp_first.py, tests/test_command.py, tests/run.py --lang python, java and csharp. Smoke, reproducing an earlier engine unit's hand measurement: a Django project indexed with the release engine and with the python typing and export fix. The verb prints known_edge 2504 -> 2522 and ambiguous_unknown 10152 -> 10134, 18 re-targeted sites, each a Model.objects.() call or a call on its result, as the hand SQL found (18 sites; its base count of ambiguous_unknown was 10156, from an older engine). Controls on real projects, the same tree indexed at two paths by one engine: no difference in Python (17430 distinct call edges) and C# (9046, with 39 dispatch edges and remote edges). Java across two paths is covered by the fixture test only; a real Java same-tree control was not run. A Java comparison of two copies of one project showed 3 re-targeted sites: one copy had a method's signature edited, which the verb now also reports as a symbol whose signature changed. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/AGENTS.md | 1 + plugins/axiomcode/mcp/server.py | 5 + plugins/axiomcode/rules/axiomcode.mdc | 1 + plugins/axiomcode/skills/axiomcode/SKILL.md | 10 + .../skills/axiomcode/reference/diff.md | 68 +++ .../skills/axiomcode/scripts/axiomcode | 7 + .../skills/axiomcode/scripts/axiomcode-diff | 396 ++++++++++++++++++ skills/axiomcode/SKILL.md | 10 + skills/axiomcode/reference/diff.md | 68 +++ tests/README.md | 3 + tests/diff_verb.py | 146 +++++++ tests/mcp.py | 2 +- 12 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 plugins/axiomcode/skills/axiomcode/reference/diff.md create mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff create mode 100644 skills/axiomcode/reference/diff.md create mode 100644 tests/diff_verb.py diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index a195df2f..c99dc574 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -13,6 +13,7 @@ repository's call graph FIRST, through the `axiomcode_*` MCP tools: axiomcode_test_impact which tests the edit in front of you has to run axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent axiomcode_graph draw the graph as one interactive HTML page, for a person + axiomcode_diff what changed between two graphs of one tree (before/after), by name and line **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. Every answer ends with `next:`, the diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 9fc1e854..4443c77e 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -308,6 +308,11 @@ def axiomcode_graph(repo: str = ".", out: str = '') -> str: """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path.""" return run(['graph', repo] + (['--out', out] if out else [])) +@srv.tool() +def axiomcode_diff(graph_a: str, graph_b: str, file: str = '', lang: str = '', limit: int = 40, as_json: bool = False) -> str: + """What changed between two graphs of the SAME tree, e.g. one tree copied and indexed before and after an engine or rules change: call edges added, removed, retiered (same callee, another tier) or re-targeted (a site whose callees changed), entry points with their reason, remote and framework edges, config bindings and symbols, and the call edges per tier (A -> B). graph_a / graph_b: a graph.sqlite, or an indexed directory (every language graph in it, paired by language). Rows are matched by file, line, column, qualified name and callee, never by id (ids hash the index directory), so one tree indexed at two paths diffs to nothing. Neither graph is rebuilt. file keeps the rows with a file containing it; limit: rows per section (default 40, 0 for all; the counts are always of the whole diff); as_json=True gives every row.""" + return run(['diff', graph_a, graph_b] + (['--file', file] if file else []) + (['--lang', lang] if lang else []) + ['--limit', str(limit)] + (['--json'] if as_json else [])) + if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on try: diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 10e19c72..7762608c 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -18,6 +18,7 @@ repository's call graph FIRST, through the `axiomcode_*` MCP tools: axiomcode_test_impact which tests the edit in front of you has to run axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent axiomcode_graph draw the graph as one interactive HTML page, for a person + axiomcode_diff what changed between two graphs of one tree (before/after), by name and line **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. Every answer ends with `next:`, the diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index c2a7fb65..35b704e0 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -32,6 +32,7 @@ From the shell the same shape is `--grep` (`--grep-limit N`); without it the ans | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | +| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | | the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -104,6 +105,15 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +## diff: two graphs of the same tree + +`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a +`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call +edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config +bindings and symbols, with the call edges per tier. Rows match by file, line, qualified name and callee, never by id +(ids hash the index directory), so one tree indexed at two paths diffs to nothing. Use it instead of hand SQL for a +before/after. Detail: `reference/diff.md`. + A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. ## What it cannot see — say so instead of guessing diff --git a/plugins/axiomcode/skills/axiomcode/reference/diff.md b/plugins/axiomcode/skills/axiomcode/reference/diff.md new file mode 100644 index 00000000..ef2e356b --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/reference/diff.md @@ -0,0 +1,68 @@ +# diff: what changed between two graphs of one tree + +```sh +axiomcode diff [--file ] [--lang ] [--limit N] [--json] +``` + +Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its +`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. + +## The before/after recipe + +```sh +rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ +AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python +AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python +cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine +cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite +axiomcode diff /tmp/before.sqlite /tmp/after.sqlite +``` + +Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild +with the installed one, which overwrites the graph under test. The diff itself never does. + +## How rows are matched + +By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so +two indexes of one tree share none, and a join on ids says everything changed. + +| kind | matched on | +|---|---| +| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | +| entry point | the method (qualified name, file:line) and the reason | +| reachable | the method | +| remote edge | transport, destination, sender, handler, confidence | +| framework edge (Python) | mechanism, name, from, to, certainty | +| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | +| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | + +Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same +tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: +compare graphs of one tree, not of two commits. + +## Reading the answer + +``` +python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) + B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) +summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 +call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … + +call edges (…): + + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site + - file:line:col Caller -> Callee [tier, kind] the reverse + ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind + > file:line:col Caller a site whose callees changed: the old set, then the new + - Callee [tier, kind] + + Callee [tier, kind] +``` + +The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). +`--file` keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the +declaration's) and counts only those. `--json` prints every row with the same counts, under +`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. + +## Not compared + +`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both +graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 770f54db..9c602ae6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -40,6 +40,12 @@ # 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. +# axiomcode diff [--file ] [--lang ] [--limit N] [--json] +# what changed between two graphs of the same tree (graph.sqlite paths, or two indexed copies of it): call edges +# added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config +# bindings and symbols, with a summary line of counts per tier. Rows are matched by file, line, qualified name and +# callee, never by id (ids hash the index directory), so one tree indexed at two paths diffs to nothing. Reads both +# graphs as they are: neither is rebuilt or refreshed. # axiomcode help [] # every verb with what it does; with a verb, that verb's own usage, printed from the script that implements it. # axiomcode install [] [--remove] [--print] @@ -160,6 +166,7 @@ case "$cmd" in changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${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[@]}"} ;; + diff) exec python3 "$H/axiomcode-diff" ${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 ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff new file mode 100755 index 00000000..b0563bb8 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""axiomcode diff: what changed between two graphs of the same tree. + + axiomcode diff [--file ] [--lang ] [--limit N] [--json] + + and are each a graph.sqlite, or a directory that holds one: a repository indexed with +`axiomcode index` (every language graph under its .axiomcode is compared, paired by language), or an out directory. +The usual pair is one tree indexed twice, before and after a change to the engine, the rules or the source, at two +paths (copy the tree, index each copy). + +Rows are matched by what stays the same when the same tree is indexed somewhere else: a file path relative to the +tree, a line and column, a qualified name, a callee as written. Never by id: an id hashes the index directory, so two +indexes of one tree share none. Two indexes of the same tree with the same engine therefore diff to nothing. + +Printed per language, A to B: + summary one line of counts per kind, and the call edges per tier (A -> B, the change) + call edges + added, - removed, ~ the same callee at another tier or call kind, > a site whose callees changed + (old set => new set), each at the line and column the call is written on, with its caller + entry points + / - a method the runtime calls, with the reason + reachable + / - a method reachable from an entry point + remote edges + / - a hop across a process (transport, destination, sender -> handler) + framework + / - an in-process framework hop (Python: a task, a signal, a dependency provider) + config + / - a configuration key bound into a declaration (Java) + symbols + / - a declaration (kind, qualified name, file:line) + +--file keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the +declaration's), and counts only those. --limit N rows per section (default 40, 0 for all); the counts are always of +the whole diff. --json prints every row. Neither graph is rebuilt or refreshed: each is read as it is. +""" +import argparse, collections, json, os, sqlite3, sys + +KINDS = ('calls', 'entry_points', 'reachable', 'remote', 'framework', 'config', 'symbols') +TITLE = {'calls': 'call edges', 'entry_points': 'entry points', 'reachable': 'reachable from an entry point', + 'remote': 'remote edges', 'framework': 'framework edges', 'config': 'config bindings', 'symbols': 'symbols'} + + +def die(msg): + print(f"axiomcode diff: {msg}", file=sys.stderr) + sys.exit(2) + + +def language_of(path): + try: + con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + r = con.execute("SELECT value FROM run WHERE key = 'language'").fetchone() + con.close() + return r[0] if r else '?' + except sqlite3.Error as e: + die(f"{path} is not a graph ({e})") + + +def graphs_of(arg): + """{language: graph.sqlite} for one argument""" + p = os.path.abspath(arg) + if os.path.isfile(p): + return {language_of(p): p} + if not os.path.isdir(p): + die(f"{arg}: no such file or directory") + found = [] + ax = os.path.join(p, '.axiomcode') + if os.path.isdir(ax): + found.append(os.path.join(ax, 'out', 'graph.sqlite')) + ld = os.path.join(ax, 'lang') + if os.path.isdir(ld): + found += [os.path.join(ld, l, 'out', 'graph.sqlite') for l in sorted(os.listdir(ld))] + else: + found.append(os.path.join(p, 'graph.sqlite')) + found.append(os.path.join(p, 'out', 'graph.sqlite')) + out = {} + for f in found: + if os.path.isfile(f): + out.setdefault(language_of(f), os.path.realpath(f)) + if not out: + die(f"{arg}: no graph.sqlite in it (index it with `axiomcode index {arg}`, or name the graph.sqlite)") + return out + + +class Graph: + def __init__(self, path): + self.path = path + self.con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + self.tables = {r[0] for r in self.con.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')")} + self.run = dict(self.q("SELECT key, value FROM run")) if 'run' in self.tables else {} + # the prefixes an absolute path in this graph starts with: the tree it was built from, and the directory + # holding .axiomcode (a --src build records the subtree). Longest first. + roots = {self.run.get('source_dir', '')} + d = os.path.dirname(os.path.realpath(path)) + while d and d != os.path.dirname(d): + if os.path.basename(d) == '.axiomcode': + roots.add(os.path.dirname(d)) + break + d = os.path.dirname(d) + self.roots = sorted((r.rstrip('/\\') + '/' for r in roots if r), key=len, reverse=True) + self.paths = dict(self.q("SELECT raw, rel FROM paths")) if 'paths' in self.tables else {} + self._decl() + + def q(self, sql, *p): + return self.con.execute(sql, p).fetchall() + + def has(self, t): + return t in self.tables + + def rel(self, f): + if not f: + return '' + f = f.replace('\\', '/') + if f in self.paths and self.paths[f]: + return self.paths[f] + for r in self.roots: + if f.startswith(r): + return f[len(r):] + return f + + def _decl(self): + """id -> (kind, qualified name, file, line), from every table that declares one""" + self.decl = {} + put = self.decl.setdefault + if self.has('symbols'): + for i, mid, tid, kind, qn, f, line in self.q( + "SELECT id, method_id, type_id, kind, qualified_name, file, line FROM symbols"): + d = (kind or '', qn or '', self.rel(f), line or 0) + for k in (i, mid, tid): + if k: put(k, d) + if self.has('methods'): + for i, qn, f, line in self.q("SELECT id, qualified_name, file_path, start_line FROM methods"): + put(i, ('method', qn or '', self.rel(f), line or 0)) + if self.has('types'): + for i, qn, f, line in self.q("SELECT id, qualified_name, file_path, start_line FROM types"): + put(i, ('type', qn or '', self.rel(f), line or 0)) + if self.has('fields'): + for i, qn, n, f, line in self.q("SELECT id, owner_qualified_name, name, file_path, start_line FROM fields"): + put(i, ('field', f"{qn}.{n}" if qn else n, self.rel(f), line or 0)) + + def name(self, i, label=''): + """a declaration by its stable key: `qualified.name file:line`; a library callee by its label""" + d = self.decl.get(i) if i else None + if d: + return f"{d[1]} {d[2]}:{d[3]}" if d[2] else d[1] + if label: + return label + return f"" if i else '?' + + def files_of(self, i): + d = self.decl.get(i) + return [d[2]] if d and d[2] else [] + + # ---- the facts, each as {stable key: (row for display, files it touches)} ------------------------------------- + + def calls(self): + """{site: {(callee, tier, kind)}} and the site's files""" + if not self.has('call_edges'): + return {}, {} + view = 'sites' if self.has('sites') else 'call_sites' + fcol = 'file' if view == 'sites' else 'file_path' + sites = {} + for i, f, line, col in self.q(f"SELECT id, {fcol}, start_line, start_column FROM {view}"): + sites[i] = (self.rel(f), line or 0, col or 0) + out = collections.defaultdict(set); files = {} + for sid, caller, callee, label, tier, kind in self.q( + "SELECT call_site_id, caller_id, callee_method_id, callee_label, tier, kind FROM call_edges"): + f, line, col = sites.get(sid) or ((self.files_of(caller) or [''])[0], 0, 0) + key = (f, line, col, self.name(caller)) + out[key].add((self.name(callee, label), tier, kind)) + files[key] = {f, *self.files_of(caller)} + files.setdefault(('callee', key), set()).update(self.files_of(callee)) + return out, files + + def entry_points(self): + if not self.has('entry_points'): + return {} + return {(self.name(m), reason): self.files_of(m) for m, reason in self.q("SELECT method_id, reason FROM entry_points")} + + def reachable(self): + if not self.has('entry_reachable'): + return {} + return {(self.name(m),): self.files_of(m) for (m,) in self.q("SELECT method_id FROM entry_reachable")} + + def _hops(self, table): + if not self.has(table): + return {} + return {(r[2] or '', r[3] or '', self.name(r[0]), self.name(r[1]), r[4] or ''): self.files_of(r[0]) + self.files_of(r[1]) + for r in self.q(f"SELECT c0, c1, c2, c3, c4 FROM {table}")} + + def remote(self): + return self._hops('ext_remote_edge') + + def framework(self): + return self._hops('ext_framework_edge') + + def config(self): + if not self.has('ext_config_binding'): + return {} + out = {} + for key, mech, tkind, target, owner in self.q("SELECT c0, c1, c2, c3, c4 FROM ext_config_binding"): + # a parameter's id joins nothing: it is named by its owner type instead, which is stable + t = self.name(target) if target in self.decl else f"a {tkind} of {self.name(owner)}" + out[(key, mech, tkind, t)] = self.files_of(target) + self.files_of(owner) + return out + + def symbols(self): + if not self.has('symbols'): + return {} + out, self.sigs = {}, {} + for k, qn, f, line, sig in self.q("SELECT kind, qualified_name, file, line, signature FROM symbols"): + key = (k or '', qn or '', self.rel(f), line or 0) + out[key] = [key[2]] + self.sigs.setdefault(key, set()).add(sig or '') + return out + + +def keep(files, frag): + return not frag or any(frag in (f or '') for f in files) + + +def diff_calls(A, B, frag): + (ea, fa), (eb, fb) = A.calls(), B.calls() + rows = {'added': [], 'removed': [], 'retiered': [], 'changed': []} + tiers = collections.defaultdict(lambda: [0, 0]) + for side, edges, files in ((0, ea, fa), (1, eb, fb)): + for site, cs in edges.items(): + if keep(files[site] | files.get(('callee', site), set()), frag): + for _c, tier, _k in cs: + tiers[tier][side] += 1 + for site in sorted(set(ea) | set(eb)): + a, b = ea.get(site, set()), eb.get(site, set()) + if a == b: + continue + fs = fa.get(site, set()) | fb.get(site, set()) | fa.get(('callee', site), set()) | fb.get(('callee', site), set()) + if not keep(fs, frag): + continue + f, line, col, caller = site + at = {'file': f, 'line': line, 'column': col, 'caller': caller} + if not a or not b: + for c, tier, kind in sorted(b or a): + rows['added' if b else 'removed'].append(dict(at, callee=c, tier=tier, kind=kind)) + continue + ca, cb = collections.defaultdict(set), collections.defaultdict(set) + for c, tier, kind in a: ca[c].add((tier, kind)) + for c, tier, kind in b: cb[c].add((tier, kind)) + if set(ca) == set(cb): + for c in sorted(ca): + if ca[c] != cb[c]: + rows['retiered'].append(dict(at, callee=c, before=sorted(ca[c]), after=sorted(cb[c]))) + else: + rows['changed'].append(dict(at, before=sorted(a - b), after=sorted(b - a))) + return rows, {t: v for t, v in sorted(tiers.items())} + + +def diff_set(ka, kb, frag, fields): + add = [dict(zip(fields, k)) for k in sorted(set(kb) - set(ka)) if keep(kb[k], frag)] + rem = [dict(zip(fields, k)) for k in sorted(set(ka) - set(kb)) if keep(ka[k], frag)] + return {'added': add, 'removed': rem} + + +FIELDS = {'entry_points': ('method', 'reason'), 'reachable': ('method',), + 'remote': ('transport', 'destination', 'from', 'to', 'confidence'), + 'framework': ('mechanism', 'name', 'from', 'to', 'certainty'), + 'config': ('key', 'mechanism', 'target_kind', 'target'), + 'symbols': ('kind', 'qualified_name', 'file', 'line')} + + +def diff_lang(pa, pb, frag): + A, B = Graph(pa), Graph(pb) + calls, tiers = diff_calls(A, B, frag) + res = {'calls': calls, 'tiers': tiers} + for k in KINDS[1:]: + ka, kb = getattr(A, k)(), getattr(B, k)() + res[k] = diff_set(ka, kb, frag, FIELDS[k]) + # a declaration at the same place under the same name whose signature changed (a parameter added, a type changed) + res['symbols']['changed'] = [dict(zip(FIELDS['symbols'], key), before=sorted(A.sigs[key]), after=sorted(B.sigs[key])) + for key in sorted(set(A.sigs) & set(B.sigs)) + if A.sigs[key] != B.sigs[key] and keep([key[2]], frag)] + meta = lambda g: {'graph': g.path, 'engine': g.run.get('engine_commit', '')[:8], 'built': g.run.get('created_at', ''), + 'source_dir': g.run.get('source_dir', '')} + res['a'], res['b'] = meta(A), meta(B) + return res + + +def counts(res): + c = res['calls'] + out = {'calls': {k: len(v) for k, v in c.items()}} + for k in KINDS[1:]: + out[k] = {'added': len(res[k]['added']), 'removed': len(res[k]['removed'])} + if 'changed' in res[k]: out[k]['changed'] = len(res[k]['changed']) + return out + + +def fmt_row(kind, r): + if kind == 'entry_points': + return f"{r['method']} [{r['reason']}]" + if kind == 'reachable': + return r['method'] + if kind == 'remote': + return f"{r['transport']} {r['destination']} {r['from']} -> {r['to']} ({r['confidence']})" + if kind == 'framework': + return f"{r['mechanism']} {r['name']} {r['from']} -> {r['to']} ({r['certainty']})" + if kind == 'config': + return f"{r['key']} {r['mechanism']} -> {r['target']}" + return f"{r['kind']} {r['qualified_name']} {r['file']}:{r['line']}" + + +def edge(c): + return f"{c[0]} [{c[1]}, {c[2]}]" + + +def show(lang, res, limit): + n = counts(res) + c = n['calls'] + parts = [f"call edges +{c['added']} -{c['removed']} ~{c['retiered']} >{c['changed']}"] + parts += [f"{TITLE[k]} +{n[k]['added']} -{n[k]['removed']}" + (f" ~{n[k]['changed']}" if 'changed' in n[k] else '') + for k in KINDS[1:]] + print(f"{lang}: A {res['a']['graph']} (engine {res['a']['engine'] or '?'})") + print(f"{' ' * len(lang)} B {res['b']['graph']} (engine {res['b']['engine'] or '?'})") + print("summary: " + " · ".join(parts)) + # every tier, the changed ones first: a count that did not move is as much the answer as one that did + tl = [f"{t} {a} -> {b} ({b - a:+d})" for t, (a, b) in res['tiers'].items() if a != b] + tl += [f"{t} {a} (=)" for t, (a, b) in res['tiers'].items() if a == b] + print("call edges per tier: " + (" · ".join(tl) if tl else "none in either graph")) + if not any(c.values()) and not any(any(v.values()) for k, v in n.items() if k != 'calls'): + print("no difference") + return + + def cap(rows): + return rows if not limit else rows[:limit] + + calls = res['calls'] + if any(c.values()): + print(f"\ncall edges ({c['added']} added, {c['removed']} removed, {c['retiered']} retiered, {c['changed']} sites re-targeted):") + for sign, k in (('+', 'added'), ('-', 'removed')): + for r in cap(calls[k]): + print(f" {sign} {r['file']}:{r['line']}:{r['column']} {r['caller']} -> {edge((r['callee'], r['tier'], r['kind']))}") + if limit and len(calls[k]) > limit: print(f" … +{len(calls[k]) - limit} more {k} (--limit 0 for all)") + for r in cap(calls['retiered']): + print(f" ~ {r['file']}:{r['line']}:{r['column']} {r['caller']} -> {r['callee']} " + f"{', '.join('/'.join(x) for x in r['before'])} => {', '.join('/'.join(x) for x in r['after'])}") + if limit and len(calls['retiered']) > limit: print(f" … +{len(calls['retiered']) - limit} more retiered (--limit 0 for all)") + for r in cap(calls['changed']): + print(f" > {r['file']}:{r['line']}:{r['column']} {r['caller']}") + for x in r['before']: print(f" - {edge(x)}") + for x in r['after']: print(f" + {edge(x)}") + if limit and len(calls['changed']) > limit: print(f" … +{len(calls['changed']) - limit} more re-targeted sites (--limit 0 for all)") + for k in KINDS[1:]: + d = res[k] + if not d['added'] and not d['removed'] and not d.get('changed'): + continue + print(f"\n{TITLE[k]} ({len(d['added'])} added, {len(d['removed'])} removed" + + (f", {len(d['changed'])} with another signature" if 'changed' in d else '') + "):") + for sign, s in (('+', 'added'), ('-', 'removed')): + for r in cap(d[s]): + print(f" {sign} {fmt_row(k, r)}") + if limit and len(d[s]) > limit: print(f" … +{len(d[s]) - limit} more {s} (--limit 0 for all)") + for r in cap(d.get('changed', [])): + print(f" ~ {fmt_row(k, r)} {' | '.join(r['before'])} => {' | '.join(r['after'])}") + if limit and len(d.get('changed', [])) > limit: print(f" … +{len(d['changed']) - limit} more changed (--limit 0 for all)") + + +def main(): + ap = argparse.ArgumentParser(prog='axiomcode diff', add_help=True, description=__doc__.split('\n')[0]) + ap.add_argument('a'); ap.add_argument('b') + ap.add_argument('--file', default='') + ap.add_argument('--lang', default=os.environ.get('AXIOMCODE_LANG', '')) + ap.add_argument('--limit', type=int, default=40) + ap.add_argument('--json', action='store_true') + o = ap.parse_args() + ga, gb = graphs_of(o.a), graphs_of(o.b) + want = [l for l in o.lang.split(',') if l] if o.lang else None + langs = [l for l in sorted(set(ga) | set(gb)) if not want or l in want] + if not langs: + die(f"no graph in language {o.lang} (A has {', '.join(sorted(ga))}; B has {', '.join(sorted(gb))})") + out, notes = {}, [] + for l in langs: + if l not in ga or l not in gb: + notes.append(f"{l}: only in {'A' if l in ga else 'B'} ({(ga.get(l) or gb.get(l))}), not compared") + continue + if os.path.realpath(ga[l]) == os.path.realpath(gb[l]): + notes.append(f"{l}: A and B are the same file ({ga[l]}); nothing to compare") + out[l] = diff_lang(ga[l], gb[l], o.file) + if o.json: + print(json.dumps({'file': o.file, 'languages': {l: dict(r, counts=counts(r)) for l, r in out.items()}, + 'notes': notes}, indent=1, default=list)) + return 0 + first = True + for l, r in out.items(): + if not first: print() + first = False + show(l, r, o.limit) + if o.file: print(f"\nscoped to rows with a file containing '{o.file}'") + for n in notes: print(n) + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except BrokenPipeError: + sys.exit(0) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 1570ee7d..fed3be25 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -32,6 +32,7 @@ From the shell the same shape is `--grep` (`--grep-limit N`); without it the ans | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | +| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | | the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -103,6 +104,15 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +## diff: two graphs of the same tree + +`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a +`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call +edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config +bindings and symbols, with the call edges per tier. Rows match by file, line, qualified name and callee, never by id +(ids hash the index directory), so one tree indexed at two paths diffs to nothing. Use it instead of hand SQL for a +before/after. Detail: `reference/diff.md`. + A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. ## What it cannot see — say so instead of guessing diff --git a/skills/axiomcode/reference/diff.md b/skills/axiomcode/reference/diff.md new file mode 100644 index 00000000..ef2e356b --- /dev/null +++ b/skills/axiomcode/reference/diff.md @@ -0,0 +1,68 @@ +# diff: what changed between two graphs of one tree + +```sh +axiomcode diff [--file ] [--lang ] [--limit N] [--json] +``` + +Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its +`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. + +## The before/after recipe + +```sh +rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ +AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python +AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python +cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine +cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite +axiomcode diff /tmp/before.sqlite /tmp/after.sqlite +``` + +Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild +with the installed one, which overwrites the graph under test. The diff itself never does. + +## How rows are matched + +By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so +two indexes of one tree share none, and a join on ids says everything changed. + +| kind | matched on | +|---|---| +| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | +| entry point | the method (qualified name, file:line) and the reason | +| reachable | the method | +| remote edge | transport, destination, sender, handler, confidence | +| framework edge (Python) | mechanism, name, from, to, certainty | +| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | +| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | + +Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same +tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: +compare graphs of one tree, not of two commits. + +## Reading the answer + +``` +python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) + B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) +summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 +call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … + +call edges (…): + + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site + - file:line:col Caller -> Callee [tier, kind] the reverse + ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind + > file:line:col Caller a site whose callees changed: the old set, then the new + - Callee [tier, kind] + + Callee [tier, kind] +``` + +The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). +`--file` keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the +declaration's) and counts only those. `--json` prints every row with the same counts, under +`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. + +## Not compared + +`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both +graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/tests/README.md b/tests/README.md index 25889f3e..752dfe36 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,6 +24,9 @@ One check needs no graph and is its own script: python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) + python3 tests/diff_verb.py `axiomcode diff` matches two graphs of one tree by file, line, name and callee, never + by id: one tree indexed at two paths diffs to nothing, one added call is exactly one + row, in Python, Java and C# (builds real graphs, needs the engine) python3 tests/graph_verb.py `axiomcode graph` draws the existing graph and rebuilds a stale one with the flags it was indexed with; no rebuild path (refresh, repair, bare index) solves a language an explicit --lang left out (builds real graphs, needs the engine) diff --git a/tests/diff_verb.py b/tests/diff_verb.py new file mode 100644 index 00000000..74cca032 --- /dev/null +++ b/tests/diff_verb.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""tests/diff_verb.py: `axiomcode diff` compares two graphs of one tree by what stays put, never by id. + +Every engine change was measured before and after by hand SQL over call_edges joined to sites and methods, because +ids hash the index directory: two graphs of one tree, built at two paths, share no id, so a join on ids says that +everything changed. The verb joins on file, line, column, qualified name and callee as written. + +Per language (Python, Java, C#), one small tree indexed three times: + + here the tree + there the same tree copied to another path (Java records absolute paths, so this also checks they are made + relative before they are compared) + added `there` with one more call written on an existing line, so no other line moves + (no tree has an entry point, so the new callee does not also become reachable: that would be a + second, correct row) + + control here vs there: no difference in any kind, while both graphs hold call edges (a diff of two empty graphs + would pass this vacuously, so the edge count is asserted too) + one row there vs added: exactly one call edge added, the new callee, and no other row of any kind + --file the same pair scoped to a file that holds no change: nothing + --json the same counts as the text + dirs the index directories and the graph.sqlite paths answer the same + + python3 tests/diff_verb.py [-v] [--lang python,java,csharp] +""" +import json, os, re, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +TREES = { + 'python': ({ + 'shop/__init__.py': '', + 'shop/orders.py': 'def helper_one():\n return 1\n\n\ndef helper_two():\n return 2\n\n\n' + 'def total():\n a = helper_one(); return a\n', + 'shop/cli.py': 'from shop.orders import total\n\n\ndef main():\n return total()\n', + }, ('shop/orders.py', 'a = helper_one(); return a', 'a = helper_one(); helper_two(); return a'), 'helper_two', 'shop/cli.py'), + 'java': ({ + 'src/main/java/shop/Orders.java': 'package shop;\n\npublic class Orders {\n int helperOne() { return 1; }\n\n' + ' int helperTwo() { return 2; }\n\n' + ' public int total() { int a = helperOne(); return a; }\n}\n', + 'src/main/java/shop/Cli.java': 'package shop;\n\npublic class Cli {\n public int run() {\n' + ' return new Orders().total();\n }\n}\n', + }, ('src/main/java/shop/Orders.java', 'int a = helperOne(); return a;', 'int a = helperOne(); helperTwo(); return a;'), 'helperTwo', 'Cli.java'), + 'csharp': ({ + 'App/App.csproj': '\n net8.0\n\n', + 'App/Orders.cs': 'namespace App;\n\npublic class Orders\n{\n int HelperOne() { return 1; }\n\n int HelperTwo() { return 2; }\n\n' + ' public int Total() { int a = HelperOne(); return a; }\n}\n', + 'App/Cli.cs': 'namespace App;\n\npublic static class Cli\n{\n public static int Run() { return new Orders().Total(); }\n}\n', + }, ('App/Orders.cs', 'int a = HelperOne(); return a;', 'int a = HelperOne(); HelperTwo(); return a;'), 'HelperTwo', 'Cli.cs'), +} +KINDS = ('entry_points', 'reachable', 'remote', 'framework', 'config', 'symbols') + + +def make(root, files): + for rel, text in files.items(): + p = os.path.join(root, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').write(text) + + +def main(argv): + verbose = '-v' in argv + langs = list(TREES) + if '--lang' in argv: + langs = argv[argv.index('--lang') + 1].split(',') + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') + for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) + + def ax(*a): + return subprocess.run(['bash', AX, *a], capture_output=True, text=True, env=env) + + work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-diffverb-')) + try: + for lang in langs: + files, (edit_file, old, new), callee, other = TREES[lang] + here, there, added = (os.path.join(work, lang, n) for n in ('here', 'there', 'added')) + make(here, files); make(there, files) + make(added, dict(files, **{edit_file: files[edit_file].replace(old, new)})) + assert files[edit_file].count(old) == 1 + built = True + for d in (here, there, added): + r = ax('index', d, '--lang', lang) + if not os.path.isfile(os.path.join(d, '.axiomcode', 'out', 'graph.sqlite')): + check(False, f'{lang}: index {os.path.basename(d)}', r.stdout + r.stderr); built = False; break + if not built: + continue + + # ── control: one tree at two paths ───────────────────────────────────────────────────────────── + r = ax('diff', here, there, '--json') + doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} + res = doc.get('languages', {}).get(lang, {}) + n = res.get('counts', {}) + edges = sum(a for a, _b in res.get('tiers', {}).values()) + check(edges >= 1, f'{lang}: control is not vacuous (the graphs hold {edges} call edge(s))', r.stdout + r.stderr) + check(bool(n) and not any(n['calls'].values()) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), + f'{lang}: the same tree indexed at two paths diffs to nothing', json.dumps(n) + r.stderr) + t = ax('diff', here, there) + check('no difference' in t.stdout, f'{lang}: and the text says so', t.stdout + t.stderr) + + # ── one added call ───────────────────────────────────────────────────────────────────────────── + r = ax('diff', there, added, '--json') + doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} + res = doc.get('languages', {}).get(lang, {}) + n = res.get('counts', {}) + rows = res.get('calls', {}).get('added', []) + check(n.get('calls') == {'added': 1, 'removed': 0, 'retiered': 0, 'changed': 0} + and len(rows) == 1 and callee in rows[0]['callee'] and rows[0]['file'].endswith(edit_file), + f'{lang}: one call added on an existing line shows exactly one call-edge row, to {callee}', r.stdout[-1500:] + r.stderr) + check(bool(n) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), + f'{lang}: and no row of any other kind', json.dumps(n)) + t = ax('diff', there, added) + plus = [l for l in t.stdout.splitlines() if re.match(r'\s+[+\-~>] ', l)] + check(len(plus) == 1 and callee in plus[0] and 'call edges +1 -0 ~0 >0' in t.stdout, + f'{lang}: the text has the same one row and summary', t.stdout + t.stderr) + # the graph.sqlite paths answer as the directories do + g1, g2 = (os.path.join(d, '.axiomcode', 'out', 'graph.sqlite') for d in (there, added)) + t2 = ax('diff', g1, g2) + strip = lambda s: [l for l in s.splitlines() if not re.match(r'^\S+: A |^\s+B ', l)] + check(strip(t2.stdout) == strip(t.stdout), f'{lang}: two graph.sqlite paths answer as the two directories do', t2.stdout + t2.stderr) + # --file: a file with no change in it scopes the diff to nothing + t3 = ax('diff', there, added, '--file', other, '--json') + n3 = json.loads(t3.stdout)['languages'][lang]['counts'] if t3.returncode == 0 else {} + check(bool(n3) and not any(n3['calls'].values()), f'{lang}: --file {other} (no change there) scopes it to nothing', t3.stdout[-800:] + t3.stderr) + t4 = ax('diff', there, added, '--file', edit_file.split('/')[-1], '--json') + n4 = json.loads(t4.stdout)['languages'][lang]['counts'] if t4.returncode == 0 else {} + check(n4.get('calls', {}).get('added') == 1, f'{lang}: --file {edit_file.split("/")[-1]} keeps the row', t4.stdout[-800:] + t4.stderr) + + # a directory with no graph is refused, and says how to make one + empty = os.path.join(work, 'empty'); os.makedirs(empty) + r = ax('diff', empty, empty) + check(r.returncode != 0 and 'axiomcode index' in r.stderr, 'a directory with no graph is refused with the command that makes one', r.stderr) + finally: + shutil.rmtree(work, ignore_errors=True) + print(('\nFAIL' if fails else '\nok') + f': {len(fails)} failure(s)') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/tests/mcp.py b/tests/mcp.py index 4e55ff52..d3322f55 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -30,7 +30,7 @@ LAUNCHER = os.path.join(ROOT, 'bin', 'axiomcode.js') SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') TOOLS = {'axiomcode_index', 'axiomcode_context', 'axiomcode_path', 'axiomcode_impact', - 'axiomcode_changed', 'axiomcode_test_impact', 'axiomcode_graph'} + 'axiomcode_changed', 'axiomcode_test_impact', 'axiomcode_graph', 'axiomcode_diff'} # every verb whose prose is paged (ax_pages.install) ends a long answer with "ask for page=2 (MCP)", so its tool has # to accept one: a footer that points at a parameter the tool does not have strands the agent on page 1 (#1202) PAGED = {'axiomcode_context', 'axiomcode_path', 'axiomcode_impact', 'axiomcode_changed', 'axiomcode_test_impact'} From 6303941227714903096fb2854e3b4803df1b15c2 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:23:54 -0700 Subject: [PATCH 041/258] impact: a mapper XML statement binds only the methods of the type its namespace names Refs #1381 What was wrong: impact lists names written in files the index does not read as source under "bound from outside the source". For a method it matched the simple name as a whole token in every such file, so `` under + `` is the SQL of UserMapper.findById, and of nothing else named + findById: not another mapper's method of that name, not a repository method that delegates to a mapper. The + scan matches the simple name in every file, so a row "names the method" in a file with a mapper namespace is + kept only when one of the query's methods of that name is declared by the type the namespace names, or + inherited by it (a statement in OrderMapper.xml binds a method OrderMapper inherits from a generic base + mapper). A row in any other file, and a row that writes the method's name in full, are left as they are.""" + rows = out.get('extbind') + if not rows: return out + import ax_nonsource + g = self.g; ns_of = {}; accepts = {} + methods = collections.defaultdict(list) # query id -> its method targets + for r in T: + if r[1] == 'method': methods[r[0]].append(r[2]) + norm = lambda n: (n or '').replace('$', '.') + def namespaces(mid): + """the qualified names a mapper namespace may write for mid: its owner's, and each subtype's""" + if mid not in accepts: + sy = g.sym.get(mid) or {} + own = g.q("SELECT owner_type_id FROM methods WHERE id = ?", sy.get('method_id') or mid) + tid = own[0]['owner_type_id'] if own else None + qns = set() + if tid: + qns = {norm(r['qualified_name']) for r in g.q( + "SELECT qualified_name FROM types WHERE id = ? OR id IN " + "(SELECT type_id FROM type_ancestors WHERE ancestor_type_id = ?)", tid, tid)} + elif sy.get('qualified_name') and '.' in sy['qualified_name']: + qns = {norm(sy['qualified_name'].rsplit('.', 1)[0])} + accepts[mid] = qns or None # None: nothing to compare, keep its rows + return accepts[mid] + keep = [] + for r in rows: + f, n, how, qq = r[0], r[2], r[3], r[-1] + if how == 'names the method': + if f not in ns_of: ns_of[f] = ax_nonsource.mapper_namespace(g.repo, f) + ns = ns_of[f] + if ns is not None: + mids = [m for m in methods.get(qq, ()) if (g.sym.get(m) or {}).get('name') == n] + if mids and not any(namespaces(m) is None or norm(ns) in namespaces(m) for m in mids): continue + keep.append(r) + out['extbind'] = keep + return out + # the facts a query writes itself; every other file in its fact directory is the graph's export, linked in PER_QUERY_WRITTEN = ('nonsource', 'qual_name', 'key_cap', 'key_use_cap', 'target', 'textuse', 'importuse', 'inside_target') SOLVE_CACHE_MAX = 200 diff --git a/tests/cases/java/mybatis-statement-in-its-namespace/case.json b/tests/cases/java/mybatis-statement-in-its-namespace/case.json new file mode 100644 index 00000000..0cf60d25 --- /dev/null +++ b/tests/cases/java/mybatis-statement-in-its-namespace/case.json @@ -0,0 +1,24 @@ +{"lang": "java", + "checks": [ + {"why": "a statement id is resolved inside its mapper's namespace: OrderMapper.findById is bound by OrderMapper.xml's + select id, number from orders where id = #{id} + + + + diff --git a/tests/cases/java/mybatis-statement-in-its-namespace/src/main/resources/mapper/UserMapper.xml b/tests/cases/java/mybatis-statement-in-its-namespace/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 00000000..5adf7e55 --- /dev/null +++ b/tests/cases/java/mybatis-statement-in-its-namespace/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,10 @@ + + + + + + From 08c828a8b76146080e2df3ef285677732551d59f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:07:26 -0700 Subject: [PATCH 042/258] java: a test class's helper method is credited only to the tests that call it Fixes #1416, #1424 What was wrong - impact.dl carried any reached callable that a test class owns to every test in that class's scope. For Java that turned an ordinary helper (a static builder one test calls, a @MethodSource factory, a helper nothing calls) into a fixture: every test of the class was listed as [fixture] "via ", and the test that really calls the helper lost its own resolved route, because test_near kept the one-hop carrier route over the two-hop direct one. - A test selected through a fixture had no chain of its own, so test-impact --why printed the test's own name as its route. The fixture's chain was never joined to it. The change - dl/impact.dl: for Java, the class-scope helper rule no longer applies. JUnit runs only its fixtures (@BeforeEach, @BeforeAll, @Before, @BeforeClass, a JUnit 3 setUp, constructors and initializers) before the tests of a class, and those are still carried by the fixture rule. Any other method runs when something calls it, and Java calls are recorded edges, so the caller's own route carries it. A helper nothing calls credits no test. A callable no edge enters that is written inside a test body is credited to that test only. - dl/impact.dl: a JUnit 5 @MethodSource("name") (or a bare @MethodSource, which names the factory after the test) links the parameterized test to its factory in the test's class or a class it extends or is nested in, as a fixture of that one test. The Other#factory form is not read yet. - A teardown (@AfterEach, @AfterAll, @After, @AfterClass and TestNG's @After* kin, a JUnit 3 tearDown) runs for every test of its class too, and the old helper rule was what carried it. It is now carried like a fixture by its own rule; without it, a smoke target lost 18 tests whose @AfterEach cleanup reaches the change. - The same scoping applies to the stub listing (test_stub). - axiomcode-impact: a fixture-routed test's JSON chain is the test followed by the fixture's own chain down to the change. The verified hop set is unchanged: the test-to-fixture hop is the framework's, not an edge. - axiomcode-test-impact: --why prints such a route as "MoneyTest.positive <- MoneyTest.bounds -> Money.cents". - New case tests/cases/java/test-helper-is-not-a-fixture: a helper one test calls ([sound], the helper hop named), a helper no test calls (near-miss control, credited to no test), a direct call, a lambda and an anonymous class inside a test, @BeforeEach and JUnit 3 setUp (still credited to every test), @AfterEach and JUnit 3 tearDown (credited to every test), a @MethodSource factory (credited to its test only, not to a plain sibling), and test-impact --why on the factory route. On the tip without this change the 4 checks about #1416 and #1424 fail; the 7 controls pass on both. Other languages keep the class-scope helper rule; the C# lambda shape in the #1416 thread is not changed here. Suites tests/run.py on the rebased tree, before (tip) and after: - java: 189 of 192 passed, 3 FAILED (entity-save-runs-its-callbacks) -> 203 of 203 passed in 55 cases (11 new checks). - python: 206 of 207 passed, 1 FAILED -> the same, same FAIL line. - csharp: 57 of 62 passed, 1 PENDING, 4 FAILED -> the same, same lines. The python and csharp failures are on the tip without this change. Smoke One Java dev project (about 1,360 files), fresh index. 15 production methods called from a non-test, non-fixture method of a test class, picked at random. impact --tests-only --json, before (tip) and after: - tests listed: 1882 -> 1841. Every dropped test checked on three targets was credited through a helper it never calls; the tests that do call the helper stay, now [sound] with the helper hop in their route. - routes through a fixture ("via"): 616 -> 448. - [sound] routes: 184 -> 221. - fixture routes whose chain was the test's own name alone: 439 -> 0. - impact --delete on an uncalled test helper: "2 test(s) reach it" -> "the change is local". Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/axiomcode-impact | 8 +++- .../skills/axiomcode/scripts/dl/impact.dl | 35 +++++++++++++++- .../test-helper-is-not-a-fixture/case.json | 42 +++++++++++++++++++ .../cents-new.java | 11 +++++ .../cents-old.java | 11 +++++ .../src/pkg/HelperTest.java | 32 ++++++++++++++ .../src/pkg/LegacyTest.java | 13 ++++++ .../src/pkg/Money.java | 11 +++++ .../src/pkg/MoneyTest.java | 21 ++++++++++ .../src/pkg/Price.java | 6 +++ .../src/pkg/SetupTest.java | 19 +++++++++ .../src/pkg/Shared.java | 15 +++++++ .../src/pkg/Unrelated.java | 15 +++++++ 13 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/case.json create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/cents-new.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/cents-old.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/HelperTest.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/LegacyTest.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Money.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/MoneyTest.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Price.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/SetupTest.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Shared.java create mode 100644 tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Unrelated.java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 25445535..ada6fa2e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2262,6 +2262,12 @@ def main(argv): 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} + # the ROUTE a record carries: a test reached through a fixture (a setUp, a @MethodSource factory) has no edge of + # its own to the change, so its chain was the test alone and `test-impact --why` printed the test's own name as + # its route (#1424). The route is the test, then the fixture's own chain down to the change; the first hop is the + # framework's, not an edge, which is why `chains` (verified hop by hop below) keeps the test's own chain. + def test_route(m, fx): + return [m] + I.chain(fx, parent) if fx else chains.get(m, []) # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} _runs = {} @@ -2479,7 +2485,7 @@ 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])) if cert != 'alongside'], 'alongside': [{'id': c, 'display': g.disp(c), 'role': 'co-located', 'why': why, 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (g.disp(x[0]), x[2], x[4], x[0])) if cert == 'alongside'], '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 (([m] + I.chain(fx, parent)) if fx else chains.get(m, []))], + '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 test_route(m, fx)], **({'script': True, 'run': script_cmd(m)} if m in script_ids else {})} 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, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 3eb20d45..d9923aa2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1006,10 +1006,31 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // ...except a callable written INSIDE a test method (a C# or Java lambda, a local function): it belongs to that test, // not to its type. Owned by the class, a `mock.Setup(s => ...)` or `Func f = () => ...` in one test carried its // callees to every test of the class as [fixture], and the test holding it lost its own route (#1556). -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), !in_test_body(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), !in_test_body(c), decl_file(c, f), is_test_file(f), !java_decl(c). .decl in_test_body(c:symbol) in_test_body(c) :- lex_in(m, c), test_method(m), !test_method(c). test_hit(q, m, d, c) :- reach(q, c, d), owner(c, _), in_test_body(c), lex_in(m, c), test_method(m). +// JAVA: A HELPER METHOD OF A TEST CLASS IS NOT A FIXTURE (#1416). JUnit runs a @BeforeEach / @BeforeAll / @Before / +// @BeforeClass method, a JUnit 3 setUp, a constructor or an initializer before the tests of its class, and those are +// `fixture` and carried by the fixture rule above; a teardown runs after each of them (java_teardown below). Anything else the class declares runs only when something calls it, +// and every Java call is a recorded edge: a test that calls the helper reaches the change through it on its own route +// (`usesHelper -> one -> parse`), and a fixture that calls it carries it as a fixture. Crediting the helper to every +// test of the class listed tests that never call it, as `[fixture]`, and kept the calling test's own resolved route +// out of the answer because the one-hop carrier route was nearer. A helper nothing calls credits no test at all. +// What is left is a callable no call site names that a test body still runs: a lambda or an anonymous class written +// inside the test, invoked through its functional interface. It serves the test that encloses it, and no sibling: +// the in_test_body rule just above carries it (#1556), for Java as for every language. +.decl java_decl(c:symbol) +java_decl(c) :- decl_file(c, f), match(".*[.]java", f). +// A TEARDOWN runs for every test of its class as a setUp does, after the body instead of before it: @AfterEach, +// @AfterAll, @After, @AfterClass (and TestNG's @AfterMethod and kin), and a JUnit 3 tearDown. A change it reaches +// fails every one of those tests, so it is carried like a fixture. graph_sql's FIXTURE_DECOR already makes these +// `fixture` (#1417); this rule is the Java backstop for a teardown that table misses, since the helper rule above no +// longer carries a Java method that is not a fixture. +.decl java_teardown(c:symbol) +java_teardown(c) :- java_decl(c), decorated(c, n), match("After.*", n), owner(c, _), !test_method(c). +java_teardown(c) :- java_decl(c), member(_, c, "tearDown", _), owner(c, _), !test_method(c). +test_hit(q, m, d, c) :- reach(q, c, d), java_teardown(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -1078,6 +1099,13 @@ uses_fixture(m, fx) :- usefixtures_scope(p, n), decl_file(m, f), file_scope(f, p fixture_scope(fx, n, q), file_scope(f, q), m != fx. uses_fixture(m, fx) :- runs_before(m, fx), test_method(m). uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m, f), file_scope(f, p), test_method(m), m != fx. +// JUnit 5's `@MethodSource("prices")` names the factory that supplies a parameterized test's arguments, and JUnit calls +// it before that test, for that test only: no call site records it (#1416, #1424). The factory is looked up in the +// test's class and the classes it extends or is nested in; without a value it is the factory named like the test. +// The `Other#factory` form names a method of another class and is not read here. +uses_fixture(m, fx) :- dec_literal(m, "MethodSource", n, _, _), test_method(m), java_decl(m), owner(m, u), scope(t, u), member(t, fx, n, _), fx != m, !test_method(fx). +uses_fixture(m, fx) :- decorated(m, "MethodSource"), !dec_literal(m, "MethodSource", _, _, _), test_method(m), java_decl(m), member(_, m, n, _), + owner(m, u), scope(t, u), member(t, fx, n, _), fx != m, !test_method(fx). // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. test_hit(q, m, d, fx) :- reach(q, fx, d), uses_fixture(m, fx), test_method(m). @@ -1109,7 +1137,10 @@ 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), !in_test_body(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), !in_test_body(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f), !java_decl(c). +// a Java helper holding the stub is walked up to its callers by the edge rule, and a lambda in a test body reaches its +// test through stub_near's lex_in step (#1416, #1556); a teardown is credited to every test of its class +test_stub(q, m) :- stub_near(q, c), java_teardown(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). .output test_stub // ── bound from outside the source ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/cases/java/test-helper-is-not-a-fixture/case.json b/tests/cases/java/test-helper-is-not-a-fixture/case.json new file mode 100644 index 00000000..a596a119 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/case.json @@ -0,0 +1,42 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a test that calls a helper of its class reaches the change through that helper on its own resolved route, and --why names the helper hop (#1416)", + "run": ["impact", "Price.parse", "--tests-only", "--why"], + "want": ["HelperTest::usesHelper", "HelperTest.usesHelper → HelperTest.one → Price.parse", "[sound]"], + "avoid": ["HelperTest::other", "HelperTest::viaLambda", "HelperTest::viaAnonymous", "[fixture]", "via HelperTest.one"]}, + {"why": "near-miss control: a helper of the test class that no test calls is credited to no test (#1416)", + "run": ["impact", "Unrelated.pong", "--tests-only"], + "want": ["0 of"], + "avoid": ["HelperTest::", "via HelperTest.unused"]}, + {"why": "control: a direct call from one test names that test only", + "run": ["impact", "Unrelated.ping", "--tests-only"], + "want": ["HelperTest::other"], + "avoid": ["HelperTest::usesHelper", "HelperTest::viaLambda"]}, + {"why": "a lambda written inside a test runs when that test calls it through its interface; it serves that test and not a sibling", + "run": ["impact", "Unrelated.lazy", "--tests-only"], + "want": ["HelperTest::viaLambda"], + "avoid": ["HelperTest::usesHelper", "HelperTest::other"]}, + {"why": "a method of an anonymous class written inside a test serves that test and not a sibling", + "run": ["impact", "Unrelated.later", "--tests-only"], + "want": ["HelperTest::viaAnonymous"], + "avoid": ["HelperTest::usesHelper", "HelperTest::other", "HelperTest::viaLambda"]}, + {"why": "a real fixture still runs before every test of its class: @BeforeEach credits both tests", + "run": ["impact", "Shared.prepare", "--tests-only", "--why"], + "want": ["SetupTest::first", "SetupTest::second", "via SetupTest.init", "[fixture]"]}, + {"why": "a teardown runs after every test of its class, so what an @AfterEach reaches is credited to each of them", + "run": ["impact", "Shared.clean", "--tests-only", "--why"], + "want": ["SetupTest::first", "SetupTest::second", "via SetupTest.done"]}, + {"why": "a JUnit 3 tearDown likewise", + "run": ["impact", "Shared.close", "--tests-only"], + "want": ["LegacyTest::testOne", "LegacyTest::testTwo"]}, + {"why": "a JUnit 3 setUp is a fixture too", + "run": ["impact", "Shared.boot", "--tests-only"], + "want": ["LegacyTest::testOne", "LegacyTest::testTwo"]}, + {"why": "a @MethodSource factory runs for the parameterized test that names it, not for every test of the class (#1416)", + "run": ["impact", "Money.cents", "--tests-only", "--why"], + "want": ["MoneyTest::positive", "via MoneyTest.bounds", "MoneyTest::roundTrip"], + "avoid": ["MoneyTest::plain"]}, + {"why": "test-impact --why prints a fixture-selected test's route through the fixture, never the test's own name alone (#1424)", + "run": ["test-impact", "{repo}", "--why", "--old", "{repo}/cents-old.java", "--new", "{repo}/cents-new.java", "--file", "src/pkg/Money.java"], + "want": ["MoneyTest.positive <- MoneyTest.bounds -> Money.cents", "MoneyTest.roundTrip -> Money.cents"], + "avoid": ["[fixture] MoneyTest.positive\n", "MoneyTest.plain"]}]} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/cents-new.java b/tests/cases/java/test-helper-is-not-a-fixture/cents-new.java new file mode 100644 index 00000000..cf1190ba --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/cents-new.java @@ -0,0 +1,11 @@ +package pkg; + +public class Money { + public static long cents(long units) { + return units * 100 + 0; + } + + public static long units(long cents) { + return cents / 100; + } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/cents-old.java b/tests/cases/java/test-helper-is-not-a-fixture/cents-old.java new file mode 100644 index 00000000..62cc0d42 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/cents-old.java @@ -0,0 +1,11 @@ +package pkg; + +public class Money { + public static long cents(long units) { + return units * 100; + } + + public static long units(long cents) { + return cents / 100; + } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/HelperTest.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/HelperTest.java new file mode 100644 index 00000000..3bf200b5 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/HelperTest.java @@ -0,0 +1,32 @@ +package pkg; + +import java.util.function.IntSupplier; +import org.junit.jupiter.api.Test; + +class HelperTest { + // a helper: it runs when a test calls it, not before every test of the class + static Price one() { return Price.parse("1"); } + + // a helper no test calls + static void unused() { Unrelated.pong(); } + + @Test + void usesHelper() { one(); } + + @Test + void other() { Unrelated.ping(); } + + @Test + void viaLambda() { + IntSupplier s = () -> Unrelated.lazy(); + s.getAsInt(); + } + + @Test + void viaAnonymous() { + Runnable r = new Runnable() { + public void run() { Unrelated.later(); } + }; + r.run(); + } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/LegacyTest.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/LegacyTest.java new file mode 100644 index 00000000..3e010439 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/LegacyTest.java @@ -0,0 +1,13 @@ +package pkg; + +import junit.framework.TestCase; + +public class LegacyTest extends TestCase { + protected void setUp() { Shared.boot(); } + + protected void tearDown() { Shared.close(); } + + public void testOne() { } + + public void testTwo() { } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Money.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Money.java new file mode 100644 index 00000000..62cc0d42 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Money.java @@ -0,0 +1,11 @@ +package pkg; + +public class Money { + public static long cents(long units) { + return units * 100; + } + + public static long units(long cents) { + return cents / 100; + } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/MoneyTest.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/MoneyTest.java new file mode 100644 index 00000000..3730f672 --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/MoneyTest.java @@ -0,0 +1,21 @@ +package pkg; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class MoneyTest { + // named by the @MethodSource below: JUnit calls it for that test only + static Stream bounds() { return Stream.of(Money.cents(1), Money.cents(2)); } + + @ParameterizedTest + @MethodSource("bounds") + void positive(long v) { } + + @Test + void roundTrip() { Money.units(Money.cents(3)); } + + @Test + void plain() { } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Price.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Price.java new file mode 100644 index 00000000..e33c599c --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Price.java @@ -0,0 +1,6 @@ +package pkg; + +public class Price { + // reached only through HelperTest.one, a helper one test calls + public static Price parse(String s) { return new Price(); } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/SetupTest.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/SetupTest.java new file mode 100644 index 00000000..56aa02aa --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/SetupTest.java @@ -0,0 +1,19 @@ +package pkg; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SetupTest { + @BeforeEach + void init() { Shared.prepare(); } + + @AfterEach + void done() { Shared.clean(); } + + @Test + void first() { } + + @Test + void second() { } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Shared.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Shared.java new file mode 100644 index 00000000..9f1b9bff --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Shared.java @@ -0,0 +1,15 @@ +package pkg; + +public class Shared { + // reached only through a @BeforeEach method, which JUnit runs before every test of its class + public static void prepare() { } + + // reached only through an @AfterEach method, which JUnit runs after every test of its class + public static void clean() { } + + // reached only through a JUnit 3 tearDown + public static void close() { } + + // reached only through a JUnit 3 setUp + public static void boot() { } +} diff --git a/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Unrelated.java b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Unrelated.java new file mode 100644 index 00000000..fdb388df --- /dev/null +++ b/tests/cases/java/test-helper-is-not-a-fixture/src/pkg/Unrelated.java @@ -0,0 +1,15 @@ +package pkg; + +public class Unrelated { + // called directly by HelperTest.other + public static void ping() { } + + // reached only through HelperTest.unused, a helper no test calls + public static void pong() { } + + // reached only through a lambda written inside HelperTest.viaLambda + public static int lazy() { return 1; } + + // reached only through an anonymous class written inside HelperTest.viaAnonymous + public static void later() { } +} From 0d9487bd529d3e55487b7d3f1bf24bca2b3bd6aa Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:54:55 -0700 Subject: [PATCH 043/258] impact: a composed or derived test annotation marks a test (Java, C#) Fixes #1418, #1497 Refs #1381 A method was a test only when a decoration's own NAME said so (Test*, Fact, Theory). JUnit 5 also runs a method under an annotation type meta-annotated @Test (a composed annotation), and xUnit runs one under an attribute class derived from FactAttribute. Such a method was left out of the test universe, treated as a helper, and what it reached was credited to a sibling test through a [fixture] route. The engines had the same gap: no entry_points row. Change: - graph_sql.derived_test_markers: the decorations declared in the repository that mark a test by what they carry, transitively: a Java annotation type whose own annotations include a test marker, and a C# *Attribute class whose base list names one. Both the exporter's test_method set (axiomcode-impact) and the SQL fast path (_test_sets) use it. IMPACT_VERSION 50. - Java engine (annotation_flow.dl): junit_entry_ann follows an annotation type meta-annotated with a junit entry annotation, and adds TestFactory and TestTemplate. - C# engine (entry-points.dl): cs_test_attr_type follows an attribute class derived from a test attribute, by the base's written name. - An annotation or attribute that carries no test marker stays out, and the name rule is unchanged. Python has no such shape (pytest collects by name); it is untouched. Cases: tests/cases/java/composed-test-annotation and tests/cases/csharp/derived-test-attribute (a user-defined marker makes its method a test, one level and two levels deep; near miss: an annotation or attribute that carries no test marker does not; control: a direct @Test or [Fact] as before). graph/test/csharp/entry-points gains the derived attributes and a near miss. Suites (before -> after): - tests/run.py --lang java: 192 of 192 in 54 cases -> 196 of 196 in 55 cases - tests/run.py --lang csharp: 57 of 62, 1 pending, 4 failed -> 61 of 66, 1 pending, the same 4 failed (they fail on the release branch too) - tests/run.py --lang python: 206 of 207, 1 failed -> 206 of 207, the same 1 failed (it fails on the release branch too) - tests/fastpath.py --lang java|csharp|python: 4 of 4 each -> 4 of 4 each - graph/test/csharp/tools/entry-points-test.sh: entry-points ok (10 entry points, 12 reachable; 8 and 10 before the new fixture rows), framework-bases ok - graph/test/csharp/run-tests.sh: cases 18 passed, 0 failed; entry-points, remote-edge and the other tool checks ok - graph/test/java/run-tests.sh --no-torture: passed 70, failed 0 Smoke (fresh index of a real copy, installed build vs this change): - a 157-file C# data-access library whose tests use seven attributes derived from FactAttribute: test entry points 363 -> 395; impact on a shared test helper, 1 of 363 tests [sound] plus 2 callers named as uncredited -> 3 of 395 tests, all [sound]. - a 10-file C# samples project with one attribute derived from FactAttribute: test entry points 0 -> 8. - a 39-file Java subset whose tests use a composed @Test annotation: test entry points 23 -> 141. The plugin's test universe was already 141 there, since that annotation's name contains Test. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../engine/framework-behavior/entry-points.dl | 9 +++ graph/csharp/souffle/decls_all.dl | 1 + .../call-edge-generation/annotation_flow.dl | 9 +++ graph/java/souffle/decls_all.dl | 1 + graph/test/csharp/entry-points/expected.entry | 4 ++ graph/test/csharp/entry-points/src/App.cs | 12 ++++ .../skills/axiomcode/scripts/axiomcode-impact | 9 ++- .../skills/axiomcode/scripts/graph_sql.py | 63 +++++++++++++++++-- .../Shop.Tests/PricerTests.cs | 27 ++++++++ .../derived-test-attribute/Shop/Pricer.cs | 9 +++ .../csharp/derived-test-attribute/case.json | 18 ++++++ .../java/composed-test-annotation/case.json | 18 ++++++ .../src/main/java/app/Ledger.java | 8 +++ .../src/test/java/app/Audited.java | 10 +++ .../src/test/java/app/IntegrationCase.java | 12 ++++ .../src/test/java/app/LedgerChecks.java | 17 +++++ .../test/java/app/SlowIntegrationCase.java | 11 ++++ 17 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 tests/cases/csharp/derived-test-attribute/Shop.Tests/PricerTests.cs create mode 100644 tests/cases/csharp/derived-test-attribute/Shop/Pricer.cs create mode 100644 tests/cases/csharp/derived-test-attribute/case.json create mode 100644 tests/cases/java/composed-test-annotation/case.json create mode 100644 tests/cases/java/composed-test-annotation/src/main/java/app/Ledger.java create mode 100644 tests/cases/java/composed-test-annotation/src/test/java/app/Audited.java create mode 100644 tests/cases/java/composed-test-annotation/src/test/java/app/IntegrationCase.java create mode 100644 tests/cases/java/composed-test-annotation/src/test/java/app/LedgerChecks.java create mode 100644 tests/cases/java/composed-test-annotation/src/test/java/app/SlowIntegrationCase.java diff --git a/graph/csharp/engine/framework-behavior/entry-points.dl b/graph/csharp/engine/framework-behavior/entry-points.dl index aefb7bcd..759405a3 100644 --- a/graph/csharp/engine/framework-behavior/entry-points.dl +++ b/graph/csharp/engine/framework-behavior/entry-points.dl @@ -25,6 +25,15 @@ entry_point(m, "test") :- attr_on(m, "METHOD", n, _), cs_test_attr(n). entry_point(m, "test") :- attr_on(m, "METHOD", n, _), cs_test_lifecycle_attr(n). entry_point(m, "test") :- attr_on(m, "METHOD", n0, _), cs_attr_suffix(n0, n), cs_test_attr(n). entry_point(m, "test") :- attr_on(m, "METHOD", n0, _), cs_attr_suffix(n0, n), cs_test_lifecycle_attr(n). +// an attribute class DERIVED from a test attribute is one: xUnit reads `[SlowFact]` through +// `SlowFactAttribute : FactAttribute` as a fact (#1497), transitively. Matched by the base's written name, since +// the framework is not in the source; an attribute derived from anything else (`AuditedAttribute : Attribute`) +// stays out. +cs_test_attr_type(cat(n, "Attribute")) :- cs_test_attr(n). +cs_test_attr_type(n0) :- type_decl("client", n0, _, _, _, t), heritage_slot("client", t, _, bn0, _, _), + last_segment(bn0, bn), cs_test_attr_type(bn). +entry_point(m, "test") :- attr_on(m, "METHOD", n0, _), cs_test_attr_type(n0). +entry_point(m, "test") :- attr_on(m, "METHOD", n0, _), cs_test_attr_type(cat(n0, "Attribute")). // `[FactAttribute]` is the same attribute as `[Fact]` cs_attr_suffix(n0, n) :- attr_on(_, "METHOD", n0, _), strlen(n0) > 9, substr(n0, strlen(n0) - 9, 9) = "Attribute", n = substr(n0, 0, strlen(n0) - 9). diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index f8bbbda2..d35e892a 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -249,6 +249,7 @@ .decl cs_string_passthrough(c0:symbol) .decl cs_string_type(c0:symbol) .decl cs_test_attr(c0:symbol) +.decl cs_test_attr_type(c0:symbol) .decl cs_test_lifecycle_attr(c0:symbol) .decl cs_url_not_path_start(c0:symbol) .decl cseg(c0:symbol,c1:number,c2:symbol) diff --git a/graph/java/engine/call-edge-generation/annotation_flow.dl b/graph/java/engine/call-edge-generation/annotation_flow.dl index b4de7727..6174ff01 100644 --- a/graph/java/engine/call-edge-generation/annotation_flow.dl +++ b/graph/java/engine/call-edge-generation/annotation_flow.dl @@ -21,6 +21,15 @@ junit_entry_ann("Test"). junit_entry_ann("Before"). junit_entry_ann("After"). junit_entry_ann("BeforeClass"). junit_entry_ann("AfterClass"). junit_entry_ann("BeforeEach"). junit_entry_ann("AfterEach"). junit_entry_ann("ParameterizedTest"). junit_entry_ann("RepeatedTest"). +junit_entry_ann("TestFactory"). junit_entry_ann("TestTemplate"). + +// A COMPOSED annotation: an annotation type meta-annotated with one of the above is read by the runner as that +// annotation (JUnit 5 `@Target(METHOD) @Test @interface IntegrationCase {}`), transitively. Its own name says +// nothing, so without this a method under it is no entry point (#1418). Only an annotation TYPE is followed: a class +// that carries @Test-named annotations is not one, and an annotation that carries none (`@Audited`) stays out. +junit_entry_ann(a) :- annotation_on(p, m, _, _, t, _), junit_entry_ann(m), + type_decl(p, a, _, _, tc, _, t), annotation_type_category(tc). +annotation_type_category("ANNOTATION_INTERFACE_TYPE"). annotation_type_category("ANNOTATION_TYPE"). http_entry_ann("GET"). http_entry_ann("POST"). http_entry_ann("PUT"). http_entry_ann("DELETE"). http_entry_ann("PATCH"). http_entry_ann("HEAD"). http_entry_ann("OPTIONS"). http_entry_ann("Path"). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 961d8a16..60412b40 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -35,6 +35,7 @@ .decl entry_reachable(c0:symbol) .decl http_entry_ann(c0:symbol) .decl junit_entry_ann(c0:symbol) +.decl annotation_type_category(c0:symbol) .decl cast_type_resolves(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl client_calls_client(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl client_calls_lib(c0:symbol,c1:symbol,c2:symbol,c3:symbol) diff --git a/graph/test/csharp/entry-points/expected.entry b/graph/test/csharp/entry-points/expected.entry index 5fa6b8e9..a0ca7830 100644 --- a/graph/test/csharp/entry-points/expected.entry +++ b/graph/test/csharp/entry-points/expected.entry @@ -2,6 +2,8 @@ entry http App.OrdersController.List entry lifecycle App.Poller.ExecuteAsync entry main App.AsyncProgram.Main entry main App.Program.Main +entry test App.DerivedAttributeTests.Nightly +entry test App.DerivedAttributeTests.Slow entry test App.MsTests.AfterEvery entry test App.MsTests.Every entry test App.MsTests.Runs @@ -10,6 +12,8 @@ entry test App.NunitTests.Works entry test App.XunitTests.Lists entry test App.XunitTests.Many reachable App.AsyncProgram.Main +reachable App.DerivedAttributeTests.Nightly +reachable App.DerivedAttributeTests.Slow reachable App.MsTests.AfterEvery reachable App.MsTests.Every reachable App.MsTests.Runs diff --git a/graph/test/csharp/entry-points/src/App.cs b/graph/test/csharp/entry-points/src/App.cs index a48f7532..16cfdb3d 100644 --- a/graph/test/csharp/entry-points/src/App.cs +++ b/graph/test/csharp/entry-points/src/App.cs @@ -74,4 +74,16 @@ [TestMethodAttribute] public void Runs() { } // test, writte [GlobalTestInitialize] public static void Every(TestContext c) { } // test (MSTest 3.10 lifecycle, #1503) [GlobalTestCleanup] public static void AfterEvery(TestContext c) { } // test } + + // an attribute derived from a test attribute is one, transitively (#1497); one derived from Attribute is not + public class SlowFactAttribute : FactAttribute { } + public sealed class NightlyFactAttribute : SlowFactAttribute { } + public sealed class TracedAttribute : System.Attribute { } + + public class DerivedAttributeTests + { + [SlowFact] public void Slow() { } // test, derived from FactAttribute + [NightlyFactAttribute] public void Nightly() { } // test, two levels down, with the suffix + [Traced] public void Traced() { } // not a test + } } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ada6fa2e..f502bdf7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -909,7 +909,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 = '48' # 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '52' # 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 is claimed by another branch); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1268,9 +1268,12 @@ class Impact: W('reexport_from', star) decs = collections.defaultdict(set) for r in (g.q("SELECT owner_id, name FROM decorations") if g.has('decorations') else []): decs[r[0]].add(r[1].split('.')[-1]) - # a test decoration, or the test* naming convention under the condition it carries (graph_sql.is_test_callable) + # a test decoration, or the test* naming convention under the condition it carries (graph_sql.is_test_callable); + # a decoration declared here as a test marker by what it carries (a composed JUnit 5 annotation meta-annotated + # @Test, an xUnit attribute derived from FactAttribute) marks a test as its runner reads it (#1418, #1497) + derived = _gs.derived_test_markers(g.q) tm = [i for i, s in g.sym.items() if s['is_test'] and s.get('method_id') and s['kind'] in ('method', 'function') - and _gs.is_test_callable(s['name'], decs.get(i, ()), s.get('owner'), s.get('file'), s.get('signature'))] + and _gs.is_test_callable(s['name'], decs.get(i, ()), s.get('owner'), s.get('file'), s.get('signature'), derived)] # a vitest / jest / mocha test is an ANONYMOUS callable handed to it(…) / test(…) / bench(…): on one TypeScript # project 6,661 of the 7,723 callables in test files are and exactly 2 carried a name the rule above # accepts, so the whole test layer was empty. The registrar on the declaration's own line names it as a test. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 2e361511..a953da2f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -618,9 +618,10 @@ def named_test(name, decs, owner, file, signature): return not r or r.group(1).strip() in ('void', 'Unit', 'None') -def is_test_callable(name, decs, owner, file, signature): - """a test the runner collects: a test decoration, or the naming convention under its condition""" - return any(is_test_decoration(d) for d in decs or ()) or named_test(name, decs, owner, file, signature) +def is_test_callable(name, decs, owner, file, signature, derived=()): + """a test the runner collects: a test decoration (by its name, or a marker declared in the repository that carries + one: derived_test_markers), or the naming convention under its condition""" + return any(test_decoration(d, derived) for d in decs or ()) or named_test(name, decs, owner, file, signature) def is_fixture_callable(name, decs): @@ -628,6 +629,54 @@ def is_fixture_callable(name, decs): return name in FIXTURE_NAMES or any(is_fixture_decoration(d) for d in decs or ()) +def _attr_stem(n): + """a C# attribute's name as written on a member: `[SlowFact]` for `SlowFactAttribute`""" + return n[:-len('Attribute')] if n.endswith('Attribute') and len(n) > len('Attribute') else n + + +def derived_test_markers(q): + """the simple names of the decorations declared IN THIS REPOSITORY that mark a test although their own name does not + say so: a Java annotation type meta-annotated with a test marker (JUnit 5's composed `@interface IntegrationCase` + carrying `@Test`), and a C# attribute class derived from one (`SlowFactAttribute : FactAttribute`), transitively. + The runner reads the meta-annotation or the base, so a method under either runs as a test; read by name alone it + was a helper, and what it reached was credited to a sibling test (#1418, #1497). + + Only declarations that ARE a decoration's type count: a Java type of the annotation category, a C# class named + *Attribute. A type that merely has Test in its own decorations or bases (a test class, a TestCase subclass) is + not a marker, and an annotation that carries no test marker (`@Audited`) stays what its name says.""" + marks = {} # declared marker name -> names it is built from + if _has(q, 'types') and _has(q, 'symbols') and _has(q, 'decorations'): + for n, d in q("""SELECT t.name, d.name FROM types t JOIN symbols s ON s.type_id = t.id AND s.method_id IS NULL + JOIN decorations d ON d.owner_id = s.id WHERE t.category LIKE 'ANNOTATION%'"""): + if n and d: marks.setdefault(n, set()).add(d.split('.')[-1]) + if _has(q, 'types') and _has(q, 'type_refs'): + spans = {} + for n, f, a, b in q("""SELECT name, file_path, start_line, end_line FROM types + WHERE name LIKE '%Attribute' AND file_path IS NOT NULL AND start_line > 0"""): + spans.setdefault(f, []).append((a, b or a, n)) + if spans: + for base, f, ln in q("SELECT name, file, line FROM type_refs WHERE context = 'BASE_LIST' AND file IS NOT NULL"): + # the base list is written in the header of the innermost type declared at or above its line + own = max(((a, n) for a, b, n in spans.get(f, ()) if a <= ln <= b), default=None) + if own and base: marks.setdefault(own[1], set()).add(base.split('.')[-1].split('<')[0]) + if not marks: return set() + def is_marker(n, found): + # a set-up or tear-down marker that contains the word ([TestInitialize]) is a fixture, not a test (#1502) + return n in found or _attr_stem(n) in found or is_test_decoration(_attr_stem(n)) + found, grew = set(), True + while grew: + grew = False + for n, via in marks.items(): + if n not in found and any(is_marker(v, found) for v in via): + found.add(n); grew = True + return found | {_attr_stem(n) for n in found} + + +def test_decoration(name, derived=()): + """whether a decoration, as written on a method, marks it as a test: by its name, or as a marker declared here""" + return is_test_decoration(name) or (name or '').split('.')[-1] in derived + + TEST_REGISTRAR = re.compile(r'\b(it|test|bench)\s*(\.\w+)*\s*(\.\w+)?\s*[(<`]') EACH_TABLE = re.compile(r'\b(it|test|bench|describe)\s*\.\s*each\b') @@ -700,11 +749,13 @@ def _test_sets(q, lines=None, rel=None): for oid, name in q("SELECT owner_id, name FROM decorations") if _has(q, 'decorations') else []: dec.setdefault(oid, []).append(name or '') tm, fx = set(), set() + derived = derived_test_markers(q) if dec else set() # a composed / derived test marker declared here (#1418) for sid, name, kind, mid, tid, owner, f, sig in q("SELECT id, name, kind, method_id, type_id, owner, file, signature FROM symbols WHERE is_test=1"): d = dec.get(sid, ()) - # the exporter's own rule (is_test_callable): a test decoration, or the test* name under its condition. The - # name alone, read with no owner condition, counted a TestWatcher's testFailed callback as a test (#1419) - if mid and kind in ('method', 'function') and is_test_callable(name, d, owner, f, sig): + # the exporter's own rule (is_test_callable): a test decoration (by its name, or a composed / derived marker + # declared here), or the test* name under its condition. The name alone, read with no owner condition, counted + # a TestWatcher's testFailed callback as a test (#1419) + if mid and kind in ('method', 'function') and is_test_callable(name, d, owner, f, sig, derived): tm.add(sid) if (tid and not mid) or kind in ('constructor', 'module') or is_fixture_callable(name, d): fx.add(sid) diff --git a/tests/cases/csharp/derived-test-attribute/Shop.Tests/PricerTests.cs b/tests/cases/csharp/derived-test-attribute/Shop.Tests/PricerTests.cs new file mode 100644 index 00000000..7ccde6ac --- /dev/null +++ b/tests/cases/csharp/derived-test-attribute/Shop.Tests/PricerTests.cs @@ -0,0 +1,27 @@ +using System; +using Shop; +using Xunit; + +namespace Shop.Tests; + +public class SlowFactAttribute : FactAttribute { } + +public sealed class NightlyFactAttribute : SlowFactAttribute { } + +[AttributeUsage(AttributeTargets.Method)] +public sealed class AuditedAttribute : Attribute { } + +public class PricerTests +{ + [SlowFact] + public void PricesSlowly() => new Pricer().Price(2); + + [NightlyFact] + public void SweepsNightly() => new Pricer().Sweep(2); + + [Audited] + public void AuditTrail() => new Pricer().Audit(2); + + [Fact] + public void TaxesPlainly() => new Pricer().Tax(2); +} diff --git a/tests/cases/csharp/derived-test-attribute/Shop/Pricer.cs b/tests/cases/csharp/derived-test-attribute/Shop/Pricer.cs new file mode 100644 index 00000000..a3d5aec4 --- /dev/null +++ b/tests/cases/csharp/derived-test-attribute/Shop/Pricer.cs @@ -0,0 +1,9 @@ +namespace Shop; + +public class Pricer +{ + public int Price(int q) => q * 2; + public int Tax(int q) => q / 10; + public int Audit(int q) => q; + public int Sweep(int q) => q + 1; +} diff --git a/tests/cases/csharp/derived-test-attribute/case.json b/tests/cases/csharp/derived-test-attribute/case.json new file mode 100644 index 00000000..d16f6ff6 --- /dev/null +++ b/tests/cases/csharp/derived-test-attribute/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", + "checks": [ + {"why": "a method under an attribute derived from FactAttribute is a test xUnit runs, so what it reaches is credited to it, not to a sibling [Fact] through a fixture route (#1497)", + "run": ["impact", "Pricer.Price", "--tests"], + "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "PricerTests::PricesSlowly"], + "avoid": ["PricerTests::TaxesPlainly", "[fixture]"]}, + {"why": "the base is followed transitively: an attribute derived from the derived one is a test marker too", + "run": ["impact", "Pricer.Sweep", "--tests"], + "want": ["1 of 3 test method(s)", "PricerTests::SweepsNightly"], + "avoid": ["PricerTests::TaxesPlainly", "[fixture]"]}, + {"why": "near miss: an attribute derived from Attribute alone does not make its method a test", + "run": ["impact", "Pricer.Audit", "--tests"], + "want": ["of 3 test method(s)", "via PricerTests.AuditTrail"], + "avoid": ["PricerTests::AuditTrail", "of 4 test method(s)"]}, + {"why": "control: a direct [Fact] is a test as before", + "run": ["impact", "Pricer.Tax", "--tests"], + "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "PricerTests::TaxesPlainly"], + "avoid": ["[fixture]"]}]} diff --git a/tests/cases/java/composed-test-annotation/case.json b/tests/cases/java/composed-test-annotation/case.json new file mode 100644 index 00000000..a24e3a8d --- /dev/null +++ b/tests/cases/java/composed-test-annotation/case.json @@ -0,0 +1,18 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a method under a composed annotation (an @interface meta-annotated @Test) is a test the runner runs, so what it reaches is credited to it, not to a sibling @Test through a fixture route (#1418)", + "run": ["impact", "Ledger.total", "--tests"], + "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "LedgerChecks::balancesTotals"], + "avoid": ["LedgerChecks::control", "[fixture]"]}, + {"why": "the meta-annotation is followed transitively: an annotation carrying the composed one is a test marker too", + "run": ["impact", "Ledger.sweep", "--tests"], + "want": ["1 of 3 test method(s)", "LedgerChecks::sweepsNightly"], + "avoid": ["LedgerChecks::control", "[fixture]"]}, + {"why": "near miss: an annotation that carries no test marker (@Audited) does not make its method a test; it stays a helper of the tests beside it", + "run": ["impact", "Ledger.audit", "--tests"], + "want": ["of 3 test method(s)", "via LedgerChecks.auditTrail"], + "avoid": ["LedgerChecks::auditTrail", "of 4 test method(s)"]}, + {"why": "control: a direct @Test is a test as before", + "run": ["impact", "Ledger.count", "--tests"], + "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "LedgerChecks::control"], + "avoid": ["[fixture]"]}]} diff --git a/tests/cases/java/composed-test-annotation/src/main/java/app/Ledger.java b/tests/cases/java/composed-test-annotation/src/main/java/app/Ledger.java new file mode 100644 index 00000000..97a91306 --- /dev/null +++ b/tests/cases/java/composed-test-annotation/src/main/java/app/Ledger.java @@ -0,0 +1,8 @@ +package app; + +public class Ledger { + public static int total() { return 1; } + public static int count() { return 2; } + public static int audit() { return 3; } + public static int sweep() { return 4; } +} diff --git a/tests/cases/java/composed-test-annotation/src/test/java/app/Audited.java b/tests/cases/java/composed-test-annotation/src/test/java/app/Audited.java new file mode 100644 index 00000000..5c2e0dee --- /dev/null +++ b/tests/cases/java/composed-test-annotation/src/test/java/app/Audited.java @@ -0,0 +1,10 @@ +package app; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@interface Audited { } diff --git a/tests/cases/java/composed-test-annotation/src/test/java/app/IntegrationCase.java b/tests/cases/java/composed-test-annotation/src/test/java/app/IntegrationCase.java new file mode 100644 index 00000000..ad04e4b9 --- /dev/null +++ b/tests/cases/java/composed-test-annotation/src/test/java/app/IntegrationCase.java @@ -0,0 +1,12 @@ +package app; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Test; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Test +@interface IntegrationCase { } diff --git a/tests/cases/java/composed-test-annotation/src/test/java/app/LedgerChecks.java b/tests/cases/java/composed-test-annotation/src/test/java/app/LedgerChecks.java new file mode 100644 index 00000000..37ab1ea0 --- /dev/null +++ b/tests/cases/java/composed-test-annotation/src/test/java/app/LedgerChecks.java @@ -0,0 +1,17 @@ +package app; + +import org.junit.jupiter.api.Test; + +class LedgerChecks { + @IntegrationCase + void balancesTotals() { Ledger.total(); } + + @SlowIntegrationCase + void sweepsNightly() { Ledger.sweep(); } + + @Audited + void auditTrail() { Ledger.audit(); } + + @Test + void control() { Ledger.count(); } +} diff --git a/tests/cases/java/composed-test-annotation/src/test/java/app/SlowIntegrationCase.java b/tests/cases/java/composed-test-annotation/src/test/java/app/SlowIntegrationCase.java new file mode 100644 index 00000000..66472f97 --- /dev/null +++ b/tests/cases/java/composed-test-annotation/src/test/java/app/SlowIntegrationCase.java @@ -0,0 +1,11 @@ +package app; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@IntegrationCase +@interface SlowIntegrationCase { } From 905968cc02b45594c2e9e5a6d8722990ded7001e Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:14:24 -0700 Subject: [PATCH 044/258] tests: the composed-annotation near miss expects no test through an uncalled Java helper The composed-test-annotation case (#1418) was written before the test-helper change (#1416) and expected its near miss, a method under @Audited that no test calls, to be carried to a sibling test as a fixture route ('via LedgerChecks.auditTrail'). With #1416 a Java helper method credits only the tests that call it, and nothing calls auditTrail, so impact on Ledger.audit now reports 0 of 3 test methods and lists auditTrail as an uncredited caller. Both halves of the near miss still hold: auditTrail is not a test, and the test count stays 3. The check now wants '0 of 3 test method(s)' and avoids the via route. --- tests/cases/java/composed-test-annotation/case.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cases/java/composed-test-annotation/case.json b/tests/cases/java/composed-test-annotation/case.json index a24e3a8d..9fffc4fb 100644 --- a/tests/cases/java/composed-test-annotation/case.json +++ b/tests/cases/java/composed-test-annotation/case.json @@ -8,10 +8,10 @@ "run": ["impact", "Ledger.sweep", "--tests"], "want": ["1 of 3 test method(s)", "LedgerChecks::sweepsNightly"], "avoid": ["LedgerChecks::control", "[fixture]"]}, - {"why": "near miss: an annotation that carries no test marker (@Audited) does not make its method a test; it stays a helper of the tests beside it", + {"why": "near miss: an annotation that carries no test marker (@Audited) does not make its method a test; it stays a helper, and a Java helper nothing calls credits no test (#1416), so no sibling is listed through it", "run": ["impact", "Ledger.audit", "--tests"], - "want": ["of 3 test method(s)", "via LedgerChecks.auditTrail"], - "avoid": ["LedgerChecks::auditTrail", "of 4 test method(s)"]}, + "want": ["0 of 3 test method(s)"], + "avoid": ["LedgerChecks::auditTrail", "of 4 test method(s)", "via LedgerChecks.auditTrail"]}, {"why": "control: a direct @Test is a test as before", "run": ["impact", "Ledger.count", "--tests"], "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "LedgerChecks::control"], From 0041850931547a0407593d32f8f5560b291d1c2a Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:13:38 -0700 Subject: [PATCH 045/258] path: walk remote and framework hops, and list by-name callers in path '*' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1421, #1469 Refs #1381 What was wrong 1. `path` searched call edges only. The engine's remote_edge (a gRPC or HTTP client and the handler that serves it) and framework_edge (a test and the fixture it names, a signal and its receiver, a C# endpoint filter and the endpoint it wraps) were read only when the two endpoints were exactly the two ends of such a row, after the search had failed. One call past the handler or the fixture, the answer was "the two are independent in this graph". Reproduced on the release branch in Java (a gRPC client and what its handler calls), Python (a test and what its fixture calls) and C# (an endpoint and what its filter calls). 2. `path '*' X` walked resolved edges only, so a call written with X's name on a receiver the engine could not type was missing, while `impact X` listed its caller as [by name]. With no resolved caller at all, the answer printed a fixed sentence and named no site. The change (plugins/axiomcode/skills/axiomcode/scripts) - axiomcode-path: new G.add_outside_call_edges and G.outside_detail. They read ext_remote_edge and ext_framework_edge (both ends callables of the graph) and add them as per-query hops with the tiers `remote` and `framework`, the same way the `written` nodes are added. They are not written to edge.facts, which impact's closure reads, so impact's answers do not change: there the two stay direct rows, as #1509 set. path() and closure() call it. The same code serves every language whose engine writes the two relations; each graph holds one language, so no hop joins two of them. - hop_detail, hop_label, verify: a hop of either tier is labelled with the relation's own words and "no call site" (`[remote · grpc at (exact) · no call site]`, `[framework · pipeline_wrap via AddEndpointFilter (registered) · no call site]`), and verify looks the hop up in its ext_ table. - path(): the header and the hop count say how many hops no call site makes; a note under the chain names each such hop with the wording the no-chain answer used before ("connected across a process", "a framework connects them"), so the existing cases keep their text. - closure(): new byname_sites and print_byname list, apart and never walked, the unresolved sites written with the target's name, by the rule impact.dl uses for its [by name] rows (not a construction, not inside a mock's stub, not in the target's own body). The empty answer names them instead of the fixed sentence. A direct caller reached through a remote or framework hop says so, and such callers are counted apart from the exact calls. - ax_edges.py: the two tiers in the rank, note and certainty tables, and OUTSIDE_CALL. Rank 5 is the default impact's route reader already gave an unlisted tier, so its routes do not move. - ax_grep.py (MCP rows): the by-name group as `[by name · receiver not typed]`. - ax_pages.py (next:): a chain with such a hop says to check it at its two ends; an empty closure with by-name sites points at them. - reference/path.md (both copies). No IMPACT_VERSION or EXPORT_VERSION change: no fact file changes. entry_points_among is not touched. Tests New cases, each with near-miss controls: - java/path-crosses-remote-and-byname: client to what the gRPC handler calls; controls: a store method nothing calls stays independent, the hop does not run backwards, a different name on the same untyped receiver is not listed, the by-name group does not grow the resolved closure. - python/path-crosses-framework-and-byname: test to what its fixture calls; controls: a test naming no fixture, a fixture no test requests, another method's name on an untyped receiver. - csharp/path-crosses-framework-and-byname: endpoint to what its filter calls; controls: an endpoint in a group with no filter, a method the filter does not call, another method's name on an untyped receiver. Suites (tests/run.py), base origin/0.1.9 (aebc68e3) vs this change rebased on it: - python: 206 of 207 -> 213 of 214 (the one failure, lambda-is-named-by-its-place, fails on the base too) - java: 192 of 192 -> 203 of 203 - csharp: 57 of 62 (1 pending, 4 failed) -> 64 of 69 (1 pending, 4 failed); the same four checks fail on the base (lambda-is-named-by-its-place, member-owner-is-its-type, unmodelled-entry-not-local twice) Smoke (same graph, the installed build vs this change): - a Python CLI project with plugins (86 framework_edge rows, 0 remote): the 3 methods one call past a framework hop that the sample found are reached by more callers through `path '*'` (379 -> 399, 325 -> 332, 362 -> 385: tests that reach them through the fixtures they name). Of 15 sampled methods whose name is written at an unresolved site, 13 now show a by-name group, and its callers equal impact's [by name] count on 15 of 15. - a small Spring application (0 framework or remote rows): of 15 sampled methods, 12 show a by-name group; the resolved closure is unchanged on 15 of 15; the by-name sites equal impact's [by name] rows on 13 of 15. The other 2 are callers that only stub the name on a mock: impact counts them in its "by name" header but prints them as stubs, so path now counts them on a line of their own (checked by hand on one of the two). - no answer printed a failed verification or a traceback (33 targets). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/reference/path.md | 18 ++- .../skills/axiomcode/scripts/ax_edges.py | 13 ++ .../skills/axiomcode/scripts/ax_grep.py | 3 + .../skills/axiomcode/scripts/ax_pages.py | 8 ++ .../skills/axiomcode/scripts/axiomcode-path | 114 ++++++++++++++++-- skills/axiomcode/reference/path.md | 18 ++- .../case.json | 27 +++++ .../src/Api.cs | 50 ++++++++ .../src/Shop.csproj | 6 + .../path-crosses-remote-and-byname/case.json | 44 +++++++ .../path-crosses-remote-and-byname/pom.xml | 6 + .../src/main/java/app/OrderClient.java | 6 + .../src/main/java/app/OrderHandler.java | 6 + .../src/main/java/app/OrderServiceGrpc.java | 6 + .../src/main/java/app/OrderStore.java | 6 + .../src/main/java/app/Widget.java | 3 + .../src/main/java/app/WidgetJob.java | 8 ++ .../src/main/java/app/WidgetMapper.java | 8 ++ .../src/main/java/app/WidgetService.java | 12 ++ .../app/__init__.py | 0 .../app/cart.py | 14 +++ .../app/jobs.py | 14 +++ .../case.json | 28 +++++ .../tests/__init__.py | 0 .../tests/conftest.py | 13 ++ .../tests/test_cart.py | 6 + 26 files changed, 416 insertions(+), 21 deletions(-) create mode 100644 tests/cases/csharp/path-crosses-framework-and-byname/case.json create mode 100644 tests/cases/csharp/path-crosses-framework-and-byname/src/Api.cs create mode 100644 tests/cases/csharp/path-crosses-framework-and-byname/src/Shop.csproj create mode 100644 tests/cases/java/path-crosses-remote-and-byname/case.json create mode 100644 tests/cases/java/path-crosses-remote-and-byname/pom.xml create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderClient.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderHandler.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderServiceGrpc.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderStore.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/Widget.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetJob.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetMapper.java create mode 100644 tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetService.java create mode 100644 tests/cases/python/path-crosses-framework-and-byname/app/__init__.py create mode 100644 tests/cases/python/path-crosses-framework-and-byname/app/cart.py create mode 100644 tests/cases/python/path-crosses-framework-and-byname/app/jobs.py create mode 100644 tests/cases/python/path-crosses-framework-and-byname/case.json create mode 100644 tests/cases/python/path-crosses-framework-and-byname/tests/__init__.py create mode 100644 tests/cases/python/path-crosses-framework-and-byname/tests/conftest.py create mode 100644 tests/cases/python/path-crosses-framework-and-byname/tests/test_cart.py diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md index 81d854c0..d0765b62 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ b/plugins/axiomcode/skills/axiomcode/reference/path.md @@ -40,10 +40,15 @@ - **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. - When a framework joins the two ends directly (a Python `.delay()` and the task it enqueues, a signal `send` and its - `@receiver`, a route table and its view, a `Depends()` default and its provider, a test and the fixture it names), the - answer prints that hop with its mechanism and the engine's confidence, labelled framework-mediated, and no longer calls - the two independent. It is not a call, so it is never part of a chain. +- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler + that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python + `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# + endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) + are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client + reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls + (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` + counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations + gets them; a hop never joins two languages' graphs. - **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, @@ -78,7 +83,10 @@ *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `--in src/main` keeps only the part + (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the + call sites written with X's name on a receiver the engine could not type (`[by name] `): the + callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, + and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) and bounded by the unresolved calls inside it. - **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 9b86a8a3..95416066 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -47,6 +47,9 @@ 'dispatch': 2, # a base method to an override that is actually instantiated 'callback_registered': 3, # handed over as a value and invoked by whoever holds it 'event_dispatch': 3, # emitted here, handled there + 'remote': 5, # a request crosses a process to its handler (remote_edge): no call site names it. + 'framework': 5, # a framework runs the other end for this one (framework_edge). Both 5, the default + # impact's route reader already gave them (P.TIER_RANK.get(t, 5)), so its routes do not move 'defines': 4, # NOT a call: the callee is written inside the caller's body 'ambient_terminal': 6, # into the platform or an ambient declaration: terminal 'intrinsic_terminal': 6, # a JSX intrinsic element or a dynamic import(): nothing the graph can name @@ -66,6 +69,8 @@ 'dispatch': 'a base method to an override the project instantiates', 'callback_registered': 'handed over as a value and invoked by whoever holds it', 'event_dispatch': 'emitted here, handled there', + 'remote': 'NOT a call site: a request crosses a process to the handler that serves it (transport and destination on the hop)', + 'framework': 'NOT a call site: a framework runs the other end for this one (mechanism and registration on the hop)', 'defines': 'NOT a call — written inside that body, so it runs only after it', 'library': 'into a dependency; the chain ends there', 'boundary_lib': 'into a dependency; the chain ends there', @@ -111,6 +116,13 @@ 'DYNAMIC_IMPORT_CALL': 'import', 'DYNAMIC_CODE_CALL': 'eval', 'DYNAMIC_CALL': 'dynamic', } +# A HOP NO CALL SITE EXPRESSES, read from the engine's own relations rather than call_edges: a request to the handler that +# serves it (ext_remote_edge) and a hand-over a framework makes (ext_framework_edge: a fixture a test names, a signal and +# its receiver, a filter wrapping an endpoint). `impact` lists the far end as a [remote] / [framework] dependent; `path` +# walks them as hops (#1469) and says on each one what it is. Only the path finder adds them: impact's closure keeps +# them as direct rows, the contract #1509 set, so they are not written to edge.facts. +OUTSIDE_CALL = ('remote', 'framework') + NOT_A_CALL = {'defines'} # a containment relation, not control reaching B. The engine's own name # for it, kept as the wire name: graph_sql.py and the rules both write it. @@ -163,6 +175,7 @@ def legend(tiers): '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 + 'remote': 'remote', 'framework': 'framework', # impact's own rung names for the same two hops (#1469) } DIRECT_CERT_DEFAULT = 'registered' # unlisted: an edge the engine asserted and this table cannot name — never `resolved` diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 5869ee6d..52d45d16 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -146,6 +146,9 @@ def path(d, code): prev = h['to'] for r in d.get('reached', []): rows.append(('reached', site(code, r['at'], f"hop {r['hops']}{stale(r)}", r['name']))) + # `path '*' X`'s by-name group (#1421): the sites impact lists as [by name], after every resolved row + for r in d.get('by_name', []): + rows.append(('by name', site(code, r['at'], f"by name · receiver not typed{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', []) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 9ca9898f..b1cdbad0 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -195,6 +195,9 @@ def next_path(text): sites = list(dict.fromkeys(re.findall(r'call @ ' + LOC + r'\]', text))) if sites: multi = ' — one hop is [multi_inferred], one of several candidates: check that call site only if the answer depends on which' if 'multi_inferred ·' in text else '' + # a hop no call site makes (#1469) has no line in that list: its two ends are on the note under the chain + if re.search(r'\[(remote|framework) · ', text): + multi += (' — and a hop no call site makes ([remote] / [framework]): check it at the two ends its note names') return (f"next: the chain is verified (every printed hop is an edge in the graph); its {len(sites)} call " f"site(s): {', '.join(sites[:6])}{' …' if len(sites) > 6 else ''}{multi}. For a change, those sites are " "what to check; to explain how it works, read each hop's body — `context \"how does …\" --from ` " @@ -215,6 +218,11 @@ def next_path(text): + ', '.join(f"{n} {loc}" for n, loc in first) + (" — read it" if len(first) == 1 else " — read those") + "; farther hops matter only if these pass the change on" + (f" (of {total.group(1)} in all)" if total else '')) + # nothing resolved calls it, and impact's [by name] callers are listed (#1421): they are the only leads there are + bn = re.findall(r'^\s+\[by name\] (\S+)\s+' + LOC, text, re.M) + if bn: + return (f"next: no resolved call reaches it; the {len(bn)} [by name] site(s) are leads, not calls: read " + + ', '.join(f"{loc}" for _, loc in bn[:4]) + " and check whether the receiver there is this method's type") # a framework hop (#1509) is the connection when no call is: both ends are printed, so point at them fw = re.findall(r'a framework connects them: (\S+) ' + LOC + r' → (\S+) ' + LOC, text) if fw: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index b201217b..dde52fe0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -816,6 +816,28 @@ class G: replace_file(tmp, p) def edges(self): return [tuple(l.rstrip('\n').split('\t')) for l in open(os.path.join(self.facts, 'edge.facts'))] + list(self.EXTRA) + def add_outside_call_edges(self): + """THE HOPS NO CALL SITE EXPRESSES, as hops of this query (#1469). A gRPC client reaches the handler that serves + it (ext_remote_edge), a test the fixture it names and a filter the endpoint it wraps (ext_framework_edge), and + `impact` already lists each far end as a [remote] / [framework] dependent. The chain search walked call edges + only, so it found the hop when the two endpoints were its own two ends and nothing one call past it: a client + and what its handler calls were "independent". Added per query, like the `written` nodes, and never to + edge.facts, which impact's closure reads: there these hops stay direct rows (#1509). Same rows in every + language that writes the relations; each graph is one language, so a hop never joins two of them.""" + if getattr(self, 'OUTSIDE', None) is not None: return + self.OUTSIDE = {} + for table, tier in (('ext_remote_edge', 'remote'), ('ext_framework_edge', 'framework')): + if not self.has(table): continue + for a, b, how, where, conf in self.q(f"SELECT c0, c1, c2, c3, c4 FROM {table}"): + if a == b or a not in self.sym or b not in self.sym: continue + self.OUTSIDE.setdefault((a, b, tier), []).append((how, where, conf)) + self.EXTRA += sorted(self.OUTSIDE) + def outside_detail(self, a, b, t): + """what an outside-call hop is, as impact words it: `grpc at (exact)` / `fixture_injection via cart (by_name)`""" + rows = sorted(set((getattr(self, 'OUTSIDE', None) or {}).get((a, b, t), []))) + if not rows: return '' + how, where, conf = rows[0] + return f"{how} at {where} ({conf})" if t == 'remote' else f"{how} via {where} ({conf})" # ── the same answers in SQL: no .facts written, no Soufflé process ────────────────────────────────────────────── def _facts_rows(g, programs): @@ -908,6 +930,7 @@ def verify(g, chain, srcs, dst, adj): if t in ('known_edge', 'multi_inferred') and not g.q("SELECT 1 FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier = ? LIMIT 1", a, b, t): bad.append((a, b, t)) if t == 'library' and not g.q("SELECT 1 FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier = 'boundary_lib' LIMIT 1", a, b): bad.append((a, b, t)) if t == 'written' and not any(r['caller_id'] == a for r in getattr(g, 'SITES', {}).get(b, [])): bad.append((a, b, t)) + if t in ax_edges.OUTSIDE_CALL and not g.q(f"SELECT 1 FROM {'ext_remote_edge' if t == 'remote' else 'ext_framework_edge'} WHERE c0 = ? AND c1 = ? LIMIT 1", a, b): bad.append((a, b, t)) # plain BFS over the same facts: the second implementation must agree on the shortest length seen = {s: 0 for s in srcs}; fr = list(srcs); d = 0; found = None while fr and found is None: @@ -1008,6 +1031,7 @@ def hop_detail(g, a, b, t): was dropped on export (`SELECT caller_id, callee_method_id, tier`), so `new Foo()`, `super(...)`, `@app.route(...)` and an ordinary invocation all printed identically in every language.""" if t in ax_edges.NOT_A_CALL: return '', None + if t in ax_edges.OUTSIDE_CALL: return g.outside_detail(a, b, t), None # no call site: the relation's own words rows = g.q("SELECT e.kind k, s.file_path f, s.start_line ln FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id " "WHERE e.caller_id = ? AND e.callee_method_id = ? ORDER BY (s.start_line IS NULL), s.start_line LIMIT 1", a, b) if not rows: return '', None @@ -1025,7 +1049,7 @@ def hop_detail(g, a, b, t): def hop_label(g, a, b, t): """[tier · kind @ where the call is written] — everything needed to check the hop by hand.""" kind, at = hop_detail(g, a, b, t) - bits = [t] + ([kind] if kind else []) + (["not a call"] if t in ax_edges.NOT_A_CALL else []) + bits = [t] + ([kind] if kind else []) + (["not a call"] if t in ax_edges.NOT_A_CALL else []) + (["no call site"] if t in ax_edges.OUTSIDE_CALL else []) return f"[{' · '.join(bits)}" + (f" @ {at}]" if at else "]") @@ -1198,8 +1222,49 @@ def entry_why(reason): """the words a listed entry point carries after its location: why a framework enters it (a test says [test] already)""" return f" — {ax_edges.entry_phrase(reason)}" if reason and reason != 'test' else '' +CTOR_SITE_KINDS = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # impact.dl's ctor_kind: a construction is not a call of a method + +def byname_sites(g, ids): + """THE CALLERS `impact` LISTS AS [by name], for `path '*' X` (#1421). An unresolved call site written with X's name + on a receiver the engine could not type: `impact X` lists its caller as a lead, and the closure, which walks + resolved edges only, left it out, so the two verbs gave different sets of direct callers and `path` gave no hint + of the second one. The same rule as dl/impact.dl's `direct(... "by name")`: a callable target, a site that is not + a construction and not inside a mock's stub or verification, and not in the target's own body. Listed apart and + never walked: the name may belong to another method, and the by-name closure stays the search `path A B` runs + only when nothing resolved connects its two ends.""" + names = sorted({g.sym[i]['name'] for i in ids if i in g.sym and g.sym[i].get('method_id') + and g.sym[i]['kind'] not in ('library', 'written', 'module') and g.sym[i].get('name')}) + if not names or not g.has('unresolved_sites'): return [], set() + stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() + own = set(ids); out = []; stubbed = set() + for n in names: + for r in g.q("SELECT s.id sid, s.caller_id c, s.callee_name cn, s.kind k, s.file_path f, s.start_line ln FROM call_sites s" + " JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name = ? OR s.callee_name LIKE ?", n, '%.' + n): + if (r['cn'] or '').split('.')[-1] != n or r['k'] in CTOR_SITE_KINDS or r['c'] in own: continue + if r['c'] not in g.sym or (g.IN and not g.under_in(g.sym[r['c']]['file'])): continue + if r['sid'] in stubs: stubbed.add(r['c']); continue # impact's "stubs it" rows: named on a mock, never run + out.append((r['c'], n, g.site_file(r['f']) if r['f'] else g.sym[r['c']]['file'], r['ln'] or g.sym[r['c']]['line'])) + return sorted(set(out), key=lambda x: (g.sym[x[0]]['is_test'], x[2] or '', x[3] or 0, g.sym[x[0]]['display'])), stubbed - {c for c, *_ in out} + + +def print_byname(g, found, limit): + named, stubbed = found + if stubbed: + print(f" +{len(stubbed)} caller(s) only stub a method of this name on a mock (receiver not typed): they run none of it;" + " `impact` lists them apart as stubs") + if not named: return + RESULT['by_name'] = [{'name': g.disp(c), 'callee': n, 'at': f"{f}:{ln}"} for c, n, f, ln in named] + print(f" by name, not resolved ({len(named)} site(s) in {len({c for c, *_ in named})} caller(s)): a call written with" + f" {' / '.join(sorted({f'`{n}`' for _, n, _, _ in named}))} on a receiver the engine could not type — `impact` lists" + " these callers as [by name]; the name may be another method's, so they are leads, not walked:") + for c, n, f, ln in named[:limit]: + print(f" [by name] {g.disp(c)} {f}:{ln} — calls `{n}` (receiver not typed)") + if len(named) > limit: print(f" … +{len(named) - limit} (--limit N)") + + def closure(g, sel, upstream, limit=40, depth=40): g.export(); label, ids = g.resolve(sel, fragment=True) + g.add_outside_call_edges() res = run(g, {'c': (ids if not upstream else [], ids if upstream else [])}) rows = [(m, int(d)) for m, d in (res['dist_up'] if upstream else res['dist'])['c'] if 0 < int(d) <= depth and m in g.sym] if g.IN: rows = [(m, d) for m, d in rows if g.under_in(g.sym[m]['file'])] # --in: only the part of the closure under that path @@ -1243,19 +1308,23 @@ def closure(g, sel, upstream, limit=40, depth=40): ex = _walk(_exact_t) in_rows = {m for m, _ in rows} exact = (ex - set(ids)) & in_rows - sent = ((_walk(lambda t: _exact_t(t) or t in ax_edges.TIER_WHY) - set(ids)) & in_rows) - exact + sent = ((_walk(lambda t: _exact_t(t) or t in ax_edges.TIER_WHY or t in ax_edges.OUTSIDE_CALL) - set(ids)) & in_rows) - exact prod = lambda ms: sum(1 for m in ms if not g.sym[m]['is_test']) n_prod, n_test = prod(in_rows), len(in_rows) - prod(in_rows) e_prod, e_test = prod(exact), len(exact) - prod(exact) print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test" - + (f"; {len(sent)} more through a request or event sent to a handler, which the framework runs for what is sent" if sent else '') + + (f"; {len(sent)} more through a request or event sent to a handler, which the framework runs for what is sent," + " or a hop a framework or another process makes ([framework] / [remote])" if sent else '') + f"; the other {len(in_rows) - len(exact) - len(sent)} only through a dispatch choice (an interface or override with several implementations, " "a call with several candidates) — they MAY run it, not must") if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") + named = byname_sites(g, ids) if upstream else ([], set()) if not rows: u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(ids))})", *ids)[0]['n'] if not upstream else 0 - print(" none — " + ("its body has %d unresolved call(s), so what it reaches is unknown, not nothing" % u if not upstream and u else "no resolved call " + ("into it; " if upstream else "out of it; ") + "an unresolved site elsewhere may still " + ("call it" if upstream else "be it"))) + print(" none — " + ("its body has %d unresolved call(s), so what it reaches is unknown, not nothing" % u if not upstream and u else "no resolved call " + ("into it; " if upstream else "out of it; ") + + (f"{len(named[0])} unresolved site(s) write its name (below)" if named[0] else "an unresolved site elsewhere may still " + ("call it" if upstream else "be it")))) + print_byname(g, named, limit) # AN EMPTY UPSTREAM CLOSURE IS THE MOST MISLEADING LINE THIS COMMAND CAN PRINT. For a live route handler, # a signal receiver or a CLI command the answer "0 methods reach it" is true of calls and false of the # program: the framework reaches it. Name the registration rather than leave the reader at a dead end. @@ -1322,9 +1391,15 @@ def closure(g, sel, upstream, limit=40, depth=40): # the call is nearly always written in the caller's own file, and repeating a 90-character # path to say so pushes the name off the line. Same file, just the line. at = f" — calls it at {':' if sf == (g.sym[m]['file'] or '') else sf + ':'}{ln}".replace('at ::', 'at :') + else: + # no call site: a request it sends or a framework hand-over reaches it, said as the hop says it + how = next((f" — [{t}: {g.outside_detail(m, i, t)}], no call site" for i in ids for t in ax_edges.OUTSIDE_CALL + if g.outside_detail(m, i, t)), '') + at = how print(f" {d:2} hop(s) {g.disp(m)} {g.loc(m)}{at}") if len(np_) > limit: print(f" … +{len(np_) - limit} production (--limit N)") if nt: print(f" +{len(nt)} test caller(s) within 2 hop(s) — `axiomcode test-impact` names them and the command that runs them") + print_byname(g, named, limit) byhop = collections.Counter(d for _, d in rows); print(" by hop: " + ', '.join(f"{d}:{n}" for d, n in sorted(byhop.items()))) # most_common breaks a tie by insertion order, which is the order the closure rows arrived in: two files with the # same count then swap depending on which engine answered. Sort the tie by name so the line is stable either way. @@ -1503,6 +1578,7 @@ def grep_for(g, name): def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): g.export() la, A = g.resolve(a); lb, B = g.resolve(b) + g.add_outside_call_edges() res = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path.dl', 'path-every.dl') if every else ('path.dl',)) if not res['hit']['fwd'] and not res['hit']['rev']: # nothing resolved connects them: now, and only now, the by-name closure res_opt = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path-opt.dl',)); res['hit_opt'] = res_opt['hit_opt']; res['parent_opt'] = res_opt['parent_opt'] @@ -1512,7 +1588,11 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): for q, srcs, dsts, word in (('fwd', A_, B_, f"{la} → {lb}"), ('rev', B_, A_, f"{lb} → {la} (the reverse direction)")): hits = sorted(res['hit'][q], key=lambda h: (int(h[1]), h[0])) if not hits: continue - print(f"{word}: {len(hits)} of {len(dsts)} target(s) reached through resolved calls; nearest at {hits[0][1]} hop(s)") + near = read_back(res['parent'], q, hits[0][0], srcs) or [] + outside = sorted({t for _, t in near[1:] if t in ax_edges.OUTSIDE_CALL}) + print(f"{word}: {len(hits)} of {len(dsts)} target(s) reached through resolved calls" + + (f" and a hop no call site expresses ([{'] and ['.join(outside)}], below)" if outside else '') + + f"; nearest at {hits[0][1]} hop(s)") if len(hits) > limit and not show_all: # a name declared under many owners (close · toString · run): say WHICH owners' declarations are reached and how far, # then print the nearest chains only; the rest is one more call away @@ -1520,7 +1600,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): for m, d in hits: own.setdefault(g.sym[m]['owner'] or os.path.basename(g.sym[m]['file'] or '?'), []).append(int(d)) print(f" reached under {len(own)} owner(s), nearest first: " + ', '.join(f"{o} ({min(ds)} hop{'s' if min(ds) != 1 else ''})" for o, ds in list(own.items())[:12]) + (f" … +{len(own) - 12}" if len(own) > 12 else '')) print(f" the {limit} nearest chains follow; --all for every one, or narrow the target: Owner.{g.sym[hits[0][0]]['name']} · --in ") - shown = 0; vbad = 0; vlen = 0; seen_tiers = set() + shown = 0; vbad = 0; vlen = 0; seen_tiers = set(); noted = set() for m, d in hits: d = int(d) if d == 0: print(f" {g.disp(m)} is itself an endpoint of both"); continue @@ -1528,9 +1608,22 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if not chain: print(f" {g.disp(m)} at {d} hops (chain could not be read back)"); continue bad, bfs = verify(g, chain, srcs, m, adj); vbad += len(bad); vlen += (bfs != d) ncall, nother = chain_size(chain); seen_tiers.update(t for _, t in chain[1:]) - print(f" {ncall} call(s)" + (f" + {nother} containment hop(s)" if nother else '') + + nout = sum(1 for _, t in chain[1:] if t in ax_edges.OUTSIDE_CALL) # a hop no call site makes is not a call either + print(f" {ncall - nout} call(s)" + (f" + {nout} hop(s) no call site makes" if nout else '') + (f" + {nother} containment hop(s)" if nother else '') + ":" + (f" ✗ {len(bad)} hop(s) NOT in the graph" if bad else '') + (f" ✗ the second traversal says {bfs}" if bfs != d else '')) print(print_chain(g, chain)); shown += 1 + # a hop no call site expresses is said in the words the no-chain answer used for it, so a reader can tell it + # from an invocation: it crosses a process, or a framework makes it, and it may run later or elsewhere + for (x, _), (y, t) in zip(chain, chain[1:]): + if t not in ax_edges.OUTSIDE_CALL or (x, y, t) in noted: continue + noted.add((x, y, t)) + for how, where, conf in sorted(set(g.OUTSIDE.get((x, y, t), []))): + if t == 'remote': + print(f" connected across a process: {g.disp(x)} → {g.disp(y)} [{how}] at {where} ({conf}) — no call site" + f" expresses this hop; `impact {g.disp(y)}` lists {g.disp(x)} as a [remote] dependent") + else: + print(f" a framework connects them: {g.disp(x)} {g.loc(x)} → {g.disp(y)} {g.loc(y)} [framework: {how} via {where}]" + f" ({conf}) — framework-mediated, not a call; `impact {g.disp(y)}` lists {g.disp(x)} as a [framework] dependent") RESULT['answers'].append(dict(chain_json(g, chain), direction=q, unverified_hops=len(bad))) if shown >= limit and not show_all: print(f" … +{len(hits) - shown} more targets (--all)"); break blind = {n for n, _ in (read_back(res['parent'], q, hits[0][0], srcs) or [])} @@ -1548,8 +1641,8 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # A CROSS-PROCESS HOP IS A CONNECTION, AND THIS SAID THERE WAS NONE (#1108). A gRPC client and the # handler it calls, or a producer and its consumer, are joined by an edge no call site expresses, so # the BFS above cannot reach it and the honest-sounding "the two are independent in this graph" was - # false. Reported separately rather than woven into the chain: it crosses a process, it may be - # asynchronous, and a reader has to be able to tell that from an invocation. + # false. The chain search now walks it (G.add_outside_call_edges, #1469) and labels the hop; this is the + # fallback for a row whose ends are not both callables of this graph, which the search cannot hold. remote_found = False if g.has('ext_remote_edge'): rows = g.q("SELECT c0, c1, c2, c3, c4 FROM ext_remote_edge WHERE (c0 IN (%s) AND c1 IN (%s)) OR (c0 IN (%s) AND c1 IN (%s))" @@ -1563,8 +1656,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): remote_found = True # A FRAMEWORK HOP INSIDE ONE PROCESS WAS THE SAME FALSE "INDEPENDENT" (#1509). `order_placed.send()` and its # @receiver, `send_report.delay()` and the task body: the engine writes framework_edge for the pair and no call - # site expresses it. Reported beside the cross-process hop and for the same reason not woven into a chain: it - # is framework-mediated, and a reader has to be able to tell that from an invocation. + # site expresses it. Walked as a labelled hop of the chain since #1469; kept here for the same fallback as above. if g.has('ext_framework_edge') and A_ and B_: rows = g.q("SELECT c0, c1, c2, c3, c4 FROM ext_framework_edge WHERE (c0 IN (%s) AND c1 IN (%s)) OR (c0 IN (%s) AND c1 IN (%s))" % (','.join('?' * len(A_)), ','.join('?' * len(B_)), ','.join('?' * len(B_)), ','.join('?' * len(A_))), diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md index 81d854c0..d0765b62 100644 --- a/skills/axiomcode/reference/path.md +++ b/skills/axiomcode/reference/path.md @@ -40,10 +40,15 @@ - **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. - When a framework joins the two ends directly (a Python `.delay()` and the task it enqueues, a signal `send` and its - `@receiver`, a route table and its view, a `Depends()` default and its provider, a test and the fixture it names), the - answer prints that hop with its mechanism and the engine's confidence, labelled framework-mediated, and no longer calls - the two independent. It is not a call, so it is never part of a chain. +- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler + that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python + `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# + endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) + are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client + reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls + (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` + counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations + gets them; a hop never joins two languages' graphs. - **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, @@ -78,7 +83,10 @@ *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `--in src/main` keeps only the part + (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the + call sites written with X's name on a receiver the engine could not type (`[by name] `): the + callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, + and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) and bounded by the unresolved calls inside it. - **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 diff --git a/tests/cases/csharp/path-crosses-framework-and-byname/case.json b/tests/cases/csharp/path-crosses-framework-and-byname/case.json new file mode 100644 index 00000000..70cdfadf --- /dev/null +++ b/tests/cases/csharp/path-crosses-framework-and-byname/case.json @@ -0,0 +1,27 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "#1469: an endpoint filter's framework hop is a hop of the chain, so the endpoint reaches what the filter calls", + "run": ["path", "OrderEndpoints.Create", "Audit.Record"], + "want": ["1 call(s) + 1 hop(s) no call site makes", "[framework · pipeline_wrap via AddEndpointFilter (registered) · no call site] TenantFilter.InvokeAsync", + "[known_edge · call @ src/Api.cs:17] Audit.Record", "a framework connects them: OrderEndpoints.Create", "verified: every printed hop is an edge"], + "avoid": ["the two are independent in this graph", "✗"]}, + {"why": "path '*' on the filter's callee reaches the endpoint it wraps", + "run": ["path", "*", "Audit.Record"], + "want": ["2 hop(s) OrderEndpoints.Create"]}, + {"why": "CONTROL: an endpoint in a group with no filter is not connected to what the filter calls", + "run": ["path", "OrderEndpoints.Health", "Audit.Record"], "expect_error": true, + "want": ["the two are independent in this graph"], + "avoid": ["[framework"]}, + {"why": "CONTROL: a method the filter does not call is not reached from the endpoint", + "run": ["path", "OrderEndpoints.Create", "Audit.Forget"], "expect_error": true, + "avoid": ["[framework ·", "OrderEndpoints.Create → Audit.Forget:"]}, + {"why": "#1421: path '*' lists the untyped-receiver call impact lists as [by name]", + "run": ["path", "*", "Ledger.Settle"], + "want": ["1 hop(s) Jobs.Nightly", "[by name] Jobs.Replay src/Api.cs:47 — calls `Settle` (receiver not typed)"]}, + {"why": "impact names the same by-name caller", + "run": ["impact", "Ledger.Settle"], + "want": ["[by name] Jobs.Replay", "[resolved] Jobs.Nightly"]}, + {"why": "CONTROL: another method's name on an untyped receiver is not a by-name caller of Settle", + "run": ["path", "*", "Ledger.Settle"], + "avoid": ["Jobs.Undo", "Reopen"]} + ]} diff --git a/tests/cases/csharp/path-crosses-framework-and-byname/src/Api.cs b/tests/cases/csharp/path-crosses-framework-and-byname/src/Api.cs new file mode 100644 index 00000000..3647a97e --- /dev/null +++ b/tests/cases/csharp/path-crosses-framework-and-byname/src/Api.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Shop; + +public static class Audit +{ + public static void Record(string what) { } + public static void Forget(string what) { } +} + +public sealed class TenantFilter : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext c, EndpointFilterDelegate next) + { + Audit.Record("tenant"); + return next(c); + } +} + +public static class OrderEndpoints +{ + public static RouteGroupBuilder MapOrders(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/orders").AddEndpointFilter(); + group.MapPost("/", Create); + // CONTROL: a sibling group with no filter + routes.MapGroup("/health").MapGet("/", Health); + return group; + } + + static string Create() => "created"; + static string Health() => "ok"; +} + +public sealed class Ledger +{ + public int Settle(int n) => n; + public int Reopen(int n) => n; +} + +public static class Jobs +{ + public static int Nightly(Ledger ledger) => ledger.Settle(1); + // the receiver's type is a library type the graph does not hold + public static int Replay(dynamic source) => source.Settle(2); + public static int Rewind(External.Source source) => source.Settle(3); + public static int Undo(External.Source source) => source.Reopen(4); +} diff --git a/tests/cases/csharp/path-crosses-framework-and-byname/src/Shop.csproj b/tests/cases/csharp/path-crosses-framework-and-byname/src/Shop.csproj new file mode 100644 index 00000000..81eb2a5b --- /dev/null +++ b/tests/cases/csharp/path-crosses-framework-and-byname/src/Shop.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/java/path-crosses-remote-and-byname/case.json b/tests/cases/java/path-crosses-remote-and-byname/case.json new file mode 100644 index 00000000..ee23dc0a --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/case.json @@ -0,0 +1,44 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "#1469: a gRPC hop is a hop of the chain, so a client reaches what its handler calls, with the remote hop labelled", + "run": ["path", "OrderClient.placeBlocking", "OrderStore.save"], + "want": ["1 call(s) + 1 hop(s) no call site makes", "[remote · grpc at app.OrderServiceGrpc/placeOrder (exact) · no call site] OrderHandler.placeOrder", + "[known_edge · call @ src/main/java/app/OrderHandler.java:5] OrderStore.save", + "connected across a process: OrderClient.placeBlocking → OrderHandler.placeOrder", "verified: every printed hop is an edge"], + "avoid": ["the two are independent in this graph", "✗"]}, + {"why": "the handler's own ends still read as one cross-process hop", + "run": ["path", "OrderClient.placeBlocking", "OrderHandler.placeOrder"], + "want": ["[remote · grpc at app.OrderServiceGrpc/placeOrder (exact) · no call site] OrderHandler.placeOrder", "[grpc] at app.OrderServiceGrpc/placeOrder (exact)"]}, + {"why": "path '*' on what the handler calls reaches the client through the remote hop, counted apart from the exact calls", + "run": ["path", "*", "OrderStore.save"], + "want": ["2 hop(s) OrderClient.placeBlocking", "Through exact calls only: 1 production, 0 test; 1 more through"]}, + {"why": "the client is a direct caller of the handler in path '*', worded as the hop, not as a call site", + "run": ["path", "*", "OrderHandler.placeOrder"], + "want": ["1 hop(s) OrderClient.placeBlocking", "[remote: grpc at app.OrderServiceGrpc/placeOrder (exact)], no call site"], + "avoid": ["calls it at"]}, + {"why": "CONTROL: a method of the handler's store that nothing calls is not reached from the client", + "run": ["path", "OrderClient.placeBlocking", "OrderStore.purge"], "expect_error": true, + "want": ["the two are independent in this graph"], + "avoid": ["[remote"]}, + {"why": "CONTROL: the remote hop runs one way; the handler does not reach the client", + "run": ["path", "OrderStore.save", "OrderClient.placeBlocking"], "expect_error": true, + "avoid": ["OrderStore.save → OrderClient.placeBlocking:"]}, + {"why": "#1421: path '*' lists the untyped-receiver site impact lists as [by name], apart from the resolved caller", + "run": ["path", "*", "WidgetMapper.archiveOld"], + "want": ["1 hop(s) WidgetJob.run", "by name, not resolved (1 site(s) in 1 caller(s))", + "[by name] WidgetService.cleanup src/main/java/app/WidgetService.java:7 — calls `archiveOld` (receiver not typed)"]}, + {"why": "impact names the same by-name caller, so the two verbs agree on the direct callers", + "run": ["impact", "WidgetMapper.archiveOld"], + "want": ["[by name] WidgetService.cleanup", "[resolved] WidgetJob.run"]}, + {"why": "#1421: with no resolved caller at all, path '*' names the by-name site instead of a fixed sentence, and next: points at it", + "run": ["path", "*", "WidgetMapper.countAll"], "expect_error": true, + "want": ["1 unresolved site(s) write its name (below)", "[by name] WidgetService.count src/main/java/app/WidgetService.java:10", + "next: no resolved call reaches it; the 1 [by name] site(s) are leads"], + "avoid": ["an unresolved site elsewhere may still call it"]}, + {"why": "CONTROL: a different name on the same untyped receiver is not a by-name caller", + "run": ["path", "*", "WidgetMapper.archiveOld"], + "avoid": ["WidgetService.count ", "countAll"]}, + {"why": "CONTROL: the by-name group is listed, never walked: the resolved closure keeps its size", + "run": ["path", "*", "WidgetMapper.archiveOld"], + "want": ["everything that can reach WidgetMapper.archiveOld: 1 method(s) in 1 file(s)"]} + ]} diff --git a/tests/cases/java/path-crosses-remote-and-byname/pom.xml b/tests/cases/java/path-crosses-remote-and-byname/pom.xml new file mode 100644 index 00000000..75d2443a --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + app + app + 1.0 + diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderClient.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderClient.java new file mode 100644 index 00000000..11d15f0c --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderClient.java @@ -0,0 +1,6 @@ +package app; + +public class OrderClient { + private OrderServiceGrpc.OrderServiceBlockingStub stub = new OrderServiceGrpc.OrderServiceBlockingStub(); + public String placeBlocking(String req) { return stub.placeOrder(req); } +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderHandler.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderHandler.java new file mode 100644 index 00000000..85981986 --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderHandler.java @@ -0,0 +1,6 @@ +package app; + +public class OrderHandler extends OrderServiceGrpc.OrderServiceImplBase { + private final OrderStore store = new OrderStore(); + @Override public void placeOrder(String req) { store.save(req); } +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderServiceGrpc.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderServiceGrpc.java new file mode 100644 index 00000000..8940155d --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderServiceGrpc.java @@ -0,0 +1,6 @@ +package app; + +public final class OrderServiceGrpc { + public static abstract class OrderServiceImplBase { public void placeOrder(String req) { } } + public static class OrderServiceBlockingStub { public String placeOrder(String req) { return null; } } +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderStore.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderStore.java new file mode 100644 index 00000000..84434d9b --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/OrderStore.java @@ -0,0 +1,6 @@ +package app; + +public class OrderStore { + public void save(String req) { } + public void purge() { } +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/Widget.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/Widget.java new file mode 100644 index 00000000..ddd05ea9 --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/Widget.java @@ -0,0 +1,3 @@ +package app; + +public class Widget { } diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetJob.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetJob.java new file mode 100644 index 00000000..1eac323f --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetJob.java @@ -0,0 +1,8 @@ +package app; + +public class WidgetJob { + private WidgetMapper mapper; + public int run() { + return mapper.archiveOld(7); + } +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetMapper.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetMapper.java new file mode 100644 index 00000000..53e760b7 --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetMapper.java @@ -0,0 +1,8 @@ +package app; + +import example.data.BaseMapper; + +public interface WidgetMapper extends BaseMapper { + int archiveOld(int days); + int countAll(); +} diff --git a/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetService.java b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetService.java new file mode 100644 index 00000000..9fbf006c --- /dev/null +++ b/tests/cases/java/path-crosses-remote-and-byname/src/main/java/app/WidgetService.java @@ -0,0 +1,12 @@ +package app; + +import example.data.ServiceImpl; + +public class WidgetService extends ServiceImpl { + public int cleanup() { + return this.baseMapper.archiveOld(30); + } + public int count() { + return this.baseMapper.countAll(); + } +} diff --git a/tests/cases/python/path-crosses-framework-and-byname/app/__init__.py b/tests/cases/python/path-crosses-framework-and-byname/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/path-crosses-framework-and-byname/app/cart.py b/tests/cases/python/path-crosses-framework-and-byname/app/cart.py new file mode 100644 index 00000000..737a993d --- /dev/null +++ b/tests/cases/python/path-crosses-framework-and-byname/app/cart.py @@ -0,0 +1,14 @@ +def build_cart(items): + return list(items) + + +def empty_cart(): + return [] + + +class Ledger: + def settle(self, n): + return n + + def reopen(self, n): + return n diff --git a/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py new file mode 100644 index 00000000..e6007ef3 --- /dev/null +++ b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py @@ -0,0 +1,14 @@ +from app.cart import Ledger + + +def nightly(ledger: Ledger): + return ledger.settle(1) + + +def replay(source): + # the receiver comes from a parameter nobody typed + return source.settle(2) + + +def rewind(source): + return source.reopen(3) diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json new file mode 100644 index 00000000..609428ed --- /dev/null +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -0,0 +1,28 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "#1530/#1469: a fixture a test names is a hop of the chain, so the test reaches what the fixture calls", + "run": ["path", "test_injected", "build_cart"], + "want": ["1 call(s) + 1 hop(s) no call site makes", "[framework · fixture_injection via cart (by_name) · no call site] cart", + "[known_edge · call @ tests/conftest.py:8] build_cart", "a framework connects them: test_injected", "verified: every printed hop is an edge"], + "avoid": ["the two are independent in this graph", "✗"]}, + {"why": "path '*' on the fixture's callee reaches the test through the framework hop", + "run": ["path", "*", "build_cart"], + "want": ["2 hop(s) test_injected [test]", "1 more through"]}, + {"why": "CONTROL: a test that names no fixture is not connected to the fixture's callee", + "run": ["path", "test_plain", "build_cart"], "expect_error": true, + "want": ["the two are independent in this graph"], + "avoid": ["[framework"]}, + {"why": "CONTROL: a fixture no test requests reaches its callee by a call and nothing reaches it through a framework hop", + "run": ["path", "*", "empty_cart"], + "want": ["1 hop(s) blank [test]"], + "avoid": ["test_injected", "[framework"]}, + {"why": "#1421: path '*' lists the untyped-receiver call impact lists as [by name]", + "run": ["path", "*", "Ledger.settle"], + "want": ["1 hop(s) nightly", "[by name] replay app/jobs.py:10 — calls `settle` (receiver not typed)"]}, + {"why": "impact names the same by-name caller", + "run": ["impact", "Ledger.settle"], + "want": ["[by name] replay", "[resolved] nightly"]}, + {"why": "CONTROL: another method's name on an untyped receiver is not a by-name caller of settle", + "run": ["path", "*", "Ledger.settle"], + "avoid": ["rewind", "reopen"]} + ]} diff --git a/tests/cases/python/path-crosses-framework-and-byname/tests/__init__.py b/tests/cases/python/path-crosses-framework-and-byname/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/path-crosses-framework-and-byname/tests/conftest.py b/tests/cases/python/path-crosses-framework-and-byname/tests/conftest.py new file mode 100644 index 00000000..a7e25154 --- /dev/null +++ b/tests/cases/python/path-crosses-framework-and-byname/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from app.cart import build_cart, empty_cart + + +@pytest.fixture +def cart(): + return build_cart([1]) + + +@pytest.fixture +def blank(): + return empty_cart() diff --git a/tests/cases/python/path-crosses-framework-and-byname/tests/test_cart.py b/tests/cases/python/path-crosses-framework-and-byname/tests/test_cart.py new file mode 100644 index 00000000..5c68b88b --- /dev/null +++ b/tests/cases/python/path-crosses-framework-and-byname/tests/test_cart.py @@ -0,0 +1,6 @@ +def test_injected(cart): + assert cart == [1] + + +def test_plain(): + assert [] == [] From 1f660d805a0931b7f5fb3ab2352f93bbb2da215e Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:48:45 -0700 Subject: [PATCH 046/258] test-impact, changed: fixture trees and case data map to the runner or test that reads them A test tree holds inputs as well as tests, and test-impact and changed read those inputs as tests. A fixture project's source under tests/ was listed as "edited test file(s), run as they are" and handed to pytest or run as a script; a golden or a case.json was matched by its file name or a directory word ("expected", "remote", "case.json") against unrelated files, one of them in another language; a Python fixture package's __version__ selected 702 tests. Nothing named the runner that reads the changed input, so the next: line pointed at an unrelated test. What changed: - A case directory beside its case runner (tests/cases///, graph/test//cases/) maps to that runner's command for the one case, a golden named for a case maps to the same case, and a rule file under a tree the runner's directory mirrors maps to that runner (ax_caserun, new; this carries the earlier unlanded case-runner change, rebased). - Fixture trees in general, judged by shape, never by a directory name. A directory under a test root is data when a runner or a test outside it names it by path (a joined path, a Path / "x" chain, a shell glob), when it holds goldens and no script or test of its own, when it is a project no build around it includes (a pyproject.toml inside a test tree, a pom no parent lists as a module, a .csproj no solution names), or when it is JVM src/test/ beside the compiled src/test/java. A directory whose own files run (scripts, collected tests, a C# or Java file with a test attribute) is never data. - A file in such a tree maps to what reads it: the path (file, or the nearest directory above it) as a test or runner writes it, where the written path must lead to this file (a path that continues into another file reads that file), a relative path counts only from a reader in the tree's own project, and a compound bare name (demo-package, pypi.json) counts from anywhere under the test root when at most three files there carry it. Scripts come first (runner by path, then scripts named run-tests*, run_tests*, test.sh or run.py above it), then the tests that name it, as one command of their framework, then a helper that reads it (a conftest.py, a resource reader), which stands for the tests beside it. With a language level below the tree, the runner's own --lang flag selects it. test-impact prints each as "case data for " with its command on its own line, and those files leave every other tier: no pytest or JUnit line on the fixture, no by-name or by-package test. - changed says "case data (...): read by ; run ``; an input, not a test to run", and a new file in a fixture tree is case data instead of "a test file: run it". - A data file's own name is no test-name match when other files in the repository share it (case.json, settings.json, greet.proto), and then neither is a bare directory word; its path (two parts or more) still is. - Skill docs (SKILL.md, reference/changed-and-tests.md, both copies) and tests/README.md describe the mapping. Tests: tests/case_runner.py builds one repository with a case runner, three languages' case data, engine suites and a dispatcher, and now a fixture tree read by a script through a joined path, goldens a pytest test opens by path, and data files with shared and unique names. It checks the fixture tree's runner command with its --lang flag, the golden's test, the case command for a case.json, no name match on a shared data-file name, and changed's wording for a new file in a fixture tree. Controls: a golden no test reads is not given its neighbour's test; editing the test that reads a golden still runs that test; a data file named by its own unique path keeps its test; a new real test file is still "a test file: run it"; a real pytest file beside case data keeps pytest. On the previous code 11 of the new checks fail. tests/changed_range.py: its fixture (tests/cases/one/case.json, read by tests/test_cases.py through a joined path) is now case data for that test with its own pytest line, instead of a text-tier name match on "case.json"; the checks say so, and still hold that no other language's file or uncollected module is on the pytest line. Suites: tests/run.py --lang python (206 of 207; the one FAIL, python/lambda-is-named-by-its-place, fails on the base too), --lang java (192 of 192), --lang csharp (57 of 62, the same 4 FAIL and 1 PENDING as the base); tests/case_runner.py; tests/changed_range.py; tests/test_command.py; tests/surfaces.py; tests/manifests.py; tests/hook_languages.py; tests/enrich_lines.py; tests/no_symlink.py; tests/refresh.py --lang python; tests/mcp_first.py. Smoke, the same edits with the installed build and with this change, each on a fresh index of a real copy: - this repository, 6 fixture and data edits (a case's source, a fixture tree's source, a golden, a case.json, a fixture project's source, a remote-edge golden): before, 0 of 6 named the runner that reads the file (pytest on a fixture's own test, "edited test file" on fixture source, 4 unrelated tools by the word "expected", 4 files by the name "case.json", a JS fixture by the word "remote"); after, 6 of 6 name it with its command. - a 240-file Python packaging tool with a fixtures directory of sample projects, 4 fixture edits: a fixture project's version string, 702 tests in 46 files before, 1 file after; a fixture pyproject.toml, 17 files matched by "pyproject.toml" before, the 4 tests that name that project after; a fixture module, "edited test file" with no command before, 8 tests naming the project after; a JSON index, a text lead to conftest.py with no command before, `pytest tests` through that conftest after. - a 400-file Java config server, 3 edits under src/test/resources: a repository file, 10 test files in three modules matched by its shared name before, the 13 tests in its own module that name its directory after; a yml, the same one test before and after; a properties file whose name is shared, one test through a comment mention before, one test naming the resources root after (a lead in both). - an 830-file C# gRPC library, 3 edits: a fixture project's .proto, 3 tests before, the 5 tests that copy that fixture project after; its .csproj, the same 2 tests; a .proto a test project compiles, 2 tests in another project matched by its shared name before, none after, with the line that says no test names it. - controls on all four, a real test edit and a code edit with ordinary tests: identical before and after. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- graph/test/csharp/run-tests.sh | 5 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 2 +- .../axiomcode/reference/changed-and-tests.md | 3 + .../skills/axiomcode/scripts/ax_caserun.py | 565 ++++++++++++++++++ .../skills/axiomcode/scripts/ax_pages.py | 2 +- .../axiomcode/scripts/axiomcode-changed | 29 +- .../skills/axiomcode/scripts/axiomcode-impact | 28 + .../axiomcode/scripts/axiomcode-test-impact | 113 +++- skills/axiomcode/SKILL.md | 2 +- .../axiomcode/reference/changed-and-tests.md | 3 + tests/README.md | 8 + tests/case_runner.py | 244 ++++++++ tests/changed_range.py | 17 +- 13 files changed, 1001 insertions(+), 20 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_caserun.py create mode 100644 tests/case_runner.py diff --git a/graph/test/csharp/run-tests.sh b/graph/test/csharp/run-tests.sh index 94ae3ab4..cbcd21a8 100755 --- a/graph/test/csharp/run-tests.sh +++ b/graph/test/csharp/run-tests.sh @@ -47,8 +47,9 @@ REPO="$(cd "$HERE/../../.." && pwd)" # fresh worktree (a landing gate's, say) has none of its own and would skip with 77. ORACLE="${AXIOM_CS_ORACLE:-$HERE/ground-truth/AxiomCsOracle/bin/Release/net8.0/axiom-cs-oracle}" -WORK="${1:-}"; ONLY=""; VERBOSE=0 -shift 2>/dev/null || true +WORK=""; ONLY=""; VERBOSE=0 +# the work dir is an optional FIRST positional argument: `--only ` alone (the usage above) is not one +case "${1:-}" in ""|-*) ;; *) WORK="$1"; shift;; esac while [ $# -gt 0 ]; do case "$1" in --only) ONLY="$2"; shift 2;; diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 35b704e0..4a2938c1 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -96,7 +96,7 @@ keys, injected beans and handlers registered as values — none has a call site. `field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 844eb7ad..85d50934 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -22,6 +22,9 @@ What to pass, and what the answer says when the question cannot be answered the | named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | | 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 file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | +| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | +| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | **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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_caserun.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_caserun.py new file mode 100644 index 00000000..85fef6bd --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_caserun.py @@ -0,0 +1,565 @@ +"""ax_caserun: a data-driven test suite, read the way it runs. + +A CASE RUNNER is a script beside a `cases/` directory that walks it: `tests/run.py` indexing each +`tests/cases///` and checking what its case.json asks, `graph/test//run-tests.sh` solving each +`cases//src`. The files under `cases/` are its INPUTS: a `test_jobs.py` inside a case is a fixture's source, +not a test anyone collects, and handing it to pytest (or running a case's `main.py`) runs nothing that checks +anything. So a changed file in a case directory is mapped to the runner, with the command that runs that one case +as the runner's own usage line spells it; a golden beside the cases (`expected/.edges`) maps the same way. + +A runner also tests the tree its own directory MIRRORS: `graph/test/python/run-tests.sh` solves every case with the +rules under `graph/python/`, so a changed rule file there (a file no graph reads, which no call edge can reach) maps +to that runner, whole. The mirror must keep at least one segment below the test directory: a top-level `tests/` +would otherwise claim the whole repository. + +A test file that merely sits NEXT TO a case directory is still its own framework's test (a `tests/test_x.py` beside +`tests/cases/` runs with pytest); only the files under `cases/` are data. +""" +import os, re, shlex + +CASES = 'cases' +TEST_DIR = re.compile(r'^(test|tests|spec|specs|__tests__|it)$', re.I) +LANGS = ('python', 'java', 'csharp', 'typescript', 'javascript', 'go', 'kotlin', 'ruby', 'rust') +_PY_MAIN = re.compile(r'''^if\s+__name__\s*==\s*['"]__main__['"]|^#!.*\bpython''', re.M) +_PY_ARGS = re.compile(r'\bsys\.argv\b|\bargparse\b') +_SH_ARGS = re.compile(r'"\$@"|\$@|\$\*|"\$1"|\$\{1[:}-]|\$1\b') +_NAMES_CASES = re.compile(r'''[\'"/]''' + CASES + r'''[\'"/]''') +# a line that walks the case directory: `os.listdir(os.path.join(HERE, 'cases'))`, `"$HERE"/cases/*/`, a glob +_WALKS_CASES = re.compile(r'''^.*(?:(?:listdir|scandir|iterdir|glob|walk)\b.*[\'"/]''' + CASES + r'''[\'"/]|[\'"/]''' + CASES + + r'''[\'"/].*(?:listdir|scandir|iterdir|glob|walk)\b|/''' + CASES + r'''/\*).*$''', re.M) +_FLAG_ALTS = re.compile(r'(--[\w-]+)[ =]\[??\]?') +_FLAG_LANG = re.compile(r'''(--lang(?:uage)?)\b''') + +_cache = {} + + +def _read(repo, rel, n=200_000): + k = (repo, rel, n) + if k not in _cache: + try: _cache[k] = open(os.path.join(repo, rel), errors='replace').read(n) + except OSError: _cache[k] = '' + return _cache[k] + + +def _is_script(repo, rel): + """a file run as a program: a shell script, a Python file with a main guard or a python shebang""" + if rel.endswith('.sh'): return True + t = _read(repo, rel) + if rel.endswith('.py'): return bool(_PY_MAIN.search(t)) + return False + + +def _walks_cases(text): + """a line of CODE that walks `cases/`: a comment saying the loop does, or a string holding a sample test that + does (a test of this very rule writes one), is not the script walking it""" + return any(_WALKS_CASES.match(ln) for ln in text.splitlines() if not ln.lstrip().startswith(('#', '"', "'", '//'))) + + +def runners_in(repo, d): + """the case runners in directory `d` (repo-relative): scripts directly in it that walk its `cases/` directory. + A script that only names one case (`cases/java/one-case`) is that case's reader, not the suite's: `readers`""" + k = ('runners', repo, d) + if k in _cache: return _cache[k] + out = [] + if os.path.isdir(os.path.join(repo, d, CASES)): + try: names = sorted(os.listdir(os.path.join(repo, d))) + except OSError: names = [] + for n in names: + rel = f"{d}/{n}" if d else n + if os.path.isfile(os.path.join(repo, rel)) and n.endswith(('.py', '.sh')) and _is_script(repo, rel) \ + and _walks_cases(_read(repo, rel)): + out.append(rel) + _cache[k] = out + return out + + +def _under_test_tree(parts): + return any(TEST_DIR.match(p) for p in parts) + + +def _manifests(repo, runner): + """file names the runner quotes (`case.json`): a directory holding one is a case""" + return set(re.findall(r'''['"/]([\w.-]+\.(?:json|ya?ml|toml|txt))['"]''', _read(repo, runner))) + + +def _case_dirs(repo, root): + try: return sorted(n for n in os.listdir(os.path.join(repo, root)) if os.path.isdir(os.path.join(repo, root, n))) + except OSError: return [] + + +def case_of(repo, rel): + """(runner, selectors, case) when `rel` is an input of a case runner, else None. `selectors` are the directory + levels between `cases/` and the case (a language), `case` is the case's directory name, or None when the file + is shared by every case (it lies directly in `cases/`, or it is a golden no case is named for).""" + parts = rel.split('/') + for i in range(len(parts) - 2, -1, -1): + if parts[i] != CASES: continue + d = '/'.join(parts[:i]) + if not _under_test_tree(parts[:i]): continue + rs = runners_in(repo, d) + if not rs: continue + below = parts[i + 1:-1] # directories between cases/ and the file + if not below: return rs, [], None + man = set().union(*(_manifests(repo, r) for r in rs)) + k = next((k for k in range(1, len(below) + 1) if man and any( + os.path.isfile(os.path.join(repo, d, CASES, *below[:k], m)) for m in man)), 1) + return rs, below[:k - 1], below[k - 1] + # a golden beside the cases: `expected/.edges` below a runner's directory, named after one of its cases + stem = parts[-1].split('.')[0] + for j in range(len(parts) - 2, -1, -1): + d = '/'.join(parts[:j]) + if parts[j] == CASES or not _under_test_tree(parts[:j]): continue + rs = runners_in(repo, d) + if rs and stem in _case_dirs(repo, f"{d}/{CASES}" if d else CASES): + return rs, [], stem + return None + + +def readers(repo, runner_dir, case): + """scripts beside a case runner that read one case by its name (`cases/java/one-case`): run whole for it""" + if not case: return [] + pat = re.compile(r'''[\'"/]''' + re.escape(case) + r'''[\'"/]''') + out = [] + try: names = sorted(os.listdir(os.path.join(repo, runner_dir))) + except OSError: names = [] + for n in names: + rel = f"{runner_dir}/{n}" if runner_dir else n + if n.endswith(('.py', '.sh')) and os.path.isfile(os.path.join(repo, rel)) and _is_script(repo, rel) \ + and pat.search(_read(repo, rel)): + out.append(rel) + return out + + +def mirrored_runners(repo, rel): + """the case runners whose test directory mirrors a directory holding `rel` (graph/test/python/run-tests.sh for + graph/python/engine/x.dl): the rules they solve their cases with""" + parts = rel.split('/') + if _under_test_tree(parts[:-1]): return [] + for i in range(len(parts) - 1): + prefix, rest = parts[:i], parts[i:-1] + for t in ('test', 'tests'): + for k in range(len(rest), 0, -1): + d = '/'.join(prefix + [t] + rest[:k]) + rs = runners_in(repo, d) + if rs: return rs + return [] + + +def _usage_lines(text, base): + """the runner's own usage lines: a comment or docstring line that starts with its file name""" + pat = re.compile(r'(?:^|[\s#"])(?:\./|[\w./-]*/)?' + re.escape(base) + r'(?=\s)(.*)$') + out = [] + for ln in text.splitlines()[:120]: + m = pat.search(ln) + if m: out.append(m.group(1)) + return out + + +def command(repo, runner, selectors=(), case=None): + """the command that runs `runner` on one case, as its usage line spells it; the whole runner without a case, or + when the runner takes no argument""" + text = _read(repo, runner) + base = os.path.basename(runner) + interp = 'python3' if runner.endswith('.py') else 'bash' + head = f"{interp} {shlex.quote(runner)}" + if case is None: return head + if not (_PY_ARGS if runner.endswith('.py') else _SH_ARGS).search(text): return head + cases_dir = os.path.join(os.path.dirname(runner), CASES, *selectors) + names = set(_case_dirs(repo, cases_dir)) + # the case goes on the command line only as the runner's own usage line puts it: a placeholder for the case, or a + # sample argument that selects one (`04`, `03-target-typed-new`), with the flag in front of it if there is one. + # A runner whose usage shows neither takes its arguments for something else, and runs whole + form = None + for u in _usage_lines(text, base): + toks = u.split() + for j, tk in enumerate(toks): + w = tk.strip('[]<>…,') + flag = toks[j - 1].strip('[') if j and toks[j - 1].lstrip('[').startswith('--') and not toks[j - 1].endswith(']') else '' + if w in ('case', 'name', 'case-name') and '<' in tk: + form = flag; break + if w and not w.startswith('-') and any(w == n or (len(w) >= 2 and w in n) for n in names): + form = flag; break + if form is not None: break + if form is None: return head + args = [f"{form} {shlex.quote(case)}" if form else shlex.quote(case)] + for s in selectors: + m = next((m for m in _FLAG_ALTS.finditer(text) if s in m.group(2).split('|')), None) + flag = m.group(1) if m else None + if not flag and s in LANGS: + lm = _FLAG_LANG.search(text); flag = lm.group(1) if lm else None + if flag: args.append(f"{flag} {shlex.quote(s)}") + return f"{head} {' '.join(args)}" + + +# --------------------------------------------------------------------------------------------------------------------- +# FIXTURE TREES IN GENERAL. `cases/` beside a runner is one shape of case data; a test tree holds others under any name +# (fixtures/, testdata/, TestData/, a project a test builds, a directory of goldens). What makes a directory under a +# test root DATA is its shape, never its name: +# - a runner or a test outside it names it by path (`os.path.join(HERE, 'fastpath_cases')`, `Path.Combine("TestData", +# ...)`, `"$HERE"/projects/*/`): it is read as input; +# - it holds goldens (`expected/`, `x.expected`, `expected.edges`) and no script or test of its own; +# - it is a project of its own that no build around it includes (a pyproject.toml or setup.py inside a test tree, a +# pom.xml no parent pom lists as a , a .csproj no solution names); +# - on a JVM layout, it is src/test/ beside the compiled src/test/java (resources: copied, never compiled). +# A directory whose own files are scripts or collected tests is a test directory, never data, whatever it holds below. +# A file inside such a tree maps to the runner or test that reads it, with that one's command; never to pytest, JUnit or +# a test named like the file. +# --------------------------------------------------------------------------------------------------------------------- +RUNNER_NAME = re.compile(r'^(?:run[-_]tests?[\w.-]*|test\.sh|run\.py)$', re.I) +GOLDEN = re.compile(r'^(?:expected|goldens?|baselines?|ground[-_]truth)(?:$|[._-])|[._-](?:expected|golden|approved)(?:$|\.)', re.I) +COLLECTED_TEST = re.compile(r'^(?:test_[^/]*\.py|[^/]*_test\.py|conftest\.py|[^/]*Tests?\.(?:java|kt|cs)|[^/]*IT\.java)$') +READER_EXT = ('.py', '.sh', '.java', '.kt', '.cs') +JVM_SRC = ('java', 'kotlin', 'groovy', 'scala') +_SKIP = ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode', 'bin', 'obj', '__pycache__', '.venv', 'venv') +_Q = '[\'"`]' + + +def _test_root(dirs): + return next((i for i, p in enumerate(dirs) if TEST_DIR.match(p)), None) + + +def _entries(repo, d): + try: return sorted(os.listdir(os.path.join(repo, d))) + except OSError: return [] + + +def _is_file(repo, rel): return os.path.isfile(os.path.join(repo, rel)) + + +_TEST_ATTR = re.compile(r'^\s*(?:\[(?:\w+\.)*(?:Fact|Theory|Test|TestMethod|TestCase|TestFixture|TestClass)\b|@(?:\w+\.)*(?:Test|ParameterizedTest|RepeatedTest|TestFactory)\b)', re.M) + + +def _runs_itself(repo, rel): + """a file that runs: a script (a runner by name, a shell script, a Python main), or a test a framework collects (by + its name, or a C#/Java/Kotlin file declaring a test method: `[Fact]`, `[Test]`, `@Test`, whatever the file is named)""" + n = os.path.basename(rel) + if RUNNER_NAME.match(n) or COLLECTED_TEST.match(n): return True + if n.endswith(('.py', '.sh')): return _is_script(repo, rel) + return n.endswith(('.cs', '.java', '.kt')) and bool(_TEST_ATTR.search(_read(repo, rel))) + + +def _is_runner(repo, rel): + """a script that runs a suite: it runs itself and is not a test module a framework collects""" + n = os.path.basename(rel) + return bool(RUNNER_NAME.match(n) or (not COLLECTED_TEST.match(n) and n.endswith(('.py', '.sh')) and _is_script(repo, rel))) + + +def _test_dir(repo, d): + """a directory whose OWN files run (scripts, collected tests): the test code, not data, whatever lies below it""" + k = ('testdir', repo, d) + if k not in _cache: + _cache[k] = any(_is_file(repo, f"{d}/{n}") and _runs_itself(repo, f"{d}/{n}") for n in _entries(repo, d)) + return _cache[k] + + +def _holds_golden(repo, d, depth=2): + for n in _entries(repo, d): + if GOLDEN.search(n): return True + if depth > 1 and os.path.isdir(os.path.join(repo, d, n)) and _holds_golden(repo, f"{d}/{n}", depth - 1): return True + return False + + +def _included(repo, d, marker): + """whether the build around `d` includes the project in it: a parent pom's , a settings.gradle include, a + solution naming the .csproj. Nothing includes a fixture project; a real module is always included""" + parts = d.split('/') + for i in range(len(parts) - 1, -1, -1): + up = '/'.join(parts[:i]) + for n in _entries(repo, up): + f = f"{up}/{n}" if up else n + if marker == 'pom.xml' and n == 'pom.xml': + rel = '/'.join(parts[i:]) + if re.search(r'\s*(?:\./)?' + re.escape(rel) + r'/?\s*', _read(repo, f)): return True + elif marker.startswith('build.gradle') and n.startswith('settings.gradle'): + if re.search('[\'":]' + re.escape(parts[-1]) + '[\'"]', _read(repo, f)): return True + elif marker.endswith('.csproj') and n.endswith(('.sln', '.slnx', '.slnf')): + if marker in _read(repo, f, 2_000_000): return True + return False + + +def _fixture_project(repo, d): + """a project of its own inside a test tree that no build around it includes""" + for n in _entries(repo, d): + if n in ('pyproject.toml', 'setup.py', 'setup.cfg'): return True + if (n in ('pom.xml', 'build.gradle', 'build.gradle.kts') or n.endswith('.csproj')) and _is_file(repo, f"{d}/{n}") \ + and not _included(repo, d, n): + return True + return False + + +_STR = re.compile(r'''(['"`])([^'"`\s]{1,240}|[^'"`\n]{0,120}/[^'"`\n]{0,120})\1|(?= limit: break + if len(files) >= limit: break + _cache[k] = r = (files, segs, tails, strs) + return r + + +_GLOBBY = re.compile(r'[*?{$]') + + +def _leads_to(repo, reader, a, rel, bare): + """whether `reader` writes a path to `a` that goes on to `rel` or stops there (or globs past it): `"TestData", + "a.json"` for TestData/a.json, `cases/*/case.json` for any case, `src/test/resources` alone. A path that carries + on into another file (`src/test/resources/other.yml`) reads that file, not this one. `bare`: a one-part match counts""" + ap = a.split('/') + nxt = rel[len(a) + 1:].split('/')[0] if rel and rel != a and rel.startswith(a + '/') else None + for ps in _reader_index(repo)[3].get(reader, ()): + for k in range(len(ap), 0 if bare else 1, -1): + tail = ap[-k:] + for i in range(len(ps) - k + 1): + if ps[i:i + k] != tail: continue + # a part written before the match that is not a's own (`app/orders` for tests/.../python/orders) is + # another directory of that name + if i and k < len(ap) and ps[i - 1] != ap[-k - 1] and not _GLOBBY.search(ps[i - 1]): continue + after = ps[i + k] if i + k < len(ps) else None + if after is None or nxt is None or after == nxt or _GLOBBY.search(after): return True + return False + + +def _namers(repo, d, rel=None, root=None): + """the readers outside `d` that name it as a path: its repo path or a run of two or more of its last parts, from + anywhere; its bare name only quoted or in a path, from a reader under d's parent (`os.path.join(HERE, + 'fixtures')`, `Path(__file__).parent / "fixtures"`, `"$HERE"/projects/*/`), or, with `root`, from a reader anywhere + under that test root (`fixture_project("demo-package")`, a classpath resource by its name). A reader in another + project (above the test root) counts only when it writes d's whole path. With `rel`, only the readers whose path + goes on to `rel` (_leads_to)""" + files, segs, tails, strs = _reader_index(repo) + parts = d.split('/') + many = set() + for i in range(len(parts) - 1): many |= tails.get('/'.join(parts[i:]), set()) + parent = '/'.join(parts[:-1]) + one = {r for r in segs.get(parts[-1], ()) if (not parent or r.startswith(parent + '/')) or (root and r.startswith(root + '/'))} + # a relative path (`src/test/resources`, `testdata/x`) is its reader's own project's: only a reader in d's project + # (the directories above its test root) means this tree by it + t = _test_root(parts) + home = '/'.join(parts[:t]) if t else '' + out = [] + for r in sorted(many | one): + if r.startswith(d + '/') or r == d: continue + if home and not r.startswith(home + '/') and d not in {'/'.join(ps) for ps in strs.get(r, ())}: continue + if rel is None or _leads_to(repo, r, d, rel, r in one): out.append(r) + return out + + +def data_tree(repo, rel): + """(the data directory holding `rel`, why) when `rel` lies in a fixture tree under a test root, else None""" + k = ('data', repo, rel) + if k in _cache: return _cache[k] + _cache[k] = r = _data_tree(repo, rel) + return r + + +def _data_tree(repo, rel): + dirs = rel.split('/')[:-1] + t = _test_root(dirs) + if t is None: return None + if dirs[t].lower() == 'test' and t and dirs[t - 1] == 'src' and len(dirs) > t + 1: + if dirs[t + 1] in JVM_SRC: return None # compiled test sources: code, not data + return '/'.join(dirs[:t + 2]), 'not compiled: the build copies it beside the test classes' + for k in range(t + 1, len(dirs)): + d = '/'.join(dirs[:k + 1]) + if _test_dir(repo, d): continue + if _namers(repo, d): + return d, 'read by path' + if GOLDEN.search(dirs[k]) or _holds_golden(repo, d): + return d, 'holds goldens' + if _fixture_project(repo, d): + return d, 'a project of its own no build includes' + return None + + +def data_readers(repo, d, rel): + """the runners and tests that read the file `rel` in the fixture tree `d`, or a directory between them, nearest + first: named as a path that leads to `rel` (_namers), or by the bare name from anywhere in the same test root when + at most a few files there carry that name (`fixture_project("demo-package")`, a classpath resource). A name every + case repeats (`case.json`) is no one file's. Failing those, a runner by its name above it in the same test root. + [(reader, how)]""" + parts = rel.split('/') + t = _test_root(parts[:-1]) + root = '/'.join(parts[:t + 1]) + chain = ['/'.join(parts[:k]) for k in range(len(parts), 0, -1) if len('/'.join(parts[:k])) >= len(d)] + for a in chain: + n = a.rsplit('/', 1)[-1] + # a plain word (`orders`, `projects`) is every fixture's vocabulary; a compound name (`demo-package`, + # `pypi.json`) is one thing's + few = len(n) >= 4 and bool(re.search(r'[-_.]', n)) and 0 < _count_under(repo, root, n, a != rel) <= 3 + rs = [r for r in _namers(repo, a, rel, root if few else None) if not r.startswith(d + '/')] + if rs: + return [(r, 'names ' + a) for r in sorted(rs, key=lambda r: (not _is_runner(repo, r), not _runs_itself(repo, r), r))] + return [(r, 'a runner by its name; it does not name this path') for r in _reader_index(repo)[0] + if RUNNER_NAME.match(os.path.basename(r)) and r.startswith(root + '/') and not r.startswith(d + '/') + and d.startswith(os.path.dirname(r) + '/')] + + +def _count_under(repo, root, name, is_dir): + """how many files (directories) named `name` lie under `root`""" + k = ('under', repo, root) + if k not in _cache: + fc, dc = {}, {} + for r, ds, fs in os.walk(os.path.join(repo, root)): + ds[:] = [x for x in ds if x not in _SKIP and not x.startswith('.')] + for f in fs: fc[f] = fc.get(f, 0) + 1 + for x in ds: dc[x] = dc.get(x, 0) + 1 + _cache[k] = (fc, dc) + return _cache[k][1 if is_dir else 0].get(name, 0) + + +def _data_plan(repo, f, cmds, owner, labels, test_command): + dt = data_tree(repo, f) + if not dt: return + d, why = dt + rd = data_readers(repo, d, f) + runs = [(r, how) for r, how in rd if _is_runner(repo, r)] + tests = [(r, how) for r, how in rd if not _is_runner(repo, r) and _runs_itself(repo, r)] + helpers = [(r, how) for r, how in rd if not _runs_itself(repo, r)] # a conftest, a resource reader: no test of its own + picked = runs[:3] or tests[:12] or helpers[:2] + owner[f] = picked[0][0] if picked else d + if not picked: + c = f"# no runner or test names {d}/ by path: grep the test tree for '{os.path.basename(d)}'" + cmds.setdefault(c, []).append(f); labels.setdefault(c, f"case data in {d}/ ({why})"); return + if not runs and tests: + # the tests that read it, as ONE command of their framework + rs = [r for r, _ in tests] + c = (test_command(rs) if test_command else None) or f"# run {', '.join(rs[:4])}" + cmds.setdefault(c, []).append(f) + labels.setdefault(c, f"case data for {', '.join(rs[:3])}" + (f" … +{len(rs) - 3}" if len(rs) > 3 else '') + + ('' if tests[0][1].startswith('names') else f" ({tests[0][1]})")) + return + for r, how in picked: + if _is_runner(repo, r): + # the whole runner: a fixture tree is not its case list, so no case goes on the line; a language level below + # the tree (fastpath_cases/python/...) selects with the runner's own --lang flag when it has one + c = command(repo, r) + lang = next((s for s in f[len(d) + 1:].split('/')[:-1] if s in LANGS), None) + lm = _FLAG_LANG.search(_read(repo, r)) if lang else None + if lm: c += f" {lm.group(1)} {lang}" + else: + c = (test_command([r]) if test_command else None) or f"# run the tests that use {r}" + cmds.setdefault(c, []).append(f) + labels.setdefault(c, f"case data for {r}" + ('' if how.startswith('names') else f" ({how})") + + (' (a helper with no test of its own: the tests beside it)' if not _runs_itself(repo, r) else '')) + + +def plan(repo, files, test_command=None): + """({command: [files]}, {file: runner}) for the files that are a case runner, its inputs, the rules it mirrors, or + a file in a fixture tree; the rest are not this module's. After a call `plan.labels` says what each command is for + (`case data for `); `test_command([test files])` builds the command of the TESTS that read a fixture tree""" + cmds, owner = {}, {} + plan.labels = labels = {} + for f in files: + if f in runners_in(repo, os.path.dirname(f)): # the runner itself: it runs whole + cmds.setdefault(command(repo, f), []).append(f); owner[f] = f; continue + c = case_of(repo, f) + if c: + rs, sel, case = c + for r in rs: + cmds.setdefault(command(repo, r, sel, case), []).append(f) + for r in readers(repo, os.path.dirname(rs[0]), case): + if r not in rs: cmds.setdefault(command(repo, r), []).append(f) + owner[f] = rs[0]; continue + for f in files: + if f in owner: continue + rs = mirrored_runners(repo, f) + for r in rs: + cmds.setdefault(command(repo, r), []).append(f) + if rs: owner[f] = rs[0] + for f in files: + if f not in owner: _data_plan(repo, f, cmds, owner, labels, test_command) + # one case run is part of a whole-runner run: say the whole one only + wholes = {c for c in cmds if c.count(' ') == 1} + for c in list(cmds): + if c not in wholes and any(c.startswith(w + ' ') for w in wholes): + w = next(w for w in wholes if c.startswith(w + ' ')); cmds[w] = sorted(set(cmds[w]) | set(cmds.pop(c))) + if c in labels: labels.setdefault(w, labels.pop(c)) + return cmds, owner + + +def in_case_dir(repo, rel): + """whether `rel` is an input under a case runner's `cases/` or in a fixture tree (data, never a test of its own)""" + c = case_of(repo, rel) + return (bool(c) and '/' + CASES + '/' in '/' + rel) or bool(data_tree(repo, rel)) + + +_JOIN = re.compile(r'''(['"][\w./-]+)['"]\s*,\s*['"](?=[\w./-]+['"])''') + + +_JOIN_CALL = re.compile(r'\b(?:join|Path|get|of|Combine|resolve)\(([^()\n]*)\)') + + +def joined_paths(text): + """`os.path.join(ROOT, 'plugins', 'scripts', 'tool')` (Paths.get, Path.Combine) read as the path it builds + (`'plugins/scripts/tool'`): a test that starts a program by a joined path names that path, and a text search for + it must see it""" + if "', '" not in text and '", "' not in text and "','" not in text and '","' not in text: return text + + def one(m): + inner = m.group(1) + for _ in range(16): + t = _JOIN.sub(r'\1/', inner) + if t == inner: break + inner = t + return m.group(0).replace(m.group(1), inner) + return _JOIN_CALL.sub(one, text) + + +def dispatcher_of(repo, rel): + """the dispatcher a program is started through: `d/tool` for `d/tool-verb`, when `d/tool` is a script that execs + `tool-` (the subcommand convention git and many CLIs use)""" + d, base = os.path.split(rel) + if '-' not in base: return None + head = base.split('-', 1)[0] + for cand in (head, head + '.sh', head + '.py'): + p = f"{d}/{cand}" if d else cand + if os.path.isfile(os.path.join(repo, p)): + t = _read(repo, p) + if re.search(re.escape(head) + r'-\$(?:\{?\w+\}?|\d)|' + re.escape(head) + r"-['\"]?\s*\+|" + re.escape(base), t): + return p + return None + + +def name_count(repo, base, dirs=False): + """how many files (`dirs`: directories) in the repository carry the name `base`: a data file's name that several + files share (every case's `case.json`) says nothing about which of them a test reads, so it is no test-name match""" + k = ('names', repo) + if k not in _cache: + cnt, dcnt = {}, {} + for root, ds, fs in os.walk(repo): + ds[:] = [d for d in ds if d not in _SKIP and not d.startswith('.')] + for f in fs: cnt[f] = cnt.get(f, 0) + 1 + for d in ds: dcnt[d] = dcnt.get(d, 0) + 1 + _cache[k] = (cnt, dcnt) + return _cache[k][1 if dirs else 0].get(base, 0) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index b1cdbad0..b076f4fc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -307,7 +307,7 @@ def next_test_impact(text): # 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|python manage\.py test|python -m unittest|python(?= \S+\.py$)|dotnet|go) [^\n]+)$', text, re.M)) + 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|python manage\.py test|python -m unittest|python(?= \S+\.py$)|python3(?= \S+\.py\b)|bash(?= \S+\.sh\b)|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:]] diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index b7e162ad..39815ccd 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -662,9 +662,12 @@ class Changed: len(re.findall(r'^\s*(?:[\w<>\[\],.?]+\s+)*(?:class|interface|enum|record|struct)\s+\w', code, re.M)) is_test = bool(re.search(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', rel, re.I)) or \ any(self.g.all_sym.get(d[2], {}).get('is_test') for d in callables) + # a new file in a fixture tree (case data a runner reads) sits under tests/ but is no test to run + data = case_data(self.repo, [rel]).get(rel) if is_test else None + if data: is_test = False out = [dict(kind='added', symbol=rel, id=None, file=rel, line=1, end=len(new.split('\n')), old_lines=[], target=None, new_file=True, is_test=is_test, detail=f"new file, {n} declaration(s)" + ('' if decls else ' by its text (the graph does not hold it yet)') - + (' — a test file: run it' if is_test else ''))] + + (' — a test file: run it' if is_test else f' — {data}' if data else ''))] ids = [d[2] for d in callables if isinstance(d[2], (int, str)) and not str(d[2]).startswith('f:')] mids = {self.g.all_sym.get(i, {}).get('method_id'): i for i in ids if self.g.all_sym.get(i, {}).get('method_id') is not None} if mids: @@ -718,6 +721,23 @@ def outside_note(files, suite=None): + ', '.join(fs[:8]) + (f' … +{len(fs) - 8}' if len(fs) > 8 else '') + " — `test-impact` names the tests that load them by name; an edit here is not 'no change'") +def case_data(repo, files): + """{file: 'case data for '} for the files a case runner reads (a case directory, a fixture tree, a golden) or + the rules it mirrors: inputs, not tests, whatever directory they sit in (ax_caserun)""" + try: + sys.path.insert(0, HERE); import ax_caserun + cmds, owner = ax_caserun.plan(os.path.realpath(repo), sorted(files)) + labels = getattr(ax_caserun.plan, 'labels', {}) + except Exception: + return {} + out = {} + for c, fs in sorted(cmds.items()): + who = (labels.get(c) or '').replace('case data for ', '') or (c.split()[1] if len(c.split()) > 1 else c) + what = (f"read by {who}" if not c.startswith('#') or labels.get(c, '').startswith('case data for') else labels.get(c, who)) \ + + ('' if c.startswith('#') else f"; run `{c}`") + for f in fs: out.setdefault(f, 'case data ' + what) + return out + def held_extensions(repo): """the file extensions some graph of this repository holds (the main one and every per-language one)""" import sqlite3, glob @@ -866,6 +886,13 @@ def main(argv): # a note with a file is an addition in that file; one without is a caveat about the whole answer, and printing it # under `added` made "the graph is built at the newer commit …" read as a new declaration for f, n, _ in notes: print(f" {'added':<10} {n}" if f else f"note: {n}") + # CASE DATA IS NOT A TEST: a changed file in a case directory or a fixture tree is an input its runner reads + cd = case_data(C.repo, {e['file'] for e in results if e.get('file') and not e.get('new_file')} | set(outside)) + if cd: + by = {} + for f, what in sorted(cd.items()): by.setdefault(what, []).append(f) + for what, fs in by.items(): + print(f"case data ({len(fs)} file(s): {', '.join(fs[:3])}{' …' if len(fs) > 3 else ''}): {what[len('case data '):]}; an input, not a test to run") groups = {} for e in results: if e.get('target') and (e['kind'] != 'added' or e.get('referenced')): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index f502bdf7..1e389bf8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -73,6 +73,27 @@ def script_run(repo, rel): _TI = importlib.util.module_from_spec(spec); spec.loader.exec_module(_TI) return _TI.script_command(repo, rel) +def _ti(): + global _TI + if '_TI' not in globals(): + spec = importlib.util.spec_from_loader('axtestimpact', importlib.machinery.SourceFileLoader('axtestimpact', os.path.join(HERE, 'axiomcode-test-impact'))) + _TI = importlib.util.module_from_spec(spec); spec.loader.exec_module(_TI) + return _TI + + +def program_starters(repo, files): + """{file: (the name a test starts it by, [those tests])} for the files among `files` that run as a program (a main + entry, a shebang, a subcommand of a dispatcher beside them): a suite that starts one as a subprocess has no call edge + to it, so `tests: 0` there is not 'nothing tests this'. test-impact's own text tier (loaders), so both name the same + tests""" + TI = _ti() + progs = {} + for f in files: + try: txt = open(os.path.join(repo, f), errors='replace').read(400_000) + except OSError: continue + if TI.PROGRAM.search(txt): progs[f] = True + return TI.loaders(repo, progs) if progs else {} + # written per query (or supplied by the path tool's own export), so the cached export is NOT expected to hold them. Leaving # `nonsource` / `qual_name` / `edge` / `named` out of this set made the completeness check below unsatisfiable, so the 15 MB # export re-ran on EVERY query — the cache never hit once. @@ -2838,6 +2859,13 @@ def main(argv): 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 " "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in test_rows[:4]) + (f" … +{len(test_rows) - 4}" if len(test_rows) > 4 else '')) + # A PROGRAM THE SUITES START AS A SUBPROCESS (a CLI script, a hook, a subcommand its dispatcher execs): no test + # calls it, so the count is 0, and a reader took that as untested and ran nothing + if not tests: + for f, (needle, ts) in sorted(program_starters(g.repo, sorted({g.sym[s]['file'] for s in seeds if s in g.sym and g.sym[s].get('file')})).items()): + print(f" NOT COUNTED: {f} runs as a program, and {len(ts)} test file(s) name it or the dispatcher that starts it, running it as a subprocess, which is" + f" no call edge: " + ', '.join(ts[:4]) + (f" … +{len(ts) - 4}" if len(ts) > 4 else '') + + f"; `axiomcode test-impact {f}` gives the commands") 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}") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index f1fc4c35..4697d43c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -61,6 +61,7 @@ TESTY = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import graph_sql +import ax_caserun def run(script, args): @@ -161,6 +162,18 @@ LANG_FAMILY = {'python': 'py', 'java': 'java', 'csharp': 'cs', 'typescript': 'js PY_TEST_FILE = re.compile(r'(^|/)(test[^/]*|[^/]*_test)\.py$') # what pytest, unittest and Django collect by default +def runs_as_test(lang, repo, rel): + """collected_by, or a SCRIPT TEST of that language (graph_sql.script_test_file: a test-tree program with its own + main, which the project runs by its command rather than a framework's): a suite that starts the changed code as a + subprocess is one of these, and it is a test to run, not a file 'no command runs'""" + if collected_by(lang, rel): return True + if not lang or _guess_lang([rel]) != lang or graph_sql.NOT_A_SCRIPT.search(rel): return False + t = _read(os.path.join(repo, rel), 400_000) + if rel.endswith('.py'): # py_plan's own reading: it exits, or it guards its main + return bool(t and (PY_EXITS.search(t) or PY_MAIN.search(t))) + return graph_sql.script_test_file(rel, t) + + def collected_by(lang, rel): """whether `lang`'s runner would run the file `rel` as a test module: a text match is a lead on any file, a command names only test modules of its own language (TypeScript and JavaScript are placed by js_plan, which says so)""" @@ -1722,6 +1735,20 @@ def loaders(repo, wanted, edited=(), lang=None, others=None): test module the runner collects outranks a helper or fixture beside it that names the file more fully.""" if not wanted: return {} want = {f: needles(f, prog) for f, prog in wanted.items()} + # A DATA FILE'S OWN NAME IS NO MATCH when other files share it: `case.json` in every case directory named five tests + # that write or glob some case.json, none of them the one reading this file. Its path (two parts or more) still is + for f, prog in wanted.items(): + b = f.rsplit('/', 1)[-1] + if not prog and ax_caserun.name_count(repo, b) > 1: + # and a bare directory word is weaker still: `Proto` for Proto/greet.proto named every test that writes Proto + want[f] = [n for n in want[f] if n != b and '/' in n] + # a program run through a dispatcher beside it (`scripts/tool` execs `scripts/tool-verb`, git's convention) is + # started by every test that starts the dispatcher: its path is the one a test names + via = {} # file -> index of its dispatcher's first needle + for f, prog in wanted.items(): + disp = ax_caserun.dispatcher_of(repo, f) if prog else None + if disp: + via[f] = len(want[f]); want[f] = want[f] + [n for n in needles(disp) if '/' in n and n not in want[f]] # the needles that are one directory of the file (data under a test tree), matched as that directory: names_dir dirs = {f: {n for n in ns if '/' not in n and n in f.split('/')[:-1] and n != os.path.basename(f)} for f, ns in want.items()} # a path or a file name stands alone (not inside a longer name); a bare word (a directory, a program's name) only @@ -1737,6 +1764,8 @@ def loaders(repo, wanted, edited=(), lang=None, others=None): return n not in dirs[f] or names_dir(f, n, t, text) for t, text in test_texts(repo): if t in wanted or t in edited: continue + if ax_caserun.in_case_dir(repo, t): continue # a case's data names things; it runs nothing + text = ax_caserun.joined_paths(text) if fam and family(t) != fam: if others is not None: for f, ns in want.items(): @@ -1746,16 +1775,61 @@ def loaders(repo, wanted, edited=(), lang=None, others=None): for k, n in enumerate(ns): if said(f, n, t, text): hits[f].setdefault(k, []).append(t) - if fam and collected_by(lang, t): runs[f].setdefault(k, []).append(t) + if fam and runs_as_test(lang, repo, t): runs[f].setdefault(k, []).append(t) break out = {} for f in wanted: by = runs[f] or hits[f] if by: - k = min(by); out[f] = (want[f][k], sorted(by[k])) + k = min(by); ts = set(by[k]) + # a test that names the program itself does not stand for the suites that start it through its dispatcher + if f in via and k < via[f]: + kd = min((j for j in by if j >= via[f]), default=None) + if kd is not None: ts |= set(by[kd]) + out[f] = (want[f][k], sorted(ts)) return out +def reader_command(repo, rs): + """the command that runs the test files `rs` that read a fixture tree; for a helper that reads it and declares no + test of its own (a conftest.py, a resource reader), the tests beside it: its directory's pytest run, its project's + dotnet test, its module's mvn test""" + lang = _guess_lang(rs) + ts = [r for r in rs if runs_as_test(lang, repo, r) and not r.endswith('conftest.py')] + c = command_for(lang, ts, [], None, repo) if ts else None + if c: return c + r = rs[0] + d = os.path.dirname(r) + if lang == 'python': + return f"pytest {shlex.quote(d or '.')}" + marker = {'csharp': lambda n: n.endswith('.csproj'), 'java': lambda n: n in ('pom.xml', 'build.gradle', 'build.gradle.kts')}.get(lang) + while marker: + try: names = os.listdir(os.path.join(repo, d)) + except OSError: names = [] + m = next((n for n in sorted(names) if marker(n)), None) + if m: + if lang == 'csharp': return f"dotnet test {shlex.quote(os.path.join(d, m) if d else m)}" + tool = 'mvn test' if m == 'pom.xml' else 'gradle test' + return f"(cd {shlex.quote(d)} && {tool})" if d else tool + if not d: break + d = os.path.dirname(d) + return None + + +def print_case_runs(case_cmds, limit=40, labels=None): + """the case runners the changed case data and rule files map to, one command per case (ax_caserun); a fixture + tree's files under the runner or test that reads them (`case data for `), its command on a line of its own""" + n = len({f for fs in case_cmds.values() for f in fs}) + print(f"\ncase data and rules ({n} file(s)): inputs a case runner or a test reads, not tests themselves; run what reads them:") + for c, fs in sorted(case_cmds.items(), key=lambda kv: (kv[0].startswith('#'), kv[0])): + if labels and c in labels: + print(f" {labels[c]}:") + print(f" {c}") + else: + print(f" {c}") + print(f" for " + ', '.join(fs[:4]) + (f" … +{len(fs) - 4}" if len(fs) > 4 else '')) + + def read_answer(out): """impact's JSON document, even when a diagnostic was printed in front of it. @@ -1830,20 +1904,31 @@ def main(argv): sys.stderr.write(err or out); return rc or 2 entries = changed.get('changed') or [] + repo = next((a for a in passthru if os.path.isdir(a)), '.') + # CASE DATA AND THE RULES A CASE RUNNER SOLVES WITH (ax_caserun): a file under a runner's cases/ is an input it + # reads, not a test, and a rule file the runner's directory mirrors has no call edge to any test. Both map to the + # runner, with the command that runs that one case, and leave every other tier: a fixture's own test_*.py handed + # to pytest, or a case's source matched by name against another case's, runs nothing that checks anything + # A FIXTURE TREE under a test root (read by path from a runner or a test, holding goldens, or a project no build + # includes) is case data the same way: its files map to the runner or test that reads it, with that one's command + case_cmds, case_owned = ax_caserun.plan(os.path.realpath(repo), sorted({e['file'] for e in entries if e.get('file')} + | set(changed.get('outside_index') or [])), + test_command=lambda r: reader_command(repo, r)) + case_labels = dict(getattr(ax_caserun.plan, 'labels', {})) + entries = [e for e in entries if e.get('file') not in case_owned] targets, by_target = [], {} for e in entries: t = e.get('target') if not t or (e.get('kind') == 'added' and not e.get('referenced')): continue if t not in by_target: by_target[t] = e; targets.append(t) - repo = next((a for a in passthru if os.path.isdir(a)), '.') heads = [f"note: {changed['range_note']}"] if changed.get('range_note') else [] if changed.get('baseline_note'): heads.append(f"note: {changed['baseline_note']}") # A CHANGED TEST FILE IS ITSELF A TEST TO RUN: a new test file, or an edited one, has no test reaching it — it is one edited = sorted({e['file'] for e in entries if e.get('file', '').endswith(CODE) and (e.get('is_test') or TESTY.search(e['file']))}) # WHAT NO CALL EDGE REACHES: files outside every indexed language (fixtures, case data), and changed code that is # also started as a program (a test that runs it as a subprocess has no edge to it). Looked for by name in the tests - outside = changed.get('outside_index') or [] + outside = [f for f in changed.get('outside_index') or [] if f not in case_owned] progs = {} for f in sorted({e['file'] for e in entries if e.get('file')} - set(edited)): try: txt = open(os.path.join(repo, f), errors='replace').read(400_000) @@ -1859,7 +1944,12 @@ def main(argv): other_lang = set().union(*other_by.values()) if other_by else set() loaded_files = sorted({t for _, ts in loaded.values() for t in ts}) - if not targets and not edited and not loaded and not outside: + if not targets and not edited and not loaded and not outside and case_cmds and not as_json: + for h in heads: print(h) + print_case_runs(case_cmds, limit, case_labels) + print("\nbound: a case runner's command runs what its cases check; a test that reaches these files some other way is not here.") + return 0 + if not targets and not edited and not loaded and not outside and not case_cmds: for h in heads: print(h) print("no changed declaration the graph can name, so no test can be selected from it.") for n in (changed.get('notes') or [])[:8]: print(f" {n}") @@ -1931,6 +2021,10 @@ def main(argv): else: stub_only.setdefault(rid, rec); stub_by[rid].add(bt) + # a test the graph found INSIDE a case's data is the fixture's own test_*.py: data a runner reads, never collected + real = os.path.realpath(repo) + case_data_tests = {i for i, r in tests.items() if r.get('at') and ax_caserun.in_case_dir(real, r['at'].split(':')[0])} + for i in case_data_tests: tests.pop(i, None) 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 # what the run command is built from — so the answer names it nowhere and the suite runs without it. @@ -1978,6 +2072,8 @@ def main(argv): **({'tests_outside_src': out_src} if out_src and not tests else {}), 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, 'named_in_other_language_tests': len(other_lang), + 'case_runs': case_cmds, + 'case_data_tests_not_selected': len(case_data_tests), 'range_note': changed.get('range_note'), 'baseline_note': changed.get('baseline_note'), 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' @@ -1992,6 +2088,11 @@ def main(argv): if edited: print(f"\nedited test file(s) ({len(edited)}), run as they are:") for f in edited[:limit]: print(f" {f}") + if case_cmds: + print_case_runs(case_cmds, limit, case_labels) + if case_data_tests: + print(f"\n not selected: {len(case_data_tests)} test function(s) the graph reached lie inside a case runner's cases/ (a" + " fixture's own tests, read as data by the runner): the runner's command above is what checks them") if not tests and targets: print("\nno test in the graph reaches any of them.") print(" that is not the same as 'no test covers this': a function invoked through an array, a") @@ -2073,7 +2174,7 @@ def main(argv): # every file of a test tree, fixtures in other languages included, and a majority of those once chose the # runner and put .cs and .ts fixtures on a `pytest` line rlang = lang or _guess_lang(run_files or loaded_files) - runnable = [f for f in loaded_files if collected_by(rlang, f)] + runnable = [f for f in loaded_files if runs_as_test(rlang, repo, f)] both = sorted(set(run_files) | set(runnable)) cmd = command_for(rlang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in runnable] if classes else [], db, repo) if cmd: print("\n with the tests above: " + cmd.replace("\n", "\n ")) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index fed3be25..91e6f7c1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -95,7 +95,7 @@ keys, injected beans and handlers registered as values — none has a call site. `field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 844eb7ad..85d50934 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -22,6 +22,9 @@ What to pass, and what the answer says when the question cannot be answered the | named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | | 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 file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | +| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | +| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | **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 diff --git a/tests/README.md b/tests/README.md index 752dfe36..1dd58778 100644 --- a/tests/README.md +++ b/tests/README.md @@ -103,6 +103,14 @@ One check needs no graph and is its own script: python3 tests/script_tests.py a script-style test (a test-tree file run as a program, no framework) is selected by test-impact with the command its project runs it by, beside a framework test that keeps its own; a helper and a runner's setup file are not (indexes two cases, needs the engine) + python3 tests/case_runner.py a changed file under a case runner's cases/ (tests/cases///, graph/test/ + /cases//) and a golden named for a case map to that runner's command for + the one case, a rule file under graph// to graph/test//run-tests.sh; a + program started through its dispatcher is credited to the test that starts it; controls: + a real pytest file beside the cases keeps pytest, a fixture's own test is not selected; + a fixture tree of any name maps to the script or test that reads it by path, a golden + to the test that opens it, and a shared data-file name (case.json) is no name match + (indexes one repository, needs the 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/case_runner.py b/tests/case_runner.py new file mode 100644 index 00000000..7d370643 --- /dev/null +++ b/tests/case_runner.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""tests/case_runner.py: a data-driven suite is run by its case runner, never by pytest on its fixtures. + +A runner script beside a `cases/` directory walks it (tests/run.py reading tests/cases///case.json, +graph/test//run-tests.sh solving each cases//src). test-impact handed a changed case file to pytest (the +fixture's own test_*.py is data, nothing collects it), matched it by name against another case's files, and said "no +test names" a rule file the engine suite reads; impact said "tests: 0" for a program the suites start as a subprocess. + +Each scenario edits a throwaway git repository and asks test-impact (and impact) what to run: + + - case data maps to the runner's command for that ONE case, as the runner's usage line spells it; + - a golden beside the cases (expected/.edges) maps to the same case; + - a rule file under graph// maps to graph/test//run-tests.sh, whole (the test tree mirrors it); + - controls: a real pytest file next to the case data still runs with pytest; a fixture's own test_*.py is not + selected; a script that only mentions cases/ in a comment is not a runner; another case's same-named file is + not named; + - a program started through a dispatcher (tools/cli execs tools/cli-.py) is credited to the test that starts + the dispatcher, in test-impact and on impact's tests line; + - a FIXTURE TREE of any name (tests/fastcases/, read by a script beside it through a joined path) is case data for + that script, with its --lang flag; a golden a test opens by path maps to that test; `changed` calls a new file in + a fixture tree case data, never "a test file: run it"; + - a data file's own name is no test-name match when other files share it (case.json, settings.json); + - controls: a golden no test reads is not given its neighbour's test; the test that reads a golden, edited, still + runs; a data file named by its own unique path keeps its test; a new real test file is still "run it". + + python3 tests/case_runner.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') + +RUNNER_PY = '''#!/usr/bin/env python3 +"""tests/run.py [ ...] [--lang python|java] + +Each case is tests/cases/// with a case.json. +""" +import json, os, sys +HERE = os.path.dirname(os.path.abspath(__file__)) +args = sys.argv[1:] +lang = args[args.index('--lang') + 1] if '--lang' in args else None +only = [a for a in args if not a.startswith('-') and a != lang] +for l in sorted(os.listdir(os.path.join(HERE, 'cases'))): + for c in sorted(os.listdir(os.path.join(HERE, 'cases', l))): + if (not only or c in only) and (not lang or l == lang): + json.load(open(os.path.join(HERE, 'cases', l, c, 'case.json'))) +sys.exit(0) +''' + +ENGINE_SH = '''#!/usr/bin/env bash +# engine suite +# ./run-tests.sh every case +# ./run-tests.sh 01 02 only cases matching those substrings +HERE="$(cd "$(dirname "$0")" && pwd)" +for a in "$@"; do :; done +for dir in "$HERE"/cases/*/; do echo "$dir"; done +''' + +ENGINE_CS_SH = '''#!/bin/bash +# ./run-tests.sh every case +# ./run-tests.sh --only 01-y one case +HERE="$(cd "$(dirname "$0")" && pwd)" +while [ $# -gt 0 ]; do case "$1" in --only) ONLY="$2"; shift 2;; *) shift;; esac; done +for dir in "$HERE"/cases/*/; do echo "$dir"; done +''' + +FILES = { + 'app/__init__.py': '', + 'app/core.py': 'def core(x):\n return x + 1\n', + # a real pytest test NEXT TO the case data: still pytest's + 'tests/test_real.py': 'from app.core import core\n\n\ndef test_core():\n assert core(1) == 2\n', + 'tests/run.py': RUNNER_PY, + # names cases/ only in a comment: not a runner + 'tests/notes.py': '# the loop in run.py iterates cases/*/ and reads each case.json\nif __name__ == "__main__":\n print("notes")\n', + 'tests/cases/python/alpha/case.json': '{"checks": []}\n', + 'tests/cases/python/alpha/app/__init__.py': '', + 'tests/cases/python/alpha/app/pricing.py': 'def price(amount):\n return amount * 1.2\n', + 'tests/cases/python/alpha/tests/test_pricing.py': 'from app.pricing import price\n\n\ndef test_price():\n assert price(10) == 12\n', + 'tests/cases/python/beta/case.json': '{"checks": []}\n', + 'tests/cases/python/beta/app/__init__.py': '', + 'tests/cases/python/beta/app/pricing.py': 'def price(amount):\n return amount\n', + 'tests/cases/python/beta/tests/test_pricing.py': 'from app.pricing import price\n\n\ndef test_price():\n assert price(3) == 3\n', + 'tests/cases/java/gamma/case.json': '{"checks": []}\n', + 'tests/cases/java/gamma/src/A.java': 'class A { int f() { return 1; } }\n', + 'tests/cases/csharp/delta/case.json': '{"checks": []}\n', + 'tests/cases/csharp/delta/A.cs': 'class A { int F() { return 1; } }\n', + 'graph/python/engine/rules.dl': '.decl edge(a: symbol, b: symbol)\n', + 'graph/java/engine/rules.dl': '.decl edge(a: symbol, b: symbol)\n', + 'graph/csharp/engine/rules.dl': '.decl edge(a: symbol, b: symbol)\n', + 'graph/test/python/run-tests.sh': ENGINE_SH, + 'graph/test/python/cases/01-x/src/main.py': 'def main():\n return 1\n', + 'graph/test/python/expected/01-x.edges': 'main -> x\n', + 'graph/test/java/run-tests.sh': ENGINE_SH, + 'graph/test/java/cases/01-z/src/A.java': 'class A {}\n', + 'graph/test/csharp/run-tests.sh': ENGINE_CS_SH, + 'graph/test/csharp/cases/01-y/src/B.cs': 'class B {}\n', + # a dispatcher and the subcommand it execs, and the test that starts the dispatcher as a subprocess + 'tools/cli': '#!/usr/bin/env bash\nH="$(cd "$(dirname "$0")" && pwd)"\ncmd="$1"; shift\nexec python3 "$H/cli-$cmd.py" "$@"\n', + 'tools/cli-report.py': ('#!/usr/bin/env python3\nimport sys\n\n\ndef render(rows):\n return ", ".join(rows)\n\n\n' + 'if __name__ == "__main__":\n print(render(sys.argv[1:]))\n'), + # A FIXTURE TREE with no cases/ runner: a script beside it reads it by a joined path, its --lang flag selects + 'tests/fast.py': ('#!/usr/bin/env python3\n"""tests/fast.py [--lang python|java]"""\nimport os, sys\n' + 'HERE = os.path.dirname(os.path.abspath(__file__))\nFP = os.path.join(HERE, "fastcases")\n\n\n' + 'if __name__ == "__main__":\n print(sorted(os.listdir(FP)))\n'), + 'tests/fastcases/python/shop/orders.py': 'def total(xs):\n return sum(xs)\n', + 'tests/fastcases/python/shop/test_orders.py': 'from shop.orders import total\n\n\ndef test_total():\n assert total([1]) == 1\n', + # goldens a pytest test opens by path; the second golden is read by nothing + 'tests/test_report.py': ('import os\nHERE = os.path.dirname(os.path.abspath(__file__))\n\n\n' + 'def test_report():\n assert open(os.path.join(HERE, "golden", "report.expected")).read()\n'), + 'tests/golden/report.expected': 'total: 1\n', + 'tests/golden/other.expected': 'total: 2\n', + # a data file whose name other files share, and one whose name is its own + 'conf/a/settings.json': '{"a": 1}\n', + 'conf/b/settings.json': '{"b": 1}\n', + 'conf/unique_rules.json': '{"r": 1}\n', + 'tests/test_conf.py': ('import json\n\n\ndef test_conf():\n assert json.load(open("conf/unique_rules.json"))\n' + ' assert "settings.json"\n'), + 'tests/smoke.py': ('import os, subprocess, sys\nROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n' + 'CLI = os.path.join(ROOT, "tools", "cli")\n' + 'out = subprocess.run(["bash", CLI, "report", "a", "b"], capture_output=True, text=True).stdout\n' + 'sys.exit(0 if out.strip() == "a, b" else 1)\n'), +} + +# (why, {file: (old, new)}, extra args, wanted substrings, unwanted substrings) +SCENARIOS = [ + ('python case data', {'tests/cases/python/alpha/app/pricing.py': ('1.2', '1.25'), + 'tests/cases/python/alpha/tests/test_pricing.py': ('== 12', '== 12.5')}, [], + ['python3 tests/run.py alpha --lang python'], + ['pytest tests/cases', 'tests/cases/python/beta', 'no test in the graph reaches', 'tests/notes.py', 'python tests/cases']), + ('engine rule and golden (python)', {'graph/python/engine/rules.dl': ('edge(', 'edges('), + 'graph/test/python/expected/01-x.edges': ('main', 'main2')}, [], + ['bash graph/test/python/run-tests.sh\n'], + ['no test names', 'run-tests.sh 01-x', 'graph/test/java', 'graph/test/csharp']), + ('engine case source (python), one case', {'graph/test/python/cases/01-x/src/main.py': ('return 1', 'return 2')}, [], + ['bash graph/test/python/run-tests.sh 01-x'], + ['python graph/test/python/cases', 'pytest graph/test']), + ('java case data and rule', {'tests/cases/java/gamma/src/A.java': ('return 1', 'return 2'), + 'graph/java/engine/rules.dl': ('edge(', 'edges(')}, [], + ['python3 tests/run.py gamma --lang java', 'bash graph/test/java/run-tests.sh'], + ['no test names', 'graph/test/python/run-tests.sh', 'pytest']), + ('csharp case data, runner flag from its usage', {'tests/cases/csharp/delta/A.cs': ('return 1', 'return 2'), + 'graph/test/csharp/cases/01-y/src/B.cs': ('class B {}', 'class B { }')}, [], + ['python3 tests/run.py delta --lang csharp', 'bash graph/test/csharp/run-tests.sh --only 01-y'], + ['no test names', 'pytest']), + ('control: real code next to case data keeps pytest', {'app/core.py': ('x + 1', 'x + 2')}, [], + ['pytest tests/test_real.py'], + ['tests/run.py', 'tests/cases/python/alpha/tests/test_pricing.py', 'case data and rules']), + ('fixture tree read by a script beside it', {'tests/fastcases/python/shop/orders.py': ('sum(xs)', 'sum(xs) + 0'), + 'tests/fastcases/python/shop/test_orders.py': ('== 1', '== 1.0')}, [], + ['case data for tests/fast.py', 'python3 tests/fast.py --lang python'], + ['pytest tests/fastcases', 'edited test file', 'BY PACKAGE', 'BY NAME']), + ('golden read by a test, by path', {'tests/golden/report.expected': ('total: 1', 'total: 1.0')}, [], + ['case data for tests/test_report.py', 'pytest tests/test_report.py'], + ['[text]']), + ('control: a golden no test reads is not given its neighbour\'s test', {'tests/golden/other.expected': ('total: 2', 'total: 3')}, [], + ['case data for tests/run.py (a runner by its name; it does not name this path)'], + ['pytest tests/test_report.py']), + ('control: editing the test that reads the golden runs that test', {'tests/test_report.py': ('.read()', '.read().strip()')}, [], + ['pytest tests/test_report.py'], + ['case data']), + ('case.json maps to its case, never to the files that write some case.json', {'tests/cases/python/alpha/case.json': ('[]', '[ ]')}, [], + ['python3 tests/run.py alpha --lang python'], + ["named as 'case.json'", 'tests/notes.py', 'tests/smoke.py']), + ('a data file\'s shared name is no test-name match', {'conf/a/settings.json': ('1', '2')}, [], + [], + ["named as 'settings.json'", 'pytest tests/test_conf.py']), + ('control: a data file named by its own unique path keeps its test', {'conf/unique_rules.json': ('1', '2')}, [], + ['tests/test_conf.py'], + ['case data']), + ('a program started through its dispatcher', {'tools/cli-report.py': ('", ".join', '" , ".join')}, [], + ['tests/smoke.py'], + ['no test names']), +] + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + work = tempfile.mkdtemp(prefix='axiomcode-case-runner-') + try: + repo = os.path.join(work, 'repo') + for rel, text in FILES.items(): + p = os.path.join(repo, rel); os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').write(text) + for rel in ('tools/cli', 'tools/cli-report.py', 'tests/run.py', 'tests/fast.py'): + os.chmod(os.path.join(repo, rel), 0o755) + for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), + ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): + sh(repo, *cmd) + built = sh(repo, AX, 'index', '.', '--lang', 'python', env=env) + check(built.returncode == 0, 'the fixture indexes', built.stdout + built.stderr) + n_ok = 0 + for why, edits, extra, want, avoid in SCENARIOS: + sh(repo, 'git', 'checkout', '-q', '--', '.') + for rel, (old, new) in edits.items(): + p = os.path.join(repo, rel); t = open(p).read() + check(old in t, f'{why}: the edit applies to {rel}'); open(p, 'w').write(t.replace(old, new, 1)) + r = sh(repo, AX, 'test-impact', '.', *extra, env=env) + out = r.stdout + r.stderr + check(r.returncode == 0 and 'Traceback' not in out, f'{why}: test-impact answers', out) + for w in want: check(w in out, f'{why}: names {w.strip()!r}', out) + for a in avoid: check(a not in out, f'{why}: does not name {a.strip()!r}', out) + n_ok += 1 + # changed: a new file inside a fixture tree is case data for its runner, not 'a test file: run it' + sh(repo, 'git', 'checkout', '-q', '--', '.') + p = os.path.join(repo, 'tests', 'fastcases', 'python', 'shop', 'test_new.py') + open(p, 'w').write('def test_new():\n assert 1\n') + r = sh(repo, AX, 'changed', '.', env=env) + out = r.stdout + r.stderr + check('case data read by tests/fast.py' in out and 'a test file: run it' not in out, + 'changed: a new file in a fixture tree is case data for its runner', out) + os.remove(p) + p = os.path.join(repo, 'tests', 'test_added.py') + open(p, 'w').write('def test_added():\n assert 1\n') + r = sh(repo, AX, 'changed', '.', env=env) + out = r.stdout + r.stderr + check('a test file: run it' in out and 'case data' not in out, 'control: changed: a new real test file is a test to run', out) + os.remove(p) + # impact's tests line: 0 tests call it, and the suite that starts it as a subprocess is named + sh(repo, 'git', 'checkout', '-q', '--', '.') + r = sh(repo, AX, 'impact', 'tools/cli-report.py:5', env=env) + out = r.stdout + r.stderr + check('tests: 0 of' in out and 'NOT COUNTED: tools/cli-report.py runs as a program' in out and 'tests/smoke.py' in out, + 'impact names the test that starts the program through its dispatcher', out) + r = sh(repo, AX, 'impact', 'app/core.py:1', env=env) + out = r.stdout + r.stderr + check('NOT COUNTED' not in out and 'tests: 1 of' in out, 'control: a function a test calls is counted, with no subprocess note', out) + check(n_ok == len(SCENARIOS) and n_ok >= 1, f'{n_ok} scenario(s) ran') + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'ok' if not fails else f'{len(fails)} FAILED'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/changed_range.py b/tests/changed_range.py index 532b9251..7edafcfd 100644 --- a/tests/changed_range.py +++ b/tests/changed_range.py @@ -12,8 +12,8 @@ control a function added to an existing file keeps its own line a copy without git a refusal naming what to pass, not invented "added" declarations control the same copy with a named file: every declaration in it counts, and its tests are named - a changed fixture named as outside the index, with the test file that names it; the command holds only - the graph's language's test modules, and another language's tests are counted, not listed + a changed fixture named as outside the index; it lies in a tree a test reads by path, so it is case data + for that test, with that test's own pytest line; files inside the tree are its data control a data file no test names: said so, never "no change" a parameter edit in a package every target `changed` prints is answered by `impact`, and test-impact resolves it control the same target under a prefix nothing declares is still refused @@ -121,14 +121,15 @@ def ax(repo, *a): check(rc == 0 and 'tests/test_pricing.py' in out, 'test-impact --range selects the tests of the branch edit', out) check('test_stock' not in out, "test-impact --range does not select the tests of upstream's commit", out) check('tests/test_newmod.py' in out, 'a new test file is itself a test to run', out) - check("named as 'case.json' by: tests/test_cases.py" in out, 'the test that loads the changed fixture is named', out) - run = [l for l in out.split('\n') if 'with the tests above:' in l] - check(len(run) == 1 and 'pytest ' in run[0] and 'tests/test_cases.py' in run[0], 'the text tier adds its Python test to the pytest line', out) + # the fixture lies in a tree tests/test_cases.py reads by path (HERE, 'cases'): case data for that test + check("case data for tests/test_cases.py" in out, 'the test that loads the changed fixture is named', out) + run = [l.strip() for l in out.split('\n') if l.strip().startswith('pytest ') and 'tests/test_cases.py' in l] + check(len(run) == 1, "the fixture's reading test gets its own pytest line", out) check(run and not any(x in run[0] for x in ('.java', '.cs', 'helper.py')), "the pytest line holds no other language's file and no module pytest does not collect", out) - check('CaseLoader.java' not in out and 'CaseLoader.cs' not in out and "another language name them too" in out, - "another language's test files are counted, not listed: that language's graph answers for them", out) - check(any(l.startswith('next: run pytest') and '.java' not in l and '.cs' not in l for l in out.split('\n')), + check('CaseLoader.java' not in out and 'CaseLoader.cs' not in out and "named as 'case.json'" not in out, + "files inside the fixture tree that name it are its data, not its readers, and no name match lists them", out) + check(any(l.startswith('next: run') and 'pytest' in l and '.java' not in l and '.cs' not in l for l in out.split('\n')), 'next: runs the Python tests only', out) check('page 1 of' not in out, 'no page footer', out) From 94257fc78342c8a3378d6c2cd31244a79a944cf4 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:12:52 -0700 Subject: [PATCH 047/258] changed, test-impact, hooks: an edited declaration is targeted by file:line, never by its name What was wrong: `changed --impact`, `test-impact` (CLI and MCP) and the edit hooks asked impact about each edited declaration by name. A name is not one declaration. On this repository 120 functions are called `main`; module-qualified, several still collide (`score.main` in four directories), and a script whose file name has a hyphen (`axiomcode-install.main`) cannot be spelled as a target at all, so it fell back to the bare `main`. An edit inside one script's `main` then answered for every `main`: 37 tests in 35 unrelated files over 13 pages, with the one test that covers the script buried at the bottom. Java and C# overloads collide the same way (`Job.run()` and `Job.run(int)` are both `Job.run`). The edit hooks were worse: they passed the short display name on purpose so the SQL fast path, which matched names exactly, could answer, and so the blast radius for an edit in one `plan` named the callers of another. The change: - axiomcode-changed: `Changed.at_line` (new) gives the declaration's `file:line` in the graph's own lines (not the baseline's, which a background refresh may have moved). `Changed.file_changes`, `Changed.new_file` and `Changed.whole_file` set every target to it; a changed parameter keeps its form as `file:line(param)`. The name stays on the printed line and in `shown_target` in --json. - graph_sql.impact (the hooks' fast path): resolves `file:line` and `file:line(param)` to the narrowest callable on that line, and takes the by-name tier from that declaration's name. - hooks/changes.py and hooks/enrich.py (`impact` inside `summarize`): ask with the file:line target, not `shown_target`. - axiomcode-test-impact `main`: a single type or field target is asked with `--kind`, so a callable on the same line (a record's constructor) cannot answer for it. - The MCP axiomcode_changed and axiomcode_test_impact tools run these same scripts. Tests: new case edit-targets-the-declaration-edited in Python (two `cli.py` scripts each with a `main`), Java and C# (two overloads of `run` plus a `run` in another class). Each checks the edited one's callers and tests only, a unique name answered as before, a renamed declaration still reporting what the old name's callers lose, and (Python) the bare name still answering for both. On the previous scripts the Python case fails 6 of 7 checks; the Java and C# cases fail every check (the overload and rename checks on their rows, the controls on the printed target). tests/fastpath.py asks the fast path `file:line` and `file:line(param)` too, against the rules. Three existing cases (Python lambda, Java annotation edit, TypeScript dotted target) and tests/changed_range.py expected the old name-shaped target and now expect file:line. Suites, on the rebased tree, after (before on the release branch tip): - tests/run.py --lang python: 213 of 214 passed; the one failure (a lambda-case control on file:line inside a method body) fails on the tip too (201 of 203 there, with one flaky index) - tests/run.py --lang java: 198 of 198 (192 of 192 on the tip; 6 are the new case) - tests/run.py --lang csharp: 63 of 68 passed, 1 pending, 4 failed (57 of 62, same 4 failing and pending lines on the tip: none from this change) - hook_languages 7 of 7, enrich_lines 45 of 45, changed_range 50 of 50, enrich_budget 17 of 17, hooks_from_path 26 of 26, test_command and hosts pass (all as before) - fastpath python, java, csharp: 6 of 6 shapes each (4 of 4 before; the two new are file:line) Smoke, one line inserted inside a commonly named function, 5 edits per project, before -> after (rows of `changed --impact --page all`, its pages, tests selected by test-impact): - this repository, five `main`s whose names collide: rows 73/56/52/115/115 -> 53/52/51/53/52; pages 2/1/1/13/13 -> 1/1/1/1/1; tests 4/0/0/37/37 -> 2/0/0/0/2. For the hyphenated installer script the 37 wrong tests go to 0 and the one test that runs it (named in its text) is the only lead left. - one Python corpus member, 5 names declared 11 to 46 times: unchanged on all 5 (their qualified names were already unique). - one Java corpus member: rows 57/109/34/35/64 -> 57/109/32/35/65, tests unchanged; the two rows gone are an overload's callers. - one C# corpus member: rows 58/68/37/32/29 -> 56/68/37/32/29; tests 108 -> 105 for the edit in an overloaded `Create`, the rest unchanged. - The target path is longer than the name, so two answers gained a page (3 -> 4, 2 -> 3) and one lost four (16 -> 12). Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/hooks/changes.py | 6 +- plugins/axiomcode/hooks/enrich.py | 6 +- .../axiomcode/reference/changed-and-tests.md | 5 +- .../axiomcode/scripts/axiomcode-changed | 33 +++- .../axiomcode/scripts/axiomcode-test-impact | 5 +- .../skills/axiomcode/scripts/graph_sql.py | 21 ++- .../axiomcode/reference/changed-and-tests.md | 5 +- .../case.json | 26 ++++ .../new-body.txt | 15 ++ .../new-other-overload.txt | 15 ++ .../new-rename.txt | 15 ++ .../new-task-run.txt | 15 ++ .../new-unique.txt | 15 ++ .../old-task.txt | 15 ++ .../old.txt | 15 ++ .../src/App/Callers.cs | 20 +++ .../src/App/Job.cs | 15 ++ .../src/App/Task.cs | 15 ++ .../src/Tests/CountedTests.cs | 14 ++ .../src/Tests/OtherTests.cs | 14 ++ .../src/Tests/PlainTests.cs | 14 ++ tests/cases/java/annotation-edit/case.json | 2 +- .../case.json | 26 ++++ .../new-body.txt | 11 ++ .../new-other-overload.txt | 11 ++ .../new-rename.txt | 11 ++ .../new-task-run.txt | 11 ++ .../new-unique.txt | 11 ++ .../old-task.txt | 11 ++ .../old.txt | 11 ++ .../src/app/Callers.java | 15 ++ .../src/app/Job.java | 11 ++ .../src/app/Task.java | 11 ++ .../src/test/CountedTest.java | 12 ++ .../src/test/OtherTest.java | 12 ++ .../src/test/PlainTest.java | 12 ++ .../alpha/cli.py | 14 ++ .../alpha/test_alpha.py | 5 + .../beta/cli.py | 10 ++ .../beta/test_beta.py | 5 + .../case.json | 147 ++++++++++++++++++ .../new-body.txt | 14 ++ .../new-rename.txt | 14 ++ .../new-unique.txt | 14 ++ .../old.txt | 14 ++ .../lambda-is-named-by-its-place/case.json | 2 +- .../cases/typescript/dotted-target/case.json | 6 +- tests/changed_range.py | 4 +- tests/fastpath.py | 9 ++ 49 files changed, 714 insertions(+), 26 deletions(-) create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/case.json create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/new-body.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/new-other-overload.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/new-rename.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/new-task-run.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/new-unique.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/old-task.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/old.txt create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Callers.cs create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Job.cs create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Task.cs create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/CountedTests.cs create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/OtherTests.cs create mode 100644 tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/PlainTests.cs create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/case.json create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/new-body.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/new-other-overload.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/new-rename.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/new-task-run.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/new-unique.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/old-task.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/old.txt create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/app/Callers.java create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/app/Job.java create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/app/Task.java create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/test/CountedTest.java create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/test/OtherTest.java create mode 100644 tests/cases/java/edit-targets-the-declaration-edited/src/test/PlainTest.java create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/alpha/cli.py create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/alpha/test_alpha.py create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/beta/cli.py create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/beta/test_beta.py create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/case.json create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/new-body.txt create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/new-rename.txt create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/new-unique.txt create mode 100644 tests/cases/python/edit-targets-the-declaration-edited/old.txt diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 939e530b..9764fe9d 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -61,10 +61,10 @@ def impact(d): It returns None for what it does not cover (a constructor, whose callers are instantiations rather than call edges); that falls through to impact.dl, which is still right for those.""" try: - j = graph_sql.impact_shaped(cwd, d.get('shown_target') or d['target']) + j = graph_sql.impact_shaped(cwd, d['target']) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d.get('shown_target') or d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) @@ -108,7 +108,7 @@ def impact(d): if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d.get('shown_target') or d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") # WHICH SIDE ANSWERED, in one word. The two paths give different answers by design — the fast path reads # call_edges and the rules add the by-name, in-scope and text layers — so a count nobody can attribute is a # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index e780b776..601f2398 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -183,10 +183,10 @@ def impact(d): 19 of 40 sampled methods; it also carries #833, which kills that script at import wherever importlib.machinery is not incidentally bound.""" try: - j = graph_sql.impact_shaped(cwd, d.get('shown_target') or d['target']) + j = graph_sql.impact_shaped(cwd, d['target']) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d.get('shown_target') or d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) @@ -213,7 +213,7 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d.get('shown_target') or d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") # `reached` is a LIST OF PLACEHOLDERS from the SQL shim (graph_sql.impact_shaped fills it with None, # deliberately, because both hooks only take len() of it — resolving a location for rows nobody prints cost # 5.7 s against 1.7 s on a wide target). Iterating it and calling .get() therefore raised AttributeError and diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 85d50934..d8260a8c 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -8,8 +8,9 @@ extends / implements, type parameters), `removed`, and `added` lines outside any nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a -signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it: the +declaration edited, as `file:line` (a name answers for every declaration carrying it: eight `main`s, two overloads), and +`file:line(param)` for a signature with one parameter changed. `--impact` runs impact on all of them as one change set. What to pass, and what the answer says when the question cannot be answered the way it was asked: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 39815ccd..5e030fe2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -16,8 +16,9 @@ the graph knows at that line, and the change is classified — the same way in e depends on them yet, so they are listed, not analysed The output is one line per changed declaration with the target `axiomcode impact` takes for it; `--impact` runs impact on all -of them as one change set (the union) and prints its answer. A method whose only change is its body is a `method` target; a -signature change with one parameter changed is `Owner.m(param)`; a field is `Owner.field`; a type header is `Type`. +of them as one change set (the union) and prints its answer. The target is the declaration edited, `file:line` in the graph's +lines, never its name (which answers for every declaration carrying it); a signature change with one parameter changed is +`file:line(param)`. The name is printed on the same line. """ import difflib, importlib.machinery, importlib.util, json, os, re, subprocess, sys, time @@ -641,12 +642,28 @@ class Changed: out = [e for e in out if not (e['kind'] == 'added' and (e['file'], e['symbol']) in sigged)] if overrun_note: adds.append(overrun_note) # THE TARGET NAMES THIS DECLARATION, NOT EVERY DECLARATION OF ITS NAME. `symbol` stays the short display; the - # target a hook hands to impact is the full dotted name, or `square` also answers for every other `square`. + # target impact is asked is where the declaration is (at_line), and `shown_target` keeps its name for reading. for e in out: t = e.get('target') + if not t or e.get('lambda_'): continue q = self.qualified(e.get('id'), e.get('kind')) - if t and q and t.startswith(e['symbol']): e['target'] = q + t[len(e['symbol']):]; e['shown_target'] = t + sfx = t[len(e['symbol']):] if t.startswith(e['symbol']) else '' + e['shown_target'] = (q + sfx) if q else t + e['target'] = self.at_line(e.get('id'), sfx) or e['shown_target'] return out, adds + def at_line(self, i, sfx=''): + """THE DECLARATION EDITED, AS file:line IN THE GRAPH'S OWN LINES. A name, even a module-qualified one, is not + one declaration: `main` in eight scripts, `score.main` in two directories, `axiomcode-install.main` (a name the + target grammar cannot spell, so it fell back to `main`), and an overload in Java or C#. Asked by name, impact + answered for all of them as one change: an edit inside one script's `main` listed 37 tests in 35 unrelated + files and missed the one test of that script. The graph's line (not the baseline's, which a refresh may have + moved) is what impact resolves back to exactly this declaration; `(p)`, a parameter, is kept after it.""" + if i is None: return None + if isinstance(i, str) and i.startswith('f:'): + row = self.g.q("SELECT file, line FROM symbols WHERE rowid = ?", int(i[2:])) if i[2:].isdigit() else None + at = f"{row[0][0]}:{row[0][1]}" if row and row[0][0] and row[0][1] else None + else: at = self.g.lambda_target(i) + return at + sfx if at else None def new_file(self, rel, new, decls, is_py): """A FILE THE BASE DOES NOT HAVE is one change: `added (N declarations)`. Read line by line as an insertion, a new module listed every function at line 1, each parameter list as a change and docstring words as methods. What @@ -679,7 +696,8 @@ class Changed: 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.g.lambda_target(i) if lam else None) or self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True)) + target=self.at_line(i) or self.qualified(i, 'method') or sy['display'], shown_target=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 @@ -694,7 +712,8 @@ class Changed: 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')) + target=self.at_line(i) or self.qualified(i, 'method') or d, shown_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)) return out def qualified(self, i, kind): @@ -875,7 +894,7 @@ def main(argv): named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] print(f"changed declarations ({len(results)})" + (" — the two texts given" if (old_f or new_f) else against if C.built_at and mode == 'worktree' and not named else (f" — {rng}" if rng else '')) + ":") for e in shown: - tail = (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else + tail = (f" → impact {e['target']}" if e.get('target') else ('' if e.get('new_file') else ' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else '')) print(f" {e['kind']:<10} {e['symbol']}" + ('' if e.get('new_file') else f" {e['file']}:{e['line']}") + (f" — {e['detail']}" if e.get('detail') else '') + tail) by_file = {} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 4697d43c..7c1014bd 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1975,7 +1975,10 @@ def main(argv): batches = [[t] for t in targets] if len(targets) <= 8 else [targets[k:k + 60] for k in range(0, len(targets), 60)] disp = {by_target[t].get('symbol'): t for t in targets} for batch in batches: - rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) + # a target is file:line (changed): a type or a field is asked as that kind, since a callable on its line (a + # record's constructor, an initializer's lambda) would otherwise answer for it + tk = by_target[batch[0]].get('target_kind') if len(batch) == 1 else None + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope + (['--kind', tk] if tk in ('type', 'field') else [])) # EVERY TARGET HERE WAS READ FROM THE GRAPH by `changed` a moment ago, so a refusal is this program failing to # say it back, not a declaration the graph lacks. `Owner.m(p)` still refused is asked as `Owner.m` (the tests # that reach the method are the ones a parameter edit can fail); a batch still refused is asked one by one, so diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index a953da2f..da4bbba2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -67,10 +67,25 @@ def impact(repo, target, depth=DEPTH): # is not a parameter of that method is a DIFFERENT question (a one-argument signature), and the rules # answer it with their own ~20 cases, so it still declines. param = None + # `file:line` — THE DECLARATION AT THAT LINE, the target `changed` hands a hook for every edited declaration. + # By name (`main`, `run`) the lookup below matched every declaration carrying it, and the hook's blast radius + # for an edit in one `plan` named the callers of another. The callable whose header is on the line; else the + # narrowest one spanning it; nothing on the line declines, as an unknown name does. + def at_line(t): + m_ = re.fullmatch(r'(.+?):(\d+)', t) + if not m_: return None + f_, l_ = m_.group(1), int(m_.group(2)) + r_ = q("SELECT id, kind, method_id FROM symbols WHERE file=? AND line=? AND method_id IS NOT NULL " + "ORDER BY COALESCE(end_line, line) - line LIMIT 1", (f_, l_)).fetchall() # a function on line 1, not its module + if not r_: + r_ = q("SELECT id, kind, method_id FROM symbols WHERE file=? AND line<=? AND COALESCE(end_line, line)>=? AND method_id IS NOT NULL " + "ORDER BY COALESCE(end_line, line) - line LIMIT 1", (f_, l_, l_)).fetchall() + return r_[:1] m_par = re.fullmatch(r'(.+?)\(\s*([A-Za-z_]\w*)\s*\)', target.strip()) if m_par: base, pname = m_par.group(1).strip(), m_par.group(2) - brows = q("SELECT id, kind, method_id FROM symbols WHERE display=? AND method_id IS NOT NULL", (base,)).fetchall() + brows = at_line(base) + if brows is None: brows = q("SELECT id, kind, method_id FROM symbols WHERE display=? AND method_id IS NOT NULL", (base,)).fetchall() if not brows: return None if not _has(lambda sql, *p_: q(sql, p_).fetchall(), 'refs'): return None ok = False @@ -82,6 +97,8 @@ def impact(repo, target, depth=DEPTH): (srow[0], srow[1], srow[2] or srow[1], pname)).fetchone(): ok = True; break if not ok: return None target, param, rows = base, pname, brows + elif at_line(target.strip()) is not None: + rows = at_line(target.strip()) else: rows = q("SELECT id, kind, method_id FROM symbols WHERE display=? AND method_id IS NOT NULL", (target,)).fetchall() if not rows: rows = q("SELECT id, kind, method_id FROM symbols WHERE display=?", (target,)).fetchall() @@ -179,6 +196,8 @@ def _at(f_, l_): # reads / uses it, by name: a site naming this method whose receiver the engine could not type. The parser # records callee_name and the bundle indexes it, so this is a lookup and not an inference. short = target.rsplit('.', 1)[-1] + if at_line(target.strip()): # file:line: the name its declaration carries + short = (q("SELECT name FROM symbols WHERE id=?", (ids[0],)).fetchone() or [short])[0] 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=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 85d50934..d8260a8c 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -8,8 +8,9 @@ extends / implements, type parameters), `removed`, and `added` lines outside any nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a -signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it: the +declaration edited, as `file:line` (a name answers for every declaration carrying it: eight `main`s, two overloads), and +`file:line(param)` for a signature with one parameter changed. `--impact` runs impact on all of them as one change set. What to pass, and what the answer says when the question cannot be answered the way it was asked: diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/case.json b/tests/cases/csharp/edit-targets-the-declaration-edited/case.json new file mode 100644 index 00000000..f923fe2a --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/case.json @@ -0,0 +1,26 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an edit inside one overload (`Job.Run(int)`) is targeted by file:line, the declaration edited: by name, `Job.Run` is both overloads, and their callers and tests came back as one change", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-body.txt", "--file", "src/App/Job.cs", "--impact"], + "want": ["body Job.Run src/App/Job.cs:10", "→ impact src/App/Job.cs:10", "Callers.Counted", "CountedTests"], + "avoid": ["Callers.Plain", "PlainTests", "OtherTests", "→ impact Job.Run"]}, + {"why": "and test-impact selects that overload's test alone", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-body.txt", "--file", "src/App/Job.cs"], + "want": ["CountedTests"], + "avoid": ["PlainTests", "OtherTests"]}, + {"why": "control: the other overload, edited, is its own callers", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-other-overload.txt", "--file", "src/App/Job.cs", "--impact"], + "want": ["→ impact src/App/Job.cs:5", "Callers.Plain", "PlainTests"], + "avoid": ["Callers.Counted", "CountedTests", "OtherTests"]}, + {"why": "control: a `Run` in another class stays that class's", + "run": ["changed", "{repo}", "--old", "{repo}/old-task.txt", "--new", "{repo}/new-task-run.txt", "--file", "src/App/Task.cs", "--impact"], + "want": ["→ impact src/App/Task.cs:5", "Callers.Other", "OtherTests"], + "avoid": ["Callers.Plain", "Callers.Counted", "PlainTests", "CountedTests"]}, + {"why": "control: a unique name is answered as it was", + "run": ["changed", "{repo}", "--old", "{repo}/old-task.txt", "--new", "{repo}/new-unique.txt", "--file", "src/App/Task.cs", "--impact"], + "want": ["body Task.Unique", "→ impact src/App/Task.cs:10", "Callers.Other", "OtherTests"], + "avoid": ["PlainTests", "CountedTests"]}, + {"why": "a renamed overload still reports what the old name's callers lose, and only that overload's", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-rename.txt", "--file", "src/App/Job.cs", "--impact"], + "want": ["Job.Run src/App/Job.cs:10", "→ impact src/App/Job.cs:10", "Callers.Counted"], + "avoid": ["Callers.Plain", "PlainTests"]}]} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/new-body.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/new-body.txt new file mode 100644 index 00000000..6334e349 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/new-body.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Job + { + public int Run() + { + return 1; + } + + public int Run(int n) + { + return n * 2 + 0; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/new-other-overload.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/new-other-overload.txt new file mode 100644 index 00000000..1b352834 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/new-other-overload.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Job + { + public int Run() + { + return 1 + 0; + } + + public int Run(int n) + { + return n * 2; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/new-rename.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/new-rename.txt new file mode 100644 index 00000000..0c95d4e2 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/new-rename.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Job + { + public int Run() + { + return 1; + } + + public int Execute(int n) + { + return n * 2; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/new-task-run.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/new-task-run.txt new file mode 100644 index 00000000..213f2ab3 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/new-task-run.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Task + { + public int Run() + { + return 7 + 0; + } + + public int Unique() + { + return 3; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/new-unique.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/new-unique.txt new file mode 100644 index 00000000..458c2e4d --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/new-unique.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Task + { + public int Run() + { + return 7; + } + + public int Unique() + { + return 1 + 2; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/old-task.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/old-task.txt new file mode 100644 index 00000000..958bfc7b --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/old-task.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Task + { + public int Run() + { + return 7; + } + + public int Unique() + { + return 3; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/old.txt b/tests/cases/csharp/edit-targets-the-declaration-edited/old.txt new file mode 100644 index 00000000..85ffcd56 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/old.txt @@ -0,0 +1,15 @@ +namespace App +{ + public class Job + { + public int Run() + { + return 1; + } + + public int Run(int n) + { + return n * 2; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Callers.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Callers.cs new file mode 100644 index 00000000..3c98f875 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Callers.cs @@ -0,0 +1,20 @@ +namespace App +{ + public class Callers + { + public int Plain(Job j) + { + return j.Run(); + } + + public int Counted(Job j) + { + return j.Run(3); + } + + public int Other(Task t) + { + return t.Run() + t.Unique(); + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Job.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Job.cs new file mode 100644 index 00000000..85ffcd56 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Job.cs @@ -0,0 +1,15 @@ +namespace App +{ + public class Job + { + public int Run() + { + return 1; + } + + public int Run(int n) + { + return n * 2; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Task.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Task.cs new file mode 100644 index 00000000..958bfc7b --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/App/Task.cs @@ -0,0 +1,15 @@ +namespace App +{ + public class Task + { + public int Run() + { + return 7; + } + + public int Unique() + { + return 3; + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/CountedTests.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/CountedTests.cs new file mode 100644 index 00000000..cef225ff --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/CountedTests.cs @@ -0,0 +1,14 @@ +using App; +using Xunit; + +namespace Tests +{ + public class CountedTests + { + [Fact] + public void Counted() + { + new Callers().Counted(new Job()); + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/OtherTests.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/OtherTests.cs new file mode 100644 index 00000000..4aa881b4 --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/OtherTests.cs @@ -0,0 +1,14 @@ +using App; +using Xunit; + +namespace Tests +{ + public class OtherTests + { + [Fact] + public void Other() + { + new Callers().Other(new Task()); + } + } +} diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/PlainTests.cs b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/PlainTests.cs new file mode 100644 index 00000000..d16ab7ae --- /dev/null +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/src/Tests/PlainTests.cs @@ -0,0 +1,14 @@ +using App; +using Xunit; + +namespace Tests +{ + public class PlainTests + { + [Fact] + public void Plain() + { + new Callers().Plain(new Job()); + } + } +} diff --git a/tests/cases/java/annotation-edit/case.json b/tests/cases/java/annotation-edit/case.json index 7d1f8e83..088c74f1 100644 --- a/tests/cases/java/annotation-edit/case.json +++ b/tests/cases/java/annotation-edit/case.json @@ -2,7 +2,7 @@ "checks": [ {"why": "an annotation added above a method whose route template holds a brace is a signature-level decoration change, not a body change, and its arguments are not read as parameters", "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/Env.java"], - "want": ["signature Env.labelled", "decoration changed: @Cacheable", "impact Env.labelled"], + "want": ["signature Env.labelled", "decoration changed: @Cacheable", "impact src/pkg/Env.java:3"], "avoid": ["body Env.labelled", "+name", "-\"/{name}"]}, {"why": "an annotation added above a plain method is reported for that method too", "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/Env.java"], diff --git a/tests/cases/java/edit-targets-the-declaration-edited/case.json b/tests/cases/java/edit-targets-the-declaration-edited/case.json new file mode 100644 index 00000000..a91bbe58 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/case.json @@ -0,0 +1,26 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "an edit inside one overload (`Job.run(int)`) is targeted by file:line, the declaration edited: by name, `Job.run` is both overloads, and their callers and tests came back as one change", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-body.txt", "--file", "src/app/Job.java", "--impact"], + "want": ["body Job.run src/app/Job.java:8", "→ impact src/app/Job.java:8", "Callers.counted", "CountedTest"], + "avoid": ["Callers.plain", "PlainTest", "OtherTest", "→ impact Job.run"]}, + {"why": "and test-impact selects that overload's test alone", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-body.txt", "--file", "src/app/Job.java"], + "want": ["CountedTest"], + "avoid": ["PlainTest", "OtherTest"]}, + {"why": "control: the other overload, edited, is its own callers", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-other-overload.txt", "--file", "src/app/Job.java", "--impact"], + "want": ["→ impact src/app/Job.java:4", "Callers.plain", "PlainTest"], + "avoid": ["Callers.counted", "CountedTest", "OtherTest"]}, + {"why": "control: a `run` in another class stays that class's", + "run": ["changed", "{repo}", "--old", "{repo}/old-task.txt", "--new", "{repo}/new-task-run.txt", "--file", "src/app/Task.java", "--impact"], + "want": ["→ impact src/app/Task.java:4", "Callers.other", "OtherTest"], + "avoid": ["Callers.plain", "Callers.counted", "PlainTest", "CountedTest"]}, + {"why": "control: a unique name is answered as it was", + "run": ["changed", "{repo}", "--old", "{repo}/old-task.txt", "--new", "{repo}/new-unique.txt", "--file", "src/app/Task.java", "--impact"], + "want": ["body Task.unique", "→ impact src/app/Task.java:8", "Callers.other", "OtherTest"], + "avoid": ["PlainTest", "CountedTest"]}, + {"why": "a renamed overload still reports what the old name's callers lose, and only that overload's", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-rename.txt", "--file", "src/app/Job.java", "--impact"], + "want": ["Job.run src/app/Job.java:8", "→ impact src/app/Job.java:8", "Callers.counted"], + "avoid": ["Callers.plain", "PlainTest"]}]} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/new-body.txt b/tests/cases/java/edit-targets-the-declaration-edited/new-body.txt new file mode 100644 index 00000000..618a3294 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/new-body.txt @@ -0,0 +1,11 @@ +package app; + +public class Job { + public int run() { + return 1; + } + + public int run(int n) { + return n * 2 + 0; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/new-other-overload.txt b/tests/cases/java/edit-targets-the-declaration-edited/new-other-overload.txt new file mode 100644 index 00000000..bacff475 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/new-other-overload.txt @@ -0,0 +1,11 @@ +package app; + +public class Job { + public int run() { + return 1 + 0; + } + + public int run(int n) { + return n * 2; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/new-rename.txt b/tests/cases/java/edit-targets-the-declaration-edited/new-rename.txt new file mode 100644 index 00000000..d106bcc8 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/new-rename.txt @@ -0,0 +1,11 @@ +package app; + +public class Job { + public int run() { + return 1; + } + + public int execute(int n) { + return n * 2; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/new-task-run.txt b/tests/cases/java/edit-targets-the-declaration-edited/new-task-run.txt new file mode 100644 index 00000000..67ce5878 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/new-task-run.txt @@ -0,0 +1,11 @@ +package app; + +public class Task { + public int run() { + return 7 + 0; + } + + public int unique() { + return 3; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/new-unique.txt b/tests/cases/java/edit-targets-the-declaration-edited/new-unique.txt new file mode 100644 index 00000000..f20b9370 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/new-unique.txt @@ -0,0 +1,11 @@ +package app; + +public class Task { + public int run() { + return 7; + } + + public int unique() { + return 1 + 2; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/old-task.txt b/tests/cases/java/edit-targets-the-declaration-edited/old-task.txt new file mode 100644 index 00000000..ebffb44c --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/old-task.txt @@ -0,0 +1,11 @@ +package app; + +public class Task { + public int run() { + return 7; + } + + public int unique() { + return 3; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/old.txt b/tests/cases/java/edit-targets-the-declaration-edited/old.txt new file mode 100644 index 00000000..ac948d68 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/old.txt @@ -0,0 +1,11 @@ +package app; + +public class Job { + public int run() { + return 1; + } + + public int run(int n) { + return n * 2; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/app/Callers.java b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Callers.java new file mode 100644 index 00000000..25828432 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Callers.java @@ -0,0 +1,15 @@ +package app; + +public class Callers { + public int plain(Job j) { + return j.run(); + } + + public int counted(Job j) { + return j.run(3); + } + + public int other(Task t) { + return t.run() + t.unique(); + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/app/Job.java b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Job.java new file mode 100644 index 00000000..ac948d68 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Job.java @@ -0,0 +1,11 @@ +package app; + +public class Job { + public int run() { + return 1; + } + + public int run(int n) { + return n * 2; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/app/Task.java b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Task.java new file mode 100644 index 00000000..ebffb44c --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/app/Task.java @@ -0,0 +1,11 @@ +package app; + +public class Task { + public int run() { + return 7; + } + + public int unique() { + return 3; + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/test/CountedTest.java b/tests/cases/java/edit-targets-the-declaration-edited/src/test/CountedTest.java new file mode 100644 index 00000000..bc5a9ee5 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/test/CountedTest.java @@ -0,0 +1,12 @@ +package test; + +import app.Callers; +import app.Job; +import org.junit.jupiter.api.Test; + +public class CountedTest { + @Test + public void counted() { + new Callers().counted(new Job()); + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/test/OtherTest.java b/tests/cases/java/edit-targets-the-declaration-edited/src/test/OtherTest.java new file mode 100644 index 00000000..b9b858f1 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/test/OtherTest.java @@ -0,0 +1,12 @@ +package test; + +import app.Callers; +import app.Task; +import org.junit.jupiter.api.Test; + +public class OtherTest { + @Test + public void other() { + new Callers().other(new Task()); + } +} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/src/test/PlainTest.java b/tests/cases/java/edit-targets-the-declaration-edited/src/test/PlainTest.java new file mode 100644 index 00000000..8beb6dc6 --- /dev/null +++ b/tests/cases/java/edit-targets-the-declaration-edited/src/test/PlainTest.java @@ -0,0 +1,12 @@ +package test; + +import app.Callers; +import app.Job; +import org.junit.jupiter.api.Test; + +public class PlainTest { + @Test + public void plain() { + new Callers().plain(new Job()); + } +} diff --git a/tests/cases/python/edit-targets-the-declaration-edited/alpha/cli.py b/tests/cases/python/edit-targets-the-declaration-edited/alpha/cli.py new file mode 100644 index 00000000..60ee51f3 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/alpha/cli.py @@ -0,0 +1,14 @@ +def helper_a(x): + return x + 1 + + +def main(): + return helper_a(1) + + +def unique_alpha(): + return 3 + + +def go_a(): + return main() + unique_alpha() diff --git a/tests/cases/python/edit-targets-the-declaration-edited/alpha/test_alpha.py b/tests/cases/python/edit-targets-the-declaration-edited/alpha/test_alpha.py new file mode 100644 index 00000000..abe8fe2b --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/alpha/test_alpha.py @@ -0,0 +1,5 @@ +from cli import go_a + + +def test_alpha(): + assert go_a() == 5 diff --git a/tests/cases/python/edit-targets-the-declaration-edited/beta/cli.py b/tests/cases/python/edit-targets-the-declaration-edited/beta/cli.py new file mode 100644 index 00000000..8657b6c7 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/beta/cli.py @@ -0,0 +1,10 @@ +def helper_b(x): + return x * 2 + + +def main(): + return helper_b(2) + + +def go_b(): + return main() diff --git a/tests/cases/python/edit-targets-the-declaration-edited/beta/test_beta.py b/tests/cases/python/edit-targets-the-declaration-edited/beta/test_beta.py new file mode 100644 index 00000000..77f8f0c4 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/beta/test_beta.py @@ -0,0 +1,5 @@ +from cli import go_b + + +def test_beta(): + assert go_b() == 4 diff --git a/tests/cases/python/edit-targets-the-declaration-edited/case.json b/tests/cases/python/edit-targets-the-declaration-edited/case.json new file mode 100644 index 00000000..070660ce --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/case.json @@ -0,0 +1,147 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "an edit inside one of two same-named functions (`main` in alpha/cli.py and in beta/cli.py, both `cli.main`) is targeted by file:line, the declaration edited, and the name stays on the line", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-body.txt", + "--file", + "alpha/cli.py" + ], + "want": [ + "body main alpha/cli.py:5", + "→ impact alpha/cli.py:5" + ], + "avoid": [ + "→ impact main", + "→ impact cli.main" + ] + }, + { + "why": "so what it reaches is that main's callers alone, not those of the other main", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-body.txt", + "--file", + "alpha/cli.py", + "--impact" + ], + "want": [ + "go_a", + "test_alpha" + ], + "avoid": [ + "go_b", + "test_beta", + "names 2 declarations" + ] + }, + { + "why": "and the tests selected for it are that main's test file, not the other one's", + "run": [ + "test-impact", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-body.txt", + "--file", + "alpha/cli.py" + ], + "want": [ + "alpha/test_alpha.py" + ], + "avoid": [ + "beta/test_beta.py" + ] + }, + { + "why": "the JSON a hook reads carries the file:line target and the name beside it", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-body.txt", + "--file", + "alpha/cli.py", + "--json" + ], + "stdout_json": true, + "want": [ + "\"target\": \"alpha/cli.py:5\"", + "\"shown_target\": \"cli.main\"" + ], + "avoid": [] + }, + { + "why": "control: a unique name is answered as it was, its own callers and tests", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-unique.txt", + "--file", + "alpha/cli.py", + "--impact" + ], + "want": [ + "body unique_alpha", + "→ impact alpha/cli.py:9", + "test_alpha" + ], + "avoid": [ + "go_b", + "test_beta" + ] + }, + { + "why": "a renamed declaration is read as its old header changed: file:line on the graph's line still names what the old name's callers lose, and only theirs", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-rename.txt", + "--file", + "alpha/cli.py", + "--impact" + ], + "want": [ + "main alpha/cli.py:5", + "→ impact alpha/cli.py:5", + "go_a" + ], + "avoid": [ + "go_b", + "test_beta" + ] + }, + { + "why": "near miss: the bare name still answers for every declaration of it, and says so", + "run": [ + "impact", + "main" + ], + "want": [ + "go_a", + "go_b" + ], + "avoid": [] + } + ] +} diff --git a/tests/cases/python/edit-targets-the-declaration-edited/new-body.txt b/tests/cases/python/edit-targets-the-declaration-edited/new-body.txt new file mode 100644 index 00000000..74d7294a --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/new-body.txt @@ -0,0 +1,14 @@ +def helper_a(x): + return x + 1 + + +def main(): + return helper_a(1) + 0 + + +def unique_alpha(): + return 3 + + +def go_a(): + return main() + unique_alpha() diff --git a/tests/cases/python/edit-targets-the-declaration-edited/new-rename.txt b/tests/cases/python/edit-targets-the-declaration-edited/new-rename.txt new file mode 100644 index 00000000..78024c96 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/new-rename.txt @@ -0,0 +1,14 @@ +def helper_a(x): + return x + 1 + + +def entry(): + return helper_a(1) + + +def unique_alpha(): + return 3 + + +def go_a(): + return entry() + unique_alpha() diff --git a/tests/cases/python/edit-targets-the-declaration-edited/new-unique.txt b/tests/cases/python/edit-targets-the-declaration-edited/new-unique.txt new file mode 100644 index 00000000..06f6a683 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/new-unique.txt @@ -0,0 +1,14 @@ +def helper_a(x): + return x + 1 + + +def main(): + return helper_a(1) + + +def unique_alpha(): + return 1 + 2 + + +def go_a(): + return main() + unique_alpha() diff --git a/tests/cases/python/edit-targets-the-declaration-edited/old.txt b/tests/cases/python/edit-targets-the-declaration-edited/old.txt new file mode 100644 index 00000000..60ee51f3 --- /dev/null +++ b/tests/cases/python/edit-targets-the-declaration-edited/old.txt @@ -0,0 +1,14 @@ +def helper_a(x): + return x + 1 + + +def main(): + return helper_a(1) + + +def unique_alpha(): + return 3 + + +def go_a(): + return main() + unique_alpha() 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 index 4f253f04..6e14609a 100644 --- a/tests/cases/python/lambda-is-named-by-its-place/case.json +++ b/tests/cases/python/lambda-is-named-by-its-place/case.json @@ -2,7 +2,7 @@ "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"], + "want": ["body Orders.totals", "→ impact lib.py:5"], "avoid": ["= 3 for t in targets), 'changed hands over a package-qualified parameter target', r.stdout + r.stderr) + check(any(re.fullmatch(r'shop/pricing\.py:\d+\(\w+\)', t) for t in targets), 'changed hands over a parameter target on the declaration edited (file:line(param))', r.stdout + r.stderr) for t in dict.fromkeys(targets): r2, o2 = ax(pk, 'impact', t, '.') check(r2 == 0 and 'matches no package' not in o2, f'the printed target {t} is answered by impact', o2) diff --git a/tests/fastpath.py b/tests/fastpath.py index d613fade..e0aa3b82 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -142,6 +142,15 @@ def main(argv=None): r = subprocess.run(['bash', AX, 'index', CASE, '--lang', LANG], capture_output=True, text=True) if r.returncode: print("FAIL index: " + (r.stderr or r.stdout)[-400:]); return 1 bad = 0; along = set(); along_of = {} + # THE SHAPE `changed` NOW EMITS FOR EVERY EDIT: file:line, the declaration edited, and file:line(p) for a parameter. + # A name answered for every declaration carrying it; the fast path must answer the line for that one alone. + import sqlite3 + fn = SHAPES[0][0].replace('#', '.').rsplit('.', 1)[-1]; par = SHAPES[1][0].rsplit('(', 1)[-1].rstrip(')') + con = sqlite3.connect(os.path.join(CASE, '.axiomcode', 'out', 'graph.sqlite')) + at = con.execute("SELECT file, line FROM symbols WHERE name=? AND method_id IS NOT NULL ORDER BY file, line LIMIT 1", (fn,)).fetchone() + con.close() + if at: SHAPES = SHAPES + [(f"{at[0]}:{at[1]}", True), (f"{at[0]}:{at[1]}({par})", True)] + else: print(f"FAIL: {fn} is not in the graph, so its file:line shapes cannot be asked"); bad += 1 try: for target, must_answer in SHAPES: fast = graph_sql.impact_shaped(CASE, target) From ddcbe2bd25052321cd07914e1454480a042f8d87 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:47:57 -0700 Subject: [PATCH 048/258] changed / edit hooks: read an edit against the file as it is, not at the graph's old line numbers What was wrong The graph's spans are line numbers in the text it was indexed from. The PreToolUse edit hook (changes.py, through `axiomcode changed --old/--new`) read the file as it is now at those numbers, and between an edit and the background refresh the file is ahead of the graph. Every line below an earlier insertion was charged to the declaration a few lines up: - a deleted line that sat where the graph had a field was reported as "removed ", while the field was still assigned; - a field line, a doc comment or a body line that sat where the graph had a method header was reported as that method's signature change; - a method moved below its neighbour was reported removed; - an edited import at the top of a Python file was "signature -x": a module's span starts at line 1, and its "header" ran to the first `def`; - removing a function answered with the callers and tests of every function of the same display name in other files: the fast path matched the bare display, and a field target was never qualified (its id `f:` was looked up as a symbol id); - after a rebase or a pull the baseline had not followed yet, the hooks reported the declarations the incoming commits changed as this session's edits; - `changed --range` read the graph's spans against the range's older text, and when a refresh had raced an edit (the rows and the recorded tree describing two texts) it printed parameter diffs of methods that did not exist on that side. The change - `changed` carries the graph's spans onto the text it compares (the file as it is for the hook, the older side of a range) across a line diff from the recorded indexed tree; a header whose own line was rewritten is found again by what it declares (file + name + the nearest declaring line), and one placed that way among several candidates is worded "may have changed". With no recorded text it checks each span's own line by name and looks again the same way. - When a span's own line in the recorded text does not name it for most of a file (checked on declarations written under their own name; annotation lines count as the start), the text is not the rows' text: declarations are placed by name and a note says so. On three dev projects' faithful texts this flags 0 of 502 files; with the text shifted by two lines it flags every file with three or more checkable declarations. - "removed" is claimed only when no line of the new text declares the name: a moved method is `body` (moved), a field still assigned elsewhere is "may have changed", a field line moved word for word is not reported, and a header that cannot be found near its line is removed when nothing declares it, "may have changed" otherwise (no more "-self, -rel" for a header that was not found). - A module never has a signature. A line whose only change is inside a comment is not a signature, field or initializer change: it is reported as `body` (comment only) of the declaration it sits in (the field on that line, else the narrowest callable or type), so `changed` never answers "no change" for it; a comment in a module's top level belongs to no declaration. - The hooks pass the edited file to the fast path, which keeps only that file's declaration when a display names several; the rules fallback and the printed hint use the qualified target; field targets are qualified. - `changed --against-head` reads the working tree against HEAD. The hooks use it after a Bash command or on a prompt while HEAD is ahead of the baseline, so commits that came in are not reported as edits. Tests New tests/edit_stale_spans.py, for Python, Java and C# (37 checks), each case with a near-miss control: a line deleted where the graph has a field (control: the field's own line deleted is still removed); a field edited where the graph has a method header (control: a real added parameter is still a signature change); a moved method; removing a function another file also declares (control: this file's caller is listed); an edited Python import; rows and recorded tree disagreeing (control: a faithful recorded tree raises no note); a rebase onto a commit that changed another file (control: an edit left uncommitted after the rebase is reported). All 37 pass with this change. Suites run on the tree rebased onto the release branch: tests/edit_stale_spans.py 37/37, tests/hook_languages.py 7/7, tests/enrich_lines.py 44/44, tests/changed_range.py 50/50, tests/run.py --lang python 190/191, --lang java 186/186, --lang csharp 55/58 (1 pending). comment-in-header passes in Python and Java (a comment-only header edit is `body` (comment only) of that function, never a signature). The failures are the release branch tip's own: lambda-is-named-by-its-place (Python and C#) and member-owner-is-its-type's pending marker (C#). Smoke (PreToolUse Edit hook, generated edits made while the file is 2 to 6 lines ahead of the graph, installed build vs this change on the same edits, good / total): - Python (a CLI project): 19/34 -> 34/34 (moved methods 0/8 -> 8/8, body edits 5/8 -> 8/8, same-name removals 0/2 -> 2/2) - Java (a REST service): 13/24 -> 24/24 (moved methods 3/8 -> 8/8, real signature changes 4/8 -> 8/8) - C# (a web application): 24/37 -> 37/37 (same-name removals 2/7 -> 7/7, real signature changes 3/8 -> 8/8) Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/hooks/changes.py | 21 +- plugins/axiomcode/hooks/enrich.py | 6 +- .../axiomcode/reference/changed-and-tests.md | 15 +- .../axiomcode/scripts/axiomcode-changed | 232 +++++++++++++++--- .../skills/axiomcode/scripts/graph_sql.py | 16 +- .../axiomcode/reference/changed-and-tests.md | 15 +- tests/edit_stale_spans.py | 219 +++++++++++++++++ 7 files changed, 464 insertions(+), 60 deletions(-) create mode 100644 tests/edit_stale_spans.py diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 9764fe9d..798c5937 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -61,10 +61,10 @@ def impact(d): It returns None for what it does not cover (a constructor, whose callers are instantiations rather than call edges); that falls through to impact.dl, which is still right for those.""" try: - j = graph_sql.impact_shaped(cwd, d['target']) + j = graph_sql.impact_shaped(cwd, d['target'] or d.get('shown_target'), file=d.get('file')) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'] or d.get('shown_target'), cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) @@ -108,7 +108,7 @@ def impact(d): if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d['target'] or d.get('shown_target')}` adds the by-name, in-scope and text layers)") # WHICH SIDE ANSWERED, in one word. The two paths give different answers by design — the fast path reads # call_edges and the rules add the by-name, in-scope and text layers — so a count nobody can attribute is a # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and @@ -151,21 +151,28 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} except Exception: pass bg = ax_fresh.baseline_graph(cwd) if bg: os.environ['AXIOMCODE_GRAPH'] = bg + # HEAD MOVED AND THE BASELINE HAS NOT FOLLOWED YET (a rebase, a pull, a checkout; the wait above ran out). Measured + # against the baseline, every declaration the incoming commits changed was reported as this session's edit. Against + # HEAD it is the working tree's own edits only; the commits that came in are nobody's edit here + try: behind = ax_fresh.base_moved(cwd) + except Exception: behind = False + AGAINST = ['--against-head'] if behind else [] if event == 'PostToolUse' and tool == 'Bash': c = str(inp.get('command', '')) if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(' + _where.SOURCE_ALT + r')\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) - j = changed([], timeout=18) + j = changed(AGAINST, timeout=18) st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] if new: - lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against the graph's commit {(j.get('built_at') or '')[:10]}) —") + lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against " + ("HEAD: the commits that came in are not counted" if AGAINST else f"the graph's commit {(j.get('built_at') or '')[:10]}") + ") —") st['reported'] = list(seen | {key(d) for d in new}); save_state(st) elif event == 'UserPromptSubmit': - j = changed([], timeout=18) + j = changed(AGAINST, timeout=18) st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] if new: - lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —") + lines = summarize(new, (f"graph: {{n}} declaration(s) changed in the working tree against HEAD (the commits that came in are not counted) and were not reported yet —" if AGAINST + else f"graph: {{n}} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —")) st['reported'] = list(seen | {key(d) for d in new}); save_state(st) try: with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: f.write(json.dumps({'event': event, 'tool': tool, 'lines': len(lines), 'chars': sum(len(l) for l in lines), 'input': {k: v for k, v in inp.items() if k in ('file_path', 'command', 'old_string', 'new_string')}, 'text': '\n'.join(lines)}) + '\n') diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 601f2398..0b60bb96 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -183,10 +183,10 @@ def impact(d): 19 of 40 sampled methods; it also carries #833, which kills that script at import wherever importlib.machinery is not incidentally bound.""" try: - j = graph_sql.impact_shaped(cwd, d['target']) + j = graph_sql.impact_shaped(cwd, d['target'] or d.get('shown_target'), file=d.get('file')) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'] or d.get('shown_target'), cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) @@ -213,7 +213,7 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d['target'] or d.get('shown_target')}` adds the by-name, in-scope and text layers)") # `reached` is a LIST OF PLACEHOLDERS from the SQL shim (graph_sql.impact_shaped fills it with None, # deliberately, because both hooks only take len() of it — resolving a location for rows nobody prints cost # 5.7 s against 1.7 s on a wide target). Iterating it and calling .get() therefore raised AttributeError and diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index d8260a8c..b045f2b6 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -8,9 +8,18 @@ extends / implements, type parameters), `removed`, and `added` lines outside any nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it: the -declaration edited, as `file:line` (a name answers for every declaration carrying it: eight `main`s, two overloads), and -`file:line(param)` for a signature with one parameter changed. `--impact` runs impact on all of them as one change set. +`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the +edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as +edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for +every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter +changed. `--impact` runs impact on all of them as one change set. + +The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an +edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and +one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere +in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went +while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and +the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. What to pass, and what the answer says when the question cannot be answered the way it was asked: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 5e030fe2..f7d6d579 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -154,7 +154,7 @@ class Changed: self.tree_differs = bool(self.base_tree) and (sh('git', 'rev-parse', 'HEAD^{tree}', cwd=self.repo) or '').strip() != self.base_tree if baseline: self.indexed_tree = self.base_tree # reading the baseline's own graph: its spans ARE the baseline's self.base_absorbed = self._absorbed() if self.tree_differs else [] - self._mapped = {} + self._mapped = {}; self.unsure = {}; self.mismatch = set() # file -> ids placed by name among several candidates self.spans = {} # file -> [(line, end, id, kind)] for r in self.g.sym.values(): if r.get('file') and r.get('line'): self.spans.setdefault(r['file'], []).append((r['line'], r['end_line'] or r['line'], r['id'], r['kind'], r['display'], r['name'])) @@ -164,40 +164,106 @@ class Changed: def decl_spans(self, rel, mode='worktree'): """the graph's declarations in `rel`, with their lines in the BASELINE text. They are recorded in the text the graph was indexed from; when a background refresh indexed a later text than the baseline, each start and end - line is carried across a line diff of the two. A declaration whose header exists only in the indexed text - (added after the baseline) is dropped here: the diff of baseline against working tree reports it as added.""" + line is carried across a line diff of the two (anchor). A declaration whose header exists only in the indexed + text (added after the baseline) is dropped here: the diff of baseline against working tree reports it as added.""" spans = self.spans.get(rel, []) - if mode in ('range', 'files') or not spans or not self.indexed_tree or not self.base_tree or self.indexed_tree == self.base_tree: return spans + if mode in ('range', 'files', 'head') or not spans or not self.indexed_tree or not self.base_tree or self.indexed_tree == self.base_tree: return spans if rel in self._mapped: return self._mapped[rel] - G = (sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) or '').split('\n') - B = (sh('git', 'show', f'{self.base_tree}:{rel}', cwd=self.repo) or '').split('\n') - if G == B: self._mapped[rel] = spans; return spans - gmap, near, blocks = {}, {}, [] # indexed line -> baseline line - for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, B, G, autojunk=False).get_opcodes(): - for k in range(j2 - j1): - if tag == 'equal': gmap[j1 + k + 1] = near[j1 + k + 1] = i1 + k + 1 - # a rewritten line: the line in the same position; an inserted one: the baseline line it follows - elif tag == 'replace': near[j1 + k + 1] = i1 + min(k + 1, i2 - i1) - else: near[j1 + k + 1] = max(1, i1) - if tag == 'replace': blocks.append((j1, j2, i1, i2)) - def at(ln, name=None): - if ln in gmap: return gmap[ln] - if name is None: return near.get(ln) # an end line: wherever it fell - # a header on a rewritten line maps to the baseline line in the same position only if that line declares - # the same name. Positions in a rewritten block pair a changed header with its old self and an INSERTED - # header with whatever old line happens to sit there, which made a new function read as one the - # baseline already had - for j1, j2, i1, i2 in blocks: - if j1 < ln <= j2: - k = i1 + (ln - j1) - return k if k <= i2 and re.search(rf'\b{re.escape(name)}\b', B[k - 1]) else None - return None - out = [] - for a, b, *rest in spans: - a2, b2 = at(a, rest[-1] or ''), at(b) - if a2 is not None: out.append((a2, max(a2, b2 or a2), *rest)) - out.sort(); self._mapped[rel] = out - return out + G = sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) + B = sh('git', 'show', f'{self.base_tree}:{rel}', cwd=self.repo) or '' + self._mapped[rel] = self.anchor(rel, spans, G, B) + return self._mapped[rel] + def graph_text(self, rel): + """the text of `rel` the graph's line numbers are in: the tree it was indexed from, uncommitted edits included; + None when that was not recorded (no git, an old graph) or the file is not in it""" + if not self.indexed_tree: return None + return sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) + @staticmethod + def declares(text, n, k, is_py): + """does this line (strings and comments blanked) DECLARE `n` as a `k`: a def / a typed method header, a class + header, an assignment to the field. A line that only names it (a call, a read) does not""" + e = re.escape(n) + if k in ('class', 'interface', 'enum', 'type', 'namespace'): + return bool(re.search(rf'\b(class|interface|enum|record|struct|trait|namespace)\s+{e}\b', text)) + if k in ('field', 'const', 'enum_member', 'variable'): + if is_py: return bool(re.search(rf'(^\s*|\bself\.|\bcls\.){e}\s*(:[^=]*)?=(?!=)|^\s*{e}\s*:', text)) + if k == 'enum_member' and re.match(rf'^\s*{e}\s*(\(|,|;|$|=|\{{)', text): return True + return bool(re.search(rf'^\s*(?:[\w<>\[\],.?@]+\s+)+{e}\s*(=(?![=>])|;|\{{|=>|$)', text)) and not re.match(r'^\s*(return|throw|new|await|using|yield)\b', text) + if is_py: return bool(re.search(rf'^\s*(async\s+)?def\s+{e}\s*[(\[]', text)) + m = re.search(rf'^\s*((?:[\w<>\[\],.?@]+\s+)*){e}\s*(<[^()]*>)?\s*\(', text) + return bool(m) and not re.match(r'^\s*(return|throw|new|await|if|while|for|foreach|switch|else|using|lock|catch|yield)\b', text) \ + and (bool(m.group(1).strip()) or not text.rstrip().endswith(';')) + def anchor(self, rel, spans, G, O): + """THE GRAPH'S LINES ARE IN THE TEXT IT WAS INDEXED FROM, and the text an edit is judged against can be a later + one: an edit made since the index (the background refresh has not run yet), or a baseline behind a refresh. Read + at the graph's numbers, every line below an insertion belonged to the declaration a few lines up: a deleted line + was taken for a field's own line and the field reported removed, a field line sitting where a method header had + been was reported as that method's signature change. Each span is carried across a line diff of the two texts; + a header whose own line was rewritten is found again by what it declares (file + name + the nearest such line), + and a declaration found only that way, among several candidates, is marked unsure: what is said of it is "may + have changed", not a verdict. With no recorded text (G is None), a span is kept where its line still names it + and otherwise looked for the same way.""" + is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', O or '')) + OL = O.split('\n'); OS = strip_code(O, hash_comments=is_py).split('\n') + shaped = lambda k, n: k != 'module' and n not in P.LAMBDA_NAMES + def found(n, k, guess, taken): + c = [j for j in range(1, len(OS) + 1) if j not in taken and self.declares(OS[j - 1], n, k, is_py)] + return (min(c, key=lambda j: (abs(j - guess), j)), len(c) > 1) if c else (None, False) + def starts(L, a, n): + """line a of L is where `n` is declared: it names it, or it is an annotation / attribute above the line that does""" + if a > len(L): return False + if re.search(rf'\b{re.escape(n)}\b', L[a - 1]): return True + return L[a - 1].strip().startswith(('@', '[')) and any(re.search(rf'\b{re.escape(n)}\b', L[x - 1]) for x in range(a + 1, min(len(L), a + 12) + 1)) + out = []; self.unsure.setdefault(rel, set()) + if G is None: + for a, b, i, k, d, n in spans: + if not shaped(k, n) or starts(OS, a, n) or (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)): + out.append((a, b, i, k, d, n)); continue + j, many = found(n, k, a, set()) + if j is None: out.append((a, b, i, k, d, n)); self.unsure[rel].add(i); continue # nothing better: kept, unsure + out.append((j, j + (b - a), i, k, d, n)) + if many or rel in self.mismatch: self.unsure[rel].add(i) + out.sort(); return out + GL = G.split('\n') + # THE RECORDED TEXT MUST BE THE ONE THE SPANS ARE IN. While a refresh is publishing, or after one raced an edit, + # the graph's rows and its recorded tree can describe two different texts; carried across a diff from the wrong + # one, a new method's span landed on an old method's lines ("signature graph_text -None, -B, -G"). Checked on the + # spans themselves: when their own lines in that text do not name them, the text is not theirs, and every + # declaration is placed by name instead, and said to be placed that way + # A span starts on its declaration's own line, or on an annotation / attribute above it. Only what is written + # under its own name is checked: a member the compiler synthesises (a Lombok getter, an implicit constructor) + # starts on its type's line, and a C# accessor `get_X` is a `get;` line. Measured on three projects' faithful + # texts: 3 of 393 files fall under 0.8 this way, and every file does with its text shifted by two lines + tstarts = {a for a, b, i, k, d, n in spans if k in ('class', 'interface', 'enum', 'type', 'namespace')} + GS = strip_code(G, hash_comments=is_py).split('\n') + chk = [(a, n) for a, b, i, k, d, n in spans if shaped(k, n) and re.fullmatch(r'[A-Za-z_$][\w$]*', n) + and not (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)) + and (k in ('class', 'interface', 'enum', 'type', 'namespace') or a not in tstarts)] + hit = sum(1 for a, n in chk if starts(GS, a, n)) + if len(chk) >= 3 and hit < 0.8 * len(chk): + self.mismatch.add(rel) + return self.anchor(rel, spans, None, O) + if GL == OL: return spans + exact, near = {}, {} # graph line -> this text's line + for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, OL, GL, autojunk=False).get_opcodes(): + for k_ in range(j2 - j1): + if tag == 'equal': exact[j1 + k_ + 1] = near[j1 + k_ + 1] = i1 + k_ + 1 + elif tag == 'replace': near[j1 + k_ + 1] = i1 + min(k_ + 1, i2 - i1) # a rewritten line: the line in its position + else: near[j1 + k_ + 1] = max(1, i1) # a line this text lacks: the one before it + taken = {exact[a] for a, b, i, k, d, n in spans if a in exact} + for a, b, i, k, d, n in spans: + a2 = exact.get(a) + if a2 is None: + if not shaped(k, n): continue # a lambda or a module whose first line is gone + a2, many = found(n, k, near.get(a) or a, taken) + if a2 is None: continue # this text does not declare it + if many: self.unsure[rel].add(i) + taken.add(a2) + b2 = a2 + (b - a) if exact.get(b) is None else exact[b] + else: + b2 = exact.get(b) or near.get(b) or a2 + (b - a) + out.append((a2, max(a2, b2), i, k, d, n)) + out.sort(); return out def rel(self, f): f = os.path.realpath(f) if os.path.exists(f) else f r = os.path.relpath(f, self.repo) if os.path.isabs(f) else f @@ -208,6 +274,7 @@ class Changed: a, b = self.range_old, self.range_new # the merge-base, not a's tip (resolve_range) return sh('git', 'show', f'{a}:{rel}', cwd=self.repo) or '', sh('git', 'show', f'{b}:{rel}', cwd=self.repo) or '' base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' and sh('git', 'cat-file', '-e', self.built_at, cwd=self.repo) is not None else 'HEAD') + if mode == 'head': base = 'HEAD' old = sh('git', 'show', f'{base}:{rel}', cwd=self.repo) or '' if mode == 'staged': new = sh('git', 'show', f':{rel}', cwd=self.repo) or '' else: @@ -224,6 +291,7 @@ class Changed: elif mode == 'staged': out = sh('git', 'diff', '--name-only', '--cached', *([self.base_tree] if self.base_tree else []), cwd=self.repo) else: base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' else 'HEAD') + if mode == 'head': base = 'HEAD' out = sh('git', 'diff', '--name-only', base, cwd=self.repo) if out is None: out = sh('git', 'diff', '--name-only', 'HEAD', cwd=self.repo); base = 'HEAD' # A NEW FILE IS A CHANGE. `git diff ` covers tracked paths only, so a file created and not yet added @@ -328,11 +396,19 @@ class Changed: OL, NL = old.split('\n'), new.split('\n') is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', new or old)) # a shebang script too (#1376) sm = difflib.SequenceMatcher(None, OL, NL, autojunk=False) - old_changed = set(); new_of = {}; added = []; removed_lines = set(); replaced = [] + old_changed = set(); new_of = {}; added = []; removed_lines = set(); replaced = []; comment_only = set() # a blank line, a comment line, a brace on its own: not a change to a declaration TRIPLE = ('"' * 3, "'" * 3) noise = lambda t: not t.strip() or t.strip().startswith(('//', '*', '/*', '#') + TRIPLE) or t.strip() in ('{', '}', '};', ')', ');') + # A COMMENT IS NOT CODE. A line whose only change is in a comment (`x = 1 # why` to `x = 1 # why not`) is read + # as no change to the code: kept as a change, a note appended to a field's line was "initializer / modifiers changed", + # and inside a def header a signature change. It is still an edit of the declaration it sits in, reported below as + # that declaration's `body` (comment only): dropped, `changed` said nothing for an edit git reports (comment_only) + OC = [x.strip() for x in strip_code(old, strings=False, hash_comments=is_py).split('\n')] + NC = [x.strip() for x in strip_code(new, strings=False, hash_comments=is_py).split('\n')] for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == 'replace' and i2 - i1 == j2 - j1 and all(OC[i1 + k] == NC[j1 + k] and OC[i1 + k] for k in range(i2 - i1)): + tag = 'equal'; comment_only |= {i1 + k + 1 for k in range(i2 - i1)} if tag == 'equal': for k in range(i2 - i1): new_of[i1 + k + 1] = j1 + k + 1 elif tag == 'replace': @@ -353,6 +429,24 @@ class Changed: slid.append((after, s_, e_)) added = slid decls = self.decl_spans(rel, getattr(self, 'mode', 'worktree')) + # two texts handed in (the edit hook): `old` is the file as it is NOW, which can be ahead of the graph when the + # background refresh has not caught up with an earlier edit. A range: `old` is a commit, and the graph was indexed + # from whatever tree it was (the branch tip after a refresh). The graph's spans are carried onto `old` first; the + # worktree and staged modes have them on the baseline already (decl_spans) + if getattr(self, 'mode', '') in ('files', 'range', 'head') and decls and old.strip(): decls = self.anchor(rel, decls, self.graph_text(rel), old) + unsure = self.unsure.get(rel, set()) + OS = strip_code(old, hash_comments=is_py).split('\n'); NS = strip_code(new, hash_comments=is_py).split('\n') + def calls_only(j): + """new line j names something only as a call or a read: no def / class / modifier / type before a name on it""" + t = NS[j - 1].strip() if j <= len(NS) else '' + if is_py: return not re.match(r'(async\s+)?(def|class)\b|@', t) + return bool(re.match(r'(return|await|throw|new|if|while|for|foreach|switch|using|lock|yield)\b', t)) or \ + bool(re.match(r'[\w.]+\s*(<[^()]*>)?\s*\(.*\)\s*;$', t)) + def still_declared(n, k, near_line): + """the line of the new text that still declares `n` as a `k`, nearest to where it was; None when none does""" + if k == 'module' or n in P.LAMBDA_NAMES: return None + c = [j for j in range(1, len(NS) + 1) if self.declares(NS[j - 1], n, k, is_py)] + return min(c, key=lambda j: (abs(j - near_line), j)) if c else None 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 = {} @@ -406,7 +500,9 @@ class Changed: # is named by it (#1377). A file's module declaration also starts on line 1 but spans past the type if m and t and m[0] == t[0] and m[1] <= t[1] and t[0] <= ln <= self.header_end(OL, t[0]): m = None if f: key = ('field', f) - elif m and m[0] <= ln <= self.header_end(OL, m[0]): key = ('signature', m) + # A MODULE HAS NO SIGNATURE. Its span starts at line 1, and the "header" read from there ran to the first line + # ending in `:`, a `def` a few lines down: an edited import came back as "signature -http" + elif m and m[3] != 'module' and m[0] <= ln <= self.header_end(OL, m[0]): key = ('signature', m) elif m: key = ('body', m) elif t and t[0] <= ln <= self.header_end(OL, t[0]): key = ('type', t) elif t: key = ('inside', t) # between members: a field the graph did not record, a comment @@ -415,6 +511,7 @@ class Changed: for ii, text in enumerate(strip_code(new).split('\n'), 1): nd[ii] = nd[ii - 1] + text.count('{') - text.count('}') ndepth_ok = lambda j: nd[j - 1] <= 2 out = [] + KIND_OF = lambda k: 'field' if k in ('field', 'const', 'enum_member', 'variable') else 'type' if k in ('class', 'interface', 'enum', 'type', 'namespace') else 'signature' KIND = lambda k: 'field' if k in ('field', 'const', 'enum_member', 'variable') else 'type' if k in ('class', 'interface', 'enum', 'type', 'namespace') else 'method' for (a, b, i, k, d, n), decs in decorated.items(): key = ('field' if k in ('field', 'const', 'enum_member', 'variable') else 'type' if k in ('class', 'interface', 'enum', 'type', 'namespace') else 'signature', (a, b, i, k, d, n)) @@ -443,7 +540,23 @@ class Changed: 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)) + not any(re.search(rf'\b{re.escape(n)}\s*\(', NL[j - 1]) and ndepth_ok(j) and not calls_only(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)) + # REMOVED IS A CLAIM THAT NOTHING DECLARES IT ANY MORE, so it is checked against the whole new text: a field + # still assigned in another line, a method moved below its neighbour, is there. Its header compared with the + # one it had says whether the move changed it; unsure either way, the answer says "may have changed" + if gone and kind in ('signature', 'body', 'field', 'type'): + again = still_declared(n, k, new_of.get(a) or a) + if again: + # the same line, word for word, elsewhere in the new text: moved with the code around it, not changed + if kind == 'field' and re.sub(r'\s', '', OS[a - 1]) and any(re.sub(r'\s', '', x) == re.sub(r'\s', '', OS[a - 1]) for x in NS): continue + if kind == 'field': + entry.update(kind='field', detail=f"may have changed: its line was removed, and {n} is still assigned at line {again}", target=d) + else: + oh_ = re.sub(r'\s', '', ' '.join(OS[a - 1:self.header_end(OS, a)])); nh_ = re.sub(r'\s', '', ' '.join(NS[again - 1:self.header_end(NS, again)])) + if oh_ == nh_: entry.update(kind='body', detail=f"moved: the same header is now at line {again}", target=d) + else: entry.update(kind=KIND_OF(k), detail=f"may have changed: its lines were removed here, and a declaration of {n} is at line {again} with another header", target=d) + if not any(e['id'] == i for e in out): out.append(entry) + continue if gone: # a header line and a body line of one declaration are two keys; it is removed once if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry['kind'] = 'removed'; entry['target'] = self.target(kind, d, k, n); out.append(entry) @@ -458,17 +571,28 @@ 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): - # read with comments blanked: a comment line above the header that mentions the name is not it + # read with comments blanked: a comment line above the header that mentions the name is not it; + # and a line that only CALLS it (`main()` under `if __name__ == ...`) is not its header either code = strip_code(NL[s - 1], strings=False, hash_comments=is_py) # a constructor's name is its type's: the type's own header (`class OrderService {`) two lines # above is not the constructor's new header (#1465) if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(n)}\b', code): continue - if re.search(rf'\b{re.escape(n)}\b', code): + if re.search(rf'\b{re.escape(n)}\b', code) and not calls_only(s): 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) + # NO NEW HEADER IS NOT AN EMPTY PARAMETER LIST. With the header not found near its old line, every old + # parameter read as removed ("signature f -self, -rel"). Declared nowhere in the new text, it is removed; + # declared elsewhere, what changed cannot be read from here + if not nh: + again = still_declared(n, k, na or a) + if not again: + if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry.update(kind='removed', target=self.target(kind, d, k, n)); out.append(entry) + continue + entry.update(detail=f"may have changed: its header is no longer at its line; a declaration of {n} is at line {again}", target=d, target_kind='method') + out.append(entry); continue # A BLOCK BODY ON THE HEADER'S LINE IS NOT HEADER EITHER. `public int total() { return 42; }` is one line, # and read to its line the whole body was header text: `return 42;` to `return 43;` came back as a # signature change (#1387) @@ -508,7 +632,12 @@ class Changed: for s in range(max(1, (na or a) - 2), min(len(NL), (na or a) + 3) + 1): if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]): nline = NL[s - 1]; break nt, nn_ = self.field_parts(nline) if nline else ('', '') - if not nline: detail = 'removed or renamed' + if not nline: + # not within a few lines of where it was: looked for in the whole new text before it is called gone + again = still_declared(n, k, na or a) + detail = (f'may have changed: still assigned at line {again}' if again else + f'may have changed: no assignment of it near its line, but the new text still names it' if any(re.search(rf'\b{re.escape(n)}\b', x) for x in NS) else + 'removed or renamed') elif ot and nt and ot != nt: detail = f'type {ot} → {nt}' elif nn_ and nn_ != n: detail = f'renamed → {nn_}' else: detail = 'initializer / modifiers changed' @@ -518,7 +647,21 @@ class Changed: entry['detail'] = 'header changed (name, extends / implements, type parameters)' + ('; decoration changed: ' + ', '.join(dict.fromkeys(decs)) + ' — the framework behaviour it turns on is NOT in the graph' if decs else ''); entry['target'] = d elif kind == 'body': entry['target'] = d else: entry['target'] = None + if i in unsure and entry['kind'] in ('signature', 'field', 'type') and not str(entry.get('detail', '')).startswith('may have changed'): + entry['detail'] = 'may have changed (placed by name: the graph is from an older text of this file): ' + str(entry.get('detail') or '') out.append(entry) + # A COMMENT-ONLY EDIT is the declaration's own: a field whose line it is, else the narrowest callable or type that + # holds it. Never a signature, never nothing; a comment in a module's top level belongs to no declaration + for ln in sorted(comment_only): + x = 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) or \ + next((m for m in [narrowest(ln, {'method', 'function', 'constructor'})] if m), None) or \ + narrowest(ln, {'class', 'interface', 'enum', 'type'}) + if not x or any(e.get('id') == x[2] for e in out): continue + a, b, i, k, d, n = x + out.append(dict(kind='body', symbol=d, id=i, file=rel, line=a, end=b, old_lines=[ln], detail='comment only', target=d, target_kind=KIND(k))) + # a declaration found removed is not also listed by a body line of it that the diff paired with other text + gone_ids = {e['id'] for e in out if e['kind'] == 'removed'} + out = [e for e in out if e['kind'] == 'removed' or e['id'] not in gone_ids] adds = [] SL = strip_code(new, hash_comments=is_py).split('\n') # the new text, strings and comments blanked ndepth = [0] * (len(NL) + 2) # brace depth at the START of each new line @@ -720,7 +863,10 @@ class Changed: if i is None: return None r = self.g.all_sym.get(i) q = r.get('qualified_name') if r else None - if not q and kind == 'field': + # a field's id is `f:` (the spans above): read that way it was never found, and every field, removed or + # retyped, went to impact by its bare display, which answers for every field of that name in the project + if not q and (kind == 'field' or str(i).startswith('f:')): + i = int(str(i)[2:]) if str(i).startswith('f:') and str(i)[2:].isdigit() else i row = self.g.q("SELECT qualified_name FROM symbols WHERE rowid = ?", i) q = row[0][0] if row else None q = P.canon(q) if q else None @@ -785,6 +931,9 @@ def main(argv): elif flag == '--new': new_f = v else: one = v if '--staged' in a: mode = 'staged'; a.remove('--staged') + # --against-head: the working tree against HEAD, not the baseline. What the edit hooks ask after a rebase, a pull + # or a checkout that the baseline has not followed yet: the commits that came in are not the agent's edits + if '--against-head' in a: mode = 'head'; a.remove('--against-head') repo = '.' if a and os.path.isdir(a[0]): repo = a.pop(0) rrepo = os.path.realpath(repo) @@ -870,6 +1019,9 @@ def main(argv): if want_suggest and mode == 'worktree' and not a and not results and not notes and not outside and git and main_graph: s_ = branch_suggestion(C.repo) if s_: suggest = dict(ref=s_[0], commits=s_[1], range=f"{s_[0]}..HEAD") + for rel in sorted(C.mismatch): + notes.append(('', f"{rel}: the graph's rows and the text it records for this file disagree (a refresh is publishing, or one " + "raced an edit): its declarations were placed by name, and what is said of them may have changed", None)) if outside: notes.append(('', outside_note(outside), None)) # A BASELINE THAT HOLDS EDITS (an explicit index of an edited tree, or a rebuild run as one) hides them: said, with how # to count them, rather than a bare "no change" over a real edit. Only when the question is the working tree as a whole diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index da4bbba2..f3932ab7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -51,8 +51,9 @@ def certain(repo): return False -def impact(repo, target, depth=DEPTH): - """{contract, reads, byname, reached, tests, overloads} for one declaration, or None when it is not in the graph.""" +def impact(repo, target, depth=DEPTH, file=None): + """{contract, reads, byname, reached, tests, overloads} for one declaration, or None when it is not in the graph. + `file`: the file the declaration is in, when the caller knows it (an edit does)""" db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode'), 'out', 'graph.sqlite') if not os.path.exists(db): return None con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) @@ -112,6 +113,13 @@ def at_line(t): rows = q("SELECT id, kind, method_id FROM symbols WHERE file=? AND method_id IS NOT NULL", (target,)).fetchall() if not rows and '.' not in target and '/' not in target: rows = q("SELECT id, kind, method_id FROM symbols WHERE name=? AND method_id IS NOT NULL", (target,)).fetchall() + # ONE DECLARATION, NOT EVERY ONE OF ITS NAME. A display is not unique: two modules both called `utils` each + # with a `helper`, two packages each with a `Config.load`. An edit knows its file, and without it the removal of + # one `helper` was answered with the callers and tests of the other as well + if file and len(rows) > 1: + ph_ = ','.join('?' * len(rows)) + keep = {r_[0] for r_ in q(f"SELECT id FROM symbols WHERE id IN ({ph_}) AND file = ?", (*[r_[0] for r_ in rows], file))} + if keep: rows = [r_ for r_ in rows if r_[0] in keep] if not rows: return None # A FIELD is declined for the same reason a constructor is, and the failure it caused was worse. What # depends on a field is a READ or a WRITE — rows in `refs` and `field_access`, not in `call_edges` — so @@ -300,11 +308,11 @@ def hook_direct(j): return [x for x in (j or {}).get('direct', []) if x.get('certainty') not in HOOK_HIDDEN] -def impact_shaped(repo, target, depth=DEPTH, tests_shown=3): +def impact_shaped(repo, target, depth=DEPTH, tests_shown=3, file=None): """the same dict shape `hooks/changes.py` already formats from `axiomcode impact --json`, so the hook's presentation is untouched by the swap. `reached` and `tests` are lists because the formatter takes len() of them; only the first few tests carry names, which is all it prints.""" - r = impact(repo, target, depth) + r = impact(repo, target, depth, file=file) if r is None: return None db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode'), 'out', 'graph.sqlite') con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index d8260a8c..b045f2b6 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -8,9 +8,18 @@ extends / implements, type parameters), `removed`, and `added` lines outside any nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it: the -declaration edited, as `file:line` (a name answers for every declaration carrying it: eight `main`s, two overloads), and -`file:line(param)` for a signature with one parameter changed. `--impact` runs impact on all of them as one change set. +`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the +edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as +edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for +every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter +changed. `--impact` runs impact on all of them as one change set. + +The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an +edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and +one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere +in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went +while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and +the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. What to pass, and what the answer says when the question cannot be answered the way it was asked: diff --git a/tests/edit_stale_spans.py b/tests/edit_stale_spans.py new file mode 100644 index 00000000..aac559c8 --- /dev/null +++ b/tests/edit_stale_spans.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""tests/edit_stale_spans.py: the edit hook judges an edit against the file as it is, not at the graph's old line numbers. + +The graph's spans are line numbers in the text it was indexed from. Between an edit and the background refresh the file +is ahead of the graph, and the PreToolUse hook (changes.py, through `axiomcode changed --old/--new`) read the edited +text at the graph's numbers: a deleted line that sat where the graph had a field was reported as that field removed, +and a field line that sat where the graph had a method header as that method's signature change. It also asserted +"removed" for a method only moved below its neighbour, read an edited import as "signature ", and answered the +removal of one `helper` with the callers of every other `helper` in the project. + +For Python, Java and C#, on a small project indexed once, with an earlier edit the graph has not seen yet: + + · a line deleted where the graph had a field: no field reported removed; + control: deleting the field's own line still reports it removed; + · a field initializer edited where the graph had a method header: the field, not the method's signature; + control: a real parameter added to that method is still a signature change; + · a method moved below its neighbour: not removed; + · removing a function whose name another file also declares: only this file's callers; + · (Python) an edited import line: never a signature of the module; + · the graph's rows and the tree it records for them disagree (a refresh raced an edit): declarations are placed by + name and the answer says so; control: a faithful recorded tree says nothing of the kind; + · after a rebase the baseline has not followed yet, the commit that came in is not reported as this session's edit; + control: an edit left uncommitted after the rebase still is. + +It indexes three small projects, so it needs the engine, as run.py does. + + python3 tests/edit_stale_spans.py +""" +import json, os, subprocess, sys, tempfile + +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-stale-') +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: fails.append(why) + +def git(repo, *a): + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', *a], cwd=repo, capture_output=True, check=True) + +n = [0] +def fire_bash(repo, cmd): + """run a shell command in the repository, then the PostToolUse Bash hook on it, with the refresher off so the + baseline stays where it was (the state a rebase leaves for as long as the rebuild takes)""" + ran = subprocess.run(cmd, shell=True, cwd=repo, capture_output=True, text=True) + if ran.returncode: print(f' (command failed: {ran.stderr.strip()[-300:]})') + n[0] += 1 + ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Bash', 'cwd': repo, 'session_id': f's{n[0]}', 'tool_input': {'command': cmd}} + r = subprocess.run([sys.executable, os.path.join(HOOKS, 'changes.py')], input=json.dumps(ev), capture_output=True, text=True, + timeout=180, env=dict(os.environ, AXIOMCODE_NO_REFRESH='1')) + out = r.stdout.strip() + try: out = json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): pass + return out + +def changed_json(repo, rel, new_text): + tf = tempfile.NamedTemporaryFile('w', suffix=os.path.splitext(rel)[1], delete=False); tf.write(new_text); tf.close() + r = subprocess.run([sys.executable, os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-changed'), repo, + '--old', os.path.join(repo, rel), '--new', tf.name, '--file', rel, '--json'], capture_output=True, text=True, timeout=120) + os.unlink(tf.name) + try: return json.loads(r.stdout) + except ValueError: return {'changed': [], 'notes': [r.stderr[-300:]]} + +def fire(repo, rel, old, new, pre=()): + """the file as committed, then `pre` (edits the graph has not seen), then the hook on the Edit old -> new""" + git(repo, 'checkout', '-q', '--', '.') + f = os.path.join(repo, rel); t = open(f).read() + for o, nn in pre: + assert o in t, o + t = t.replace(o, nn, 1) + open(f, 'w').write(t) + assert old in t, old + n[0] += 1 + ev = {'hook_event_name': 'PreToolUse', 'tool_name': 'Edit', 'cwd': repo, 'session_id': f's{n[0]}', + 'tool_input': {'file_path': f, 'old_string': old, 'new_string': new}} + r = subprocess.run([sys.executable, os.path.join(HOOKS, 'changes.py')], input=json.dumps(ev), capture_output=True, text=True, timeout=180) + out = r.stdout.strip() + try: out = json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): pass + return out + +PY = { + 'pkg/__init__.py': '', 'pkg/a/__init__.py': '', 'pkg/b/__init__.py': '', + 'pkg/a/utils.py': ('"""Utilities for a."""\nimport os\n\n\ndef helper(x):\n """Return x plus one."""\n return x + 1\n\n\n' + 'class Stale:\n """Tracks stale files."""\n\n def __init__(self, root):\n self.root = root\n self.files = []\n\n' + ' def mark_text(self, path, text):\n """Mark one file."""\n self.files.append((path, text))\n return len(self.files)\n\n' + ' def count(self):\n return len(self.files)\n'), + 'pkg/b/utils.py': 'def helper(y):\n return y * 2\n', + 'pkg/a/use.py': 'from pkg.a.utils import helper, Stale\n\n\ndef run():\n s = Stale("/")\n s.mark_text("p", "t")\n return helper(1) + s.count()\n', + 'pkg/b/go.py': 'from pkg.b.utils import helper\n\n\ndef go():\n return helper(3)\n', +} +JAVA = { + 'src/main/java/a/Util.java': 'package a;\n\npublic class Util {\n /** Return x plus one. */\n public static int helper(int x) {\n return x + 1;\n }\n}\n', + 'src/main/java/b/Util.java': 'package b;\n\npublic class Util {\n public static int helper(int y) {\n return y * 2;\n }\n}\n', + 'src/main/java/a/Stale.java': ('package a;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class Stale {\n private final String root;\n' + ' private List files = new ArrayList<>();\n\n public Stale(String root) {\n this.root = root;\n }\n\n' + ' /** Mark one file. */\n public int markText(String path, String text) {\n files.add(path);\n return files.size();\n }\n\n' + ' public int count() {\n return files.size();\n }\n}\n'), + 'src/main/java/a/Use.java': 'package a;\n\npublic class Use {\n public int run() {\n Stale s = new Stale("/");\n s.markText("p", "t");\n return Util.helper(1) + s.count();\n }\n}\n', + 'src/main/java/b/Go.java': 'package b;\n\npublic class Go {\n public int go() {\n return Util.helper(3);\n }\n}\n', +} +CS = { + 'src/App/App.csproj': '\n net8.0\n\n', + 'src/App/UtilA.cs': 'namespace A\n{\n public static class Util\n {\n public static int Helper(int x)\n {\n return x + 1;\n }\n }\n}\n', + 'src/App/UtilB.cs': 'namespace B\n{\n public static class Util\n {\n public static int Helper(int y)\n {\n return y * 2;\n }\n }\n}\n', + 'src/App/Stale.cs': ('using System.Collections.Generic;\n\nnamespace A\n{\n public class Stale\n {\n private readonly string root;\n' + ' private List files = new List();\n\n public Stale(string root)\n {\n this.root = root;\n }\n\n' + ' /// Mark one file.\n public int MarkText(string path, string text)\n {\n files.Add(path);\n return files.Count;\n }\n\n' + ' public int Count()\n {\n return files.Count;\n }\n }\n}\n'), + 'src/App/Use.cs': 'namespace A\n{\n public class Use\n {\n public int Run()\n {\n var s = new Stale("/");\n s.MarkText("p", "t");\n return Util.Helper(1) + s.Count();\n }\n }\n}\n', + 'src/App/Go.cs': 'namespace B\n{\n public class Go\n {\n public int Go2()\n {\n return Util.Helper(3);\n }\n }\n}\n', +} + +def project(lang, files): + repo = tempfile.mkdtemp(prefix=f'ax-stale-{lang}-') + for p, t in files.items(): + os.makedirs(os.path.dirname(os.path.join(repo, p)), exist_ok=True) + open(os.path.join(repo, p), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + built = subprocess.run(['bash', AX, 'index', repo, '--lang', lang], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + print(f'FAIL could not index the {lang} project; the engine is needed\n ' + built.stderr.strip()[-300:]); sys.exit(1) + return repo + +# per language: the Stale file, the lines each case needs, the Util file, the other file's caller, this file's caller +CASES = { + 'python': dict(files=PY, other_util='pkg/b/utils.py', other_sig='def helper(y):', other_sig_new='def helper(y, z=0):', + stale='pkg/a/utils.py', util='pkg/a/utils.py', other='go', mine='run', field='files', + after_root=' self.root = root\n', added=' self.seen = 0\n', top='import os\n', + field_line=' self.files = []\n', field_edit=' self.files = list()\n', method='mark_text', + sig=' def mark_text(self, path, text):', sig_new=' def mark_text(self, path, text, force=False):', + helper='def helper(x):\n """Return x plus one."""\n return x + 1\n', + moved=(' def mark_text(self, path, text):\n """Mark one file."""\n self.files.append((path, text))\n return len(self.files)\n\n', + ' def count(self):\n return len(self.files)\n')), + 'java': dict(files=JAVA, other_util='src/main/java/b/Util.java', other_sig='public static int helper(int y)', other_sig_new='public static int helper(int y, int z)', + stale='src/main/java/a/Stale.java', util='src/main/java/a/Util.java', other='Go.go', mine='Use.run', field='files', + after_root=' private final String root;\n', added=' private int seen = 0;\n', top='import java.util.List;\n', + field_line=' private List files = new ArrayList<>();\n', field_edit=' private List files = new ArrayList<>(8);\n', method='markText', + sig='public int markText(String path, String text)', sig_new='public int markText(String path, String text, boolean force)', + helper=' /** Return x plus one. */\n public static int helper(int x) {\n return x + 1;\n }\n', + moved=(' /** Mark one file. */\n public int markText(String path, String text) {\n files.add(path);\n return files.size();\n }\n\n', + ' public int count() {\n return files.size();\n }\n')), + 'csharp': dict(files=CS, other_util='src/App/UtilB.cs', other_sig='public static int Helper(int y)', other_sig_new='public static int Helper(int y, int z)', + stale='src/App/Stale.cs', util='src/App/UtilA.cs', other='Go.Go2', mine='Use.Run', field='files', + after_root=' private readonly string root;\n', added=' private int seen = 0;\n', top='using System.Collections.Generic;\n', + field_line=' private List files = new List();\n', field_edit=' private List files = new List(8);\n', method='MarkText', + sig='public int MarkText(string path, string text)', sig_new='public int MarkText(string path, string text, bool force)', + helper=' public static int Helper(int x)\n {\n return x + 1;\n }\n', + moved=(' /// Mark one file.\n public int MarkText(string path, string text)\n {\n files.Add(path);\n return files.Count;\n }\n\n', + ' public int Count()\n {\n return files.Count;\n }\n')), +} + +only = sys.argv[1:] or list(CASES) +for lang in only: + c = CASES[lang]; repo = project(lang, c['files']) + # enough lines above the field that its line now sits where the graph has the method's header + pad = [(c['top'], c['top'] + ''.join(f"{'#' if lang == 'python' else '//'} pad {k}\n" for k in range({'python': 2, 'java': 7, 'csharp': 8}[lang])))] + + out = fire(repo, c['stale'], c['added'], '', pre=[(c['after_root'], c['after_root'] + c['added'])]) + check(f'{lang}: deleting a line added since the index, where the graph has a field, removes no field', 'removed' not in out, out) + out = fire(repo, c['stale'], c['field_line'], '') + check(f'{lang}: control: deleting the field\'s own line reports it removed', f"removed Stale.{c['field']}" in out, out) + + out = fire(repo, c['stale'], c['field_line'], c['field_edit'], pre=pad) + check(f'{lang}: a field edited where the graph had a method header is the field, not the method', f"field Stale.{c['field']}" in out and 'signature' not in out, out) + out = fire(repo, c['stale'], c['sig'], c['sig_new'], pre=pad) + check(f'{lang}: control: a parameter added to that method is still its signature change', f"signature Stale.{c['method']}" in out and '+force' in out, out) + + a, b = c['moved'] + out = fire(repo, c['stale'], a + b, b + '\n' + a.rstrip('\n') + '\n') + check(f'{lang}: a method moved below its neighbour is not removed', 'removed' not in out, out) + + out = fire(repo, c['util'], c['helper'], '') + check(f'{lang}: removing a function lists this file\'s caller', c['mine'] in out and 'removed' in out, out) + check(f'{lang}: ... and not the caller of the same-named function in another file', c['other'] not in out, out) + + if lang == 'python': + out = fire(repo, c['stale'], 'import os\n', 'import os, http\n') + check('python: an edited import line is never a signature of the module', 'signature' not in out, out) + git(repo, 'checkout', '-q', '--', '.') + + # the rows are the committed text's, and the tree the graph says it was built from is a later one + it = os.path.join(repo, '.axiomcode', 'out', 'indexed-tree'); faithful = open(it).read() + f = os.path.join(repo, c['stale']); t0 = open(f).read(); shifted = t0.replace(pad[0][0], pad[0][1], 1) + open(f, 'w').write(shifted); git(repo, 'commit', '-qam', 'shift') + open(it, 'w').write(subprocess.run(['git', 'rev-parse', 'HEAD^{tree}'], cwd=repo, capture_output=True, text=True).stdout.strip() + '\n') + j = changed_json(repo, c['stale'], shifted.replace(c['field_line'], c['field_edit'], 1)) + kinds = [f"{e['kind']} {e['symbol']}" for e in j.get('changed', [])] + check(f'{lang}: rows and recorded tree disagree: the field edit is still the field, not a signature', + f"field Stale.{c['field']}" in kinds and not any(k.startswith('signature') for k in kinds), kinds) + check(f'{lang}: ... and the answer says the declarations were placed by name', any('disagree' in x for x in j.get('notes', [])), j.get('notes')) + git(repo, 'reset', '-q', '--hard', 'HEAD~1'); open(it, 'w').write(faithful) + open(f, 'w').write(shifted) + j = changed_json(repo, c['stale'], shifted.replace(c['field_line'], c['field_edit'], 1)) + check(f'{lang}: control: a faithful recorded tree is not called a disagreement', not any('disagree' in x for x in j.get('notes', [])) and + f"field Stale.{c['field']}" in [f"{e['kind']} {e['symbol']}" for e in j.get('changed', [])], j) + git(repo, 'checkout', '-q', '--', '.') + + # a rebase onto a commit that changed another file's function: HEAD moved, the baseline did not + base = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo, capture_output=True, text=True).stdout.strip() + git(repo, 'checkout', '-q', '-b', 'feature') + u = os.path.join(repo, c['util']); ut = open(u).read(); open(u, 'w').write(ut.replace('return x + 1', 'return x + 2', 1)); git(repo, 'commit', '-qam', 'mine') + mine = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=repo, capture_output=True, text=True).stdout.strip() + git(repo, 'checkout', '-q', base) + o = os.path.join(repo, c['other_util']); ot = open(o).read(); open(o, 'w').write(ot.replace(c['other_sig'], c['other_sig_new'], 1)); git(repo, 'commit', '-qam', 'upstream') + git(repo, 'checkout', '-q', 'feature') + out = fire_bash(repo, f'git rebase {base}') + check(f'{lang}: after a rebase, the commit that came in is not reported as an edit', 'signature' not in out and '+z' not in out, out) + git(repo, 'reset', '-q', '--hard', mine) + out = fire_bash(repo, f"git rebase {base} && {sys.executable} -c \"import sys; p=sys.argv[1]; t=open(p).read(); open(p,'w').write(t.replace(sys.argv[2], sys.argv[3], 1))\" {c['stale']} '{c['sig']}' '{c['sig_new']}'") + check(f'{lang}: control: an edit left uncommitted after the rebase is reported', f"signature Stale.{c['method']}" in out, out or subprocess.run(['git', 'status', '--short'], cwd=repo, capture_output=True, text=True).stdout) + +print(f"\n{len(checked) - len(fails)}/{len(checked)} passed") +if not checked: sys.exit(1) +sys.exit(1 if fails else 0) From 0730b22f4880d4c3e21c7b63094db8293d12be99 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:51:15 -0700 Subject: [PATCH 049/258] changed / edit hooks: compare a line of several statements or declarations one statement at a time What was wrong `changed` (and the edit hooks, which call it) mapped a changed line to the FIRST field the graph declares on that line and read the whole line as that field's declaration. On a line holding several statements or declarations: - Python `con = j.get(...); dr = ...; rc = j.get(...)` with only `rc`'s right-hand side edited came back as "field rc renamed -> con": the line's first statement named the field, and the field's own statement was never looked at; - `size = 1; label = "b"` in a class body, `self.a = 1; self.b = 2`, and a module's `LIMIT = 3; NAME = "x"` charged an edit of the second name to the first; - Java and C# `private String label = "b"; private int count = 0;` answered "Box.count renamed -> label", and `int size = 1, weight = 2;` charged `weight`'s edit (or rename) to `size`; - `a, b = x, y`, an unpacking `a, b = f()` and `for k, v in ...` reported one name, often "renamed" to its neighbour; - declarators with no initializer (`protected final int a, b;`) were read as one field whose type was `int a,`, so a rename of `b` became "a removed or renamed" plus an unrelated added field; - a name that a script's top-level code assigns (the graph's `variable`, as `rc` above, which sits under a top-level `if` in a hook script) was printed as a `field`, as if some type owned it. The change - A changed line is split into statements before it is matched: `;` outside brackets, a Python compound header with its body on the same line (`with open(p) as f: f.write(t)`), one statement per declarator in Java and C# (each spelled with the shared type), and Python `a, b = x, y` into one assignment per name when the two sides pair up. - Each field the graph declares on the line is matched to its own statement, and only a field whose own statement changed is reported, compared with the statement it became (the one declaring the same name, else the one at its place). A rename, a retype and an initializer change are read from that pair; a statement gone from the line with nothing declaring the name any more is `removed`; one moved word for word to a nearby line is not reported. One name of an unpacking or a `for` target list is untouched while its place and what it is bound to are unchanged. - A line where no field's statement changed goes to the enclosing callable (a joined line of locals in a method is that method's body change, as before); a new declaration added to a line is `added`, and a renamed field is not also listed as a new field of the new name. - A `variable` is labelled `variable`, not `field`, in `changed`, in the edit hooks' blocks and in the hook validator; `--json` keeps kind `field` and adds `label`. - Lines holding one statement and one declaration take the path they took before. This is built on the edit-hook stale-spans change (its commit is the parent of this one), which carries the graph's spans onto the edited text; the two touch the same function and are meant to land in that order. Tests New cases tests/cases/{python,java,csharp}/one-line-statements (8 + 7 + 4 checks), each with near-miss controls: a real rename of the second name on the line is still a rename to its new name; a retype is a type change of that field only; an initializer edit is an initializer change of that field only; a joined line of locals in a method is still the method's body. Without the change only the locals controls pass (1 of 6 Python, 1 of 6 Java, 1 of 4 C#, checked before the tuple and no-initializer checks were added). Suites run on the tree rebased onto the release branch (on top of the stale-spans commit): tests/run.py --lang python 198/199, --lang java 193/193, --lang csharp 59/62 (1 pending); tests/hook_languages.py 7/7, tests/enrich_lines.py 44/44, tests/edit_stale_spans.py 37/37, tests/changed_range.py 50/50. comment-in-header passes. The failures are the release branch tip's own: lambda-is-named-by-its-place (Python and C#) and member-owner-is-its-type's pending marker (C#). Smoke (the file's own text, one generated edit per declaration on every line where the graph declares two or more fields or variables: an initializer edit of that one declaration, and a rename of it; good = `changed --old/--new` names exactly that declaration with the right kind of change; installed build vs this change): - this repository's plugin scripts (Python, 56 such lines, 120 of 201 edits sampled): initializer edits 3/59 -> 58/58, renames 0/61 -> 62/62 - a Python CLI library (5 lines, all unpackings): 0/4 -> 4/4, renames 0/2 -> 2/2 - a Python web application (2 lines): 0/1 -> 1/1, renames 0/4 -> 4/4 - a Java data-binding library (25 lines, mostly `int a, b;` declarators): initializer edits 2/2 -> 2/2, renames 0/56 -> 56/56 - a C# API gateway (2 lines): renames 0/5 -> 5/5 Lines like these are rare in real Java and C# (two Java projects checked had 4 and 25 such lines, most records and enums, which keep the path they had), and common in Python scripts. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/enrich.py | 2 +- plugins/axiomcode/hooks/validate.py | 8 +- .../axiomcode/reference/changed-and-tests.md | 4 +- .../axiomcode/scripts/axiomcode-changed | 193 +++++++++++++++++- .../csharp/one-line-statements/case.json | 86 ++++++++ .../csharp/one-line-statements/new_count.cs | 15 ++ .../csharp/one-line-statements/new_heft.cs | 15 ++ .../csharp/one-line-statements/new_local.cs | 15 ++ .../csharp/one-line-statements/new_name.cs | 15 ++ .../csharp/one-line-statements/src/Box.cs | 15 ++ .../java/one-line-statements/New_count.java | 13 ++ .../one-line-statements/New_count_long.java | 13 ++ .../java/one-line-statements/New_extra.java | 13 ++ .../java/one-line-statements/New_heft.java | 13 ++ .../java/one-line-statements/New_high.java | 13 ++ .../java/one-line-statements/New_local.java | 13 ++ .../java/one-line-statements/New_weight.java | 13 ++ .../cases/java/one-line-statements/case.json | 148 ++++++++++++++ .../src/main/java/a/Box.java | 13 ++ .../python/one-line-statements/case.json | 172 ++++++++++++++++ .../python/one-line-statements/new_hi.py | 18 ++ .../python/one-line-statements/new_label.py | 18 ++ .../python/one-line-statements/new_local.py | 18 ++ .../python/one-line-statements/new_rc.py | 18 ++ .../python/one-line-statements/new_rx.py | 18 ++ .../python/one-line-statements/new_self_b.py | 18 ++ .../python/one-line-statements/new_tag.py | 18 ++ .../python/one-line-statements/new_top.py | 18 ++ .../python/one-line-statements/src/app.py | 18 ++ 30 files changed, 939 insertions(+), 17 deletions(-) create mode 100644 tests/cases/csharp/one-line-statements/case.json create mode 100644 tests/cases/csharp/one-line-statements/new_count.cs create mode 100644 tests/cases/csharp/one-line-statements/new_heft.cs create mode 100644 tests/cases/csharp/one-line-statements/new_local.cs create mode 100644 tests/cases/csharp/one-line-statements/new_name.cs create mode 100644 tests/cases/csharp/one-line-statements/src/Box.cs create mode 100644 tests/cases/java/one-line-statements/New_count.java create mode 100644 tests/cases/java/one-line-statements/New_count_long.java create mode 100644 tests/cases/java/one-line-statements/New_extra.java create mode 100644 tests/cases/java/one-line-statements/New_heft.java create mode 100644 tests/cases/java/one-line-statements/New_high.java create mode 100644 tests/cases/java/one-line-statements/New_local.java create mode 100644 tests/cases/java/one-line-statements/New_weight.java create mode 100644 tests/cases/java/one-line-statements/case.json create mode 100644 tests/cases/java/one-line-statements/src/main/java/a/Box.java create mode 100644 tests/cases/python/one-line-statements/case.json create mode 100644 tests/cases/python/one-line-statements/new_hi.py create mode 100644 tests/cases/python/one-line-statements/new_label.py create mode 100644 tests/cases/python/one-line-statements/new_local.py create mode 100644 tests/cases/python/one-line-statements/new_rc.py create mode 100644 tests/cases/python/one-line-statements/new_rx.py create mode 100644 tests/cases/python/one-line-statements/new_self_b.py create mode 100644 tests/cases/python/one-line-statements/new_tag.py create mode 100644 tests/cases/python/one-line-statements/new_top.py create mode 100644 tests/cases/python/one-line-statements/src/app.py diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 798c5937..31146724 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -73,7 +73,7 @@ def impact(d): # name or a text match, and neither a hand-off nor a truncated fan-out outranks a resolved call. rank = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'in scope': 4, 'by name': 5, 'text': 6} for d, j in results: - hd = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') + hd = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(hd + " (impact unavailable)"); continue con = j.get('contract', []); dr = sorted(graph_sql.hook_direct(j), key=lambda x: (rank.get(x['certainty'], 9), x['display'])); rc = j.get('reached', []); ts = j.get('tests', []) prod = [x for x in dr if x['role'] in ('produces', 'writes')]; reads = [x for x in dr if x['role'] in ('reads', 'uses')] diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 0b60bb96..bf95aadf 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -193,7 +193,7 @@ def impact(d): base = (ch.get('built_at') or '')[:10] if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base else '') + " —") for d, j in results: - head = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') + head = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(head + " (impact unavailable)"); continue con = j.get('contract', []); dr = graph_sql.hook_direct(j); rc = j.get('reached', []); ts = j.get('tests', []) # ordered as ax_edges.DIRECT_ORDER and impact's CERT are: an edge the engine asserted outranks a diff --git a/plugins/axiomcode/hooks/validate.py b/plugins/axiomcode/hooks/validate.py index bc893390..fe3d7ba0 100644 --- a/plugins/axiomcode/hooks/validate.py +++ b/plugins/axiomcode/hooks/validate.py @@ -162,7 +162,7 @@ def check_change(self, text, rel, old_text, new_text): try: truth = json.loads(r.stdout) except Exception: self.fact(False, f"change {rel}: `changed` failed: {(r.stdout + r.stderr).strip()[-160:]}"); return os.unlink(fo.name); os.unlink(fn.name) - tset = {(d['kind'], d['symbol']) for d in truth.get('changed', [])} + tset = {(d.get('label') or d['kind'], d['symbol']) for d in truth.get('changed', [])} cur = None for l in text.split('\n')[1:]: # "(impact unavailable)" is the hook giving up — the CLI raised or blew its timeout. It is a legitimate @@ -171,15 +171,15 @@ def check_change(self, text, rel, old_text, new_text): # numbers reads exactly like a fix that turns wrong numbers into right ones. This runs the CLI itself, # without the hook's timeout, so the question it asks is answerable: did the hook give up on something # the tool can answer? - mu = re.match(r' (signature|body|field|type|removed|added) (\S+).*\(impact unavailable\)$', l) + mu = re.match(r' (signature|body|field|variable|type|removed|added) (\S+).*\(impact unavailable\)$', l) if mu: cur = None self.fact(False, f"change {rel}: the hook gave up on {mu.group(1)} {mu.group(2)} — the block carries" f" no claim about it at all, while this check answers it from the same CLI") continue - mm = re.match(r' (signature|body|field|type|removed|added) (\S+)(?: — (.*))?$', l) + mm = re.match(r' (signature|body|field|variable|type|removed|added) (\S+)(?: — (.*))?$', l) if mm: - cur = next((d for d in truth.get('changed', []) if d['symbol'] == mm.group(2) and d['kind'] == mm.group(1)), None) + cur = next((d for d in truth.get('changed', []) if d['symbol'] == mm.group(2) and (d.get('label') or d['kind']) == mm.group(1)), None) self.fact(cur is not None, f"change {rel}: block names {mm.group(1)} {mm.group(2)} but `changed` on the same texts gives {sorted(tset)[:4]}") if cur and cur.get('target'): r2 = subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), cur['target'], self.repo, '--json', '--depth', '12'] + (['--kind', cur['target_kind']] if cur.get('target_kind') and cur['target_kind'] != 'param' and '(' not in cur['target'] else []), capture_output=True, text=True) diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index b045f2b6..ace7b82b 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -3,7 +3,9 @@ `axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: `signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer), `type` (a header: name, +`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a +script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, +`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index f7d6d579..2b435e98 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -9,7 +9,8 @@ the graph knows at that line, and the change is classified — the same way in e signature a method's header changed: parameters added / removed / renamed / retyped, the return type, type parameters body only lines inside a method's body changed - field a field's declared type, name or initializer changed + field a field's declared type, name or initializer changed (`variable` for a name a script's top-level code assigns); + on a line of several statements or declarations, only the one whose own statement changed type a type's header changed (its name, what it extends or implements, its type parameters) removed a declaration the new text no longer has added lines the old text did not have, outside any changed declaration (a new method, a new field): nothing @@ -390,13 +391,134 @@ class Changed: m = re.match(r'^(?:self\.)?([A-Za-z_$][\w$]*)\s*=', t) if m: return '', m.group(1) return '', '' + @staticmethod + def statements(blank, text, is_py): + """A LINE CAN HOLD SEVERAL STATEMENTS, and each is compared on its own. `blank` is the line with strings and + comments blanked, `text` the same line with only comments blanked (the two are aligned character for character). + Split at each `;` outside brackets; in Java and C# a field declaration with several declarators (`int a = 1, b = 2`) + is one statement per declarator, each spelled with the shared type (`int b = 2`). Read as one line, the whole line + was taken for its first statement: an edit to `rc = ...` in `con = ...; rc = ...` came back as "field rc renamed -> con".""" + cuts, depth, s = [], 0, 0 + for x, ch in enumerate(blank): + if ch in '([{': depth += 1 + elif ch in ')]}': depth = max(0, depth - 1) + elif ch == ';' and depth == 0: cuts.append((s, x)); s = x + 1 + # a Python compound header with its body on the same line (`with open(p) as f: f.write(t)`): two statements + elif is_py and ch == ':' and depth == 0 and not cuts and re.match(r'\s*(async\s+)?(with|for|while|if|elif|else|try|except|finally)\b', blank) \ + and blank[x + 1:].strip(): cuts.append((s, x + 1)); s = x + 1 + cuts.append((s, len(blank))) + out = [] + for a, b in cuts: + if not text[a:b].strip(): continue + out.extend(Changed.unpack(blank[a:b], text[a:b]) if is_py else Changed.declarators(blank[a:b], text[a:b])) + return out + @staticmethod + def targets(stmt): + """the names a Python assignment binds when it binds several (`a, self.b = ...` -> [a, b]); [] otherwise""" + m = re.match(r'^\s*\(?\s*((?:\*?(?:self\.|cls\.)?[A-Za-z_]\w*\s*,\s*)+\*?(?:self\.|cls\.)?[A-Za-z_]\w*)\s*,?\s*\)?\s*=(?!=)', stmt) or \ + re.match(r'^\s*(?:async\s+)?for\s+\(?\s*((?:(?:self\.|cls\.)?[A-Za-z_]\w*\s*,\s*)*(?:self\.|cls\.)?[A-Za-z_]\w*)\s*,?\s*\)?\s+in\s', stmt) or \ + re.match(r'^\s*(?:async\s+)?(?:with|except)\b.*\bas\s+\(?\s*([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*\)?\s*:?\s*$', stmt) + return [re.sub(r'^\*?(self\.|cls\.)?', '', x.strip()) for x in m.group(1).split(',')] if m else [] + @staticmethod + def bound_value(stmt): + """what a Python assignment or `for` binds its names to: the text after `=`, or after `in`""" + m = re.match(r'^\s*(?:async\s+)?for\s.+?\sin\s(.*)$', stmt) or re.match(r'^\s*(?:async\s+)?(?:with|except)\b(.*)\bas\s', stmt) or re.match(r'^[^=]*?(?])=(?!=)(.*)$', stmt) + return re.sub(r'\s', '', m.group(1)) if m else None + @staticmethod + def unpack(blank, text): + """`a, b = x, y` -> [`a = x`, `b = y`]: each name with its own value, when the two sides pair up one to one. An + unpacking (`a, b = f()`) stays one statement, and binds both names""" + st = text.strip(); lead = len(text) - len(text.lstrip()); bl = blank[lead:lead + len(st)] + ts = Changed.targets(st); m = re.search(r'(?])=(?![=])', bl) + if len(ts) < 2 or not m: return [st] if st else [] + vals, depth, s = [], 0, m.end() + for x in range(m.end(), len(bl)): + ch = bl[x] + if ch in '([{': depth += 1 + elif ch in ')]}': depth = max(0, depth - 1) + elif ch == ',' and depth == 0: vals.append(st[s:x].strip()); s = x + 1 + if st[s:].strip(): vals.append(st[s:].strip()) + lhs = [x.strip() for x in st[:m.start()].strip().strip('()').split(',') if x.strip()] + if len(vals) != len(lhs) or any(v.startswith('*') for v in vals + lhs): return [st] + return [f"{a} = {v}" for a, v in zip(lhs, vals)] + @staticmethod + def declarators(blank, text): + """`private int a = 1, b = 2` -> [`private int a = 1`, `private int b = 2`]; any other statement is itself""" + st = text.strip(); lead = len(text) - len(text.lstrip()); bl = blank[lead:lead + len(st)] + head = re.split(r'(?])=(?![=>])', bl, maxsplit=1)[0] + if '(' in head or ',' not in bl: return [st] + # the first declarator ends at the first comma outside brackets (and outside `<...>` in the type) + cut, depth, angle = None, 0, 0 + for x, ch in enumerate(bl): + if ch in '([{': depth += 1 + elif ch in ')]}': depth = max(0, depth - 1) + elif ch == '<' and x < len(head): angle += 1 + elif ch == '>' and x < len(head): angle = max(0, angle - 1) + elif ch == ',' and depth == 0 and angle == 0: cut = x; break + if cut is None: return [st] + ty, nm = Changed.field_parts(st[:cut]) + if not ty or not nm: return [st] + m = re.match(rf'^(.*?\S)\s+{re.escape(nm)}\s*(?==|,|\[|$)', st) + if not m: return [st] + prefix, s0 = m.group(1) + ' ', len(m.group(1)) + 1 + while s0 < len(st) and st[s0].isspace(): s0 += 1 + segs, depth, s = [], 0, s0 + for x in range(s0, len(bl)): + ch = bl[x] + if ch in '([{': depth += 1 + elif ch in ')]}': depth = max(0, depth - 1) + elif ch == ',' and depth == 0: segs.append((s, x)); s = x + 1 + segs.append((s, len(bl))) + out = [] + for a, b in segs: + seg = st[a:b].strip() + # what follows a comma is a declarator only when it starts with a name (`b = 2`, `b`): the comma inside + # `new HashMap()` is not one, and that piece belongs to the declarator before it + if out and not re.match(r'^[A-Za-z_$][\w$]*\s*(=(?![=>])|\[|$)', seg): out[-1] += ', ' + seg; continue + out.append(seg) + return [st] if len(out) < 2 else [prefix + x for x in out] + def statement_fields(self, fs, ob, ok, nb, nk, j, is_py): + """`fs` are the fields the graph declares on one old line; `ob`/`ok` that line (strings and comments blanked / + comments blanked), `nb`/`nk` the new line it became (j; empty when it was deleted). Returns the fields whose OWN + statement changed, each with (old statement, new statement or None, j), and whether a statement that declares + none of them changed as well. None when neither line holds more than one statement (the line is compared whole) + or a field's own statement cannot be told apart on it.""" + os_ = self.statements(ob, ok, is_py); ns_ = self.statements(nb, nk, is_py) if j else [] + if len(os_) < 2 and len(ns_) < 2 and len(fs) < 2: return None + norm = lambda s: re.sub(r'\s', '', s) + on, nn = [norm(x) for x in os_], [norm(x) for x in ns_] + decl_of = lambda s, n, k: self.field_parts(s)[1] == n or self.declares(s, n, k, is_py) or (is_py and n in self.targets(s)) + own = {} + for x in fs: + q = next((q for q, s in enumerate(os_) if decl_of(s, x[5], x[3])), None) + if q is None: return None + own[x] = q + pos = {} + for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, on, nn, autojunk=False).get_opcodes(): + for q in range(i1, i2): pos[q] = (j1 + q - i1) if tag in ('equal', 'replace') and j1 + q - i1 < j2 else None + touched, paired = [], set() + for x, q in own.items(): + if on[q] in nn: paired.add(nn.index(on[q])); continue # its statement is unchanged (maybe moved along the line) + p = next((p for p, s in enumerate(ns_) if decl_of(s, x[5], x[3])), None) + if p is None: p = pos.get(q) + if p is not None: paired.add(p) + # one name of `a, b = f()` or `for a, b in ...`: untouched while its place and what it is bound to are the same + ots = self.targets(os_[q]) if is_py else [] + if ots and p is not None and x[5] in ots: + nts = self.targets(ns_[p]) + if len(nts) == len(ots) and nts[ots.index(x[5])] == x[5] and self.bound_value(os_[q]) == self.bound_value(ns_[p]): continue + touched.append((x, (os_[q], ns_[p] if p is not None else None, j))) + mine = set(own.values()) + others = any(on[q] not in nn for q in range(len(os_)) if q not in mine) or \ + any(nn[p] not in on and p not in paired and not self.field_parts(ns_[p])[1] for p in range(len(ns_))) + return touched, others # ── one file: the declarations touched and how ──────────────────────────────────────────────────────────── def file_changes(self, rel, old, new): OL, NL = old.split('\n'), new.split('\n') is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', new or old)) # a shebang script too (#1376) sm = difflib.SequenceMatcher(None, OL, NL, autojunk=False) - old_changed = set(); new_of = {}; added = []; removed_lines = set(); replaced = []; comment_only = set() + old_changed = set(); new_of = {}; added = []; removed_lines = set(); replaced = []; comment_only = set(); rep_of = {} # a blank line, a comment line, a brace on its own: not a change to a declaration TRIPLE = ('"' * 3, "'" * 3) noise = lambda t: not t.strip() or t.strip().startswith(('//', '*', '/*', '#') + TRIPLE) or t.strip() in ('{', '}', '};', ')', ');') @@ -414,6 +536,7 @@ class Changed: elif tag == 'replace': old_changed |= {i + 1 for i in range(i1, i2) if not noise(OL[i])} or ({i1 + 1} if any(not noise(NL[j]) for j in range(j1, j2)) else set()) new_of.update({i1 + 1: j1 + 1}); replaced.append((i1, j1 + 1, j2)) # the new side may declare something new too + for x in range(i1, i2): rep_of[x + 1] = (j1 + 1, j2) elif tag == 'delete': got = {i + 1 for i in range(i1, i2) if not noise(OL[i])}; old_changed |= got; removed_lines |= got elif tag == 'insert': @@ -475,7 +598,15 @@ class Changed: for a, b, i, k, d, n in decls: if ln < a <= ln + 3 and all(not L[x - 1].strip() or L[x - 1].strip().startswith('@') for x in range(ln + 1, a)): return (a, b, i, k, d, n) return None - decorated = {} + decorated = {}; stmt_pair = {} + OK = strip_code(old, strings=False, hash_comments=is_py).split('\n'); NK = strip_code(new, strings=False, hash_comments=is_py).split('\n') + def counterpart(ln): + """the new line an old changed line became: the most similar line of its replaced block; None when deleted""" + if ln in removed_lines: return None + if ln in rep_of: + j1, j2 = rep_of[ln]; o = re.sub(r'\s', '', OK[ln - 1]) + return max(range(j1, j2 + 1), key=lambda j: (difflib.SequenceMatcher(None, o, re.sub(r'\s', '', NK[j - 1]), autojunk=False).ratio(), -abs(j - j1))) + return new_of.get(ln) for ln in sorted(old_changed): if OL[ln - 1].strip().startswith('@') and not narrowest(ln, {'method', 'function', 'constructor', 'module'}): dd = decl_after(ln, OL) @@ -486,9 +617,20 @@ class Changed: if dd: decorated.setdefault(dd, []).extend(NL[j - 1].strip().split('(')[0] for j in range(j1, j2 + 1) if NL[j - 1].strip()); added.remove((after, j1, j2)) 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) + fs = [(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')] + f = fs[0] if fs else None + # SEVERAL STATEMENTS ON ONE LINE are matched one by one: only a field whose own statement changed is charged + # with the edit, and each is compared with its own counterpart (stmt_pair). A line of which no field's + # statement changed is the enclosing callable's (or the type's) like any other line + j = counterpart(ln) if fs else None + per = self.statement_fields(fs, OS[ln - 1], OK[ln - 1], NS[j - 1] if j else '', NK[j - 1] if j else '', j, is_py) if fs else None + if per is not None: + touched, others = per + for x, pair in touched: hits.setdefault(('field', x), set()).add(ln); stmt_pair[x[2]] = pair + if not others: continue # a new declaration on the line is read below, as added + f = 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 + elif 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'}) @@ -629,8 +771,28 @@ class Changed: elif kind == 'field': ot, on_ = self.field_parts(OL[a - 1]); na = new_of.get(a) nline = '' - for s in range(max(1, (na or a) - 2), min(len(NL), (na or a) + 3) + 1): - if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]): nline = NL[s - 1]; break + if i in stmt_pair: + # one of several statements on its line: its own statement, and the one it became + ost, nst, jn = stmt_pair[i] + ot, on_ = self.field_parts(ost); nline = nst or ''; na = jn or na + if is_py and n in self.targets(ost) and nst: + # one name of an unpacking (`a, b = f()`): renamed when the name at its place is another + ots, nts = self.targets(ost), self.targets(nst) + if len(ots) == len(nts) and nts[ots.index(n)] != n: nline = f"{nts[ots.index(n)]} = _" + elif n in nts: nline = f"{n} = _" + # not on the line it was paired with: its statement on a line nearby (a joined line split in two). + # Word for word there, it moved and did not change; otherwise that statement is what it became + if not nst: + near = [x for s in range(max(1, (na or a) - 3), min(len(NL), (na or a) + 3) + 1) for x in self.statements(NS[s - 1], NK[s - 1], is_py) + if self.field_parts(x)[1] == n or self.declares(x, n, k, is_py)] + if any(re.sub(r'\s', '', x) == re.sub(r'\s', '', ost) for x in near): continue + if near: nst = nline = near[0] + if not nst and not still_declared(n, k, na or a): + if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry.update(kind='removed', target=self.target(kind, d, k, n)); out.append(entry) + continue + else: + for s in range(max(1, (na or a) - 2), min(len(NL), (na or a) + 3) + 1): + if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]): nline = NL[s - 1]; break nt, nn_ = self.field_parts(nline) if nline else ('', '') if not nline: # not within a few lines of where it was: looked for in the whole new text before it is called gone @@ -649,6 +811,9 @@ class Changed: else: entry['target'] = None if i in unsure and entry['kind'] in ('signature', 'field', 'type') and not str(entry.get('detail', '')).startswith('may have changed'): entry['detail'] = 'may have changed (placed by name: the graph is from an older text of this file): ' + str(entry.get('detail') or '') + # A VARIABLE IS NOT A FIELD. The graph records a name assigned in a script's top-level code (under `if`, + # `for`, `with`) as a `variable`; it was printed "field rc", as if some type owned it + if entry['kind'] == 'field' and k == 'variable': entry['label'] = 'variable' out.append(entry) # A COMMENT-ONLY EDIT is the declaration's own: a field whose line it is, else the narrowest callable or type that # holds it. Never a signature, never nothing; a comment in a module's top level belongs to no declaration @@ -726,8 +891,12 @@ class Changed: re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st))) if m and not re.match(r'^(if|for|while|switch|catch|synchronized|return|new|super|this|else)$', m.group(1)) and '=' not in st.split('(')[0]: names.append((m.group(1), 'method', j)); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue - ty, nm = self.field_parts(st) - if nm and ty and st.endswith(';') and '(' not in st.split('=')[0]: names.append((nm, 'field', j)) + # each declaration on the line: `int a = 1; int b = 2;` and `int a = 1, b = 2;` declare two fields + for s2 in ([st] if py or not st.endswith(';') else self.statements(SL[j - 1], NK[j - 1], False)): + s2 = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', s2) + if py and self.targets(s2): continue # `a, b = ...` is not ` b` + ty, nm = self.field_parts(s2) + if nm and ty and '(' not in s2.split('=')[0] and not any(x[0] == nm for x in names): names.append((nm, 'field', j)) # a declaration the old file already has — same owner, same name, same arity — is not new (an added OVERLOAD is) def types_of(hdr): """the parameter types of a header, as simple names: `List xs, int n` → [List, int]; a signature without names too""" @@ -783,6 +952,10 @@ class Changed: # An added OVERLOAD keeps its row: the old declaration is untouched there, so no signature row names it. sigged = {(e['file'], e['symbol']) for e in out if e['kind'] in ('signature', 'field', 'type')} out = [e for e in out if not (e['kind'] == 'added' and (e['file'], e['symbol']) in sigged)] + # a field renamed on its line is that field's change, not also a new field of the new name + renamed = {(e['file'], e['symbol'].rsplit('.', 1)[0] + '.' + str(e.get('detail'))[len('renamed → '):] if '.' in e['symbol'] else str(e.get('detail'))[len('renamed → '):]) + for e in out if e['kind'] == 'field' and str(e.get('detail', '')).startswith('renamed → ')} + out = [e for e in out if not (e['kind'] == 'added' and (e['file'], e['symbol']) in renamed)] if overrun_note: adds.append(overrun_note) # THE TARGET NAMES THIS DECLARATION, NOT EVERY DECLARATION OF ITS NAME. `symbol` stays the short display; the # target impact is asked is where the declaration is (at_line), and `shown_target` keeps its name for reading. @@ -1048,7 +1221,7 @@ def main(argv): for e in shown: tail = (f" → impact {e['target']}" if e.get('target') else ('' if e.get('new_file') else ' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else '')) - print(f" {e['kind']:<10} {e['symbol']}" + ('' if e.get('new_file') else f" {e['file']}:{e['line']}") + (f" — {e['detail']}" if e.get('detail') else '') + tail) + print(f" {e.get('label') or e['kind']:<10} {e['symbol']}" + ('' if e.get('new_file') else f" {e['file']}:{e['line']}") + (f" — {e['detail']}" if e.get('detail') else '') + tail) by_file = {} for e in named: by_file.setdefault(e['file'], []).append(e) for f, es in by_file.items(): diff --git a/tests/cases/csharp/one-line-statements/case.json b/tests/cases/csharp/one-line-statements/case.json new file mode 100644 index 00000000..d363cc13 --- /dev/null +++ b/tests/cases/csharp/one-line-statements/case.json @@ -0,0 +1,86 @@ +{ + "lang": "csharp", + "src": "src", + "checks": [ + { + "why": "the second of two field declarations on one line changed its initializer: that field, `count`, not the line's first", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/Box.cs", + "--new", + "{repo}/new_count.cs", + "--file", + "src/Box.cs" + ], + "want": [ + "field Box.count", + "initializer" + ], + "avoid": [ + "renamed", + "Box.label" + ] + }, + { + "why": "`const int Limit = 3; const string Name = \"x\";`: an edit to `Name` is `Name`, not `Limit`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/Box.cs", + "--new", + "{repo}/new_name.cs", + "--file", + "src/Box.cs" + ], + "want": [ + "field Box.Name", + "initializer" + ], + "avoid": [ + "Box.Limit" + ] + }, + { + "why": "control: the second declarator of `int size = 1, weight = 2;` renamed is still a field rename", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/Box.cs", + "--new", + "{repo}/new_heft.cs", + "--file", + "src/Box.cs" + ], + "want": [ + "field Box.weight", + "renamed → heft" + ], + "avoid": [ + "Box.size" + ] + }, + { + "why": "several local declarations on one line of a method: the method's body changed, no field is named", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/Box.cs", + "--new", + "{repo}/new_local.cs", + "--file", + "src/Box.cs" + ], + "want": [ + "body Box.Run" + ], + "avoid": [ + " field " + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/one-line-statements/new_count.cs b/tests/cases/csharp/one-line-statements/new_count.cs new file mode 100644 index 00000000..6e50dc04 --- /dev/null +++ b/tests/cases/csharp/one-line-statements/new_count.cs @@ -0,0 +1,15 @@ +namespace A +{ + public class Box + { + private int size = 1, weight = 2; + private string label = "b"; private int count = 9; + const int Limit = 3; const string Name = "x"; + + public int Run(int k) + { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + size + weight + count + Limit + label.Length + Name.Length; + } + } +} diff --git a/tests/cases/csharp/one-line-statements/new_heft.cs b/tests/cases/csharp/one-line-statements/new_heft.cs new file mode 100644 index 00000000..6804c52d --- /dev/null +++ b/tests/cases/csharp/one-line-statements/new_heft.cs @@ -0,0 +1,15 @@ +namespace A +{ + public class Box + { + private int size = 1, heft = 2; + private string label = "b"; private int count = 0; + const int Limit = 3; const string Name = "x"; + + public int Run(int k) + { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + size + weight + count + Limit + label.Length + Name.Length; + } + } +} diff --git a/tests/cases/csharp/one-line-statements/new_local.cs b/tests/cases/csharp/one-line-statements/new_local.cs new file mode 100644 index 00000000..79404914 --- /dev/null +++ b/tests/cases/csharp/one-line-statements/new_local.cs @@ -0,0 +1,15 @@ +namespace A +{ + public class Box + { + private int size = 1, weight = 2; + private string label = "b"; private int count = 0; + const int Limit = 3; const string Name = "x"; + + public int Run(int k) + { + int con = k + 1; int dr = k + 2; int rc = k + 4; + return con + dr + rc + size + weight + count + Limit + label.Length + Name.Length; + } + } +} diff --git a/tests/cases/csharp/one-line-statements/new_name.cs b/tests/cases/csharp/one-line-statements/new_name.cs new file mode 100644 index 00000000..cf04990d --- /dev/null +++ b/tests/cases/csharp/one-line-statements/new_name.cs @@ -0,0 +1,15 @@ +namespace A +{ + public class Box + { + private int size = 1, weight = 2; + private string label = "b"; private int count = 0; + const int Limit = 3; const string Name = "y"; + + public int Run(int k) + { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + size + weight + count + Limit + label.Length + Name.Length; + } + } +} diff --git a/tests/cases/csharp/one-line-statements/src/Box.cs b/tests/cases/csharp/one-line-statements/src/Box.cs new file mode 100644 index 00000000..661fc7ec --- /dev/null +++ b/tests/cases/csharp/one-line-statements/src/Box.cs @@ -0,0 +1,15 @@ +namespace A +{ + public class Box + { + private int size = 1, weight = 2; + private string label = "b"; private int count = 0; + const int Limit = 3; const string Name = "x"; + + public int Run(int k) + { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + size + weight + count + Limit + label.Length + Name.Length; + } + } +} diff --git a/tests/cases/java/one-line-statements/New_count.java b/tests/cases/java/one-line-statements/New_count.java new file mode 100644 index 00000000..82e299ae --- /dev/null +++ b/tests/cases/java/one-line-statements/New_count.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private int count = 9; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_count_long.java b/tests/cases/java/one-line-statements/New_count_long.java new file mode 100644 index 00000000..ed4ce802 --- /dev/null +++ b/tests/cases/java/one-line-statements/New_count_long.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private long count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_extra.java b/tests/cases/java/one-line-statements/New_extra.java new file mode 100644 index 00000000..701fd096 --- /dev/null +++ b/tests/cases/java/one-line-statements/New_extra.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private int count = 0; private int extra = 1; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_heft.java b/tests/cases/java/one-line-statements/New_heft.java new file mode 100644 index 00000000..72e57511 --- /dev/null +++ b/tests/cases/java/one-line-statements/New_heft.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, heft = 2; + private String label = "b"; private int count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_high.java b/tests/cases/java/one-line-statements/New_high.java new file mode 100644 index 00000000..4014cdeb --- /dev/null +++ b/tests/cases/java/one-line-statements/New_high.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private int count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, high; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_local.java b/tests/cases/java/one-line-statements/New_local.java new file mode 100644 index 00000000..118c1933 --- /dev/null +++ b/tests/cases/java/one-line-statements/New_local.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private int count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 4; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/New_weight.java b/tests/cases/java/one-line-statements/New_weight.java new file mode 100644 index 00000000..5379ecdb --- /dev/null +++ b/tests/cases/java/one-line-statements/New_weight.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 5; + private String label = "b"; private int count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/java/one-line-statements/case.json b/tests/cases/java/one-line-statements/case.json new file mode 100644 index 00000000..a021ad92 --- /dev/null +++ b/tests/cases/java/one-line-statements/case.json @@ -0,0 +1,148 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "the second of two field declarations on one line changed its initializer: that field, `count`; it was 'Box.count renamed -> label', the line's first declaration", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_count.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "field Box.count", + "initializer" + ], + "avoid": [ + "renamed", + "Box.label" + ] + }, + { + "why": "`int size = 1, weight = 2;`: an edit to the second declarator's initializer is `weight`, not `size`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_weight.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "field Box.weight", + "initializer" + ], + "avoid": [ + "Box.size" + ] + }, + { + "why": "control: the second declarator renamed is still a field rename", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_heft.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "field Box.weight", + "renamed → heft" + ], + "avoid": [ + "Box.size" + ] + }, + { + "why": "control: a field retyped on a shared line is a type change of that field only", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_count_long.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "field Box.count", + "type int → long" + ], + "avoid": [ + "Box.label" + ] + }, + { + "why": "a declaration added after another on its line is a new field, and the unchanged one is not reported", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_extra.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "Box.extra", + "new field" + ], + "avoid": [ + "field Box.count", + "Box.label" + ] + }, + { + "why": "several local declarations on one line of a method: the method's body changed, no field is named", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_local.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "body Box.run" + ], + "avoid": [ + " field " + ] + }, + { + "why": "`protected int lo, hi;` (declarators with no initializer): `hi` renamed is `hi`'s rename, not `lo`'s, and not also a new field `high`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Box.java", + "--new", + "{repo}/New_high.java", + "--file", + "src/main/java/a/Box.java" + ], + "want": [ + "field Box.hi", + "renamed → high" + ], + "avoid": [ + "Box.lo", + "added Box.high" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/java/one-line-statements/src/main/java/a/Box.java b/tests/cases/java/one-line-statements/src/main/java/a/Box.java new file mode 100644 index 00000000..2df9cee8 --- /dev/null +++ b/tests/cases/java/one-line-statements/src/main/java/a/Box.java @@ -0,0 +1,13 @@ +package a; + +public class Box { + private int size = 1, weight = 2; + private String label = "b"; private int count = 0; + static final int LIMIT = 3; static final String NAME = "x"; + protected int lo, hi; + + public int run(int k) { + int con = k + 1; int dr = k + 2; int rc = k + 3; + return con + dr + rc + lo + hi + size + weight + count + LIMIT + label.length() + NAME.length(); + } +} diff --git a/tests/cases/python/one-line-statements/case.json b/tests/cases/python/one-line-statements/case.json new file mode 100644 index 00000000..678bb2dc --- /dev/null +++ b/tests/cases/python/one-line-statements/case.json @@ -0,0 +1,172 @@ +{ + "lang": "python", + "src": "src", + "checks": [ + { + "why": "only the right-hand side of `rc` changed in a line of four `;`-joined statements: `rc` is reported, as a variable (it is assigned in script code, no type owns it), with its initializer changed; it was 'field rc renamed -> con', the line's first statement", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_rc.py", + "--file", + "src/app.py" + ], + "want": [ + "variable rc", + "initializer" + ], + "avoid": [ + "renamed", + "field rc", + " con ", + " dr " + ] + }, + { + "why": "control: `rc` really renamed in the same line is still a rename, to the name its own statement now has", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_rx.py", + "--file", + "src/app.py" + ], + "want": [ + "variable rc", + "renamed → rx" + ], + "avoid": [ + "renamed → con", + " dr " + ] + }, + { + "why": "a class attribute's initializer edited in `size = 1; label = \"b\"`: the edit is `label`'s, not the line's first attribute `size`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_label.py", + "--file", + "src/app.py" + ], + "want": [ + "field Box.label", + "initializer" + ], + "avoid": [ + "Box.size" + ] + }, + { + "why": "control: the class attribute renamed on that line is still a field rename", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_tag.py", + "--file", + "src/app.py" + ], + "want": [ + "field Box.label", + "renamed → tag" + ], + "avoid": [ + "Box.size" + ] + }, + { + "why": "`self.a = 1; self.b = 2`: an edit to `self.b` is `Box.b`, not `Box.a`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_self_b.py", + "--file", + "src/app.py" + ], + "want": [ + "field Box.b" + ], + "avoid": [ + "Box.a" + ] + }, + { + "why": "the same joined line inside a method holds locals: the method's body changed, and no field or variable is named", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_local.py", + "--file", + "src/app.py" + ], + "want": [ + "body Box.run" + ], + "avoid": [ + " field ", + " variable " + ] + }, + { + "why": "`j = {}; lo, hi = 0, 10`: a tuple assignment pairs each name with its value; only `hi`'s value changed, so `hi` alone is reported", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_hi.py", + "--file", + "src/app.py" + ], + "want": [ + "variable hi", + "initializer" + ], + "avoid": [ + "variable lo", + "variable j", + "renamed" + ] + }, + { + "why": "control: `hi` renamed in the tuple is still a rename of `hi`, not a change to `lo`", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/app.py", + "--new", + "{repo}/new_top.py", + "--file", + "src/app.py" + ], + "want": [ + "variable hi", + "renamed → top" + ], + "avoid": [ + "variable lo", + "variable j" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/one-line-statements/new_hi.py b/tests/cases/python/one-line-statements/new_hi.py new file mode 100644 index 00000000..0d4318ac --- /dev/null +++ b/tests/cases/python/one-line-statements/new_hi.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 20 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_label.py b/tests/cases/python/one-line-statements/new_label.py new file mode 100644 index 00000000..b489cf89 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_label.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "c" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_local.py b/tests/cases/python/one-line-statements/new_local.py new file mode 100644 index 00000000..0bcb0e2e --- /dev/null +++ b/tests/cases/python/one-line-statements/new_local.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', ()) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_rc.py b/tests/cases/python/one-line-statements/new_rc.py new file mode 100644 index 00000000..feef85ae --- /dev/null +++ b/tests/cases/python/one-line-statements/new_rc.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', None); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_rx.py b/tests/cases/python/one-line-statements/new_rx.py new file mode 100644 index 00000000..646a09e5 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_rx.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rx = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_self_b.py b/tests/cases/python/one-line-statements/new_self_b.py new file mode 100644 index 00000000..6b321f5a --- /dev/null +++ b/tests/cases/python/one-line-statements/new_self_b.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 3 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_tag.py b/tests/cases/python/one-line-statements/new_tag.py new file mode 100644 index 00000000..100d5e3e --- /dev/null +++ b/tests/cases/python/one-line-statements/new_tag.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; tag = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/new_top.py b/tests/cases/python/one-line-statements/new_top.py new file mode 100644 index 00000000..010cd533 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_top.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, top = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) diff --git a/tests/cases/python/one-line-statements/src/app.py b/tests/cases/python/one-line-statements/src/app.py new file mode 100644 index 00000000..0909ae70 --- /dev/null +++ b/tests/cases/python/one-line-statements/src/app.py @@ -0,0 +1,18 @@ +LIMIT = 3; NAME = "x" + + +class Box: + size = 1; label = "b" + + def __init__(self): + self.a = 1; self.b = 2 + + def run(self, j): + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []) + return len(con) + len(dr) + len(rc) + self.a + self.b + self.size + len(self.label) + LIMIT + + +if __name__ == '__main__': + j = {}; lo, hi = 0, 10 + con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + print(len(con), len(dr), len(rc), len(ts), len(NAME)) From 51167ae57421ad195b4674e350345913edd361cd Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:34:38 -0700 Subject: [PATCH 050/258] docs: the skill's root copy of changed-and-tests.md says what the plugin copy says The one-line-statements change edited only the plugin copy of reference/changed-and-tests.md; the two copies were identical on the release branch. The root copy now carries the same sentence about a line of several statements being compared one statement at a time. --- skills/axiomcode/reference/changed-and-tests.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index b045f2b6..ace7b82b 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -3,7 +3,9 @@ `axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: `signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer), `type` (a header: name, +`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a +script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, +`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two From 424bd70b65d5d91feaa1d2b4e474f0c0860bc000 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:57:03 -0700 Subject: [PATCH 051/258] freshness: a read-only query mode, and a query that starts a rebuild says so first A query was a silent writer. Asked from a graph that the installed axiomcode would build differently (another engine or other rules in its build stamp, or files edited since), a CLI or MCP query started a background rebuild and said so only on its last stderr line; the refresh hook did the same, silently, after a shell command that only read. A graph built with a checkout's own engine and measured was overwritten with the installed engine's this way. AXIOMCODE_NO_REFRESH=1 existed but no verb, flag or MCP argument exposed it, and the skill did not mention it. This is stacked on fix/index-says-which-engine (itself on fix/no-downgrade-rebuild), rebased onto 0.1.8: those two commits change when a rebuild happens and are not yet landed, so they are part of this branch's base. The change: - `--no-refresh` on every query verb (context, path, impact, changed, test-impact, graph) sets AXIOMCODE_NO_REFRESH for that query: the answer comes from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. `graph --no-refresh` draws a stale graph as it is and says how many files it predates. - The MCP tools context, path, impact, changed, test_impact and graph take refresh (default true); refresh=false passes --no-refresh. An answer's --no-refresh is written refresh=False on the MCP surface. - A query that starts a background rebuild says so on its answer's FIRST line: the engine it rebuilds with, the reason (the build-stamp difference, or how many files were edited), and the flag that prevents it. A query that finds a rebuild already running (a hook's, the timer's) says that instead. With --json the same line is freshness.rebuild_started or freshness.rebuild_running, so the document stays one JSON object. - The refresh hook, the changes hook's baseline wait, and the MCP server's start-up check and timer never rebuild a graph whose build stamp differs from the installed axiomcode (engine_change). The refresh hook says so as context once per session and difference. A stale graph this axiomcode built is refreshed by hooks and queries as before. - Documented in SKILL.md and reference/context.md, impact.md, path.md, changed-and-tests.md (both copies), each verb's --help, the dispatcher's help and the README. Tests: tests/freshness.py read_only_checks (a graph stamped by another engine: the first line, --no-refresh starting nothing, --json, the dispatcher flag on all six verbs, an already-running refresher, hook_kick once per session, the refresh hook end to end on a read-only Bash event) with near-miss controls (a same-engine graph with an edit still refreshes and says so first; no line when nothing started or runs), and the MCP refresh parameter. tests/graph_verb.py: --no-refresh draws a stale graph without rebuilding; without it the graph is rebuilt as before (graph now honours AXIOMCODE_NO_REFRESH, which that suite sets for every call, so its rebuild step drops it). Suites run on the rebased tree: tests/freshness.py, mcp.py, mcp_docs.py, surfaces.py, mcp_first.py, engine_choice.py, corrupt_graph.py, graph_verb.py, hook_languages.py, enrich_lines.py, refresh.py --lang python, java, csharp. All pass. tests/refresh.py's timer check ("the MCP server timer found an edit nothing else saw") failed twice under load on this branch before its test was changed and passed once on the base: the server's own start-up refresher could pick the test's edit up before the timer did. The test now waits for that refresher to let go of its lock before it edits; java then passed. csharp's "after a pull with an edit uncommitted" check failed once with the machine at a load average of 184 (the baseline had not moved within its wait) and passed on the rerun; the change does not touch that path. Smoke (two small projects, Python and Java, fresh index each, the build stamp edited to name another engine): - before (installed build): `impact` answered with the rows first and the rebuild note last on stderr, and the graph was rebuilt with the installed engine in 8 to 11 s; the installed refresh hook, fed a PostToolUse `cat ` event, printed nothing and the graph was rebuilt in 10 to 14 s. - after, CLI `impact --no-refresh`: not rebuilt in 20 s (both projects); the note says refresh is off. - after, MCP axiomcode_impact(refresh=False): not rebuilt in 20 s (both). - after, refresh hook on the same read-only event: context says the graph is kept and how to rebuild it; silent the second time in the session; not rebuilt in 20 s (both). - after, CLI and MCP with no flag: the first line says this query started a rebuild, with the engine and the reason, and the graph was rebuilt in 11 to 12 s; where the installed hook's refresher was still running, the first line said a rebuild was already running instead. - near-miss (Python, same engine, a file edited): the hook said nothing and rebuilt the graph in 11 s, as before. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- README.md | 5 +- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/refresh.py | 15 +- plugins/axiomcode/mcp/server.py | 52 +++--- plugins/axiomcode/skills/axiomcode/SKILL.md | 9 +- .../axiomcode/reference/changed-and-tests.md | 6 + .../skills/axiomcode/reference/context.md | 7 +- .../skills/axiomcode/reference/impact.md | 5 + .../skills/axiomcode/reference/path.md | 5 + .../skills/axiomcode/scripts/ax_fresh.py | 82 +++++++++- .../skills/axiomcode/scripts/axiomcode | 18 ++- .../axiomcode/scripts/axiomcode-changed | 7 +- .../axiomcode/scripts/axiomcode-context | 7 +- .../skills/axiomcode/scripts/axiomcode-graph | 8 +- .../skills/axiomcode/scripts/axiomcode-impact | 7 +- .../skills/axiomcode/scripts/axiomcode-path | 7 +- .../axiomcode/scripts/axiomcode-test-impact | 7 +- skills/axiomcode/SKILL.md | 9 +- .../axiomcode/reference/changed-and-tests.md | 6 + skills/axiomcode/reference/context.md | 7 +- skills/axiomcode/reference/impact.md | 5 + skills/axiomcode/reference/path.md | 5 + tests/freshness.py | 150 +++++++++++++++++- tests/graph_verb.py | 9 +- tests/refresh.py | 9 ++ 25 files changed, 397 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 3bd2a018..23ca5395 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,10 @@ a session sits idle. The graph records when and why it was built in `index_meta` `AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still ends with a `graph refresh: OFF` line naming the files it predates. When a name asked about finds nothing and an edit since the graph was built writes that name, the line says so, since the declaration may simply be too new -for the graph. The log is `.axiomcode/refresh.log`. +for the graph. `--no-refresh` on any query verb (MCP `refresh=false`) does the same for one query: a read-only answer +from the graph as it is. A query that does start a rebuild says so on its answer's first line, with the reason. The +hooks and the MCP server's timer never rebuild a graph another axiomcode built (another engine, other rules or another +`IMPACT_VERSION` in its build stamp); a hook says so once per session. The log is `.axiomcode/refresh.log`. ## Graph output diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 31146724..13fbc350 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -147,7 +147,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} import ax_fresh # a commit, a merge or a pull since the baseline was set: let it follow HEAD first (0.2 s when no file changed), # or every committed edit is reported again as changed - try: ax_fresh.wait_baseline(cwd, 8) + try: ax_fresh.wait_baseline(cwd, 8, hook=True) # never a rebuild of a graph another axiomcode built except Exception: pass bg = ax_fresh.baseline_graph(cwd) if bg: os.environ['AXIOMCODE_GRAPH'] = bg diff --git a/plugins/axiomcode/hooks/refresh.py b/plugins/axiomcode/hooks/refresh.py index 80de47e9..ae16a473 100644 --- a/plugins/axiomcode/hooks/refresh.py +++ b/plugins/axiomcode/hooks/refresh.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Keep the graph current between runs (#1305): after an edit, a shell command, a finished turn, at session start and -on a prompt, start the background refresher and return. Nothing is waited on and nothing is printed. +on a prompt, start the background refresher and return. Nothing is waited on, and nothing is printed except, once per +session, that a graph another axiomcode built is kept rather than rebuilt (ax_fresh.hook_kick). The refresher (skills/axiomcode/scripts/ax_fresh.py) compares the files the parser reads against the table recorded at the last build, and rebuilds only when one differs, one build at a time per repository, while every verb and hook @@ -30,9 +31,19 @@ repos = [] repos = [r for r in repos if os.path.exists(os.path.join(r, '.axiomcode', 'out', 'graph.sqlite'))] if not repos: sys.exit(0) +# A GRAPH ANOTHER AXIOMCODE BUILT IS NOT THE HOOKS' TO REBUILD. A hook fires on a shell command that only read, and a +# graph built with a checkout's own engine (and measured) was rebuilt with the installed one behind the agent's back. Such +# a graph is kept, and said so once per session (ax_fresh.hook_kick); an edit over a graph this axiomcode built still +# starts the refresher, as before. +said = [] try: import ax_fresh for d in repos: - ax_fresh.kick(d, trigger=f"the {ev.get('hook_event_name') or 'hook'} hook" + (f" after {ev['tool_name']}" if ev.get('tool_name') else '')) + n = ax_fresh.hook_kick(d, f"the {ev.get('hook_event_name') or 'hook'} hook" + (f" after {ev['tool_name']}" if ev.get('tool_name') else ''), + session=str(ev.get('session_id') or '')) + if n: said.append(n if len(repos) == 1 else f"{d}: {n}") except Exception: pass # a hook never fails the tool call it rides on +if said and (ev.get('hook_event_name') or '') in ('PostToolUse', 'UserPromptSubmit', 'SessionStart'): + try: _host.emit(ev['hook_event_name'], '\n'.join(said)) + except Exception: pass diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 4443c77e..0228e834 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -122,7 +122,9 @@ def scripts_module(name): PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh'} + '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh'} +# a CLI switch that turns a parameter OFF: `--no-refresh` is refresh=False here +NEGATED = {'--no-refresh'} SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh'} PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # 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") @@ -140,6 +142,7 @@ def one(m): p = PARAM.get(flag) if not p: return m.group(0) if flag in SWITCH: return f"{p}=True" + (sep + val if val else '') + if flag in NEGATED: return f"{p}=False" + (sep + val if val else '') if val == 'all': return f'{p}="all"' # `--page all` is page="all", a string, not a name if val == 'N|all': return f'{p}=N or {p}="all"' return f"{p}={val}" if val else p @@ -190,7 +193,8 @@ async def call_tool(self, name, arguments, *a, **k): # the SDK: refused as a # AXIOMCODE_REFRESH_INTERVAL seconds (default 900, 15 minutes; 0 turns it off) have passed since the LAST UPDATE of a # repository it has answered for (a refresh, or a check by an edit, a prompt, a query or the timer itself) it asks the # refresher to look: a rebuild if a file changed or HEAD moved, otherwise only the time of the check is recorded. An -# active session updates that time itself, so the timer mostly fires for one that has gone quiet. +# active session updates that time itself, so the timer mostly fires for one that has gone quiet. Like the hooks, the +# timer never rebuilds a graph another axiomcode built (ax_fresh.hook_kick): only a query or `index` does, saying so. SEEN = set() # the flags whose next argument is their value, as the dispatcher skips them when it looks for the repository VALUED = {'--in', '--from', '--budget', '--seeds', '--depth', '--limit', '--tests-in', '--kind', '--range', '--old', @@ -202,7 +206,7 @@ def _timer(interval): for repo in list(SEEN): try: fresh = scripts_module('ax_fresh') - if time.time() - fresh.last_update(repo) >= interval: fresh.kick(repo, trigger='the timer') + if time.time() - fresh.last_update(repo) >= interval: fresh.hook_kick(repo, 'the timer') except Exception: pass def run(args, cwd=None, timeout=900): @@ -260,6 +264,10 @@ def _pg(page): v = _page_arg(page) return ['--page', v] if v else [] +def NOREF(refresh): + """refresh=False is the CLI's --no-refresh: a read-only query, which starts no rebuild of the graph""" + return [] if refresh else ['--no-refresh'] + def grep(full, limit=0): return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) @@ -270,43 +278,43 @@ 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: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0) -> 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]`), capped with a count of the rest; limit=N lists more, 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.""" +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True) -> 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]`), capped with a count of the rest; limit=N lists more, 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" flow = source or explain or from_.strip() or _paged(page) or budget a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a) + return run(a + NOREF(refresh)) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 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.""" +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, refresh: bool = True) -> 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" paged = _paged(page) 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) + return run(a + NOREF(refresh)) @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: Page = 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 (the CLI's --tests-only); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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).""" +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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> 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 (the CLI's --tests-only); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" prose = full or why or delete or _paged(page) 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 []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a) + return run(a + NOREF(refresh)) @srv.tool() -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1) -> str: - """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer.""" +def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True) -> str: + """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a) + return run(a + NOREF(refresh)) @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: Page = 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]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose.""" +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True) -> 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]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" prose = full or why or _paged(page) a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a) + return run(a + NOREF(refresh)) @srv.tool() -def axiomcode_graph(repo: str = ".", out: str = '') -> str: - """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path.""" - return run(['graph', repo] + (['--out', out] if out else [])) +def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: + """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path. refresh=False: drawn from the graph as it is, never rebuilt first.""" + return run(['graph', repo] + (['--out', out] if out else []) + NOREF(refresh)) @srv.tool() def axiomcode_diff(graph_a: str, graph_b: str, file: str = '', lang: str = '', limit: int = 40, as_json: bool = False) -> str: @@ -316,7 +324,7 @@ def axiomcode_diff(graph_a: str, graph_b: str, file: str = '', lang: str = '', l if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on try: - scripts_module('ax_fresh').kick(os.getcwd(), trigger='the MCP server starting') + scripts_module('ax_fresh').hook_kick(os.getcwd(), 'the MCP server starting') if os.path.isdir(os.path.join(os.getcwd(), '.axiomcode')): SEEN.add(os.path.realpath(os.getcwd())) interval = float(os.environ.get('AXIOMCODE_REFRESH_INTERVAL') or 900) if interval > 0: diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 4a2938c1..36fa651f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -33,7 +33,7 @@ From the shell the same shape is `--grep` (`--grep-limit N`); without it the ans | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | | what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -46,6 +46,13 @@ Rules that decide whether an answer means anything: progress, and answers from a graph that includes every edit. A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. +- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, + `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes + from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built + on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a + background rebuild with this axiomcode's engine, and the answer's FIRST line says so + (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph + another axiomcode built; they say so once per session. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index ace7b82b..4b16dde9 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -1,5 +1,11 @@ # changed, test-impact, and the edit hooks +**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the +baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit +hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another +IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` +rebuilds it, the query saying so on its first line. + `axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: `signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), diff --git a/plugins/axiomcode/skills/axiomcode/reference/context.md b/plugins/axiomcode/skills/axiomcode/reference/context.md index 1460b86a..742356c7 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/context.md +++ b/plugins/axiomcode/skills/axiomcode/reference/context.md @@ -6,9 +6,14 @@ nothing usable, the graph never touched again. ``` axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… + [--explain | --no-explain] [--from ]… [--no-refresh] ``` +**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + Deterministic — no model, no embedding index, no network. The task text is split into content terms (stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, prefix, substring, then file path — each weighted by inverse document frequency over the graph's own diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 14a5474a..09d2022f 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -2,6 +2,11 @@ The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. +**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + `axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: `Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md index d0765b62..f17b0484 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ b/plugins/axiomcode/skills/axiomcode/reference/path.md @@ -1,5 +1,10 @@ # path — the endpoint grammar and what it cannot find +**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + - **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 1c789b03..ea63bb67 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -33,7 +33,8 @@ the build's first line: the engine and parser it uses and where they came from; exit 3 when the checkout's own engine parts dangle (never built over) -Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) +Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off (a query verb's --no-refresh, and the MCP tools' refresh=false, +set it for that one query: a read-only answer from the graph as it is, still saying which edits it predates); AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on the machine, the rest queued; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file waits for a refresh expected to finish within it, AXIOMCODE_FRESH=1 (--fresh) makes it wait for the refresh whatever it @@ -874,6 +875,62 @@ def refresh_off(repo): of this tree to compare with""" return bool(os.environ.get('AXIOMCODE_NO_REFRESH')) and not os.environ.get('AXIOMCODE_GRAPH') and has_graph(repo) +# ── READ-ONLY QUERIES, AND WHAT A HOOK MAY REBUILD ───────────────────────────────────────────────────────────────────── +# A query was a silent writer: asked from a graph that the installed axiomcode would build differently (another engine, +# other rules, files edited since), it started a background rebuild and the next answer came from a graph nobody asked +# for. A graph built with a checkout's own engine and measured was overwritten that way by a read (a query, or a hook +# firing on a shell command that only read). Now: +# - `--no-refresh` on every query verb (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) answers from the +# graph as it is: nothing is rebuilt, and the answer still says which edits it predates; +# - a query that DOES start a rebuild says so on its first line, with the reason and that flag (started_line); +# - a hook, and the MCP server's own timer, never rebuild a graph another axiomcode built (engine_change): the hook +# says so once per session instead (hook_kick). An edit over a graph this axiomcode built still refreshes as before. +NO_REFRESH_HOW = "pass --no-refresh (MCP refresh=false, or AXIOMCODE_NO_REFRESH=1) to query without rebuilding" + +def engine_label(repo): + """' at ' of the engine a rebuild here would use, or 'the installed one'""" + e = current_engine(repo) + if not e: return 'the installed one' + try: v = json.load(open(os.path.join(e, 'package.json'))).get('version', '?') + except (OSError, ValueError): v = '?' + return f"{v} at {os.path.normpath(e)}" + +def started_line(repo, s, running=False): + """the first line of an answer whose query started a background rebuild: why, with what, and how not to. running: + the rebuild was already running (a hook's, the timer's, another query's), and may replace the graph all the same""" + files = edited(s) + # the edited files are named on the answer's last line (note), where each row in them is also marked: counted here + why = s.get('engine') or (f"{len(files)} file(s) edited since it was built" if files else 'the graph is out of date') + lead = ('a background rebuild of the graph is already running' if running else + 'this query started a background rebuild of the graph') + return (f"graph refresh: {lead} with engine {engine_label(repo)} ({why}); this answer is from the graph as it was, " + f"and {'that rebuild may replace' if running else 'the rebuild replaces'} it; {NO_REFRESH_HOW}") + +def refresher_running(repo): + """a refresher (the worker kick starts) holds its lock: whatever the query does, a rebuild may replace the graph""" + p = os.path.join(repo, '.axiomcode', 'refresh.lock') + if not os.path.exists(p): return False + fd = os.open(p, os.O_RDWR) + try: return not _flock(fd, False) + finally: os.close(fd) # closing drops a lock this probe took + +def running_line(repo, s): return started_line(repo, s, running=True) + +def hook_kick(repo, trigger, session=''): + """what a hook (and the MCP server's timer) runs in place of kick: '' after starting the refresher as kick does, or, + for a graph another axiomcode built (engine_change), nothing started and one line to say so, once per session and + difference ('' when it was said already). A graph a newer axiomcode built is never rebuilt anyway (newer_build)""" + if not enabled(repo): return '' + held = engine_change(repo) + if not held: + kick(repo, trigger); return '' + key = f"{session}|{held}" + if read_state(repo).get('hook_held') == key: return '' + write_state(repo, hook_held=key) + return (f"graph refresh: {held}; the hooks do not rebuild a graph another axiomcode built, so it is kept as it is. " + f"`axiomcode index` rebuilds it with engine {engine_label(repo)}, and so does a query, which says so on its first " + f"line; {NO_REFRESH_HOW}") + def kick(repo, trigger='an edit'): """start the worker and return at once; a no-op without a graph (the FIRST build takes minutes and is the caller's decision, see ax_contract.ensure_graph) or when one is already running""" @@ -1073,10 +1130,12 @@ def wait(repo, seconds): if time.time() >= end: return s time.sleep(0.5) -def wait_baseline(repo, seconds): +def wait_baseline(repo, seconds, hook=False): """for `changed` and `test-impact`: when HEAD moved since the baseline was set, start the refresher and wait for it - to move the baseline (0.2 s when no file changed, a build of HEAD's text when the tree is dirty). '' or a note.""" + to move the baseline (0.2 s when no file changed, a build of HEAD's text when the tree is dirty). '' or a note. + hook: asked by a hook, which never rebuilds a graph another axiomcode built (hook_kick)""" if not enabled(repo) or not base_moved(repo): return '' + if hook and engine_change(repo): return '' if newer_build(repo): return (f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}), and the graph was built by a newer " "axiomcode, which this one does not rebuild: the baseline stays where it was, so this answer also counts what the " @@ -1135,7 +1194,7 @@ def note(s, marked=None, named=None, off=False): 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" + if off: says = "refresh is OFF (--no-refresh, or 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, " \ @@ -1154,7 +1213,7 @@ def note(s, marked=None, named=None, off=False): return first + (f"graph refresh: this answer is from that graph, which predates edits to {head}" + (rows if marked is not None else '; read those files for their current text')) if off: - return first + (f"graph refresh: OFF (AXIOMCODE_NO_REFRESH is set), no refresh is running — this answer is from a graph that " + return first + (f"graph refresh: OFF (--no-refresh, or AXIOMCODE_NO_REFRESH is set), no refresh is running — this answer is from a graph that " f"predates edits to {head}" + (rows if marked is not None else '; read those files for their current text') + "; `axiomcode index` rebuilds it") if s.get('failed'): @@ -1353,7 +1412,9 @@ def with_note(n): # the answer as it is, then return with_note(n) s = status(repo) if s['state'] == 'unknown': - if not off: kick(repo, 'a query') + if not off and kick(repo, 'a query') and '--json' not in argv: + print("graph refresh: this query started a background rebuild (the graph predates the file table, so what it was " + f"built from is not known); this answer is from the graph as it was; {NO_REFRESH_HOW}", flush=True) passthrough() 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 @@ -1362,7 +1423,9 @@ def with_note(n): # the answer as it is, then return with_note(note(s)) # a graph a newer axiomcode built is never rebuilt here: no refresh is started or waited for, as with refresh off hold = off or bool(s.get('newer')) - if not s.get('failed') and not hold: kick(repo, 'a query') + started = not s.get('failed') and not hold and kick(repo, 'a query') + # a refresher already running (a hook's, the timer's, another query's) replaces this graph all the same: said too + running = not started and not s.get('failed') and not hold and refresher_running(repo) as_json = '--json' in argv if fresh and not s.get('failed') and not hold: left = expected_left(repo) @@ -1401,8 +1464,13 @@ def with_note(n): # the answer as it is, then **({'built_with': s['built_with']} if s.get('built_with') 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 {})) + if started: obj['freshness']['rebuild_started'] = started_line(repo, s) + elif running: obj['freshness']['rebuild_running'] = running_line(repo, s) marked = json.dumps(obj, indent=1, ensure_ascii=False) + '\n' except ValueError: pass + # A QUERY THAT STARTED A REBUILD SAYS SO FIRST: the answer is from the graph as it was, and the rebuild replaces that + # graph. Said last (on stderr, after the rows) it was missed, and a graph that had been measured was gone + elif (started or running) and not fresh: sys.stdout.write((started_line if started else running_line)(repo, s) + '\n') sys.stdout.write(marked); sys.stdout.flush() msg = note(s, n, named=missed, off=off) if msg: print(msg, file=sys.stderr) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 9c602ae6..5e4ae000 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -3,20 +3,20 @@ # # 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] [--grep] +# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] [--no-refresh] [--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. # --in is repeatable and takes a list, and several are COMBINED, not intersected, so a change that # spans two roots is answerable in one call. # Ends by saying what it could not see. Every other verb needs a name you already have; this one does not. -# axiomcode graph [] [--out ] +# axiomcode graph [] [--out ] [--no-refresh] # the graph as one page, every language the repository was indexed in. Drawn from the existing graph when it is up # to date (seconds, no engine run); rebuilt first only when a source file changed, and then with the --lang, --src # 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] [--grep] +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] [--no-refresh] [--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,19 +24,19 @@ # 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] [--grep] +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] [--no-refresh] [--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] / # [by name] / [text]), the transitive callers by hop with the entry points and tests, and the unresolved-site bound. # --kind takes method|field|type|param|typeparam|var|config, and the language's own word for the same thing # (class / interface / enum / namespace, const / enum_member / variable, function / constructor). -# axiomcode changed [] […] [--range a..b | --staged] [--impact] [--json] +# axiomcode changed [] […] [--range a..b | --staged] [--impact] [--json] [--no-refresh] # which declarations an edit changed and HOW (signature: params added / removed / retyped, return type; field: its type, # 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] [--grep] +# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--no-refresh] [--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. @@ -56,6 +56,11 @@ # 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. +# --no-refresh (every query verb: context, path, impact, changed, test-impact, graph; MCP refresh=false) is a READ-ONLY +# query: the answer comes from the graph as it is and no rebuild is started, as AXIOMCODE_NO_REFRESH=1 does for a whole +# shell; it still says which edits the graph predates. Without it a query on a graph that is out of date (files edited +# since, or built by another axiomcode) starts a background rebuild with the engine this axiomcode uses, and says so on +# the answer's first line. The hooks never rebuild a graph another axiomcode built; they say so once per session. # --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. @@ -113,6 +118,7 @@ while [ $# -gt 0 ]; do --src) export AXIOMCODE_SRC="$2"; shift 2 ;; --library) export AXIOMCODE_LIBRARY="$2"; shift 2 ;; --fresh) export AXIOMCODE_FRESH=1; shift ;; + --no-refresh) export AXIOMCODE_NO_REFRESH=1; shift ;; --grep) GREP=1; shift ;; --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; *) ARGS+=("$1"); shift ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 2b435e98..84e2b8c6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -1,7 +1,12 @@ #!/usr/bin/env python3 -"""axiomcode changed [] [… [--whole]] [--range .. | --staged] [--old --new --file ] [--impact] [--json] +"""axiomcode changed [] [… [--whole]] [--range .. | --staged] [--old --new --file ] [--impact] [--json] [--no-refresh] which declarations an edit changed, and HOW — then (--impact) what that reaches. +--no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the +graph as it is and never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph +that is out of date (files edited since, or built by another axiomcode) starts a background rebuild with this +axiomcode's engine, and the answer's first line says so, why, and how to prevent it. + The graph describes the tree as it was when it was built (the build stamps the commit). An edit is read against that version: by default the working tree against the tree the graph was indexed from (its commit plus any edits uncommitted at index time; the commit, then HEAD, for a graph that did not record one), `--range a..b` two commits, `--staged` the index, or `--old/--new/--file` two texts of one file (what a hook has). Each changed line is mapped onto the declarations diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 398d91b2..7052d34c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--explain | --no-explain] [--from ]… [--page N|all] [--page-budget N] [--json] +"""axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--explain | --no-explain] [--from ]… [--page N|all] [--page-budget N] [--json] [--fresh] [--no-refresh] + +--no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the +graph as it is and never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph +that is out of date (files edited since, or built by another axiomcode) starts a background rebuild with this +axiomcode's engine, and the answer's first line says so, why, and how to prevent it. A task that asks HOW something works ("how does X …", "explain …", "what happens when …", or --explain) also gets the call flow: the spine of the mechanism in the order the calls are written, each step with its edge's certainty, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index 199a9524..af4b1b6c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """axiomcode graph — the graph of a codebase as one interactive page. - axiomcode graph [] [--out ] + axiomcode graph [] [--out ] [--no-refresh] axiomcode graph [] --lang [,…] [--src ] [--library [,…]] [--out …] With no flags the page is drawn from the graph `axiomcode index` built, and the engine does not run: @@ -11,6 +11,8 @@ With no flags the page is drawn from the graph `axiomcode index` built, and the only the languages the index chose are solved and no graph appears for a language it left out. - there is no graph yet: the repository is indexed first (every language present, or the flags given). Flags given here are a request for that graph, as `axiomcode index` takes them. +--no-refresh (MCP refresh=false, or AXIOMCODE_NO_REFRESH=1) is read-only: the page is drawn from the graph that is there +even when it is out of date, and the graph is never rebuilt; the answer says how many files it predates. A repository indexed in several languages is drawn in all of them, on one page. The command prints what it drew as prose, and the page's absolute path last. `axiomcode graph export []` writes the page from the graph as it is, @@ -277,6 +279,10 @@ def build(repo, page, library=None): if c is not None and not any(c) and not ax_fresh.graph_broken(repo): return export(repo, page, how=f"graph up to date ({langs}): drawn from it, no rebuild.") n = sum(len(x) for x in c) if c else 0 + if os.environ.get('AXIOMCODE_NO_REFRESH') and not ax_fresh.graph_broken(repo): + # READ-ONLY (--no-refresh, MCP refresh=false, AXIOMCODE_NO_REFRESH=1): the graph there is drawn as it is, and said + return export(repo, page, how=f"refresh off (--no-refresh): drawn from the graph as it is, not rebuilt ({langs})" + + (f"; it predates edits to {n} file(s) since it was built" if n else '') + ".") flags = ' '.join(f for f in (('--lang ' + t['lang']) if not t.get('lang_auto', True) else 'every language present', ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 1e389bf8..26ffe474 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1,7 +1,12 @@ #!/usr/bin/env python3 -"""axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--page N|all] [--budget N] [--json] +"""axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--page N|all] [--budget N] [--json] [--fresh] [--no-refresh] what has to be looked at again when a declaration changes — and how sure each entry is. +--no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the +graph as it is and never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph +that is out of date (files edited since, or built by another axiomcode) starts a background rebuild with this +axiomcode's engine, and the answer's first line says so, why, and how to prevent it. + A target is a declaration, written as it appears in the code. Separators are interchangeable in every language: util.square, src.util.square and src/util#square name the same declaration; the spelling as written is tried first, and a dotted name that fits declarations differing only in separators is refused with each listed. Its kind is read diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index dde52fe0..6bcbdf70 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1,7 +1,12 @@ #!/usr/bin/env python3 -"""axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] is there a chain of calls from A to B, and through what? +"""axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] [--no-refresh] is there a chain of calls from A to B, and through what? axiomcode path --selftest the engine's own expected edges, replayed through this tool +--no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the +graph as it is and never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph +that is out of date (files edited since, or built by another axiomcode) starts a background rebuild with this +axiomcode's engine, and the answer's first line says so, why, and how to prevent it. + An endpoint is anything that names code, written the way it appears in the code — resolved EXACTLY, never guessed: Owner.method Owner.Inner.method method (a free function, or that name under any owner) Type (every method it declares) file.ts:123 (the method containing that line) file.py / dir/file.ts (every method in the file) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 7c1014bd..d984ad6c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +"""axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--no-refresh] + +--no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the +graph as it is and never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph +that is out of date (files edited since, or built by another axiomcode) starts a background rebuild with this +axiomcode's engine, and the answer's first line says so, why, and how to prevent it. … only these files; a named file with no edit against its base (or any named file on a copy without git) counts WHOLE: the tests of everything declared in it diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 91e6f7c1..b7e76787 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -33,7 +33,7 @@ From the shell the same shape is `--grep` (`--grep-limit N`); without it the ans | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | | what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -46,6 +46,13 @@ Rules that decide whether an answer means anything: progress, and answers from a graph that includes every edit. A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. +- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, + `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes + from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built + on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a + background rebuild with this axiomcode's engine, and the answer's FIRST line says so + (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph + another axiomcode built; they say so once per session. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index ace7b82b..4b16dde9 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -1,5 +1,11 @@ # changed, test-impact, and the edit hooks +**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the +baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit +hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another +IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` +rebuilds it, the query saying so on its first line. + `axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: `signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), diff --git a/skills/axiomcode/reference/context.md b/skills/axiomcode/reference/context.md index 1460b86a..742356c7 100644 --- a/skills/axiomcode/reference/context.md +++ b/skills/axiomcode/reference/context.md @@ -6,9 +6,14 @@ nothing usable, the graph never touched again. ``` axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… + [--explain | --no-explain] [--from ]… [--no-refresh] ``` +**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + Deterministic — no model, no embedding index, no network. The task text is split into content terms (stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, prefix, substring, then file path — each weighted by inverse document frequency over the graph's own diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 14a5474a..09d2022f 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -2,6 +2,11 @@ The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. +**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + `axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: `Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md index d0765b62..f17b0484 100644 --- a/skills/axiomcode/reference/path.md +++ b/skills/axiomcode/reference/path.md @@ -1,5 +1,10 @@ # path — the endpoint grammar and what it cannot find +**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and +never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of +date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and +says so on the answer's first line, with the reason. + - **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so diff --git a/tests/freshness.py b/tests/freshness.py index 5750502b..d2c7d82d 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -342,8 +342,9 @@ def engine_checks(): 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')}) + first, _, rest = r.stdout.partition('\n') 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 + first.startswith('graph refresh: this query started a background rebuild') and rest.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')) @@ -625,6 +626,139 @@ def take(lock, secs): shutil.rmtree(work, ignore_errors=True) +# ── read-only ───────────────────────────────────────────────────────────────────────────────────────────────────── +# a stand-in verb as in DRIVER, with kick recorded in /kicked, so a check can tell whether a rebuild was started +DRIVER_KICKS = r""" +import os, sys +sys.path.insert(0, sys.argv[1]); import ax_fresh +def kick(repo, *a, **k): + if os.environ.get('KICK_BUSY'): return False # a refresher already holds the lock: kick starts nothing + open(os.path.join(repo, 'kicked'), 'a').write('x\n'); return True +ax_fresh.kick = kick +sys.exit(ax_fresh.query(os.path.realpath(sys.argv[2]), sys.argv[3], sys.argv[5:], fresh=bool(os.environ.get('AXIOMCODE_FRESH')))) +""" + + +def read_only_checks(): + """a query that starts a background rebuild says so on its answer's FIRST line, with the reason and how to prevent it; + --no-refresh (AXIOMCODE_NO_REFRESH=1, MCP refresh=false) answers from the graph as it is and starts none. The hooks and + the MCP timer never rebuild a graph another axiomcode built, and a hook says so once per session. The near-miss: a + graph this axiomcode built with a file edited since is still refreshed by a query, and by a hook, as before""" + work = tempfile.mkdtemp(prefix='axiomcode-readonly-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e1 = fake_engine(os.path.join(work, 'e1'), version='1.0.0'); os.environ['AXIOMCODE_ENGINE'] = e1 + driver = os.path.join(work, 'driver.py'); open(driver, 'w').write(DRIVER_KICKS) + + def repo_of(name, foreign): + repo = fake_repo(work, name, build_seconds='300 0'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + if foreign: # built with another engine: a checkout's own, say, which the installed one would rebuild differently + tp = os.path.join(repo, '.axiomcode/out/files.json'); t = json.load(open(tp)) + t['built_by'].update(engine=os.path.join(work, 'checkout'), engine_version='1.0.0', engine_hash='0' * 40, engine_stat='0' * 40) + json.dump(t, open(tp, 'w')) + return repo + + def query(repo, *extra, **env): + e = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_FRESH', 'AXIOMCODE_GRAPH')} + e.update(AXIOMCODE_FRESH_WAIT='0', **env) + try: os.remove(os.path.join(repo, 'kicked')) + except OSError: pass + verb = [sys.executable, '-c', "import sys; print(sys.argv[1])", json.dumps(dict(direct=[dict(at='shop/api.py:5')])) if '--json' in extra else ROWS] + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, 'impact', '--', *verb, *extra], capture_output=True, text=True, env=e, timeout=60) + return r.stdout, r.stderr, os.path.exists(os.path.join(repo, 'kicked')) + + # ── a graph another engine built ── + repo = repo_of('foreign', True) + reason = ax_fresh.engine_change(repo) + check("read-only: a graph another engine built is one this axiomcode would rebuild", reason.startswith('graph built by an older axiomcode (engine 1.0.0 00000000 -> '), reason) + out, err, kicked = query(repo) + first, _, rest = out.partition('\n') + check("read-only: a query that starts a rebuild says so on its FIRST line: that it started one, with which engine, why, and the flag that prevents it", + kicked and first.startswith('graph refresh: this query started a background rebuild of the graph with engine 1.0.0 at ' + os.path.normpath(e1)) + and reason in first and '--no-refresh' in first and 'refresh=false' in first and 'AXIOMCODE_NO_REFRESH=1' in first + and rest.strip() == ROWS.strip(), (out, err)) + out, err, kicked = query(repo, AXIOMCODE_NO_REFRESH='1') + check("read-only: with AXIOMCODE_NO_REFRESH=1 (what --no-refresh sets) nothing is rebuilt, the answer is the verb's own, and the note says refresh is off", + not kicked and out.strip() == ROWS.strip() and 'this query started' not in out + err and reason in err and '--no-refresh' in err, (out, err)) + out, err, kicked = query(repo, '--json') + fr = (json.loads(out) if out.strip().startswith('{') else {}).get('freshness', {}) + check("read-only: --json stays one JSON document, and carries the same line as freshness.rebuild_started", + kicked and fr.get('rebuild_started', '').startswith('graph refresh: this query started a background rebuild'), (out, err)) + + # ── the near-miss: a graph this axiomcode built, a file edited since ── + repo = repo_of('edited', False) + check("read-only: control: the same engine is not another axiomcode", ax_fresh.engine_change(repo) == '', ax_fresh.engine_change(repo)) + open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') + out, err, kicked = query(repo) + first, _, rest = out.partition('\n') + check("read-only: control: a query on a stale graph this axiomcode built still refreshes it, and says so first (the edits counted, the rows marked)", + kicked and first.startswith('graph refresh: this query started a background rebuild') and '1 file(s) edited since it was built' in first + and 'shop/api.py' not in first and 'shop/api.py:5 - calls it' + ax_fresh.MARK in rest and 'predates edits to shop/api.py' in err, (out, err)) + # a refresher this query did not start (a hook's, the timer's) is running: the graph is replaced all the same + import fcntl + lf = os.open(os.path.join(repo, '.axiomcode', 'refresh.lock'), os.O_RDWR | os.O_CREAT, 0o644); fcntl.flock(lf, fcntl.LOCK_EX) + try: out, err, kicked = query(repo, KICK_BUSY='1') + finally: os.close(lf) + first, _, rest = out.partition('\n') + check("read-only: a query that finds a rebuild already running says so first, as one that may replace the graph it answers from", + not kicked and first.startswith('graph refresh: a background rebuild of the graph is already running') and 'may replace it' in first + and '--no-refresh' in first and 'shop/api.py:5 - calls it' + ax_fresh.MARK in rest, (out, err)) + out, err, kicked = query(repo, KICK_BUSY='1') + check("read-only: control: no refresher running and none started, no such line", not kicked and not out.startswith('graph refresh:'), (out, err)) + out, err, kicked = query(repo, AXIOMCODE_NO_REFRESH='1') + check("read-only: control: with refresh off the same query starts nothing and still marks the edited file's rows", + not kicked and not out.startswith('graph refresh:') and 'shop/api.py:5 - calls it' + ax_fresh.MARK in out and 'graph refresh: OFF' in err, (out, err)) + + # ── the dispatcher: --no-refresh on every query verb sets AXIOMCODE_NO_REFRESH for it ── + shim = os.path.join(work, 'shim'); os.makedirs(shim) + open(os.path.join(shim, 'python3'), 'w').write('#!/bin/sh\necho "NO_REFRESH=${AXIOMCODE_NO_REFRESH:-} $*"\n'); os.chmod(os.path.join(shim, 'python3'), 0o755) + env = {k: v for k, v in os.environ.items() if k != 'AXIOMCODE_NO_REFRESH'}; env['PATH'] = shim + os.pathsep + env.get('PATH', '') + said = {} + for verb in ('context', 'path', 'impact', 'changed', 'test-impact', 'graph'): + args = {'context': ['a task'], 'path': ['A', 'B'], 'impact': ['X']}.get(verb, []) + on = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo, '--no-refresh'], capture_output=True, text=True, env=env).stdout + off = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo], capture_output=True, text=True, env=env).stdout + said[verb] = (on, off) + check("read-only: `--no-refresh` on each query verb (context, path, impact, changed, test-impact, graph) sets AXIOMCODE_NO_REFRESH and is not passed on as an argument", + all('NO_REFRESH=1 ' in on and '--no-refresh' not in on for on, _ in said.values()), said) + check("read-only: control: without it the verbs run with refresh on", all(o and 'NO_REFRESH=1' not in o for _, o in said.values()), said) + + # ── the hooks ── + calls = []; real = ax_fresh.kick + ax_fresh.kick = lambda repo, *a, **k: calls.append(repo) or True + try: + repo = repo_of('hook-foreign', True) + n1 = ax_fresh.hook_kick(repo, 'the PostToolUse hook after Bash', session='s1') + n2 = ax_fresh.hook_kick(repo, 'the PostToolUse hook after Bash', session='s1') + n3 = ax_fresh.hook_kick(repo, 'the PostToolUse hook after Bash', session='s2') + check("read-only: a hook never rebuilds a graph another axiomcode built, and says so, with the reason and how to rebuild it", + not calls and reason.split(' (')[0] in n1 and 'the hooks do not rebuild' in n1 and '`axiomcode index`' in n1 and '--no-refresh' in n1, (calls, n1)) + check("read-only: it says so once per session, and again in a new one", n2 == '' and n3 == n1, (n2, n3)) + repo = repo_of('hook-edited', False) + open(os.path.join(repo, 'shop/api.py'), 'a').write('\n# edited\n') + n = ax_fresh.hook_kick(repo, 'the PostToolUse hook after Edit', session='s1') + check("read-only: control: a hook still refreshes a stale graph this axiomcode built, saying nothing", calls == [repo] and n == '', (calls, n)) + calls.clear(); os.environ['AXIOMCODE_NO_REFRESH'] = '1' + n = ax_fresh.hook_kick(repo, 'the PostToolUse hook after Edit', session='s1') + check("read-only: AXIOMCODE_NO_REFRESH=1 turns the hooks' refresh off too", not calls and n == '', (calls, n)) + finally: + ax_fresh.kick = real; os.environ.pop('AXIOMCODE_NO_REFRESH', None) + # the refresh hook end to end, on a shell command that only read: the note comes back as context, and no worker starts + repo = repo_of('hook-e2e', True) + ev = dict(hook_event_name='PostToolUse', tool_name='Bash', tool_input=dict(command='cat shop/api.py'), cwd=repo, session_id='e2e') + hook = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'refresh.py') + env = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'CURSOR_VERSION')} + h = subprocess.run([sys.executable, hook], input=json.dumps(ev), capture_output=True, text=True, env=env, cwd=repo, timeout=30) + ctx = (json.loads(h.stdout) if h.stdout.strip().startswith('{') else {}).get('hookSpecificOutput', {}).get('additionalContext', '') + check("read-only: the refresh hook after a read-only shell command keeps a graph another axiomcode built, and says so as context", + h.returncode == 0 and 'the hooks do not rebuild' in ctx and not os.path.exists(os.path.join(repo, '.axiomcode', 'refresh.log')), (h.stdout, h.stderr)) + h = subprocess.run([sys.executable, hook], input=json.dumps(ev), capture_output=True, text=True, env=env, cwd=repo, timeout=30) + check("read-only: and the second time in that session it is silent", h.returncode == 0 and not h.stdout.strip(), h.stdout) + finally: + 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." @@ -693,10 +827,22 @@ def mcp_checks(): len(seen) == 4 and all('--fresh' in s for s in seen[:3]) and '--fresh' not in seen[3], seen) check("mcp: an answer's --fresh is written as the parameter", 'fresh=True' in m.mcp_words('ask again with --fresh to wait'), m.mcp_words('ask again with --fresh to wait')) + tools = ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact', 'axiomcode_changed', 'axiomcode_test_impact', 'axiomcode_graph') + check("mcp: every query tool takes refresh", all('refresh' in m.PARAMS.get(t, []) for t in tools), {t: m.PARAMS.get(t) for t in tools}) + seen.clear() + fn('axiomcode_impact')(['X'], refresh=False); fn('axiomcode_path')('A', 'B', refresh=False); fn('axiomcode_context')('t', refresh=False) + fn('axiomcode_changed')(refresh=False); fn('axiomcode_test_impact')(refresh=False); fn('axiomcode_graph')(refresh=False) + fn('axiomcode_impact')(['X']); fn('axiomcode_changed')(); fn('axiomcode_graph')() + check("mcp: refresh=false passes --no-refresh to the CLI, and only when asked", + len(seen) == 9 and all('--no-refresh' in s for s in seen[:6]) and not any('--no-refresh' in s for s in seen[6:]), seen) + w = m.mcp_words('pass --no-refresh (MCP refresh=false) to query without rebuilding') + check("mcp: an answer's --no-refresh is written as refresh=False", 'refresh=False' in w and '--no-refresh' not in w, w) + check("mcp: the CLI's no_refresh is refused, naming refresh", 'refresh' in (m.unknown_arguments('axiomcode_impact', {'no_refresh': True}) or ''), + m.unknown_arguments('axiomcode_impact', {'no_refresh': True})) if __name__ == '__main__': - prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); cap_checks(); lock_checks(); named_checks(); mcp_checks() + prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); read_only_checks(); cap_checks(); lock_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/graph_verb.py b/tests/graph_verb.py index 0e3de1b4..2f680ffa 100644 --- a/tests/graph_verb.py +++ b/tests/graph_verb.py @@ -99,7 +99,14 @@ def check(ok, why, detail=''): # an edit: the graph is stale, and is rebuilt as it was indexed with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') - g = sh(repo, 'bash', AX, 'graph', repo, env=env) + # read-only first: --no-refresh (and AXIOMCODE_NO_REFRESH=1, which this env sets) draws the stale graph as it is + stamp = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) + g = sh(repo, 'bash', AX, 'graph', repo, '--no-refresh', env=env) + check(g.returncode == 0 and 'refresh off (--no-refresh): drawn from the graph as it is' in g.stdout + and 'predates edits to 1 file(s)' in g.stdout and (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == stamp, + 'stale: --no-refresh draws the graph as it is, says it is out of date, and never rebuilds it', g.stdout + g.stderr) + # the near-miss: asked without it (and with no AXIOMCODE_NO_REFRESH), the stale graph is rebuilt as it was indexed + g = sh(repo, 'bash', AX, 'graph', repo, env={k: v for k, v in env.items() if k != 'AXIOMCODE_NO_REFRESH'}) html = open(page).read() if os.path.exists(page) else '' check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) diff --git a/tests/refresh.py b/tests/refresh.py index dd10229d..49e0f4ef 100644 --- a/tests/refresh.py +++ b/tests/refresh.py @@ -328,6 +328,15 @@ def ti(): # with nothing change env=dict(env, AXIOMCODE_REFRESH_INTERVAL='3')) try: time.sleep(1.5) + # the server's own start-up check starts a refresher too: wait for it (and any a query left running) to let + # go of the lock, or under load it is that one, not the timer, that finds the edit below + import fcntl + deadline = time.time() + 120 + while time.time() < deadline: + fd = os.open(os.path.join(repo, '.axiomcode', 'refresh.lock'), os.O_RDWR | os.O_CREAT, 0o644) + try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB); break + except OSError: time.sleep(0.3) + finally: os.close(fd) open(f, 'a').write('\n// edited in an editor\n' if lang not in ('python',) else '\n# edited in an editor\n') deadline = time.time() + 300 while time.time() < deadline and 'found by the timer' not in meta().get('refresh_reason', ''): time.sleep(0.5) From b07a590ac17042c60c9a111f32b421989f690efb Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:02:44 -0700 Subject: [PATCH 052/258] python: framework dispatch for DI markers, signals, lifecycle hooks, routes, commands and model hooks Fixes #1515, #1517, #1519, #1520, #1521, #1522, #1523, #1524, #1532, #1536, #1551 What was wrong - Depends() was read only as a parameter default and only when its argument named a def: Annotated metadata, an Annotated alias (often imported), dependencies=[...] on a route or router, a class, the bare Depends() class shortcut and a callable instance all linked nothing (#1521, #1522). - A signal receiver was joined only through a bare name: @receiver([a, b]), a module attribute (signals.x.connect / signals.x.send) and asend were missed (#1515). A send that joined through an imported signal was also reported no_receiver, because the diagnostic negated per signal identity instead of per site (#1517). - Signals the ORM sends itself (post_save, pre_delete, ...) had no publisher: the client never writes a send (#1551). - An empty route path (@router.get("")), api_route, add_api_route(path, fn), Route / WebSocketRoute entries (including an endpoint class and routes=[...] as a keyword) were not HTTP entry points (#1519, #1524). - Middleware, exception handlers, on_event handlers and lifespan= callables were not entry points (#1523); nor was a management command's handle, and call_command("") reached nothing (#1520). - validator, root_validator, field_serializer, model_serializer and model_post_init were not hooks (#1536), and building a model reached none of its validators, its post-init hook or its default factories (#1532). The change (graph/python/engine, every framework name in knobs.dl) - dispatch.dl section 4: py_signal_ref names a signal by bare name or module attribute; a receiver decorator takes a list; the unjoined diagnostic negates per site; model signals join an imported library signal name, the receiver's sender= class and the class of the write (instance save/delete, or the manager's create). - dispatch.dl section 5: the DI marker is read in defaults, Annotated (in place or through an alias followed across the import), and dependency lists; its target is a def, a class constructor, the annotated class for a bare marker, or an instance's __call__ (also when the instance is imported). - dispatch.dl section 6: bare hook decorators on a model class, model_post_init on a model class, and model_hook edges from a construction or model_validate call to the construction-time validators, the post-init hook and default factories. - dispatch.dl sections 6b and 6c: lifecycle entry points; management commands as cli entry points and command_dispatch edges from call_command. - url dispatch: Route / WebSocketRoute entries, endpoint= keyword, endpoint classes (verb handlers through the MRO), routes= keyword tables, and one separator between a prefix and a pattern that starts with "/". - entry-points.dl: an empty first path argument, and route registration by call. - schema.ts vocabulary: python emits cli and lifecycle; di_provider and orm_hook meanings widened (SCHEMA.md regenerated). Tests - New cases 37-model-signals, 38-app-wiring, 39-model-construction-hooks, 40-empty-route-path, 41-di-marker-forms, 42-signal-forms, each with near-miss controls (a list handed to a non-receiver decorator, an untyped save, a class with no receiver, a registry's add_route("name", fn), a bare exception_handler, a lifespan_seconds keyword, a Command class outside a commands package, model_post_init on a plain class, a serializer that must not be reached by building, a same-named alias in another module, a second __call__). Numbered 40-42 rather than 34-36, which the release branch already uses. - Every golden of the six new cases was blessed and read line by line against its source: each expected framework edge and entry point is present at its declaration line, and no control produced a row. In 42-signal-forms two sends are reported no_receiver: the send on a signal nothing receives, and the asend on an ordinary typed object (the control), which the diagnostic lists as a send-shaped site nothing joins, as case 21 already does for its untyped mailer.send. The case comment now says so. - 21-url-and-signal-dispatch and 29-framework-edge-consumers: one fewer framework_unjoined no_receiver row each. Both were an imported signal's send that DID join (its edge is in the golden before and after), reported unjoined by the per-identity negation (#1517). The row left in 21 is the untyped mailer.send control. - Base is the release branch without this commit, candidate is with it: graph/test/python/run-tests.sh: base passed 36 failed 0, candidate passed 42 failed 0. tests/run.py --lang python: 230 of 231 on both; the one failure is python/lambda-is-named-by-its-place, failing on the base too. tests/fastpath.py --lang python: 5 of 6 on both (the alongside-tier check, failing on the base too); this commit touches no hook. Smoke (dev projects, measured before the rebase: installed 2e1cf6cf before, this change after) - a 700-file FastAPI service: http entry points 200 -> 290 (empty-path routes), di_provider entry points 3 -> 9, di_provider edges 6 -> 472, lifecycle 0 -> 5, model_hook edges 0 -> 82, call edges unchanged (22871). `impact` on the database-session provider (a generator def used as Depends()) 1 [by name] row -> 235 framework rows; on a callable-instance dependency's `__call__` 0 -> 101; on the current-user provider 1 -> 49 framework rows. Every new http row sampled is an `@router.get("")` or `@router.post("")` handler. - a 500-file Django service: cli entry points 0 -> 17 (one per management command module), orm_hook 4 -> 5 (a model_post_init override); url unchanged (132). - on the rebased tree, case 41 indexed with this build: `impact` on the provider behind an imported Annotated alias lists its handler as a framework row; the same-named alias in another module marks a provider that stays unreached. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- graph/bundle/SCHEMA.md | 8 +- graph/bundle/schema.ts | 8 +- .../engine/config-resolution/entry-points.dl | 25 + .../python/engine/config-resolution/knobs.dl | 122 +++++ .../engine/framework-behavior/dispatch.dl | 442 ++++++++++++++++-- graph/python/souffle/decls_all.dl | 23 + .../cases/37-model-signals/src/models.py | 33 ++ .../cases/37-model-signals/src/views.py | 40 ++ .../python/cases/38-app-wiring/src/main.py | 123 +++++ .../cases/38-app-wiring/src/shop/__init__.py | 0 .../cases/38-app-wiring/src/shop/cli.py | 6 + .../cases/38-app-wiring/src/shop/jobs.py | 10 + .../src/shop/management/__init__.py | 0 .../src/shop/management/commands/__init__.py | 0 .../shop/management/commands/close_orders.py | 13 + .../cases/38-app-wiring/src/shop/orders.py | 2 + .../39-model-construction-hooks/src/models.py | 62 +++ .../src/service.py | 21 + .../cases/40-empty-route-path/src/main.py | 94 ++++ .../cases/41-di-marker-forms/src/deps.py | 68 +++ .../cases/41-di-marker-forms/src/handlers.py | 69 +++ .../cases/41-di-marker-forms/src/other.py | 15 + .../cases/42-signal-forms/src/handlers.py | 45 ++ .../cases/42-signal-forms/src/orders.py | 34 ++ .../cases/42-signal-forms/src/signals.py | 25 + .../21-url-and-signal-dispatch.framework | 4 +- .../29-framework-edge-consumers.framework | 3 +- .../python/expected/37-model-signals.edges | 12 + .../python/expected/37-model-signals.entries | 4 + .../expected/37-model-signals.framework | 9 + .../python/expected/37-model-signals.tiers | 30 ++ .../test/python/expected/38-app-wiring.edges | 22 + .../python/expected/38-app-wiring.entries | 13 + .../python/expected/38-app-wiring.framework | 10 + .../test/python/expected/38-app-wiring.tiers | 32 ++ .../39-model-construction-hooks.edges | 18 + .../39-model-construction-hooks.entries | 7 + .../39-model-construction-hooks.framework | 21 + .../39-model-construction-hooks.tiers | 33 ++ .../python/expected/40-empty-route-path.edges | 11 + .../expected/40-empty-route-path.entries | 6 + .../expected/40-empty-route-path.framework | 5 + .../python/expected/40-empty-route-path.tiers | 26 ++ .../python/expected/41-di-marker-forms.edges | 10 + .../expected/41-di-marker-forms.entries | 19 + .../expected/41-di-marker-forms.framework | 19 + .../python/expected/41-di-marker-forms.tiers | 24 + .../python/expected/42-signal-forms.edges | 11 + .../python/expected/42-signal-forms.entries | 6 + .../python/expected/42-signal-forms.framework | 12 + .../python/expected/42-signal-forms.tiers | 29 ++ 51 files changed, 1643 insertions(+), 41 deletions(-) create mode 100644 graph/test/python/cases/37-model-signals/src/models.py create mode 100644 graph/test/python/cases/37-model-signals/src/views.py create mode 100644 graph/test/python/cases/38-app-wiring/src/main.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/__init__.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/cli.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/jobs.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/management/__init__.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/management/commands/__init__.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/management/commands/close_orders.py create mode 100644 graph/test/python/cases/38-app-wiring/src/shop/orders.py create mode 100644 graph/test/python/cases/39-model-construction-hooks/src/models.py create mode 100644 graph/test/python/cases/39-model-construction-hooks/src/service.py create mode 100644 graph/test/python/cases/40-empty-route-path/src/main.py create mode 100644 graph/test/python/cases/41-di-marker-forms/src/deps.py create mode 100644 graph/test/python/cases/41-di-marker-forms/src/handlers.py create mode 100644 graph/test/python/cases/41-di-marker-forms/src/other.py create mode 100644 graph/test/python/cases/42-signal-forms/src/handlers.py create mode 100644 graph/test/python/cases/42-signal-forms/src/orders.py create mode 100644 graph/test/python/cases/42-signal-forms/src/signals.py create mode 100644 graph/test/python/expected/37-model-signals.edges create mode 100644 graph/test/python/expected/37-model-signals.entries create mode 100644 graph/test/python/expected/37-model-signals.framework create mode 100644 graph/test/python/expected/37-model-signals.tiers create mode 100644 graph/test/python/expected/38-app-wiring.edges create mode 100644 graph/test/python/expected/38-app-wiring.entries create mode 100644 graph/test/python/expected/38-app-wiring.framework create mode 100644 graph/test/python/expected/38-app-wiring.tiers create mode 100644 graph/test/python/expected/39-model-construction-hooks.edges create mode 100644 graph/test/python/expected/39-model-construction-hooks.entries create mode 100644 graph/test/python/expected/39-model-construction-hooks.framework create mode 100644 graph/test/python/expected/39-model-construction-hooks.tiers create mode 100644 graph/test/python/expected/40-empty-route-path.edges create mode 100644 graph/test/python/expected/40-empty-route-path.entries create mode 100644 graph/test/python/expected/40-empty-route-path.framework create mode 100644 graph/test/python/expected/40-empty-route-path.tiers create mode 100644 graph/test/python/expected/41-di-marker-forms.edges create mode 100644 graph/test/python/expected/41-di-marker-forms.entries create mode 100644 graph/test/python/expected/41-di-marker-forms.framework create mode 100644 graph/test/python/expected/41-di-marker-forms.tiers create mode 100644 graph/test/python/expected/42-signal-forms.edges create mode 100644 graph/test/python/expected/42-signal-forms.entries create mode 100644 graph/test/python/expected/42-signal-forms.framework create mode 100644 graph/test/python/expected/42-signal-forms.tiers diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index b0222d9e..65f9a6c0 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -646,10 +646,10 @@ Methods the runtime invokes without a client call site — process roots, test m | `main` | java, csharp | A static `main`. C#: a static `Main`, or the method top-level statements compile to. | | `test` | java, typescript, csharp | Java: a JUnit test or lifecycle method. TypeScript: a function body handed to a test registrar (`it`, `describe`), inline or named, which the runner invokes. C#: an xUnit, NUnit or MSTest test method, or a set-up or tear-down hook of one. | | `http` | java, python, typescript, csharp | A route handler a web framework invokes on a request. Java: a JAX-RS / Spring MVC handler. Python: a function registered with a decorator naming an HTTP verb and a URL path. TypeScript: a handler passed to a route registration (`app.get('/x', h)`), inline or named, or a method carrying a route decorator inside a container-owned class (`@Controller` + `@Get`). C#: a routed controller action, or a Razor Pages page model's `On[Handler][Async]` method. | -| `cli` | java | A CLI command method (picocli etc.). | +| `cli` | java, python | A CLI command method. Java: picocli and the like. Python: the handler of a management command, a `Command` class in a `management/commands/` module, which the command runner calls by the module's file name. | | `bean_ctor` | java, typescript | Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does. | | `factory` | java | A `@Bean` factory method. | -| `lifecycle` | java, typescript, csharp | Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host's base through the project's own base class. | +| `lifecycle` | java, typescript, csharp, python | Python: a callable an application registers to run around requests, on an error or at start and stop: a middleware, an exception handler, a startup or shutdown handler, a lifespan. Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host's base through the project's own base class. | | `queue` | java, csharp | A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler. | | `scheduled` | java | A `@Scheduled` method. | | `grpc_service` | java, python, csharp | A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python). | @@ -670,8 +670,8 @@ Methods the runtime invokes without a client call site — process roots, test m | `fixture` | python | A declared fixture that some collected test requests by parameter name. The runner calls it to build the argument. | | `url` | python | A view named as a value in a module-level route table. The framework calls it on a request. | | `signal_receiver` | python | A handler attached to a signal, by decorator or by connect(). It runs when the signal fires, whether or not this tree contains the send. | -| `di_provider` | python | A provider named in a dependency-injection marker in a parameter default. The framework calls it and passes the result in. | -| `orm_hook` | python, csharp | A hook the data layer calls; nothing in the client does. Python: a lifecycle or validation hook registered by decoration. C#: an Entity Framework Core override or implementation: `OnModelCreating`, `OnConfiguring`, `IEntityTypeConfiguration.Configure`, a migration's `Up`/`Down`, a save-changes interceptor, `IDesignTimeDbContextFactory.CreateDbContext`. | +| `di_provider` | python | A provider named in a dependency-injection marker: a parameter default, `Annotated` metadata (in place or through an alias), or a `dependencies=[...]` list on a route or router. A def, a class (its constructor) or a callable instance (its `__call__`). The framework calls it and passes the result in. | +| `orm_hook` | python, csharp | A hook the data layer calls; nothing in the client does. Python: a lifecycle, validation or serialization hook registered by decoration, or a `model_post_init` override on a model class. C#: an Entity Framework Core override or implementation: `OnModelCreating`, `OnConfiguring`, `IEntityTypeConfiguration.Configure`, a migration's `Up`/`Down`, a save-changes interceptor, `IDesignTimeDbContextFactory.CreateDbContext`. | | `framework_hook` | csharp | A method a framework calls on a class because the class derives from one of its base types or implements one of its interfaces, directly or through the project's own bases: an options setup class, a view component, an authorization handler, a model binder, a gRPC interceptor, a FluentValidation validator's constructor and overrides, a MediatR request or notification handler, `Dispose`/`DisposeAsync` on an `IDisposable`/`IAsyncDisposable`. | | `hub` | csharp | A public instance method of a SignalR hub. A connected client invokes it by name. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index b4544ded..00335314 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -674,10 +674,10 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'main', languages: ['java', 'csharp'], meaning: 'A static `main`. C#: a static `Main`, or the method top-level statements compile to.' }, { table: 'entry_points', column: 'reason', value: 'test', languages: ['java', 'typescript', 'csharp'], meaning: 'Java: a JUnit test or lifecycle method. TypeScript: a function body handed to a test registrar (`it`, `describe`), inline or named, which the runner invokes. C#: an xUnit, NUnit or MSTest test method, or a set-up or tear-down hook of one.' }, { table: 'entry_points', column: 'reason', value: 'http', languages: ['java', 'python', 'typescript', 'csharp'], meaning: 'A route handler a web framework invokes on a request. Java: a JAX-RS / Spring MVC handler. Python: a function registered with a decorator naming an HTTP verb and a URL path. TypeScript: a handler passed to a route registration (`app.get(\'/x\', h)`), inline or named, or a method carrying a route decorator inside a container-owned class (`@Controller` + `@Get`). C#: a routed controller action, or a Razor Pages page model\'s `On[Handler][Async]` method.' }, - { table: 'entry_points', column: 'reason', value: 'cli', languages: J, meaning: 'A CLI command method (picocli etc.).' }, + { table: 'entry_points', column: 'reason', value: 'cli', languages: ['java', 'python'], meaning: 'A CLI command method. Java: picocli and the like. Python: the handler of a management command, a `Command` class in a `management/commands/` module, which the command runner calls by the module\'s file name.' }, { table: 'entry_points', column: 'reason', value: 'bean_ctor', languages: ['java', 'typescript'], meaning: 'Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does.' }, { table: 'entry_points', column: 'reason', value: 'factory', languages: J, meaning: 'A `@Bean` factory method.' }, - { table: 'entry_points', column: 'reason', value: 'lifecycle', languages: ['java', 'typescript', 'csharp'], meaning: 'Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host\'s base through the project\'s own base class.' }, + { table: 'entry_points', column: 'reason', value: 'lifecycle', languages: ['java', 'typescript', 'csharp', 'python'], meaning: 'Python: a callable an application registers to run around requests, on an error or at start and stop: a middleware, an exception handler, a startup or shutdown handler, a lifespan. Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host\'s base through the project\'s own base class.' }, { table: 'entry_points', column: 'reason', value: 'queue', languages: ['java', 'csharp'], meaning: 'A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler.' }, { table: 'entry_points', column: 'reason', value: 'scheduled', languages: J, meaning: 'A `@Scheduled` method.' }, { table: 'entry_points', column: 'reason', value: 'grpc_service', languages: ['java', 'python', 'csharp'], meaning: 'A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python).' }, @@ -698,8 +698,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'fixture', languages: P, meaning: 'A declared fixture that some collected test requests by parameter name. The runner calls it to build the argument.' }, { table: 'entry_points', column: 'reason', value: 'url', languages: P, meaning: 'A view named as a value in a module-level route table. The framework calls it on a request.' }, { table: 'entry_points', column: 'reason', value: 'signal_receiver', languages: P, meaning: 'A handler attached to a signal, by decorator or by connect(). It runs when the signal fires, whether or not this tree contains the send.' }, - { table: 'entry_points', column: 'reason', value: 'di_provider', languages: P, meaning: 'A provider named in a dependency-injection marker in a parameter default. The framework calls it and passes the result in.' }, - { table: 'entry_points', column: 'reason', value: 'orm_hook', languages: ['python', 'csharp'], meaning: 'A hook the data layer calls; nothing in the client does. Python: a lifecycle or validation hook registered by decoration. C#: an Entity Framework Core override or implementation: `OnModelCreating`, `OnConfiguring`, `IEntityTypeConfiguration.Configure`, a migration\'s `Up`/`Down`, a save-changes interceptor, `IDesignTimeDbContextFactory.CreateDbContext`.' }, + { table: 'entry_points', column: 'reason', value: 'di_provider', languages: P, meaning: 'A provider named in a dependency-injection marker: a parameter default, `Annotated` metadata (in place or through an alias), or a `dependencies=[...]` list on a route or router. A def, a class (its constructor) or a callable instance (its `__call__`). The framework calls it and passes the result in.' }, + { table: 'entry_points', column: 'reason', value: 'orm_hook', languages: ['python', 'csharp'], meaning: 'A hook the data layer calls; nothing in the client does. Python: a lifecycle, validation or serialization hook registered by decoration, or a `model_post_init` override on a model class. C#: an Entity Framework Core override or implementation: `OnModelCreating`, `OnConfiguring`, `IEntityTypeConfiguration.Configure`, a migration\'s `Up`/`Down`, a save-changes interceptor, `IDesignTimeDbContextFactory.CreateDbContext`.' }, { table: 'entry_points', column: 'reason', value: 'framework_hook', languages: C, meaning: 'A method a framework calls on a class because the class derives from one of its base types or implements one of its interfaces, directly or through the project\'s own bases: an options setup class, a view component, an authorization handler, a model binder, a gRPC interceptor, a FluentValidation validator\'s constructor and overrides, a MediatR request or notification handler, `Dispose`/`DisposeAsync` on an `IDisposable`/`IAsyncDisposable`.' }, { table: 'entry_points', column: 'reason', value: 'hub', languages: C, meaning: 'A public instance method of a SignalR hub. A connected client invokes it by name.' }, diff --git a/graph/python/engine/config-resolution/entry-points.dl b/graph/python/engine/config-resolution/entry-points.dl index 99c835ce..97cf4f7f 100644 --- a/graph/python/engine/config-resolution/entry-points.dl +++ b/graph/python/engine/config-resolution/entry-points.dl @@ -62,6 +62,8 @@ http_route_deco(dp) :- decorator_text("client", dp, _, argc, _), argc != "0", // — a path-absolute is the only form a router accepts). Nothing else about the argument // is assumed: not its position (`@routes.route("GET", "/x")` puts the verb first), not // its spelling (`{key}`, ``, `:key` are all path parameters), not its length. +// The one path that does not begin with "/" is the empty one, handled by the second +// clause below. // // THIS CONDITION IS WHY THE VERB LIST IS SAFE. `patch` is an HTTP method AND the name of // the stdlib's mock entry point, so `mock.patch` satisfies . exactly. @@ -76,9 +78,32 @@ http_route_path(d) :- annotation_arg("client", _, v, _, _, "false", d, _), // mounted under a prefix serves at the prefix itself (`APIRouter(prefix="/orders")` + `@router.post("")`). http_route_path(d) :- annotation_arg("client", _, "", "STRING_LITERAL", "0", "false", d, _). +// THE EMPTY PATH (#1519). A router or blueprint that carries a prefix serves the prefix +// itself with "": `APIRouter(prefix="/orders")` + `@router.get("")` is `GET /orders`, and +// a Flask blueprint with url_prefix does the same with `@bp.route("")`. The patch target +// the rule above keeps out cannot reach this clause: `mock.patch("")` raises TypeError at +// decoration time, so no module that imports carries it. Only the FIRST POSITIONAL +// argument may be empty, and only a plain string literal: an empty second argument +// (`@x.route("GET", "")`) is not the path. +http_route_path(d) :- annotation_arg("client", _, "", "STRING_LITERAL", "0", "false", d, _). + entry_point(m, "http") :- decorator_target("client", _, m, h), decorator_text("client", dp, _, _, h), http_route_deco(dp), http_route_path(h). +// THE SAME REGISTRATION WRITTEN AS A CALL (#1524): `router.add_api_route("/x", fn)`, +// `app.add_url_rule("/x", view_func=fn)`. The method must be catalogued and, like the +// decorator, carry a URL path, so a registry's `add_route("name", fn)` is not a route. The +// handler is the argument that denotes a def, by position or by a catalogued keyword; the +// path string never does. +.decl http_route_call(e:symbol) +http_route_call(e) :- call_decl("client", _, meth, _, e, _), py_http_route_register_method(meth), + call_arg(e, _, a), expr_node("client", "LITERAL", _, v, a), + strlen(v) > 0, substr(v, 0, 1) = "/". +entry_point(m, "http") :- http_route_call(e), call_arg(e, _, a), + expr_denotes_method("client", a, m), m != "". +entry_point(m, "http") :- http_route_call(e), py_http_route_endpoint_kw(k), + call_kwarg(e, k, a), expr_denotes_method("client", a, m), m != "". + // Only the HTTP rule is written here. A `__main__` guard and a CLI command are also // entry points, but neither was MEASURED on a subject yet, and an unmeasured rule is how // a fabricated edge gets in. They belong in a follow-up with their own numbers. diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index c2f6c78f..0d001dcb 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -40,6 +40,21 @@ py_http_route_verb("patch"). py_http_route_verb("head"). py_http_route_verb("options"). py_http_route_verb("websocket"). +// `@router.api_route("/bulk", methods=[...])`: one handler for the verbs it lists (#1524). +py_http_route_verb("api_route"). + +// ── py_http_route_register_method(Name) / py_http_route_endpoint_kw(Keyword) ── +// The imperative spelling of the same registration: `router.add_api_route("/x", fn)`, +// `app.add_url_rule("/x", view_func=fn)`, `app.add_route("/x", fn)`. Matched only with a +// URL path argument (a string starting with "/"), exactly like the decorator, so a +// registry's own `add_route("name", fn)` is not a route. +py_http_route_register_method("add_api_route"). +py_http_route_register_method("add_api_websocket_route"). +py_http_route_register_method("add_route"). +py_http_route_register_method("add_websocket_route"). +py_http_route_register_method("add_url_rule"). +py_http_route_endpoint_kw("endpoint"). +py_http_route_endpoint_kw("view_func"). // ── py_grpc_servicer_suffix(Suffix) / py_grpc_generated_module_suffix(Suffix) ── // The two names protoc chooses, and the only two this ecosystem needs. @@ -170,6 +185,13 @@ py_route_table("url_patterns"). py_route_entry_fn("path"). py_route_entry_fn("re_path"). py_route_entry_fn("url"). +// Starlette's route table entries: `routes = [Route("/x", fn), WebSocketRoute("/ws", fn)]` (#1524). +py_route_entry_fn("Route"). +py_route_entry_fn("WebSocketRoute"). + +// ── py_route_endpoint_kw(Keyword): an entry's view passed by keyword ───────── +// `WebSocketRoute("/feed", endpoint=feed)`. +py_route_endpoint_kw("endpoint"). // ── py_route_include_fn(Name) — mounts another table under a prefix ────────── // `path("orders/", include(order_patterns))`. The argument is a table like @@ -194,6 +216,14 @@ py_view_handler_name("head"). py_view_handler_name("options"). py_view_handler_name("trace"). +// ── py_endpoint_handler_name(Name): what the framework calls on an endpoint CLASS ─ +// `Route("/widgets", Widgets)` with `class Widgets(HTTPEndpoint)`: the method named for +// the request's verb, as for as_view(); a websocket endpoint's three handlers besides. +py_endpoint_handler_name(n) :- py_view_handler_name(n). +py_endpoint_handler_name("on_connect"). +py_endpoint_handler_name("on_receive"). +py_endpoint_handler_name("on_disconnect"). + // ── py_signal_receiver_deco(Tail) — attaches a handler to a signal ─────────── // `@receiver(order_placed)`. The publisher writes `order_placed.send(sender=...)`; // the two ends are joined by the SIGNAL OBJECT and never by a call. @@ -206,6 +236,28 @@ py_signal_receiver_deco("receiver"). py_signal_connect_method("connect"). py_signal_send_method("send"). py_signal_send_method("send_robust"). +py_signal_send_method("asend"). +py_signal_send_method("asend_robust"). + +// ── py_model_signal_method(Signal, Method) — signals the ORM sends itself ─── +// A model's `save()` sends pre_save and post_save, `delete()` pre_delete and post_delete, +// and the manager's `create()` / `get_or_create()` / `update_or_create()` save through +// `save()`. The client writes no `send` for these, so the write IS the publisher. Matched +// only for a signal IMPORTED from outside the client and a receiver scoped by `sender=` +// to the class being written. +py_model_signal_method("pre_save", "save"). +py_model_signal_method("post_save", "save"). +py_model_signal_method("pre_save", "create"). +py_model_signal_method("post_save", "create"). +py_model_signal_method("pre_save", "get_or_create"). +py_model_signal_method("post_save", "get_or_create"). +py_model_signal_method("pre_save", "update_or_create"). +py_model_signal_method("post_save", "update_or_create"). +py_model_signal_method("pre_delete", "delete"). +py_model_signal_method("post_delete", "delete"). +py_signal_sender_kw("sender"). +// `Order.objects.create(...)`: the class attribute holding the default manager. +py_model_manager_attr("objects"). // ── py_di_marker(Name) — a default value naming a provider to call ─────────── // `def handler(svc = Depends(get_service))`. The marker takes the provider as a @@ -214,6 +266,19 @@ py_signal_send_method("send_robust"). py_di_marker("Depends"). py_di_marker("Security"). +// ── py_di_marker_kwarg(Name): the keyword a marker takes its provider by ───── +// `Depends(dependency=get_service)` is the same marker as `Depends(get_service)`. +py_di_marker_kwarg("dependency"). + +// ── py_di_dependencies_kwarg(Name): a list of markers guarding routes ──────── +// `@router.get("/", dependencies=[Depends(check)])`, `APIRouter(dependencies=[...])`, +// `app.include_router(r, dependencies=[...])`. Each provider in it runs per request. +py_di_dependencies_kwarg("dependencies"). + +// ── py_annotated_form(Name): `Annotated[T, *metadata]`, whose metadata a marker ── +// may sit in: `svc: Annotated[Service, Depends(get_service)]`. +py_annotated_form("Annotated"). + // ── py_orm_hook_deco(Tail) — a lifecycle or validation hook the library calls ─ // `@event.listens_for(User, "before_insert")`, `@validates("email")`, // `@field_validator("name")`. The library invokes these; no client call site does. @@ -221,6 +286,63 @@ py_orm_hook_deco("listens_for"). py_orm_hook_deco("validates"). py_orm_hook_deco("field_validator"). py_orm_hook_deco("model_validator"). +// The v1 spellings and the serializers (#1536). A serializer runs on dump, not on build. +py_orm_hook_deco("validator"). +py_orm_hook_deco("root_validator"). +py_orm_hook_deco("field_serializer"). +py_orm_hook_deco("model_serializer"). + +// ── py_model_construct_hook_deco(Tail): the hooks that run when a model is BUILT ─ +// `Widget(**data)` and `Widget.model_validate(data)` run these; see py_model_build (#1532). +py_model_construct_hook_deco("field_validator"). +py_model_construct_hook_deco("model_validator"). +py_model_construct_hook_deco("validator"). +py_model_construct_hook_deco("root_validator"). + +// ── py_model_lifecycle_method(Name): a method the library calls on every instance ─ +// `def model_post_init(self, ctx)` on a model class (one whose lineage leaves the client). +py_model_lifecycle_method("model_post_init"). + +// ── py_model_build_method(Name): a classmethod that builds and validates a model ─ +py_model_build_method("model_validate"). +py_model_build_method("model_validate_json"). +py_model_build_method("parse_obj"). +py_model_build_method("parse_raw"). + +// ── py_default_factory_kw(Keyword): a field default the constructor calls ──── +// `Field(default_factory=new_id)`, `field(default_factory=list)`. +py_default_factory_kw("default_factory"). + +// ── py_app_hook_deco(Tail): an application lifecycle registration (#1523) ──── +// `@app.middleware("http")`, `@app.exception_handler(ValueError)`, `@app.errorhandler(404)`, +// `@app.on_event("startup")`. Matched only with an argument naming what it hooks. +py_app_hook_deco("middleware"). +py_app_hook_deco("exception_handler"). +py_app_hook_deco("errorhandler"). +py_app_hook_deco("on_event"). + +// ── py_app_lifecycle_kw(Keyword): an application argument holding callables it runs ─ +// `FastAPI(lifespan=lifespan)`, `Starlette(on_startup=[boot], on_shutdown=[stop])`. +py_app_lifecycle_kw("lifespan"). +py_app_lifecycle_kw("on_startup"). +py_app_lifecycle_kw("on_shutdown"). + +// ── py_middleware_register_fn(Name) / py_middleware_handler_name(Name) ────── +// `app.add_middleware(Timing)` and `Middleware(Timing)` hand the framework a CLASS; it +// builds it and calls `dispatch` (a BaseHTTPMiddleware) or `__call__` (a plain ASGI one). +py_middleware_register_fn("add_middleware"). +py_middleware_register_fn("Middleware"). +py_middleware_handler_name("dispatch"). +py_middleware_handler_name("__call__"). + +// ── py_command_* : a management command (#1520) ────────────────────────────── +// `/management/commands/.py` declares `class Command(BaseCommand)` with a +// `handle` method; the runner (and `call_command("")`) runs it by the file name. +py_command_dir("/management/commands/"). +py_command_package("management.commands."). +py_command_class("Command"). +py_command_handler_name("handle"). +py_command_call_fn("call_command"). // ── py_fixture_scope_file(Basename) — the file whose fixtures are inherited ── // A fixture is NOT visible by name alone. The runner resolves it at the TEST'S diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index 7b3e7bfa..8fecb08e 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -37,7 +37,7 @@ // ── WHAT IT EMITS, AND WHY NOT AS A CALL ──────────────────────────────────── // framework_edge(FromMethod, ToMethod, Mechanism, Detail, Confidence) // Mechanism task_dispatch | fixture_injection | url_dispatch | -// signal_dispatch | di_provider | orm_hook +// signal_dispatch | di_provider | model_hook | command_dispatch // Detail what joined the two ends: the dispatch method, the parameter name, // the route table, the signal binding, the marker, the hook target // Confidence registered both ends carry the framework's own registration @@ -72,6 +72,7 @@ py_fw_deco_tail(v) :- py_fixture_deco(v). py_fw_deco_tail(v) :- py_parametrize_deco(v). py_fw_deco_tail(v) :- py_signal_receiver_deco(v). py_fw_deco_tail(v) :- py_orm_hook_deco(v). +py_fw_deco_tail(v) :- py_app_hook_deco(v). // py_deco_named(DecoratorHash, Tail) -- the decorator's dotted path IS the tail, or // ends with "." ++ tail. A bare `@task` and `@app.task` both match; `@mytask` does not, @@ -360,6 +361,8 @@ py_route_entry_call(site, e) :- py_route_root_value(val) :- assign_pair("client", tgt, val), expr_binding("client", b, "STORE", tgt), binding_decl("client", n, _, _, _, b), py_route_table(n). +// The same table handed to the application as a keyword, `Starlette(routes=[...])` (#1524). +py_route_root_value(val) :- call_kwarg(_, k, val), py_route_table(k). .decl py_route_table_value(val:symbol) py_route_table_value(val) :- py_route_root_value(val). @@ -421,14 +424,30 @@ py_route_entry_in(val, e) :- py_route_table_value(val), expr_parent("client", val, "ELEMENT", _, e), py_route_entry_call(_, e). .decl py_route_prefix(val:symbol, p:symbol) py_route_prefix(val, "/") :- py_route_root_value(val). -py_route_prefix(val2, cat(p, pat)) :- +py_route_prefix(val2, r) :- py_route_prefix(val, p), strlen(p) < 200, - py_route_entry_in(val, i), py_route_pattern(i, pat), - call_arg(i, _, inc), py_route_mount(inc, val2). + py_route_entry_in(val, i), + call_arg(i, _, inc), py_route_mount(inc, val2), py_route_join(val, i, r). .decl py_route_served(site:symbol, path:symbol) -py_route_served(site, cat(p, pat)) :- - py_route_prefix(val, p), py_route_entry_in(val, e), py_route_pattern(e, pat), - py_route_entry_call(site, e). +py_route_served(site, r) :- + py_route_prefix(val, _), py_route_entry_in(val, e), py_route_entry_call(site, e), + py_route_join(val, e, r). + +// py_route_join(Table, Entry, Path): the entry's pattern under the table's prefix. A +// pattern written with its leading "/" (`Route("/widgets", ...)`) under a prefix that ends +// with one is joined on ONE separator, not two (#1524). A prefix is never empty (the root +// is "/"). Written with constraints rather than a negated helper, because the prefix is +// recursive through this relation and a negation inside that cycle does not stratify. +.decl py_route_join(val:symbol, e:symbol, r:symbol) +py_route_join(val, e, cat(p, pat)) :- py_route_prefix(val, p), py_route_entry_in(val, e), + py_route_pattern(e, pat), substr(p, max(0, strlen(p) - 1), 1) != "/". +py_route_join(val, e, p) :- py_route_prefix(val, p), py_route_entry_in(val, e), + py_route_pattern(e, pat), strlen(pat) = 0. +py_route_join(val, e, cat(p, pat)) :- py_route_prefix(val, p), py_route_entry_in(val, e), + py_route_pattern(e, pat), strlen(pat) > 0, substr(pat, 0, 1) != "/". +py_route_join(val, e, cat(p, substr(pat, 1, strlen(pat) - 1))) :- py_route_prefix(val, p), + py_route_entry_in(val, e), py_route_pattern(e, pat), + substr(p, max(0, strlen(p) - 1), 1) = "/", strlen(pat) > 0, substr(pat, 0, 1) = "/". // The view an entry names. A route registers the declaration written under any // decorator, not what the decorator returns (#1510): `@login_required def settings` @@ -438,6 +457,9 @@ py_route_served(site, cat(p, pat)) :- // value semantics of expr_denotes_method answer. .decl py_route_entry_arg(site:symbol, a:symbol) py_route_entry_arg(site, a) :- py_route_entry_call(site, e), call_arg(e, _, a). +// ... or by keyword: `WebSocketRoute("/feed", endpoint=feed)` (#1524). +py_route_entry_arg(site, a) :- py_route_entry_call(site, e), + py_route_endpoint_kw(k), call_kwarg(e, k, a). .decl py_route_arg_names_replaced(a:symbol, m:symbol) py_route_arg_names_replaced(a, m) :- @@ -469,6 +491,15 @@ py_route_view(site, m) :- py_view_handler_name(n), mro_lookup("client", t, n, m), method_decl("client", _, _, _, _, m). +// AN ENDPOINT CLASS (#1524). `Route("/widgets", Widgets)` hands the framework the class +// itself; it instantiates it per request and calls the method named for the request's +// verb (or, for a websocket endpoint, its connect / receive / disconnect handler). The +// handlers are looked up through the MRO, client methods only, as for as_view() above. +py_route_view(site, m) :- + py_route_entry_arg(site, a), expr_type_class_object("client", a, t), + py_endpoint_handler_name(n), mro_lookup("client", t, n, m), + method_decl("client", _, _, _, _, m). + // The view is whichever argument of the entry DENOTES a def. A route entry takes the // pattern as a string and the view as a reference, so the string arguments simply do // not denote a method and drop out without a position rule. @@ -537,29 +568,57 @@ py_signal_identity(b, r) :- py_import_binds_binding(b, r). // each write the signal's NAME, but a name reference carries its own binding hash per // occurrence; only the DECLARING binding is shared. Joining the raw hashes silently // produced no edge at all while both halves looked correct on their own. +// +// py_signal_ref(Expr, SignalBinding): an expression that names a signal, in either of the +// two spellings a project writes: the bare name (`order_placed`, imported or declared +// here) or the module attribute (`signals.order_closed` after `import signals`), which is +// joined like an import, on the resolved module and the member's own name (#1515). +// Seeded from the places a signal is named, so the join never runs over every name in +// the tree. +.decl py_signal_ref_demand(e:symbol) +py_signal_ref_demand(a) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_signal_receiver_deco(v), + decorator_expr("client", de, h), call_arg(de, "0", a). +py_signal_ref_demand(el) :- py_signal_ref_demand(a), expr_parent("client", a, "ELEMENT", _, el). +py_signal_ref_demand(rexpr) :- call_decl("client", _, meth, _, _, site), + py_signal_connect_method(meth), call_receiver("client", _, _, rexpr, site). +py_signal_ref_demand(rexpr) :- call_decl("client", _, meth, _, _, site), + py_signal_send_method(meth), call_receiver("client", _, _, rexpr, site). + +.decl py_signal_ref(e:symbol, sigb:symbol) +py_signal_ref(e, sigb) :- py_signal_ref_demand(e), + expr_binding("client", b, ctx, e), ctx != "STORE", + binding_lookup("client", b, lb), py_signal_identity(lb, sigb). +py_signal_ref(e, sigb) :- py_signal_ref_demand(e), + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_names_module("client", obj, mod), py_module_level_binding(mod, n, sigb). + +// A receiver decorator takes one signal or a LIST of them: `@receiver([a, b])` attaches +// the function to each (#1515). +.decl py_signal_refs(e:symbol, sigb:symbol) +py_signal_refs(e, sigb) :- py_signal_ref(e, sigb). +py_signal_refs(e, sigb) :- py_signal_ref_demand(e), + expr_parent("client", e, "ELEMENT", _, el), py_signal_ref(el, sigb). + py_signal_receiver(m, sigb) :- decorator_target("client", _, m, h), m != "", py_deco_named(h, v), py_signal_receiver_deco(v), - decorator_expr("client", de, h), call_arg(de, "0", a), - expr_binding("client", b, ctx, a), ctx != "STORE", - binding_lookup("client", b, lb), py_signal_identity(lb, sigb). + decorator_expr("client", de, h), call_arg(de, "0", a), py_signal_refs(a, sigb). py_signal_receiver(m, sigb) :- call_decl("client", _, meth, _, ce, site), py_signal_connect_method(meth), - call_receiver("client", "NAME", _, rexpr, site), - expr_binding("client", b, ctx, rexpr), ctx != "STORE", - binding_lookup("client", b, lb), py_signal_identity(lb, sigb), + call_receiver("client", _, _, rexpr, site), py_signal_ref(rexpr, sigb), call_arg(ce, "0", a), expr_denotes_method("client", a, m), m != "". // A receiver runs when the signal fires, whether or not this tree contains the send. entry_point(m, "signal_receiver") :- py_signal_receiver(m, _). -// The publisher: `.send(sender=...)` on the same binding. +// The publisher: `.send(sender=...)` (or send_robust, asend, asend_robust) on the +// same signal, named either way. .decl py_signal_send(site:symbol, from:symbol, sigb:symbol) py_signal_send(site, from, sigb) :- call_decl("client", _, meth, _, _, site), py_signal_send_method(meth), - call_receiver("client", "NAME", _, rexpr, site), - expr_binding("client", b, ctx, rexpr), ctx != "STORE", - binding_lookup("client", b, lb), py_signal_identity(lb, sigb), + call_receiver("client", _, _, rexpr, site), py_signal_ref(rexpr, sigb), call_context("client", _, from, _, _, site), from != "". // The two ends meet ONLY through the same binding. An ordinary `mailer.send(...)` has @@ -568,6 +627,62 @@ py_signal_send(site, from, sigb) :- framework_edge(from, to, "signal_dispatch", "signal", "registered") :- py_signal_send(_, from, sigb), py_signal_receiver(to, sigb). +// A send is joined when ANY of its identities has a receiver. An imported signal has two +// (the importing module's own binding and the declaring one), and the receiver is +// registered on the declaring one only, so a negation per identity reported a send that +// had joined as `no_receiver` (#1517). The diagnostic negates per site. +.decl py_signal_send_joined(site:symbol) +py_signal_send_joined(site) :- py_signal_send(site, _, sigb), py_signal_receiver(_, sigb). + +// ── MODEL SIGNALS: sent by the library on the client's behalf (#1551) ───────── +// `post_save`, `pre_delete` and the rest are declared by the ORM, not by the client, and +// the ORM sends them from inside `save()`, `delete()` and the manager's `create()`. So the +// client has no `send` to join: the publisher is the code that saves the model, and the +// receiver is scoped to ONE model by `sender=`. The join is therefore +// the signal's NAME imported from outside the client, catalogued in knobs.dl with +// the write methods that fire it; +// the sender's CLASS written as `sender=Order` on the receiver, and the class of the +// receiver of the write (`order.save()`, `Order.objects.create()`). +// A receiver whose static class is a base of the sender may be saving that model, so the +// sender may be the write's class or a subclass of it; never an unrelated class. +.decl py_lib_signal_name(lb:symbol, sig:symbol) +py_lib_signal_name(lb, orig) :- import_binding("client", lb, i), + import_alias("client", orig, _, i), py_model_signal_method(orig, _), + !py_import_binds_binding(lb, _). + +.decl py_model_signal_receiver(m:symbol, sig:symbol, t:symbol) +py_model_signal_receiver(m, sig, t) :- + decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_signal_receiver_deco(v), + decorator_expr("client", de, h), call_arg(de, "0", a), py_signal_refs(a, lb), + py_lib_signal_name(lb, sig), + py_signal_sender_kw(k), call_kwarg(de, k, s), expr_type_class_object("client", s, t). +py_model_signal_receiver(m, sig, t) :- + call_decl("client", _, meth, _, ce, site), py_signal_connect_method(meth), + call_receiver("client", _, _, rexpr, site), py_signal_ref(rexpr, lb), + py_lib_signal_name(lb, sig), + call_arg(ce, "0", a), expr_denotes_method("client", a, m), m != "", + py_signal_sender_kw(k), call_kwarg(ce, k, s), expr_type_class_object("client", s, t). + +// The write: an instance method on a receiver of a known class (`order.save()`), or the +// manager's method on the class (`Order.objects.create(...)`). +.decl py_model_write(e:symbol, from:symbol, meth:symbol, t:symbol) +py_model_write(e, from, meth, t) :- + call_decl("client", _, meth, _, e, site), py_model_signal_method(_, meth), + call_recv_type(e, t), + call_context("client", _, from, _, _, site), from != "". +py_model_write(e, from, meth, t) :- + call_decl("client", _, meth, _, e, site), py_model_signal_method(_, meth), + call_receiver_object(e, obj), + expr_node("client", "ATTRIBUTE_ACCESS", _, attr, obj), py_model_manager_attr(attr), + expr_parent("client", obj, "ATTRIBUTE_OBJECT", _, cls), + expr_type_class_object("client", cls, t), + call_context("client", _, from, _, _, site), from != "". + +framework_edge(from, to, "signal_dispatch", sig, "registered") :- + py_model_write(_, from, meth, t), py_model_signal_method(sig, meth), + py_model_signal_receiver(to, sig, t2), type_self_or_ancestor("client", t2, t). + // ───────────────────────────────────────────────────────────────────────────── // 5. DEPENDENCY-INJECTION PROVIDERS (handler -> provider) // ───────────────────────────────────────────────────────────────────────────── @@ -575,19 +690,152 @@ framework_edge(from, to, "signal_dispatch", "signal", "registered") :- // VALUE and the framework calls it. A provider may declare its own, so the chain has // to carry. // -// THE ARGUMENT MUST DENOTE A DEF. `Depends(get_service())` calls the provider eagerly: -// that is an ordinary call site, already a resolved edge, and the argument expression -// denotes the RESULT rather than the function -- so it drops out here with no rule of -// its own. `Depends()` names nothing and matches nothing. +// THE MARKER IS READ WHEREVER THE FRAMEWORK READS IT, not only as a default: +// def h(svc = Depends(get_service)) a parameter's default +// def h(svc: Annotated[Service, Depends(get_service)]) Annotated metadata +// ServiceDep = Annotated[Service, Depends(get_service)] the same, through an alias +// def h(svc: ServiceDep) (often in another module) +// @router.get("/", dependencies=[Depends(check)]) a route's dependency list +// router = APIRouter(dependencies=[Depends(check)]) every route on that router +// Reading only the first made the documented spelling (Annotated) invisible. +// +// WHAT THE FRAMEWORK CALLS is decided by the marker's argument, not by its name: +// a def the def Depends(get_service) +// a class its constructor Depends(Filter) +// nothing the annotated class's c: Filter = Depends() +// constructor c: Annotated[Filter, Depends()] +// an instance type(instance).__call__ Depends(checker) +// A class is found through expr_type_class_object and an instance through expr_type, +// so an instance resolves to ITS class's __call__, never to another __call__ that only +// shares the name. +// +// `Depends(get_service())` calls the provider eagerly: that is an ordinary call site, +// already a resolved edge, and the argument denotes the RESULT, so the def clause drops +// it. Only when that result is an instance of a class with __call__ does the instance +// clause see it, and that __call__ is exactly what the framework then calls. + +// ── py_di_marker_call(MarkerExpr, Marker) ──────────────────────────────────── +.decl py_di_marker_call(de:symbol, marker:symbol) +py_di_marker_call(de, marker) :- + call_decl("client", _, marker, _, de, _), py_di_marker(marker). + +// ── py_di_marker_arg(MarkerExpr, ArgExpr): the provider, by position or keyword ── +.decl py_di_marker_arg(de:symbol, a:symbol) +py_di_marker_arg(de, a) :- py_di_marker_call(de, _), call_arg(de, "0", a). +py_di_marker_arg(de, a) :- py_di_marker_call(de, _), py_di_marker_kwarg(k), + call_kwarg(de, k, a). + +// `Depends()` and `Depends(use_cache=False)` name no provider. +.decl py_di_marker_bare(de:symbol) +py_di_marker_bare(de) :- py_di_marker_call(de, _), !py_di_marker_arg(de, _). + +// ── py_di_marker_target(MarkerExpr, Callable): what the framework calls ─────── +.decl py_di_marker_target(de:symbol, to:symbol) +py_di_marker_target(de, to) :- py_di_marker_arg(de, a), + expr_denotes_method("client", a, to), to != "". +py_di_marker_target(de, to) :- py_di_marker_arg(de, a), + expr_type_class_object("client", a, t), type_constructor("client", t, to). +py_di_marker_target(de, to) :- py_di_marker_arg(de, a), + expr_type("client", a, t), type_call_target("client", t, to). +// ... and an instance IMPORTED from the module that builds it (`from deps import quota`). +// A value-shaped import carries no type, so it is followed to the declaring module-level +// binding (py_signal_identity, section 4) and typed from the construction assigned there. +py_di_marker_target(de, to) :- py_di_marker_arg(de, a), + expr_binding("client", b, ctx, a), ctx != "STORE", + binding_lookup("client", b, lb), py_import_binds_binding(lb, rb), + expr_binding("client", rb, "STORE", tgt), assign_pair("client", tgt, val), + call_constructs_type(val, t), type_call_target("client", t, to). + +// ── py_annotated_subscript(Subscript) / py_annotated_element(Subscript, Element) ── +// `Annotated[T, m1, m2]`, spelled bare or as `typing.Annotated`. The IR has two shapes: +// in an annotation the arguments sit directly under the subscript, in an assigned value +// they are wrapped in a TUPLE first. +.decl py_annotated_subscript(s:symbol) +py_annotated_subscript(s) :- expr_node("client", "SUBSCRIPT", _, _, s), + expr_parent("client", s, "SUBSCRIPT_OBJECT", _, o), + expr_node("client", "NAME_REFERENCE", _, n, o), py_annotated_form(n). +py_annotated_subscript(s) :- expr_node("client", "SUBSCRIPT", _, _, s), + expr_parent("client", s, "SUBSCRIPT_OBJECT", _, o), + expr_node("client", "ATTRIBUTE_ACCESS", _, n, o), py_annotated_form(n). +.decl py_annotated_element(s:symbol, el:symbol) +py_annotated_element(s, el) :- py_annotated_subscript(s), + expr_parent("client", s, "SUBSCRIPT_INDEX", _, el), + !expr_node("client", "TUPLE", _, _, el). +py_annotated_element(s, el) :- py_annotated_subscript(s), + expr_parent("client", s, "SUBSCRIPT_INDEX", _, tup), + expr_node("client", "TUPLE", _, _, tup), + expr_parent("client", tup, "ELEMENT", _, el). + +// ── py_di_param_annotated(ParamHash, AnnotatedSubscript) ───────────────────── +// The parameter's annotation is `Annotated[...]` itself, or a name bound to one. The +// alias is a module-level VALUE, and the parser gives a value-shaped import no target +// hash, so an imported alias is followed through py_signal_identity (resolved module +// plus the member's own name, section 4). +.decl py_di_param_annotated(ph:symbol, s:symbol) +py_di_param_annotated(ph, s) :- py_annotated_subscript(s), + expr_root_context("client", "ANNOTATION", s), + expr_owner("client", "METHOD_PARAMETER", ph, _, s). +py_di_param_annotated(ph, s) :- + expr_node("client", "NAME_REFERENCE", "ANNOTATION", _, e), + expr_owner("client", "METHOD_PARAMETER", ph, _, e), + expr_binding("client", b, ctx, e), ctx != "STORE", + binding_lookup("client", b, lb), py_signal_identity(lb, ab), + expr_binding("client", ab, "STORE", tgt), assign_pair("client", tgt, s), + py_annotated_subscript(s). + +// ── py_di_dependency_list(CallExpr, MarkerExpr): `dependencies=[Depends(x), ...]` ── +.decl py_di_dependency_list(ce:symbol, de:symbol) +py_di_dependency_list(ce, de) :- py_di_dependencies_kwarg(k), call_kwarg(ce, k, lst), + expr_parent("client", lst, "ELEMENT", _, de), py_di_marker_call(de, _). + +// A router built with a dependency list, keyed on the binding it is stored into. +.decl py_di_router_dependency(rb:symbol, de:symbol) +py_di_router_dependency(rb, de) :- py_di_dependency_list(ce, de), + assign_pair("client", tgt, ce), expr_binding("client", rb, "STORE", tgt). + .decl py_di_injection(from:symbol, to:symbol, marker:symbol) +// (a) the parameter's default value py_di_injection(from, to, marker) :- param_decl("client", _, _, _, from, ph), from != "", param_default_expr("client", de, ph), - call_decl("client", _, marker, _, de, _), py_di_marker(marker), - call_arg(de, "0", a), - expr_denotes_method("client", a, to), to != "", to != from. - + py_di_marker_call(de, marker), py_di_marker_target(de, to), to != from. +// (b) Annotated metadata, written in place or through an alias +py_di_injection(from, to, marker) :- + param_decl("client", _, _, _, from, ph), from != "", + py_di_param_annotated(ph, s), py_annotated_element(s, de), + py_di_marker_call(de, marker), py_di_marker_target(de, to), to != from. +// (c) `Depends()` naming no provider: the class the parameter is annotated with +py_di_injection(from, to, marker) :- + param_decl("client", _, _, _, from, ph), from != "", + param_default_expr("client", de, ph), + py_di_marker_call(de, marker), py_di_marker_bare(de), + param_declared_type("client", ph, t), type_constructor("client", t, to). +py_di_injection(from, to, marker) :- + param_decl("client", _, _, _, from, ph), from != "", + py_di_param_annotated(ph, s), py_annotated_element(s, de), + py_di_marker_call(de, marker), py_di_marker_bare(de), + py_annotated_element(s, te), te != de, + expr_type_class_object("client", te, t), type_constructor("client", t, to). +// (d) a route decorator's dependency list guards the decorated handler +py_di_injection(from, to, marker) :- + decorator_target("client", _, from, h), from != "", + decorator_expr("client", dx, h), py_di_dependency_list(dx, de), + py_di_marker_call(de, marker), py_di_marker_target(de, to), to != from. +// (e) a router's dependency list guards every handler routed through that router +py_di_injection(from, to, marker) :- + decorator_target("client", _, from, h), from != "", + decorator_expr("client", dx, h), + expr_parent("client", dx, "RECEIVER", _, attr), + expr_parent("client", attr, "ATTRIBUTE_OBJECT", _, obj), + expr_binding("client", ob, ctx, obj), ctx != "STORE", + binding_lookup("client", ob, lb), py_signal_identity(lb, rb), + py_di_router_dependency(rb, de), + py_di_marker_call(de, marker), py_di_marker_target(de, to), to != from. + +// A provider in ANY dependency list runs, whether or not the guarded handlers are +// known here (an application-wide list, or one passed when a router is included). entry_point(m, "di_provider") :- py_di_injection(_, m, _). +entry_point(m, "di_provider") :- py_di_dependency_list(_, de), py_di_marker_target(de, m). framework_edge(from, to, "di_provider", marker, "registered") :- py_di_injection(from, to, marker). @@ -595,9 +843,10 @@ framework_edge(from, to, "di_provider", marker, "registered") :- // 6. ORM AND MODEL HOOKS // ───────────────────────────────────────────────────────────────────────────── // A lifecycle or validation hook is registered by decoration and called by the library. -// There is no second end in the client at all -- nothing here fires the hook -- so this -// mechanism produces an ENTRY POINT and no framework_edge. Claiming an edge would mean -// naming a caller that does not exist. +// Most have no second end in the client -- nothing here fires the hook -- so the hook is +// an ENTRY POINT. The one client end some of them do have is the code that BUILDS the +// model, which runs its construction-time hooks; that edge is drawn below (model_hook), +// and no other caller is claimed. // // The registration must NAME ITS TARGET: a hook always says what it hooks (a mapper and // an event, or the fields it validates). A project's own decorator spelled the same way @@ -608,7 +857,142 @@ py_orm_hook(m, v) :- decorator_target("client", _, m, h), m != "", py_deco_named(h, v), py_orm_hook_deco(v), decorator_text("client", _, _, argc, h), argc != "0", argc != "". +// A MODEL CLASS: one whose lineage leaves the client, through a base the engine cannot +// see (an unresolved import) or one declared in a library. A validation library's hooks +// are methods of such a class; a class with no base, or only client bases, is not one. +.decl py_type_external_lineage(t:symbol) +py_type_external_lineage(t) :- type_self_or_ancestor("client", t, a), + type_base_unresolved("client", a, _, _). +py_type_external_lineage(t) :- type_self_or_ancestor("client", t, a), + type_base_resolved("client", a, _, b), !type_decl("client", _, _, _, b). + +// A hook written bare (`@root_validator`, `@model_serializer`) names no target, so the +// rule above does not see it. On a method of a model class the decorator is the +// registration (#1536); on anything else a bare decorator of that name is not trusted. +py_orm_hook(m, v) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_orm_hook_deco(v), + decorator_text("client", _, _, argc, h), ( argc = "0" ; argc = "" ), + method_owner("client", t, m), py_type_external_lineage(t). + +// A lifecycle method the library calls on every instance it builds, found by its NAME on +// a model class: `def model_post_init(self, ctx)` overrides the library's own (#1536). On +// a class that is not a model, a method of that name is an ordinary method. +.decl py_model_post_init(m:symbol, t:symbol) +py_model_post_init(m, t) :- py_model_lifecycle_method(n), + method_decl("client", n, _, _, _, m), method_owner("client", t, m), + py_type_external_lineage(t). + entry_point(m, "orm_hook") :- py_orm_hook(m, _). +entry_point(m, "orm_hook") :- py_model_post_init(m, _). + +// ── WHAT BUILDING A MODEL RUNS (#1532) ─────────────────────────────────────── +// `Widget(**data)` and `Widget.model_validate(data)` run the class's validators, its +// post-init hook and the default factories of the fields left out, and none of those has +// a call site: the library's constructor calls them. The client's side of the hop is the +// construction, so the edge runs from the code that builds the model to each of them. +// a construction-time validator declared on the class or an ancestor (a serializer +// runs on dump, not on build, and is not reached here) +// the post-init hook the one the class's MRO selects +// a default factory `Field(default_factory=f)` or `field(default_factory=f)` +// in the class body of the class or an ancestor; a +// dataclass runs it from its generated __init__ the same way +.decl py_model_build(e:symbol, from:symbol, t:symbol) +py_model_build(e, from, t) :- call_constructs_type(e, t), + call_decl("client", _, _, _, e, site), + call_context("client", _, from, _, _, site), from != "". +py_model_build(e, from, t) :- + call_decl("client", _, meth, _, e, site), py_model_build_method(meth), + call_receiver_object(e, obj), expr_type_class_object("client", obj, t), + py_type_external_lineage(t), + call_context("client", _, from, _, _, site), from != "". + +.decl py_model_build_runs(t:symbol, m:symbol, detail:symbol) +py_model_build_runs(t, m, v) :- py_model_build(_, _, t), + type_self_or_ancestor("client", t, a), method_owner("client", a, m), + py_orm_hook(m, v), py_model_construct_hook_deco(v). +py_model_build_runs(t, m, n) :- py_model_build(_, _, t), + py_model_lifecycle_method(n), mro_lookup("client", t, n, m), py_model_post_init(m, _). +py_model_build_runs(t, m, k) :- py_model_build(_, _, t), + type_self_or_ancestor("client", t, a), + call_context("client", _, body, a, _, site), method_kind("client", "CLASS_INITIALIZER", _, body), + call_decl("client", _, _, _, fe, site), + py_default_factory_kw(k), call_kwarg(fe, k, f), expr_denotes_method("client", f, m), m != "". + +framework_edge(from, to, "model_hook", detail, "registered") :- + py_model_build(_, from, t), py_model_build_runs(t, to, detail), from != to. + +// ───────────────────────────────────────────────────────────────────────────── +// 6b. APPLICATION LIFECYCLE (#1523) +// ───────────────────────────────────────────────────────────────────────────── +// An application registers callables it runs around every request, on an error, or at +// start and stop. None has a call site in the client, so each is an entry point: +// @app.middleware("http") a decorator in the py_app_hook_deco catalogue, which +// @app.exception_handler(Exc) must NAME what it hooks (an argument): a bare +// @app.on_event("startup") decorator of the same name is some object's method +// FastAPI(lifespan=fn) a keyword in py_app_lifecycle_kw, holding the callable +// Starlette(on_startup=[fn]) or a list of them +// app.add_middleware(Timing) a middleware CLASS, registered by a catalogued method +// Middleware(Timing) or wrapper; the framework builds it and calls its +// request handler (py_middleware_handler_name) +// A decorated def (`@asynccontextmanager async def lifespan`) is named by the def written +// under the decorator, as a route is. +.decl py_app_hook(m:symbol) +py_app_hook(m) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_app_hook_deco(v), + decorator_text("client", _, _, argc, h), argc != "0", argc != "". + +.decl py_app_lifecycle_value(a:symbol) +py_app_lifecycle_value(a) :- py_app_lifecycle_kw(k), call_kwarg(_, k, a). +py_app_lifecycle_value(el) :- py_app_lifecycle_kw(k), call_kwarg(_, k, a), + expr_parent("client", a, "ELEMENT", _, el). +py_app_hook(m) :- py_app_lifecycle_value(a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + binding_binds_replaced_method("client", b2, m). +py_app_hook(m) :- py_app_lifecycle_value(a), expr_denotes_method("client", a, m), m != "". + +.decl py_middleware_class(t:symbol) +py_middleware_class(t) :- call_decl("client", _, meth, _, e, _), py_middleware_register_fn(meth), + call_arg(e, "0", a), expr_type_class_object("client", a, t). +py_app_hook(m) :- py_middleware_class(t), py_middleware_handler_name(n), + mro_lookup("client", t, n, m), method_decl("client", _, _, _, _, m). +py_app_hook(m) :- py_middleware_class(t), type_constructor("client", t, m), + method_decl("client", _, _, _, _, m). + +entry_point(m, "lifecycle") :- py_app_hook(m). + +// ───────────────────────────────────────────────────────────────────────────── +// 6c. MANAGEMENT COMMANDS (#1520) +// ───────────────────────────────────────────────────────────────────────────── +// A command is a module in a `management/commands/` package whose class of the catalogued +// name defines the handler; the runner imports the module by its FILE NAME and calls the +// handler. `call_command("close_orders")` is the in-process way to run one, keyed on that +// same name. All three names are the framework's convention (py_command_* in knobs.dl); a +// class of that name anywhere else is an ordinary class. +.decl py_command_type(t:symbol, q:symbol) +py_command_type(t, q) :- py_command_class(cn), type_decl("client", cn, _, _, t), + type_module("client", mod, t), module_file("client", p, mod), + py_command_dir(d), contains(d, cat("/", p)), + module_decl("client", _, q, _, mod). +.decl py_command_handler(t:symbol, m:symbol) +py_command_handler(t, m) :- py_command_type(t, _), py_command_handler_name(n), + mro_lookup("client", t, n, m), method_decl("client", _, _, _, _, m). + +entry_point(m, "cli") :- py_command_handler(_, m). + +// `call_command("")` reaches the handler of the command module named : the +// module's qualified name is `.management.commands.`. +.decl py_command_call(site:symbol, from:symbol, name:symbol) +py_command_call(site, from, n) :- call_decl("client", _, fn, _, e, site), py_command_call_fn(fn), + call_arg(e, "0", a), expr_node("client", "LITERAL", _, n, a), n != "", + call_context("client", _, from, _, _, site), from != "". +framework_edge(from, m, "command_dispatch", n, "registered") :- + py_command_call(_, from, n), py_command_type(t, q), py_command_package(pq), + sfx = cat(".", cat(pq, n)), strlen(q) > strlen(sfx), + substr(q, max(0, strlen(q) - strlen(sfx)), strlen(sfx)) = sfx, + py_command_handler(t, m). +framework_edge(from, m, "command_dispatch", n, "registered") :- + py_command_call(_, from, n), py_command_type(t, q), py_command_package(pq), + q = cat(pq, n), py_command_handler(t, m). // ───────────────────────────────────────────────────────────────────────────── // 7. DIAGNOSTICS — the half that says what did NOT join @@ -629,5 +1013,5 @@ framework_unjoined(e, "task_dispatch", meth) :- call_decl("client", _, meth, _, e, site), py_task_dispatch_method(meth), !py_task_dispatch(site, _, _, _). framework_unjoined(e, "signal_dispatch", "no_receiver") :- - py_signal_send(site, _, sigb), !py_signal_receiver(_, sigb), + py_signal_send(site, _, _), !py_signal_send_joined(site), call_decl("client", _, _, _, e, site). diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 09a7d62c..4cb014d5 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -65,7 +65,30 @@ .decl py_signal_connect_method(v:symbol) .decl py_signal_send_method(v:symbol) .decl py_di_marker(v:symbol) +.decl py_di_marker_kwarg(v:symbol) +.decl py_di_dependencies_kwarg(v:symbol) +.decl py_annotated_form(v:symbol) .decl py_orm_hook_deco(v:symbol) +.decl py_http_route_register_method(v:symbol) +.decl py_http_route_endpoint_kw(v:symbol) +.decl py_route_endpoint_kw(v:symbol) +.decl py_endpoint_handler_name(v:symbol) +.decl py_model_signal_method(sig:symbol, meth:symbol) +.decl py_signal_sender_kw(v:symbol) +.decl py_model_manager_attr(v:symbol) +.decl py_model_construct_hook_deco(v:symbol) +.decl py_model_lifecycle_method(v:symbol) +.decl py_model_build_method(v:symbol) +.decl py_default_factory_kw(v:symbol) +.decl py_app_hook_deco(v:symbol) +.decl py_app_lifecycle_kw(v:symbol) +.decl py_middleware_register_fn(v:symbol) +.decl py_middleware_handler_name(v:symbol) +.decl py_command_dir(v:symbol) +.decl py_command_package(v:symbol) +.decl py_command_class(v:symbol) +.decl py_command_handler_name(v:symbol) +.decl py_command_call_fn(v:symbol) .decl decorator_order(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl decorator_target(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl decorator_text(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) diff --git a/graph/test/python/cases/37-model-signals/src/models.py b/graph/test/python/cases/37-model-signals/src/models.py new file mode 100644 index 00000000..be81f1eb --- /dev/null +++ b/graph/test/python/cases/37-model-signals/src/models.py @@ -0,0 +1,33 @@ +"""Model signals the ORM sends from inside save() and delete() on the client's behalf. + +The client never writes a `send` for these: `Order.objects.create(...)` and +`order.save()` fire post_save with sender=Order. A receiver is scoped by `sender=`. +""" +from django.db import models +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver + + +class Order(models.Model): + name = models.CharField(max_length=40) + + +class Invoice(models.Model): + total = models.IntegerField(default=0) + + +def audit_order(sender, instance, created, **kwargs): + return instance + + +post_save.connect(audit_order, sender=Order) + + +@receiver(pre_delete, sender=Order) +def before_order_delete(sender, instance, **kwargs): + return instance + + +@receiver(post_save, sender=Invoice) +def audit_invoice(sender, instance, **kwargs): + return instance diff --git a/graph/test/python/cases/37-model-signals/src/views.py b/graph/test/python/cases/37-model-signals/src/views.py new file mode 100644 index 00000000..64de26fc --- /dev/null +++ b/graph/test/python/cases/37-model-signals/src/views.py @@ -0,0 +1,40 @@ +"""The code that saves and deletes models, and never names a signal.""" +from models import Invoice, Order + + +def create_order(name): + return Order.objects.create(name=name) + + +def rename_order(order: Order, name): + order.name = name + order.save() + + +def drop_order(order: Order): + order.delete() + + +def touch_invoice(invoice: Invoice): + invoice.save() + + +# ── NOT a model signal, and each would be if one condition were dropped ───── +def save_draft(draft): + """The receiver's type is unknown: no model, no sender to match.""" + draft.save() + + +def drop_invoice(invoice: Invoice): + """Invoice has a post_save receiver and no delete receiver.""" + invoice.delete() + + +class Cache: + def save(self): + return None + + +def save_cache(cache: Cache): + """A `save` on a class no receiver names as its sender.""" + cache.save() diff --git a/graph/test/python/cases/38-app-wiring/src/main.py b/graph/test/python/cases/38-app-wiring/src/main.py new file mode 100644 index 00000000..826bb114 --- /dev/null +++ b/graph/test/python/cases/38-app-wiring/src/main.py @@ -0,0 +1,123 @@ +"""Callables an ASGI application registers and the framework calls, with no call site. + +Lifecycle: a lifespan context manager, a middleware class, a middleware function, an +exception handler, a startup handler. Routes: api_route, add_api_route, and a routes +list holding an endpoint class and a websocket function. +""" +from contextlib import asynccontextmanager + +from fastapi import APIRouter, FastAPI +from starlette.applications import Starlette +from starlette.endpoints import HTTPEndpoint +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.routing import Route, WebSocketRoute + + +@asynccontextmanager +async def lifespan(app): + yield + + +app = FastAPI(lifespan=lifespan) + + +class Timing(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + return await call_next(request) + + def label(self): + """Not called by the framework: stays an ordinary method.""" + return "timing" + + +app.add_middleware(Timing) + + +@app.middleware("http") +async def add_header(request, call_next): + return await call_next(request) + + +@app.exception_handler(ValueError) +async def on_value_error(request, exc): + return None + + +@app.on_event("startup") +async def boot(): + return None + + +router = APIRouter() + + +@router.api_route("/bulk", methods=["GET", "POST"]) +async def bulk(): + return [] + + +async def cancel(key: int): + return key + + +router.add_api_route("/orders/{key}/cancel", cancel, methods=["POST"]) + + +class Widgets(HTTPEndpoint): + async def get(self, request): + return None + + async def post(self, request): + return None + + def render(self): + """Not named for a verb: not an entry point.""" + return None + + +async def feed(websocket): + return None + + +async def health(request): + return None + + +routes = [Route("/widgets", Widgets), WebSocketRoute("/feed", endpoint=feed)] +side = Starlette(routes=[Route("/health", health)]) + + +# ── NOT registrations, and each would be if one condition were dropped ────── +class Registry: + def add_route(self, name, fn): + return fn + + def exception_handler(self, fn): + return fn + + +registry = Registry() + + +def plugin_hook(): + return None + + +registry.add_route("pkg.hooks.plugin", plugin_hook) + + +@registry.exception_handler +def bare_handler(exc): + """The decorator names nothing it handles.""" + return exc + + +def configure(lifespan_seconds=0): + return lifespan_seconds + + +def tick(): + return 0 + + +configure(lifespan_seconds=tick) diff --git a/graph/test/python/cases/38-app-wiring/src/shop/__init__.py b/graph/test/python/cases/38-app-wiring/src/shop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/38-app-wiring/src/shop/cli.py b/graph/test/python/cases/38-app-wiring/src/shop/cli.py new file mode 100644 index 00000000..faef5b20 --- /dev/null +++ b/graph/test/python/cases/38-app-wiring/src/shop/cli.py @@ -0,0 +1,6 @@ +"""A class called Command outside a commands package: not a management command.""" + + +class Command: + def handle(self, *args, **options): + return 0 diff --git a/graph/test/python/cases/38-app-wiring/src/shop/jobs.py b/graph/test/python/cases/38-app-wiring/src/shop/jobs.py new file mode 100644 index 00000000..3594e455 --- /dev/null +++ b/graph/test/python/cases/38-app-wiring/src/shop/jobs.py @@ -0,0 +1,10 @@ +from django.core.management import call_command + + +def nightly(): + call_command("close_orders") + + +def unknown(): + """No command module of this name: nothing to reach.""" + call_command("reopen_orders") diff --git a/graph/test/python/cases/38-app-wiring/src/shop/management/__init__.py b/graph/test/python/cases/38-app-wiring/src/shop/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/38-app-wiring/src/shop/management/commands/__init__.py b/graph/test/python/cases/38-app-wiring/src/shop/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/38-app-wiring/src/shop/management/commands/close_orders.py b/graph/test/python/cases/38-app-wiring/src/shop/management/commands/close_orders.py new file mode 100644 index 00000000..a67bee09 --- /dev/null +++ b/graph/test/python/cases/38-app-wiring/src/shop/management/commands/close_orders.py @@ -0,0 +1,13 @@ +"""A management command: the module's file name is the command's name.""" +from django.core.management.base import BaseCommand + +from shop.orders import close_all + + +class Command(BaseCommand): + def handle(self, *args, **options): + return close_all() + + def summary(self): + """Not what the command runner calls.""" + return "close" diff --git a/graph/test/python/cases/38-app-wiring/src/shop/orders.py b/graph/test/python/cases/38-app-wiring/src/shop/orders.py new file mode 100644 index 00000000..25db1397 --- /dev/null +++ b/graph/test/python/cases/38-app-wiring/src/shop/orders.py @@ -0,0 +1,2 @@ +def close_all(): + return 0 diff --git a/graph/test/python/cases/39-model-construction-hooks/src/models.py b/graph/test/python/cases/39-model-construction-hooks/src/models.py new file mode 100644 index 00000000..ae14c75c --- /dev/null +++ b/graph/test/python/cases/39-model-construction-hooks/src/models.py @@ -0,0 +1,62 @@ +"""What building a validated model runs: its validators, its post-init hook and its +default factories. None of them has a call site in the client. +""" +from pydantic import (BaseModel, Field, field_serializer, field_validator, + model_serializer, root_validator, validator) + + +def new_id() -> str: + return "w-1" + + +def new_tags(): + return [] + + +class Widget(BaseModel): + id: str = Field(default_factory=new_id) + size: int + + @field_validator("size") + @classmethod + def check_size(cls, v): + return max(v, 0) + + @validator("id") + def check_id(cls, v): + return v.strip() + + @root_validator + def check_all(cls, values): + return values + + @field_serializer("size") + def dump_size(self, v): + """Runs on dump, not on construction: an entry point, not reached by building.""" + return str(v) + + @model_serializer + def dump(self): + return {} + + def model_post_init(self, ctx): + self.size = self.size or 1 + + +class Gizmo(Widget): + tags: list = Field(default_factory=new_tags) + + +class Plain: + """Not a model: a method of this name is an ordinary method.""" + + def model_post_init(self, ctx): + return ctx + + +class Gadget: + def __init__(self, size): + self.size = self.check_size(size) + + def check_size(self, v): + return max(v, 0) diff --git a/graph/test/python/cases/39-model-construction-hooks/src/service.py b/graph/test/python/cases/39-model-construction-hooks/src/service.py new file mode 100644 index 00000000..e726643e --- /dev/null +++ b/graph/test/python/cases/39-model-construction-hooks/src/service.py @@ -0,0 +1,21 @@ +from models import Gadget, Gizmo, Plain, Widget + + +def make_widget(data: dict): + return Widget(**data) + + +def load_widget(data: dict): + return Widget.model_validate(data) + + +def make_gizmo(): + return Gizmo(size=1) + + +def make_gadget(size: int): + return Gadget(size) + + +def make_plain(): + return Plain() diff --git a/graph/test/python/cases/40-empty-route-path/src/main.py b/graph/test/python/cases/40-empty-route-path/src/main.py new file mode 100644 index 00000000..d47099ca --- /dev/null +++ b/graph/test/python/cases/40-empty-route-path/src/main.py @@ -0,0 +1,94 @@ +"""A route registered with an EMPTY path, and the decorators that look like it. + +A router or blueprint that carries a prefix serves the prefix itself with an empty +path: `APIRouter(prefix="/orders")` plus `@router.get("")` is `GET /orders`, and a +Flask blueprint with `url_prefix` does the same with `@bp.route("")`. The framework +invokes the handler on a request exactly as it does for `@router.get("/{key}")`. + +The negative half: an empty string is accepted only as the FIRST POSITIONAL argument +of a catalogued . decorator. Everything below the second rule would +become an entry point if one of those conditions were dropped. +""" +from unittest import mock + + +class _Router: + """Stands in for a FastAPI router / Flask blueprint; no framework is staged.""" + def route(self, path, *a, **kw): return lambda f: f + def get(self, path, *a, **kw): return lambda f: f + def post(self, path, *a, **kw): return lambda f: f + def put(self, path, *a, **kw): return lambda f: f + def delete(self, path, *a, **kw): return lambda f: f + def patch(self, path, *a, **kw): return lambda f: f + def memoize(self, key): return lambda f: f + + +router = _Router() +bp = _Router() +cache = _Router() + + +def get(path): + return lambda f: f + + +# ── the real thing: the collection endpoints of a prefixed router ──────────── +@router.get("") +def list_orders(): + return [] + + +@router.post("") +def create_order(body): + return body + + +@router.patch('') +def patch_orders(body): + return body + + +@bp.route("") +def list_widgets(): + return "[]" + + +# ── the sibling that already worked, for comparison ───────────────────────── +@router.get("/{key}") +def get_order(key): + return key + + +# ── NOT routes ────────────────────────────────────────────────────────────── +@mock.patch("main.helper") +def patched_test(_m): + """. holds; the argument is a dotted target, not a path.""" + return None + + +@cache.memoize("") +def memoized(): + """Empty first argument, but `memoize` is not an HTTP verb.""" + return None + + +@get("") +def bare_verb(): + """Empty path on a bare `@get` with no receiver: a local helper, not a route.""" + return None + + +@router.delete("orders", "") +def empty_second_argument(): + """The empty string is not the path argument; neither argument starts with "/".""" + return None + + +@router.put(path="") +def keyword_path(): + """Keyword path: excluded for "" exactly as a keyword "/x" is excluded today.""" + return None + + +def helper(): + return "helper" diff --git a/graph/test/python/cases/41-di-marker-forms/src/deps.py b/graph/test/python/cases/41-di-marker-forms/src/deps.py new file mode 100644 index 00000000..05cfefe4 --- /dev/null +++ b/graph/test/python/cases/41-di-marker-forms/src/deps.py @@ -0,0 +1,68 @@ +"""Providers, the classes a marker can name, and module-level Annotated aliases. + +An alias is a module-level VALUE, and the handler that uses it usually imports it: +`CartDep = Annotated[dict, Depends(load_cart)]` here, `cart: CartDep` in handlers.py. +""" +from typing import Annotated + +from fastapi import Depends + + +def load_user(): + return {} + + +def load_owner(): + return {} + + +def load_cart(): + return {} + + +def load_shape_size(): + return 1 + + +def check_region(): + return None + + +def check_quota(): + return None + + +def check_app(): + return None + + +class WidgetFilter: + def __init__(self, q: str = ""): + self.q = q + + +class ColorFilter: + def __init__(self, color: str = ""): + self.color = color + + +class ShapeFilter: + def __init__(self, size: int = Depends(load_shape_size)): + self.size = size + + +class QuotaCheck: + def __call__(self, n: int = 0): + return n < 5 + + +class RateCheck: + """A second __call__ nothing hands to a marker: it must stay unreached.""" + + def __call__(self, n: int = 0): + return n < 9 + + +quota = QuotaCheck() +CartDep = Annotated[dict, Depends(load_cart)] +ShapeDep = Annotated[ShapeFilter, Depends()] diff --git a/graph/test/python/cases/41-di-marker-forms/src/handlers.py b/graph/test/python/cases/41-di-marker-forms/src/handlers.py new file mode 100644 index 00000000..6b756d81 --- /dev/null +++ b/graph/test/python/cases/41-di-marker-forms/src/handlers.py @@ -0,0 +1,69 @@ +"""Every place FastAPI reads a Depends marker, and every kind of provider it takes. + +Each handler below is guarded by exactly the providers its comment names. The +controls at the end are each one condition away from an injection. +""" +from typing import Annotated + +from fastapi import APIRouter, Depends, FastAPI + +from deps import (CartDep, ColorFilter, ShapeDep, WidgetFilter, check_app, + check_quota, check_region, load_owner, load_user, quota) + +app = FastAPI(dependencies=[Depends(check_app)]) +router = APIRouter(dependencies=[Depends(check_region)]) +plain_router = APIRouter() + + +@router.get("/a") +def by_default(user: dict = Depends(load_user)): + """A parameter's default: the form that already worked. Plus check_region.""" + return user + + +@router.get("/b") +def by_annotated(owner: Annotated[dict, Depends(load_owner)]): + """Annotated metadata, written in place. Plus check_region.""" + return owner + + +@router.get("/c") +def by_alias(cart: CartDep): + """An Annotated alias imported from another module. Plus check_region.""" + return cart + + +@router.get("/d", dependencies=[Depends(check_quota)]) +def by_decorator(): + """The route's own dependency list, and the router's. check_quota, check_region.""" + return [] + + +@plain_router.get("/widgets") +def list_widgets(f: WidgetFilter = Depends(WidgetFilter), c: ColorFilter = Depends(), + ok: bool = Depends(quota), s: ShapeDep = None): + """A class, the class shortcut, an instance, and the shortcut through an alias. + + WidgetFilter.__init__, ColorFilter.__init__, QuotaCheck.__call__ and + ShapeFilter.__init__; NOT RateCheck.__call__, and NOT check_region: this router + has no dependency list. + """ + return f, c, ok, s + + +@plain_router.get("/kw") +def by_keyword(owner: dict = Depends(dependency=load_owner)): + """The provider passed by keyword.""" + return owner + + +# ── NOT injection, and each would be if one condition were dropped ────────── +@plain_router.get("/meta") +def plain_metadata(n: Annotated[int, "a note"], m: Annotated[dict, load_user]): + """Annotated metadata with no marker in it: a string, and a bare def.""" + return n, m + + +def bare_untyped(x=Depends()): + """The class shortcut with no annotation names no class.""" + return x diff --git a/graph/test/python/cases/41-di-marker-forms/src/other.py b/graph/test/python/cases/41-di-marker-forms/src/other.py new file mode 100644 index 00000000..b5da2ab0 --- /dev/null +++ b/graph/test/python/cases/41-di-marker-forms/src/other.py @@ -0,0 +1,15 @@ +"""An alias with the SAME NAME as one in deps.py, marking a different provider. + +handlers.py imports `CartDep` from deps, so check_other must stay unreached: the +alias is followed through the import, never matched by its name. +""" +from typing import Annotated + +from fastapi import Depends + + +def check_other(): + return None + + +CartDep = Annotated[dict, Depends(check_other)] diff --git a/graph/test/python/cases/42-signal-forms/src/handlers.py b/graph/test/python/cases/42-signal-forms/src/handlers.py new file mode 100644 index 00000000..76b0f650 --- /dev/null +++ b/graph/test/python/cases/42-signal-forms/src/handlers.py @@ -0,0 +1,45 @@ +"""Every documented way to attach a receiver to a signal declared in another module.""" +from django.dispatch import receiver + +import signals +from signals import order_paid, order_placed, order_shipped + + +@receiver(order_placed) +def on_placed(sender, **kw): + return sender + + +@receiver([order_placed, order_shipped]) +def audit(sender, **kw): + """One receiver, a LIST of signals: it runs for each of them.""" + return sender + + +@receiver(order_paid) +def on_paid(sender, **kw): + return sender + + +@receiver(signals.order_shipped) +def on_shipped_attr(sender, **kw): + """The signal named through its module.""" + return sender + + +def on_closed(sender, **kw): + return sender + + +signals.order_closed.connect(on_closed) + + +# ── NOT receivers, and each would be if one condition were dropped ────────── +def remember(items): + return lambda f: f + + +@remember([order_placed, order_shipped]) +def not_a_receiver(sender, **kw): + """A list of signals handed to a decorator that is not a receiver decorator.""" + return sender diff --git a/graph/test/python/cases/42-signal-forms/src/orders.py b/graph/test/python/cases/42-signal-forms/src/orders.py new file mode 100644 index 00000000..0c8fcf35 --- /dev/null +++ b/graph/test/python/cases/42-signal-forms/src/orders.py @@ -0,0 +1,34 @@ +"""The publishers, in a third module: a name, a module attribute, and the async form.""" +import signals +from signals import order_paid, order_placed, order_voided + + +def place(order): + order_placed.send(sender=order) + + +def ship(order): + signals.order_shipped.send(sender=order) + + +async def pay(order): + await order_paid.asend(sender=order) + + +def close(order): + signals.order_closed.send_robust(sender=order) + + +def void(order): + """A send nothing is attached to: unjoined, as is the Outbox control below.""" + order_voided.send(sender=order) + + +class Outbox: + async def asend(self, sender, **kw): + return sender + + +async def not_a_signal(outbox: Outbox, order): + """An ordinary `asend`: the receiver is not a signal, so no edge (it is listed unjoined).""" + await outbox.asend(sender=order) diff --git a/graph/test/python/cases/42-signal-forms/src/signals.py b/graph/test/python/cases/42-signal-forms/src/signals.py new file mode 100644 index 00000000..0f5db17e --- /dev/null +++ b/graph/test/python/cases/42-signal-forms/src/signals.py @@ -0,0 +1,25 @@ +"""Signals declared in their own module, which every publisher and receiver imports. + +The stand-in class keeps the case self-contained: nothing from the framework is staged. +""" + + +class Signal: + def send(self, sender, **kw): + return sender + + def send_robust(self, sender, **kw): + return sender + + async def asend(self, sender, **kw): + return sender + + def connect(self, fn, sender=None, **kw): + return fn + + +order_placed = Signal() +order_shipped = Signal() +order_paid = Signal() +order_closed = Signal() +order_voided = Signal() diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.framework b/graph/test/python/expected/21-url-and-signal-dispatch.framework index 24cf8eba..620020a4 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.framework +++ b/graph/test/python/expected/21-url-and-signal-dispatch.framework @@ -7,8 +7,8 @@ url_dispatch registered main. (main.py) -> main.legacy (main.py) [route_table] url_dispatch registered urls. (urls.py) -> views.profile (views.py) [/profile//] url_dispatch registered urls. (urls.py) -> views.settings_page (views.py) [/settings/] -── framework_unjoined (2) ── - 2 signal_dispatch no_receiver +── framework_unjoined (1) ── + 1 signal_dispatch no_receiver ── remote_edge (0) ── ── remote_unserved (0) ── ── remote_unsent (0) ── diff --git a/graph/test/python/expected/29-framework-edge-consumers.framework b/graph/test/python/expected/29-framework-edge-consumers.framework index 5c99eef8..6d03b6c1 100644 --- a/graph/test/python/expected/29-framework-edge-consumers.framework +++ b/graph/test/python/expected/29-framework-edge-consumers.framework @@ -2,8 +2,7 @@ signal_dispatch registered app.models.Order.place (app/models.py) -> app.handlers.on_placed (app/handlers.py) [signal] task_dispatch registered app.producer.enqueue (app/producer.py) -> app.tasks.send_report (app/tasks.py) [delay] task_dispatch registered app.producer.reschedule (app/producer.py) -> app.tasks.retry_report (app/tasks.py) [apply_async] -── framework_unjoined (3) ── - 1 signal_dispatch no_receiver +── framework_unjoined (2) ── 2 task_dispatch delay ── remote_edge (0) ── ── remote_unserved (0) ── diff --git a/graph/test/python/expected/37-model-signals.edges b/graph/test/python/expected/37-model-signals.edges new file mode 100644 index 00000000..a50f46f3 --- /dev/null +++ b/graph/test/python/expected/37-model-signals.edges @@ -0,0 +1,12 @@ +ambiguous_unknown DECORATOR_APPLICATION models. -> - +ambiguous_unknown METHOD_CALL models.Invoice. -> - +ambiguous_unknown METHOD_CALL models.Order. -> - +ambiguous_unknown METHOD_CALL views.create_order -> - +ambiguous_unknown METHOD_CALL views.drop_invoice -> - +ambiguous_unknown METHOD_CALL views.drop_order -> - +ambiguous_unknown METHOD_CALL views.rename_order -> - +ambiguous_unknown METHOD_CALL views.save_draft -> - +ambiguous_unknown METHOD_CALL views.touch_invoice -> - +boundary_lib DECORATOR_CALL models. -> external:receiver +boundary_lib METHOD_CALL models. -> external:post_save.connect +known_edge METHOD_CALL views.save_cache -> views.Cache.save diff --git a/graph/test/python/expected/37-model-signals.entries b/graph/test/python/expected/37-model-signals.entries new file mode 100644 index 00000000..1ba1a65f --- /dev/null +++ b/graph/test/python/expected/37-model-signals.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + signal_receiver models.audit_invoice models.py:32 + signal_receiver models.audit_order models.py:19 + signal_receiver models.before_order_delete models.py:27 diff --git a/graph/test/python/expected/37-model-signals.framework b/graph/test/python/expected/37-model-signals.framework new file mode 100644 index 00000000..93506d14 --- /dev/null +++ b/graph/test/python/expected/37-model-signals.framework @@ -0,0 +1,9 @@ +── framework_edge (4) ── + signal_dispatch registered views.create_order (views.py) -> models.audit_order (models.py) [post_save] + signal_dispatch registered views.drop_order (views.py) -> models.before_order_delete (models.py) [pre_delete] + signal_dispatch registered views.rename_order (views.py) -> models.audit_order (models.py) [post_save] + signal_dispatch registered views.touch_invoice (views.py) -> models.audit_invoice (models.py) [post_save] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/37-model-signals.tiers b/graph/test/python/expected/37-model-signals.tiers new file mode 100644 index 00000000..a7e08a5c --- /dev/null +++ b/graph/test/python/expected/37-model-signals.tiers @@ -0,0 +1,30 @@ +distinct call sites emitted: 14 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 10 rows 10 sites ambiguous_unknown + 3 rows 3 sites boundary_lib + 1 rows 1 sites known_edge + +--- edge rows by call kind --- + 2 DECORATOR_APPLICATION + 2 DECORATOR_CALL + 10 METHOD_CALL + +--- unresolved reasons --- + 2 decorator_factory_result_untyped + 4 escape_hatch + 1 untyped_receiver:class_attribute_absent + 2 untyped_receiver:local_untyped + 1 untyped_receiver:parameter + +--- the engine's own conservation ledger --- + 14 _total_sites + 10 ambiguous_unknown + 3 boundary_lib + 1 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 14 + minus extra rows from multi-target sites 0 + = tier/site pairs 14 + engine's conserved site total 14 diff --git a/graph/test/python/expected/38-app-wiring.edges b/graph/test/python/expected/38-app-wiring.edges new file mode 100644 index 00000000..89dad32f --- /dev/null +++ b/graph/test/python/expected/38-app-wiring.edges @@ -0,0 +1,22 @@ +ambiguous_unknown DECORATOR_APPLICATION main. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE main. -> - +ambiguous_unknown DECORATOR_BARE main. -> - +ambiguous_unknown SIMPLE_CALL main.Timing.dispatch -> - +ambiguous_unknown SIMPLE_CALL main.add_header -> - +ambiguous_unknown SIMPLE_CALL shop.jobs.nightly -> - +ambiguous_unknown SIMPLE_CALL shop.jobs.unknown -> - +boundary_lib DECORATOR_CALL main. -> external:APIRouter.api_route +boundary_lib DECORATOR_CALL main. -> external:FastAPI.exception_handler +boundary_lib DECORATOR_CALL main. -> external:FastAPI.middleware +boundary_lib DECORATOR_CALL main. -> external:FastAPI.on_event +boundary_lib METHOD_CALL main. -> external:APIRouter.add_api_route +boundary_lib METHOD_CALL main. -> external:FastAPI.add_middleware +boundary_lib SIMPLE_CALL main. -> builtin:object.__init__ +boundary_lib SIMPLE_CALL main. -> external:APIRouter +boundary_lib SIMPLE_CALL main. -> external:FastAPI +boundary_lib SIMPLE_CALL main. -> external:Route +boundary_lib SIMPLE_CALL main. -> external:Starlette +boundary_lib SIMPLE_CALL main. -> external:WebSocketRoute +known_edge METHOD_CALL main. -> main.Registry.add_route +known_edge SIMPLE_CALL main. -> main.configure +known_edge SIMPLE_CALL shop.management.commands.close_orders.Command.handle -> shop.orders.close_all diff --git a/graph/test/python/expected/38-app-wiring.entries b/graph/test/python/expected/38-app-wiring.entries new file mode 100644 index 00000000..566d43cc --- /dev/null +++ b/graph/test/python/expected/38-app-wiring.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + cli shop.management.commands.close_orders.Command.handle shop/management/commands/close_orders.py:8 + http main.bulk main.py:55 + http main.cancel main.py:59 + lifecycle main.Timing.dispatch main.py:25 + lifecycle main.add_header main.py:37 + lifecycle main.boot main.py:47 + lifecycle main.lifespan main.py:17 + lifecycle main.on_value_error main.py:42 + url main.Widgets.get main.py:67 + url main.Widgets.post main.py:70 + url main.feed main.py:78 + url main.health main.py:82 diff --git a/graph/test/python/expected/38-app-wiring.framework b/graph/test/python/expected/38-app-wiring.framework new file mode 100644 index 00000000..8c42c13f --- /dev/null +++ b/graph/test/python/expected/38-app-wiring.framework @@ -0,0 +1,10 @@ +── framework_edge (5) ── + command_dispatch registered shop.jobs.nightly (shop/jobs.py) -> shop.management.commands.close_orders.Command.handle (shop/management/commands/close_orders.py) [close_orders] + url_dispatch registered main. (main.py) -> main.Widgets.get (main.py) [/widgets] + url_dispatch registered main. (main.py) -> main.Widgets.post (main.py) [/widgets] + url_dispatch registered main. (main.py) -> main.feed (main.py) [/feed] + url_dispatch registered main. (main.py) -> main.health (main.py) [/health] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/38-app-wiring.tiers b/graph/test/python/expected/38-app-wiring.tiers new file mode 100644 index 00000000..4df06d8a --- /dev/null +++ b/graph/test/python/expected/38-app-wiring.tiers @@ -0,0 +1,32 @@ +distinct call sites emitted: 26 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 10 rows 10 sites ambiguous_unknown + 13 rows 13 sites boundary_lib + 3 rows 3 sites known_edge + +--- edge rows by call kind --- + 4 DECORATOR_APPLICATION + 1 DECORATOR_ATTRIBUTE + 1 DECORATOR_BARE + 4 DECORATOR_CALL + 3 METHOD_CALL + 13 SIMPLE_CALL + +--- unresolved reasons --- + 2 callee_is_parameter + 4 decorator_factory_result_untyped + 2 no_rule + 2 unmodelled_decorator + +--- the engine's own conservation ledger --- + 26 _total_sites + 10 ambiguous_unknown + 13 boundary_lib + 3 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 26 + minus extra rows from multi-target sites 0 + = tier/site pairs 26 + engine's conserved site total 26 diff --git a/graph/test/python/expected/39-model-construction-hooks.edges b/graph/test/python/expected/39-model-construction-hooks.edges new file mode 100644 index 00000000..3c8b20b6 --- /dev/null +++ b/graph/test/python/expected/39-model-construction-hooks.edges @@ -0,0 +1,18 @@ +ambiguous_unknown DECORATOR_APPLICATION models.Widget. -> - +ambiguous_unknown DECORATOR_BARE models.Widget. -> - +ambiguous_unknown METHOD_CALL models.Widget.check_id -> - +ambiguous_unknown METHOD_CALL service.load_widget -> - +ambiguous_unknown SIMPLE_CALL service.make_widget -> - +boundary_lib DECORATOR_BARE models.Widget. -> builtin:classmethod +boundary_lib DECORATOR_CALL models.Widget. -> external:field_serializer +boundary_lib DECORATOR_CALL models.Widget. -> external:field_validator +boundary_lib DECORATOR_CALL models.Widget. -> external:validator +boundary_lib SIMPLE_CALL models.Gadget.check_size -> builtin:max +boundary_lib SIMPLE_CALL models.Gizmo. -> external:Field +boundary_lib SIMPLE_CALL models.Widget. -> external:Field +boundary_lib SIMPLE_CALL models.Widget.check_size -> builtin:max +boundary_lib SIMPLE_CALL models.Widget.dump_size -> builtin:str +boundary_lib SIMPLE_CALL service.make_gizmo -> builtin:object.__init__ +boundary_lib SIMPLE_CALL service.make_plain -> builtin:object.__init__ +known_edge SELF_CALL models.Gadget.__init__ -> models.Gadget.check_size +known_edge SIMPLE_CALL service.make_gadget -> models.Gadget.__init__ diff --git a/graph/test/python/expected/39-model-construction-hooks.entries b/graph/test/python/expected/39-model-construction-hooks.entries new file mode 100644 index 00000000..18c0f2d8 --- /dev/null +++ b/graph/test/python/expected/39-model-construction-hooks.entries @@ -0,0 +1,7 @@ +── entry_point (6) ── + orm_hook models.Widget.check_all models.py:30 + orm_hook models.Widget.check_id models.py:26 + orm_hook models.Widget.check_size models.py:22 + orm_hook models.Widget.dump models.py:39 + orm_hook models.Widget.dump_size models.py:34 + orm_hook models.Widget.model_post_init models.py:42 diff --git a/graph/test/python/expected/39-model-construction-hooks.framework b/graph/test/python/expected/39-model-construction-hooks.framework new file mode 100644 index 00000000..394cd852 --- /dev/null +++ b/graph/test/python/expected/39-model-construction-hooks.framework @@ -0,0 +1,21 @@ +── framework_edge (16) ── + model_hook registered service.load_widget (service.py) -> models.Widget.check_all (models.py) [root_validator] + model_hook registered service.load_widget (service.py) -> models.Widget.check_id (models.py) [validator] + model_hook registered service.load_widget (service.py) -> models.Widget.check_size (models.py) [field_validator] + model_hook registered service.load_widget (service.py) -> models.Widget.model_post_init (models.py) [model_post_init] + model_hook registered service.load_widget (service.py) -> models.new_id (models.py) [default_factory] + model_hook registered service.make_gizmo (service.py) -> models.Widget.check_all (models.py) [root_validator] + model_hook registered service.make_gizmo (service.py) -> models.Widget.check_id (models.py) [validator] + model_hook registered service.make_gizmo (service.py) -> models.Widget.check_size (models.py) [field_validator] + model_hook registered service.make_gizmo (service.py) -> models.Widget.model_post_init (models.py) [model_post_init] + model_hook registered service.make_gizmo (service.py) -> models.new_id (models.py) [default_factory] + model_hook registered service.make_gizmo (service.py) -> models.new_tags (models.py) [default_factory] + model_hook registered service.make_widget (service.py) -> models.Widget.check_all (models.py) [root_validator] + model_hook registered service.make_widget (service.py) -> models.Widget.check_id (models.py) [validator] + model_hook registered service.make_widget (service.py) -> models.Widget.check_size (models.py) [field_validator] + model_hook registered service.make_widget (service.py) -> models.Widget.model_post_init (models.py) [model_post_init] + model_hook registered service.make_widget (service.py) -> models.new_id (models.py) [default_factory] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/39-model-construction-hooks.tiers b/graph/test/python/expected/39-model-construction-hooks.tiers new file mode 100644 index 00000000..a4e4b8af --- /dev/null +++ b/graph/test/python/expected/39-model-construction-hooks.tiers @@ -0,0 +1,33 @@ +distinct call sites emitted: 21 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 8 rows 8 sites ambiguous_unknown + 11 rows 11 sites boundary_lib + 2 rows 2 sites known_edge + +--- edge rows by call kind --- + 3 DECORATOR_APPLICATION + 3 DECORATOR_BARE + 3 DECORATOR_CALL + 2 METHOD_CALL + 1 SELF_CALL + 9 SIMPLE_CALL + +--- unresolved reasons --- + 1 construction_of_dynamically_extended_class + 3 decorator_factory_result_untyped + 1 no_rule + 2 unmodelled_decorator + 1 untyped_receiver:parameter + +--- the engine's own conservation ledger --- + 21 _total_sites + 8 ambiguous_unknown + 11 boundary_lib + 2 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 21 + minus extra rows from multi-target sites 0 + = tier/site pairs 21 + engine's conserved site total 21 diff --git a/graph/test/python/expected/40-empty-route-path.edges b/graph/test/python/expected/40-empty-route-path.edges new file mode 100644 index 00000000..052b5acb --- /dev/null +++ b/graph/test/python/expected/40-empty-route-path.edges @@ -0,0 +1,11 @@ +ambiguous_unknown DECORATOR_APPLICATION main. -> - +boundary_lib DECORATOR_CALL main. -> external:mock.patch +boundary_lib SIMPLE_CALL main. -> builtin:object.__init__ +known_edge DECORATOR_CALL main. -> main._Router.delete +known_edge DECORATOR_CALL main. -> main._Router.get +known_edge DECORATOR_CALL main. -> main._Router.memoize +known_edge DECORATOR_CALL main. -> main._Router.patch +known_edge DECORATOR_CALL main. -> main._Router.post +known_edge DECORATOR_CALL main. -> main._Router.put +known_edge DECORATOR_CALL main. -> main._Router.route +known_edge DECORATOR_CALL main. -> main.get diff --git a/graph/test/python/expected/40-empty-route-path.entries b/graph/test/python/expected/40-empty-route-path.entries new file mode 100644 index 00000000..b0166047 --- /dev/null +++ b/graph/test/python/expected/40-empty-route-path.entries @@ -0,0 +1,6 @@ +── entry_point (5) ── + http main.create_order main.py:42 + http main.get_order main.py:58 + http main.list_orders main.py:37 + http main.list_widgets main.py:52 + http main.patch_orders main.py:47 diff --git a/graph/test/python/expected/40-empty-route-path.framework b/graph/test/python/expected/40-empty-route-path.framework new file mode 100644 index 00000000..b9cbca06 --- /dev/null +++ b/graph/test/python/expected/40-empty-route-path.framework @@ -0,0 +1,5 @@ +── framework_edge (0) ── +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/40-empty-route-path.tiers b/graph/test/python/expected/40-empty-route-path.tiers new file mode 100644 index 00000000..fa837ac6 --- /dev/null +++ b/graph/test/python/expected/40-empty-route-path.tiers @@ -0,0 +1,26 @@ +distinct call sites emitted: 23 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 10 rows 10 sites ambiguous_unknown + 4 rows 4 sites boundary_lib + 9 rows 9 sites known_edge + +--- edge rows by call kind --- + 10 DECORATOR_APPLICATION + 10 DECORATOR_CALL + 3 SIMPLE_CALL + +--- unresolved reasons --- + 10 decorator_factory_result_untyped + +--- the engine's own conservation ledger --- + 23 _total_sites + 10 ambiguous_unknown + 4 boundary_lib + 9 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 23 + minus extra rows from multi-target sites 0 + = tier/site pairs 23 + engine's conserved site total 23 diff --git a/graph/test/python/expected/41-di-marker-forms.edges b/graph/test/python/expected/41-di-marker-forms.edges new file mode 100644 index 00000000..0a449dee --- /dev/null +++ b/graph/test/python/expected/41-di-marker-forms.edges @@ -0,0 +1,10 @@ +ambiguous_unknown DECORATOR_APPLICATION handlers. -> - +boundary_lib DECORATOR_CALL handlers. -> external:APIRouter.get +boundary_lib DECORATOR_CALL handlers. -> external:Depends +boundary_lib SIMPLE_CALL deps. -> builtin:object.__init__ +boundary_lib SIMPLE_CALL deps. -> external:Depends +boundary_lib SIMPLE_CALL deps.ShapeFilter. -> external:Depends +boundary_lib SIMPLE_CALL handlers. -> external:APIRouter +boundary_lib SIMPLE_CALL handlers. -> external:Depends +boundary_lib SIMPLE_CALL handlers. -> external:FastAPI +boundary_lib SIMPLE_CALL other. -> external:Depends diff --git a/graph/test/python/expected/41-di-marker-forms.entries b/graph/test/python/expected/41-di-marker-forms.entries new file mode 100644 index 00000000..de8fa537 --- /dev/null +++ b/graph/test/python/expected/41-di-marker-forms.entries @@ -0,0 +1,19 @@ +── entry_point (18) ── + di_provider deps.ColorFilter.__init__ deps.py:45 + di_provider deps.QuotaCheck.__call__ deps.py:55 + di_provider deps.ShapeFilter.__init__ deps.py:50 + di_provider deps.WidgetFilter.__init__ deps.py:40 + di_provider deps.check_app deps.py:35 + di_provider deps.check_quota deps.py:31 + di_provider deps.check_region deps.py:27 + di_provider deps.load_cart deps.py:19 + di_provider deps.load_owner deps.py:15 + di_provider deps.load_shape_size deps.py:23 + di_provider deps.load_user deps.py:11 + http handlers.by_alias handlers.py:31 + http handlers.by_annotated handlers.py:25 + http handlers.by_decorator handlers.py:37 + http handlers.by_default handlers.py:19 + http handlers.by_keyword handlers.py:55 + http handlers.list_widgets handlers.py:43 + http handlers.plain_metadata handlers.py:62 diff --git a/graph/test/python/expected/41-di-marker-forms.framework b/graph/test/python/expected/41-di-marker-forms.framework new file mode 100644 index 00000000..4c301663 --- /dev/null +++ b/graph/test/python/expected/41-di-marker-forms.framework @@ -0,0 +1,19 @@ +── framework_edge (14) ── + di_provider registered deps.ShapeFilter.__init__ (deps.py) -> deps.load_shape_size (deps.py) [Depends] + di_provider registered handlers.by_alias (handlers.py) -> deps.check_region (deps.py) [Depends] + di_provider registered handlers.by_alias (handlers.py) -> deps.load_cart (deps.py) [Depends] + di_provider registered handlers.by_annotated (handlers.py) -> deps.check_region (deps.py) [Depends] + di_provider registered handlers.by_annotated (handlers.py) -> deps.load_owner (deps.py) [Depends] + di_provider registered handlers.by_decorator (handlers.py) -> deps.check_quota (deps.py) [Depends] + di_provider registered handlers.by_decorator (handlers.py) -> deps.check_region (deps.py) [Depends] + di_provider registered handlers.by_default (handlers.py) -> deps.check_region (deps.py) [Depends] + di_provider registered handlers.by_default (handlers.py) -> deps.load_user (deps.py) [Depends] + di_provider registered handlers.by_keyword (handlers.py) -> deps.load_owner (deps.py) [Depends] + di_provider registered handlers.list_widgets (handlers.py) -> deps.ColorFilter.__init__ (deps.py) [Depends] + di_provider registered handlers.list_widgets (handlers.py) -> deps.QuotaCheck.__call__ (deps.py) [Depends] + di_provider registered handlers.list_widgets (handlers.py) -> deps.ShapeFilter.__init__ (deps.py) [Depends] + di_provider registered handlers.list_widgets (handlers.py) -> deps.WidgetFilter.__init__ (deps.py) [Depends] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/41-di-marker-forms.tiers b/graph/test/python/expected/41-di-marker-forms.tiers new file mode 100644 index 00000000..85ad730a --- /dev/null +++ b/graph/test/python/expected/41-di-marker-forms.tiers @@ -0,0 +1,24 @@ +distinct call sites emitted: 32 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 7 rows 7 sites ambiguous_unknown + 25 rows 25 sites boundary_lib + +--- edge rows by call kind --- + 7 DECORATOR_APPLICATION + 8 DECORATOR_CALL + 17 SIMPLE_CALL + +--- unresolved reasons --- + 7 decorator_factory_result_untyped + +--- the engine's own conservation ledger --- + 32 _total_sites + 7 ambiguous_unknown + 25 boundary_lib + +--- reconciling rows against the conserved site count --- + edge rows 32 + minus extra rows from multi-target sites 0 + = tier/site pairs 32 + engine's conserved site total 32 diff --git a/graph/test/python/expected/42-signal-forms.edges b/graph/test/python/expected/42-signal-forms.edges new file mode 100644 index 00000000..c42f783d --- /dev/null +++ b/graph/test/python/expected/42-signal-forms.edges @@ -0,0 +1,11 @@ +ambiguous_unknown DECORATOR_APPLICATION handlers. -> - +ambiguous_unknown METHOD_CALL handlers. -> - +ambiguous_unknown METHOD_CALL orders.close -> - +ambiguous_unknown METHOD_CALL orders.pay -> - +ambiguous_unknown METHOD_CALL orders.place -> - +ambiguous_unknown METHOD_CALL orders.ship -> - +ambiguous_unknown METHOD_CALL orders.void -> - +boundary_lib DECORATOR_CALL handlers. -> external:receiver +boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ +known_edge DECORATOR_CALL handlers. -> handlers.remember +known_edge METHOD_CALL orders.not_a_signal -> orders.Outbox.asend diff --git a/graph/test/python/expected/42-signal-forms.entries b/graph/test/python/expected/42-signal-forms.entries new file mode 100644 index 00000000..34341537 --- /dev/null +++ b/graph/test/python/expected/42-signal-forms.entries @@ -0,0 +1,6 @@ +── entry_point (5) ── + signal_receiver handlers.audit handlers.py:14 + signal_receiver handlers.on_closed handlers.py:30 + signal_receiver handlers.on_paid handlers.py:20 + signal_receiver handlers.on_placed handlers.py:9 + signal_receiver handlers.on_shipped_attr handlers.py:25 diff --git a/graph/test/python/expected/42-signal-forms.framework b/graph/test/python/expected/42-signal-forms.framework new file mode 100644 index 00000000..f7bc2993 --- /dev/null +++ b/graph/test/python/expected/42-signal-forms.framework @@ -0,0 +1,12 @@ +── framework_edge (6) ── + signal_dispatch registered orders.close (orders.py) -> handlers.on_closed (handlers.py) [signal] + signal_dispatch registered orders.pay (orders.py) -> handlers.on_paid (handlers.py) [signal] + signal_dispatch registered orders.place (orders.py) -> handlers.audit (handlers.py) [signal] + signal_dispatch registered orders.place (orders.py) -> handlers.on_placed (handlers.py) [signal] + signal_dispatch registered orders.ship (orders.py) -> handlers.audit (handlers.py) [signal] + signal_dispatch registered orders.ship (orders.py) -> handlers.on_shipped_attr (handlers.py) [signal] +── framework_unjoined (2) ── + 2 signal_dispatch no_receiver +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/42-signal-forms.tiers b/graph/test/python/expected/42-signal-forms.tiers new file mode 100644 index 00000000..e28d1d9d --- /dev/null +++ b/graph/test/python/expected/42-signal-forms.tiers @@ -0,0 +1,29 @@ +distinct call sites emitted: 22 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 11 rows 11 sites ambiguous_unknown + 9 rows 9 sites boundary_lib + 2 rows 2 sites known_edge + +--- edge rows by call kind --- + 5 DECORATOR_APPLICATION + 5 DECORATOR_CALL + 7 METHOD_CALL + 5 SIMPLE_CALL + +--- unresolved reasons --- + 5 decorator_factory_result_untyped + 3 untyped_receiver:attribute_object_untyped + 3 untyped_receiver:local_untyped + +--- the engine's own conservation ledger --- + 22 _total_sites + 11 ambiguous_unknown + 9 boundary_lib + 2 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 22 + minus extra rows from multi-target sites 0 + = tier/site pairs 22 + engine's conserved site total 22 From 70ecacd5bf9e083ae5717128735c41a9d168eb0f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:28:40 -0700 Subject: [PATCH 053/258] csharp: type expressions through generic returns, locators, argument lambdas and library accessibility; file every type reference Fixes #1434, #1473, #1448, #1471, #1549, #1441, #1442, #1538 What was wrong - #1434: a C# type reference outside a base list had no file, because its owner (an expression, method, parameter, field) was looked up as a type; the impact rule then found no enclosing callable and dropped it, so a type registered only as `UseMiddleware()` or `AddSingleton()` was called local. - #1473: `new T()` of a class with no written constructor is classified known_implicit_ctor with no callee, so impact on the type listed no instantiation. - #1448, #1549: a call on the result of a method returning a type parameter (the declaring type's `T Create()` on `IFactory`, or the method's own `T Dep()`) lost its receiver and was labelled external:T.M. - #1471: `GetRequiredService()` and `GetService()` are listed as locators typed by their argument, but no expression-typing rule read that list. - #1441: a property read on a receiver with no type (`options.Value.MaxCount`) produced no site at all, so it was missing from impact, path and bound. - #1442: an implicitly typed lambda passed as an argument was left untyped, so calls on its parameter were by-name leads. - #1538: with --library, an internal member inherited from a library base won an unbound simple name over a client type of the same name. The change - axiomcode-index resolves a type reference's file through every row kind that can own one (expressions, methods, parameters, fields, properties, events, type parameters, variables, attributes, usings). - The engine names the type an implicit construction builds (ctor_implicit_type, exported); impact reads it as "instantiates it", verified against that relation. IMPACT_VERSION 41. - expr-type.dl substitutes a method's return type parameter from the call's written or inferred type argument, and a declaring type's parameter from the receiver's type arguments, mirroring the property and field clauses; it types a cs_di_resolve locator call from its single type argument unless the client declares a method of that name. - call_chain.dl keeps an unresolved member access on an untyped site receiver as an ambiguous_unknown property_read or property_write site; the bundle writes its accessor name (get_X, set_X) as the site's callee name so by-name lookups offer it. - lambda-parameters.dl takes an argument lambda's delegate type from the resolved callee's parameter at that position, only where the callee has no overload in its type and the parameter is not params; an extension called on a receiver shifts the position by one. - member-lookup.dl drops private, internal and private protected library members from every lookup that crosses from client code into a staged library. Tests - Engine cases 19-generic-method-return, 20-generic-type-method-return, 21-untyped-receiver-member and 22-argument-lambda-parameters, each with near-miss controls, scored against the compiler oracle. - Plugin cases a-type-named-outside-a-base-list, a-type-without-a-constructor-is-instantiated, a-located-service-is-typed-by-its-argument, an-untyped-property-read-is-a-lead, an-argument-lambda-takes-the-callee-parameter-type and an-internal-library-member-hides-nothing (tests/run.py gains a "library" key). unmodelled-entry-not-local now also expects the AddHostedService registration row that #1434 makes visible; its verdict is still NOT SAFE. - graph/test/csharp/run-tests.sh: 22 of 22 cases, the staging test and every tool test pass. tests/run.py --lang csharp: 80 of 83 checks pass on the rebased tree, 1 is pending and 2 fail; the two failures (lambda-is-named-by-its-place, and the member-owner-is-its-type pending marker that now passes) are known failures of the release branch, not of this change. Smoke (one dev project, 254 C# files, fresh index before and after) - type references carrying a file: 213 of 3337 before, 3337 of 3337 after. - implicit constructions with a named type: 0 before, 46 after; impact on one view model went from no producer to 4 resolved instantiations, each checked by reading the line. - unresolved property read sites kept: 40 more ambiguous_unknown rows; resolved and multi_inferred edge counts unchanged (743 and 79), so no edge was added or removed there. - The eight issue reproductions all answer as each issue expects. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- graph/bundle/build.ts | 7 + graph/bundle/languages.ts | 9 ++ .../engine/call-edge-generation/call_chain.dl | 77 ++++++++++ .../engine/expression-resolution/expr-type.dl | 134 ++++++++++++++++++ .../engine/resolution/lambda-parameters.dl | 43 ++++++ .../csharp/engine/resolution/member-lookup.dl | 29 +++- graph/csharp/souffle/decls_all.dl | 19 +++ graph/csharp/souffle/export_manifest.tsv | 2 + graph/test/csharp/README.md | 4 + .../src/19-generic-method-return.csproj | 7 + .../src/GenericMethodReturn.cs | 100 +++++++++++++ .../src/20-generic-type-method-return.csproj | 7 + .../src/GenericTypeMethodReturn.cs | 104 ++++++++++++++ .../src/21-untyped-receiver-member.csproj | 7 + .../src/UntypedReceiverMember.cs | 44 ++++++ .../src/22-argument-lambda-parameters.csproj | 7 + .../src/ArgumentLambdaParameters.cs | 45 ++++++ .../skills/axiomcode/scripts/axiomcode-impact | 9 +- .../skills/axiomcode/scripts/axiomcode-index | 58 +++++++- .../skills/axiomcode/scripts/dl/impact.dl | 5 + .../case.json | 16 +++ .../src/App.csproj | 9 ++ .../src/Orders.cs | 6 + .../src/Program.cs | 13 ++ .../case.json | 42 ++++++ .../src/App.csproj | 9 ++ .../src/Gadgets.cs | 37 +++++ .../src/Program.cs | 7 + .../src/Widgets.cs | 30 ++++ .../case.json | 18 +++ .../src/App.csproj | 6 + .../src/Orders.cs | 17 +++ .../case.json | 13 ++ .../src/App.csproj | 5 + .../src/Widgets.cs | 22 +++ .../app/App.cs | 9 ++ .../app/App.csproj | 8 ++ .../case.json | 9 ++ .../lib/Example.Kit/Example.Kit.csproj | 5 + .../lib/Example.Kit/Kit.cs | 8 ++ .../case.json | 16 +++ .../src/App.csproj | 8 ++ .../src/Widgets.cs | 16 +++ .../unmodelled-entry-not-local/case.json | 2 +- 44 files changed, 1038 insertions(+), 10 deletions(-) create mode 100644 graph/test/csharp/cases/19-generic-method-return/src/19-generic-method-return.csproj create mode 100644 graph/test/csharp/cases/19-generic-method-return/src/GenericMethodReturn.cs create mode 100644 graph/test/csharp/cases/20-generic-type-method-return/src/20-generic-type-method-return.csproj create mode 100644 graph/test/csharp/cases/20-generic-type-method-return/src/GenericTypeMethodReturn.cs create mode 100644 graph/test/csharp/cases/21-untyped-receiver-member/src/21-untyped-receiver-member.csproj create mode 100644 graph/test/csharp/cases/21-untyped-receiver-member/src/UntypedReceiverMember.cs create mode 100644 graph/test/csharp/cases/22-argument-lambda-parameters/src/22-argument-lambda-parameters.csproj create mode 100644 graph/test/csharp/cases/22-argument-lambda-parameters/src/ArgumentLambdaParameters.cs create mode 100644 tests/cases/csharp/a-located-service-is-typed-by-its-argument/case.json create mode 100644 tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/App.csproj create mode 100644 tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Orders.cs create mode 100644 tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Program.cs create mode 100644 tests/cases/csharp/a-type-named-outside-a-base-list/case.json create mode 100644 tests/cases/csharp/a-type-named-outside-a-base-list/src/App.csproj create mode 100644 tests/cases/csharp/a-type-named-outside-a-base-list/src/Gadgets.cs create mode 100644 tests/cases/csharp/a-type-named-outside-a-base-list/src/Program.cs create mode 100644 tests/cases/csharp/a-type-named-outside-a-base-list/src/Widgets.cs create mode 100644 tests/cases/csharp/a-type-without-a-constructor-is-instantiated/case.json create mode 100644 tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/App.csproj create mode 100644 tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/Orders.cs create mode 100644 tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/case.json create mode 100644 tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/App.csproj create mode 100644 tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/Widgets.cs create mode 100644 tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.cs create mode 100644 tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.csproj create mode 100644 tests/cases/csharp/an-internal-library-member-hides-nothing/case.json create mode 100644 tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Example.Kit.csproj create mode 100644 tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Kit.cs create mode 100644 tests/cases/csharp/an-untyped-property-read-is-a-lead/case.json create mode 100644 tests/cases/csharp/an-untyped-property-read-is-a-lead/src/App.csproj create mode 100644 tests/cases/csharp/an-untyped-property-read-is-a-lead/src/Widgets.cs diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts index c2a515f6..072e9594 100644 --- a/graph/bundle/build.ts +++ b/graph/bundle/build.ts @@ -529,6 +529,13 @@ export async function buildCore(inp: BuildInputs): Promise { } } } + // a site the IR writes no name for, where the engine derived the accessor it calls (#1441) + if (A.raw.siteNames) { + for (const [site, name] of await readSource(rawDir, A.raw.siteNames)) { + const row = sites.get(site as string); + if (row) fill(row, 3, nul(name as string)); + } + } // a site keyed on a type (Python METACLASS_CREATION) is positioned at the class declaration for (const row of sites.values()) { if (row[5] !== null) continue; diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index dc86c997..286ec18a 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -149,6 +149,13 @@ export interface LanguageAdapter { * relation and absent from `fields`, so the join lost the row silently. */ configBinding?: RawSource; + /** + * (site, name), #1441. The accessor name of a site the IR gives no written name, where + * the engine knows it: a property read on a receiver with no type is a site with no + * target, and without the name a by-name lookup cannot offer it. Fills callee_name only + * where the IR left it NULL. + */ + siteNames?: RawSource; }; ir: { methods: MethodsIR; @@ -379,6 +386,8 @@ const CSHARP: LanguageAdapter = { // site, caller, field, fieldProvenance, tier, access: the Java shape (#1445). Only resolved // sites have a row; a property is a call and stays in call edges through its accessor. fieldAccess: { file: 'field-access.csv', columns: [0, 1, 2, 3, 4, 5] }, + // (site, recv, accessor name): an unresolved member access, get_X or set_X + siteNames: { file: 'site-unresolved-member.csv', columns: [0, 2] }, }, ir: { // A C# method row carries BOTH its module and its type, and the type is empty for a diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index 18ded41f..b44340cf 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -134,6 +134,17 @@ call_class(prov, e, "ambiguous_dynamic") :- call_class(prov, e, "known_implicit_ctor") :- call_target_count(prov, e, 0), call_ctor_implicit(prov, e, _). +// ...AND WHICH TYPE IT CONSTRUCTS. The edge above has no callee, truthfully: there is +// no constructor to point at. But the site still instantiates a type the engine +// knows, and without the type a consumer asking "who creates SystemClock" found +// nothing, while the same `new` of a class that writes a constructor is an edge to +// that constructor (#1473). One row per declaration of the type (each part of a +// partial class), keyed on the site, so it joins call_sites without touching the +// edge table. +ctor_implicit_type(e, t) :- + call_class("client", e, "known_implicit_ctor"), + call_ctor_implicit("client", e, gk), type_group("client", t, gk). + // AN OPERATOR OR CAST THAT RAN NO USER CODE. `i + 1`, `s == null`, `(int)d`: the // operator is one of the language's built-ins and the cast is a numeric or // reference conversion. There is no method anywhere -- System.Int32 declares no @@ -352,6 +363,72 @@ call_chain_edge(e, caller, "-", label, "external", "boundary_lib", "indexer") :- external_element_access("client", e, label), call_from_expr("client", e, caller). +// A MEMBER ACCESS ON A RECEIVER THAT IS A SITE WITH NO TYPE. `options.Value.MaxCount` +// on an IOptions the run was not given: `options.Value` is labelled +// above, and nothing says what it returns, so `.MaxCount` has no member to look up +// and no type to name. It reached the output as no row at all, while the sibling +// `options.Value.Clamp()` is an ambiguous_unknown invocation, so impact dropped the +// reader from its answer and from its `bound:` (#1441). +// +// THE RECEIVER MUST BE A SITE, not merely an untyped expression. A namespace +// qualifier (`System.Text` in `System.Text.Encoding.UTF8`) is an untyped member +// access too, and it is not a call. A call, a property read or an indexer is one, and +// its result having no type is exactly the case this covers. Recursive, so every +// link of `a.B.C.D` past the first untyped one is its own site. +// +// No target. The accessor NAME is kept (get_ for a read or read-write, set_ for a +// write) in site_unresolved_member, which the bundle writes as the site's callee_name, +// so a by-name lookup offers the reader as a lead exactly as it offers a method call +// on the same receiver. +member_access_site_untyped(prov, q) :- + csite(prov, q), !expr_type(prov, q, _). +member_access_site_untyped(prov, q) :- + external_property_read(prov, q, _), !expr_type(prov, q, _). +member_access_site_untyped(prov, q) :- + external_element_access(prov, q, _), !expr_type(prov, q, _). +member_access_site_untyped(prov, q) :- + unresolved_member_access(prov, q, _, _). +// ...and through the syntax that passes a value on unchanged: `f()!.X`, `(f()).X`. +member_access_site_untyped(prov, e) :- + expr_kind(prov, e, "UNARY"), expr_operator(prov, e, "!"), + expr_child(prov, e, "UNARY_OPERAND", _, x), member_access_site_untyped(prov, x). +member_access_site_untyped(prov, e) :- + expr_kind(prov, e, "PARENTHESIZED"), !expr_type(prov, e, _), + expr_child(prov, e, "PARENTHESIZED_OPERAND", _, x), member_access_site_untyped(prov, x). + +unresolved_member_access(prov, acc, n, kind) :- + expr_kind(prov, acc, "MEMBER_ACCESS"), + !member_access_resolved(prov, acc), + !external_property_read(prov, acc, _), + !dynamic_property_read(prov, acc), + expr_qualifier_child(prov, acc, q), + member_access_site_untyped(prov, q), + expr_member_name_child(prov, acc, nameExpr), + expr_written_name(prov, nameExpr, n), + unresolved_member_access_kind(prov, acc, kind). +// A compound assignment (`x.N += 1`) is kept as the read: one site, one name. +unresolved_member_access_kind(prov, acc, "property_read") :- + expr_kind(prov, acc, "MEMBER_ACCESS"), !expr_is_write_target(prov, acc). +unresolved_member_access_kind(prov, acc, "property_write") :- + expr_kind(prov, acc, "MEMBER_ACCESS"), expr_is_write_target(prov, acc). + +// (site, receiver text, accessor name), in the shape of site_unresolved_named: the +// receiver text groups the failures, twenty reads off one untyped value being one gap. +unresolved_member_accessor(acc, cat("get_", n)) :- + unresolved_member_access("client", acc, n, "property_read"). +unresolved_member_accessor(acc, cat("set_", n)) :- + unresolved_member_access("client", acc, n, "property_write"). +site_unresolved_member(acc, recv, name) :- + unresolved_member_accessor(acc, name), + expr_qualifier_child("client", acc, q), expr_written_name("client", q, recv). +site_unresolved_member(acc, "", name) :- + unresolved_member_accessor(acc, name), + expr_qualifier_child("client", acc, q), !expr_written_name("client", q, _). + +call_chain_edge(acc, caller, "-", "-", "-", "ambiguous_unknown", kind) :- + unresolved_member_access("client", acc, _, kind), + call_from_expr("client", acc, caller). + // A PROPERTY READ THROUGH `dynamic`. The same declared blind spot as a call through // `dynamic`, and it reaches the output for the same reason: a site that produced no // row is indistinguishable from one the engine never saw. No target, because there diff --git a/graph/csharp/engine/expression-resolution/expr-type.dl b/graph/csharp/engine/expression-resolution/expr-type.dl index 1814228c..5c962379 100644 --- a/graph/csharp/engine/expression-resolution/expr-type.dl +++ b/graph/csharp/engine/expression-resolution/expr-type.dl @@ -274,6 +274,140 @@ expr_new_type(prov, e, gk) :- expr_type(prov, e, gk) :- expr_target_any(prov, e, m), method_return_type_group(prov, e2gk, gk), e2gk = m. +// ── ...AND WHERE THAT RETURN TYPE IS THE METHOD'S OWN TYPE PARAMETER ──────── +// `T Dep()` and `T Echo(T value)`: the return type is a reference to a type +// parameter, which resolves to no type on purpose (generics.dl), so the clause above +// types `_p.Dep()` as nothing and `.Spin()` on it was labelled external:T.Spin +// (#1549). The CALL says what T is, in one of two places, and both are the compiler's +// own answer rather than an approximation of it: +// +// EXPLICIT: the type argument written at the call, matched to the parameter by +// POSITION, as type_arg_for does for a type's parameters. +// INFERRED: with no type argument written, the static type of the argument passed +// to a parameter declared exactly `T`. An argument typed as a set gives the set. +// +// THE RETURN TYPE MUST BE `T` ITSELF. `T[]` and `List` HAVE the parameter and +// are not it; typing either as T would make the call's result its element. +// THE PARAMETER MUST BE THE METHOD'S. A method of a generic TYPE returning the +// type's `T` is #1448, answered by the receiver's type arguments, not the call's. +// NOT INFERRED FOR AN EXTENSION METHOD, whose receiver is parameter 0 and not an +// ARGUMENT child: every ordinal would be one off, and `Pick(this Foo f, T a, Bar b)` +// would take T from `b`. Nor through a named argument, a `params` parameter or a +// `T[]` parameter, where the ordinal or the element does not line up. +gm_return_param(prov, m, tp) :- + method_return_type_ref(prov, m, tr), type_ref_type_param(prov, tr, tp), + !type_ref_array_rank(prov, tr, _), + type_param_owner(prov, tp, m, "METHOD"). + +// A type argument written at the call: `Dep()`, root references only. +call_method_type_arg(prov, e, pos, tr) :- + type_ref_owner(prov, tr, e, "EXPRESSION"), type_ref_context(prov, tr, "METHOD_TYPE_ARGUMENT"), + !type_ref_parent(prov, tr, _, _, _), + type_ref_root_position(prov, tr, pos). + +expr_type(prov, e, gk) :- + expr_target_any(prov, e, m), gm_return_param(prov, m, tp), + type_param_decl(prov, m, "METHOD", pos, _, tp), + call_method_type_arg(prov, e, pos, atr), type_ref_resolves(prov, atr, gk). +// A type argument that is itself a type parameter in scope, `Dep()` inside a +// method `where TW : Widget`, is typed by its constraint, as a value declared TW is. +expr_type(prov, e, gk) :- + expr_target_any(prov, e, m), gm_return_param(prov, m, tp), + type_param_decl(prov, m, "METHOD", pos, _, tp), + call_method_type_arg(prov, e, pos, atr), + type_ref_type_param(prov, atr, atp), type_param_bound(prov, atp, gk). + +// NOT INFERRED BESIDE A NON-GENERIC TWIN: `T Echo(T v)` and `Widget Echo(Gadget g)` +// in one type, called `Echo(new Gadget())`. Both are applicable with the same parameter +// types and C# picks the non-generic one, but overload selection may keep both, and +// the generic's inferred T would then give the call a second result type. The twin +// is found by name and parameter count on the declaration alone, so the guard is +// conservative: it gives up the inference rather than guessing which one wins. +gm_plain_twin(prov, m) :- + gm_return_param(prov, m, _), + method_in_type(prov, gk, m), method_name(prov, m, n), method_paramc(prov, m, pc), + method_in_type(prov, gk, m2), method_name(prov, m2, n), method_paramc(prov, m2, pc), + method_type_arity(prov, m2, "0"). + +// A parameter declared exactly the method's type parameter, by ordinal. +gm_infer_param(prov, m, k) :- + gm_return_param(prov, m, tp), + !method_is_extension(prov, m), !gm_plain_twin(prov, m), + param_decl(prov, m, pos, _, p), param_type_ref(prov, p, ptr), + type_ref_type_param(prov, ptr, tp), !type_ref_array_rank(prov, ptr, _), + !param_is_params(prov, p), + k = to_number(pos). + +// The argument's ORDINAL among the ARGUMENT children, as overload-args.dl counts it, +// at a site with no type argument written and no named argument. +gm_arg_at(prov, e, k, a) :- + call_type_argc(prov, e, "0"), !call_has_named_args(prov, e), + expr_argument(prov, e, pos, a), p = to_number(pos), + k = count : { expr_argument(prov, e, q, _), to_number(q) < p }. + +expr_type(prov, e, gk) :- + expr_target_any(prov, e, m), gm_infer_param(prov, m, k), + gm_arg_at(prov, e, k, a), expr_type(prov, a, gk). + +// ── ...AND A SERVICE LOCATOR, TYPED BY THE TYPE IT IS ASKED FOR ──────────── +// `sp.GetRequiredService()` returns an IOrderJob, and the knob table +// already says so (framework-behavior/knobs.dl, cs_di_resolve). The method itself is +// an extension in a package the run is rarely given, so it resolves to nothing and +// the clauses above never see its `T`: `job.Run()` on the result was a by-name lead +// and `sp.GetRequiredService().Start()` had no receiver type (#1471). +// Typed here from the ONE type argument written at the call, the same argument the +// explicit clause above reads for an in-source generic method, so a staged +// declaration of the locator, whose return is its own `T`, gives the same answer. +// NOT for a name the client declares itself: a project's own `GetService()` +// is typed by its own declaration, whatever it returns. The non-generic +// `GetService(typeof(T))` returns object and is typed by the cast around it. +expr_type(prov, e, gk) :- + call_callee_name(prov, e, n), cs_di_resolve(n), !cs_di_resolve_declared(n), + call_type_argc(prov, e, "1"), + call_method_type_arg(prov, e, "0", atr), type_ref_resolves(prov, atr, gk). +cs_di_resolve_declared(n) :- cs_di_resolve(n), method_name("client", _, n). + +// ── ...AND WHERE IT IS THE DECLARING TYPE'S PARAMETER ─────────────────────── +// `T Create()` on `interface IFactory`: here the RECEIVER says what T is, not the +// call. `IFactory f; f.Create().Count()` typed the call as the parameter, +// so `.Count()` was labelled external:T.Count (#1448), while `f.Current.Count()` +// through a property declared `T` resolved: member_type_substituted below does this +// for properties and fields, and this is the same substitution for a method's return. +// It is the shape of `IDbContextFactory.CreateDbContext()`. +// +// Each clause mirrors one of the property clauses below, so the two cannot disagree +// about which argument fills which parameter: the receiver's own reference +// (type_arg_for), a generic base the reference's type passed its parameter to +// (base_param_fill), and a base closed with a concrete argument (type_arg_fixed). +// As there, the return type must be `T` itself, not `T[]` or `List`, and the +// parameter is matched through its OWNER'S group rather than its spelling alone, so +// a derived type that renames the parameter substitutes nothing by coincidence. +decl_return_param(prov, m, ownerGk, pname) :- + method_return_type_ref(prov, m, tr), type_ref_type_param(prov, tr, tp), + !type_ref_array_rank(prov, tr, _), + type_param_owner(prov, tp, t, "TYPE"), type_param_name(prov, tp, pname), + type_group(prov, t, ownerGk). +// A staged generic type's method, called from the client: the declaration is the +// library's and the reference that supplies the argument is the client's, as for +// `IOptions.Value` below. +decl_return_param("client", m, ownerGk, pname) :- decl_return_param("lib", m, ownerGk, pname). + +expr_type(prov, e, argGk) :- + expr_target_any(prov, e, m), decl_return_param(prov, m, gk, pname), + call_receiver_expr(prov, e, r), expr_type_via_ref(prov, r, tr), + type_ref_resolves(prov, tr, gk), + type_arg_for(prov, tr, pname, argGk). +expr_type(prov, e, argGk) :- + expr_target_any(prov, e, m), decl_return_param(prov, m, baseGk, basePname), + call_receiver_expr(prov, e, r), expr_type_via_ref(prov, r, tr), + type_ref_resolves(prov, tr, gk), type_group(prov, derivedT, gk), + base_param_fill(prov, derivedT, baseGk, basePname, derivedPname), + type_arg_for(prov, tr, derivedPname, argGk). +expr_type(prov, e, argGk) :- + expr_target_any(prov, e, m), decl_return_param(prov, m, baseGk, basePname), + call_recv_type(prov, e, recvGk), + type_arg_fixed(prov, recvGk, baseGk, basePname, argGk). + // A LIBRARY METHOD'S RETURN TYPE, READ FROM A CLIENT SITE. Resolved in the // LIBRARY's scope and then mirrored, rather than resolved in the client's: // `string` inside a library file means what that file's scope says it means, and diff --git a/graph/csharp/engine/resolution/lambda-parameters.dl b/graph/csharp/engine/resolution/lambda-parameters.dl index ecfc8ce5..bd74bde3 100644 --- a/graph/csharp/engine/resolution/lambda-parameters.dl +++ b/graph/csharp/engine/resolution/lambda-parameters.dl @@ -51,6 +51,49 @@ lambda_target_ref(prov, c, tr) :- expr_cast_ref(prov, e, tr), expr_child(prov, e // Through `?:`, `??`, parentheses and `!`. lambda_target_ref(prov, c, tr) :- lambda_target_ref(prov, p, tr), delegate_value_branch(prov, p, c). +// ── A LAMBDA PASSED AS AN ARGUMENT, TO A CALL THAT HAS ONE CANDIDATE ──────── +// `r.Setup(o => o.Reset())` with `Registry Setup(Action configure)`: +// the lambda is converted to the parameter it is passed to, so `o` is a +// WidgetOptions (#1442). The header's caveat is about OVERLOADS, where which +// parameter binds depends on the lambda; so this takes the parameter only where +// the callee has no overload to choose between: no other method of that name in +// its declaring type, and the parameter is not a `params` one. That is decided +// from the declarations alone, so it cannot feed back into the overload choice it +// would otherwise depend on. +// +// The ordinal is the argument's among the ARGUMENT children, with no named +// argument at the site (a named one may bind anywhere). An extension method called +// on a receiver (`services.Configure(o => ...)`) takes its receiver as parameter 0, +// so the argument at ordinal k fills parameter k + 1. +lambda_target_ref(prov, a, tr) :- + lambda_arg_at(prov, e, k, a), + expr_target_any(prov, e, m), lambda_callee_single(prov, m), !method_is_extension(prov, m), + lambda_callee_param_ref(prov, m, k, tr). +lambda_target_ref(prov, a, tr) :- + lambda_arg_at(prov, e, k, a), + expr_target_any(prov, e, m), lambda_callee_single(prov, m), method_is_extension(prov, m), + call_receiver_expr(prov, e, r), expr_type(prov, r, _), + lambda_callee_param_ref(prov, m, k + 1, tr). + +// A lambda among a call's ARGUMENT children, by ordinal, at a site with no named argument. +lambda_arg_at(prov, e, k, a) :- + expr_argument(prov, e, pos, a), expr_anon_decl(prov, a, _), + !call_has_named_args(prov, e), + p = to_number(pos), + k = count : { expr_argument(prov, e, q, _), to_number(q) < p }. + +lambda_callee_single(prov, m) :- + method_in_type(prov, gk, m), method_name(prov, m, n), + !lambda_callee_overloaded(prov, gk, n). +lambda_callee_overloaded(prov, gk, n) :- + method_in_type(prov, gk, m1), method_name(prov, m1, n), + method_in_type(prov, gk, m2), method_name(prov, m2, n), m1 != m2. + +// The root type reference of the callee's parameter at an ordinal, not a `params` one. +lambda_callee_param_ref(prov, m, k, tr) :- + param_decl(prov, m, pos, _, p), k = to_number(pos), + !param_is_params(prov, p), param_type_ref(prov, p, tr). + // A property's declared type, as a reference. property_type reads the NAME, which // drops the arguments `Func` is made of. lambda_property_type_ref(prov, p, tr) :- diff --git a/graph/csharp/engine/resolution/member-lookup.dl b/graph/csharp/engine/resolution/member-lookup.dl index 88f67303..6d3d4e41 100644 --- a/graph/csharp/engine/resolution/member-lookup.dl +++ b/graph/csharp/engine/resolution/member-lookup.dl @@ -43,7 +43,8 @@ member_cand(prov, gk, n, kind, mem, d) :- // The chain may cross into a staged library. member_cand("client", gk, n, kind, mem, d) :- type_base_chain("client", gk, bgk, d), - type_member_name("lib", bgk, n, kind, mem). + type_member_name("lib", bgk, n, kind, mem), + !lib_member_inaccessible(mem). // AND THE RECEIVER'S OWN TYPE MAY BE THE LIBRARY'S. `void M(Greeter g) => g.Hello()` // asks member_lookup("client", , "Hello"), and the members of that group @@ -52,7 +53,8 @@ member_cand("client", gk, n, kind, mem, d) :- // client simply holding one. Without it a call on a library-typed receiver resolves // to nothing even with the library staged and its name resolved. member_cand("client", gk, n, kind, mem, 0) :- - type_member_name("lib", gk, n, kind, mem). + type_member_name("lib", gk, n, kind, mem), + !lib_member_inaccessible(mem). // AND THE LIBRARY TYPE'S OWN BASE CHAIN. The clause above is distance 0 only, so // a client site saw a staged type's own members and none it inherits -- and the @@ -64,7 +66,28 @@ member_cand("client", gk, n, kind, mem, 0) :- // invisible. That is most of `object`: ToString, Equals, GetHashCode, GetType. member_cand("client", gk, n, kind, mem, d) :- type_base_chain("lib", gk, bgk, d), - type_member_name("lib", bgk, n, kind, mem). + type_member_name("lib", bgk, n, kind, mem), + !lib_member_inaccessible(mem). + +// ── A LIBRARY MEMBER THE CLIENT CANNOT ACCESS IS NOT A MEMBER TO IT ───────── +// The three clauses above cross from client code into a staged library, and C# +// member lookup removes every member that is not accessible at the site before it +// picks the nearest one (spec 12.5). A `private`, `internal` or `private protected` +// member of another assembly is never accessible from the client, so it neither +// resolves a client access nor hides anything: inside `Gadget : WidgetBase`, the +// unqualified `Limits` in `Limits.Allow(n)` is the client's own class `Limits`, not +// the library base's `internal List Limits` (#1538). Without it the inherited +// internal member won the simple-name lookup and the call went to a library getter. +// `protected` and `protected internal` stay: a derived client type may use them. +// InternalsVisibleTo is not modelled; an assembly that grants it is rare, and the +// cost there is a missed edge, not a wrong one. +lib_member_inaccessible(m) :- lib_cs_method(_, _, _, _, _, _, a, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, m), cs_access_outside_assembly_denied(a). +lib_member_inaccessible(p) :- lib_cs_property(_, _, _, _, _, a, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, p), cs_access_outside_assembly_denied(a). +lib_member_inaccessible(f) :- lib_cs_field(_, _, _, _, _, a, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, f), cs_access_outside_assembly_denied(a). +lib_member_inaccessible(ev) :- lib_cs_event(_, _, _, _, a, _, _, _, _, _, _, _, _, _, _, _, ev), cs_access_outside_assembly_denied(a). +cs_access_outside_assembly_denied("PRIVATE"). +cs_access_outside_assembly_denied("INTERNAL"). +cs_access_outside_assembly_denied("PRIVATE_PROTECTED"). // ── AN EXPLICIT INTERFACE IMPLEMENTATION IS EXCLUDED ──────────────────────── // It is not a member of the declaring type. Removed from the candidate set rather diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index d35e892a..2bb9352f 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -95,6 +95,7 @@ .decl call_is_unqualified(c0:symbol,c1:symbol) .decl call_kind(c0:symbol,c1:symbol,c2:symbol) .decl call_member_dispatches(c0:symbol,c1:symbol) +.decl call_method_type_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl call_module(c0:symbol,c1:symbol,c2:symbol) .decl call_names_bodiless(c0:symbol,c1:symbol) .decl call_needs_ctor_lookup(c0:symbol,c1:symbol) @@ -163,12 +164,14 @@ .decl conv_verb(c0:symbol,c1:symbol) .decl conversion_candidate_type(c0:symbol,c1:symbol,c2:symbol) .decl conversion_target_name(c0:symbol,c1:symbol,c2:symbol) +.decl cs_access_outside_assembly_denied(c0:symbol) .decl cs_alias_framework_type(c0:symbol,c1:symbol) .decl cs_amqp_bind(c0:symbol) .decl cs_amqp_publish(c0:symbol) .decl cs_attr_suffix(c0:symbol,c1:symbol) .decl cs_client_factory_generic(c0:symbol) .decl cs_di_resolve(c0:symbol) +.decl cs_di_resolve_declared(c0:symbol) .decl cs_ef_context_base(c0:symbol) .decl cs_ef_interceptor_register(c0:symbol) .decl cs_ef_save_call(c0:symbol,c1:symbol) @@ -261,6 +264,8 @@ .decl ctl_route_c(c0:symbol,c1:symbol,c2:symbol) .decl ctl_route_tok(c0:symbol,c1:symbol,c2:symbol) .decl ctor_accepts_argc(c0:symbol,c1:symbol,c2:symbol) +.decl ctor_implicit_type(c0:symbol,c1:symbol) +.decl decl_return_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl delegate_arg_expr(c0:symbol,c1:symbol) .decl delegate_group_demand(c0:symbol,c1:symbol) .decl delegate_group_method(c0:symbol,c1:symbol,c2:symbol) @@ -398,6 +403,10 @@ .decl generated_accessor_read(c0:symbol,c1:symbol,c2:symbol) .decl generated_property(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl generated_property_type(c0:symbol,c1:symbol,c2:symbol) +.decl gm_arg_at(c0:symbol,c1:symbol,c2:number,c3:symbol) +.decl gm_infer_param(c0:symbol,c1:symbol,c2:number) +.decl gm_plain_twin(c0:symbol,c1:symbol) +.decl gm_return_param(c0:symbol,c1:symbol,c2:symbol) .decl gp_bind_arg(c0:symbol,c1:symbol) .decl gp_bind_param(c0:symbol,c1:symbol,c2:number) .decl gp_c_nargs(c0:symbol,c1:number) @@ -497,7 +506,11 @@ .decl interp_step(c0:symbol,c1:number,c2:symbol) .decl invocation_site(c0:symbol,c1:symbol,c2:symbol) .decl invocation_site_classified(c0:symbol,c1:symbol) +.decl lambda_arg_at(c0:symbol,c1:symbol,c2:number,c3:symbol) .decl lambda_assigned_type_ref(c0:symbol,c1:symbol,c2:symbol) +.decl lambda_callee_overloaded(c0:symbol,c1:symbol,c2:symbol) +.decl lambda_callee_param_ref(c0:symbol,c1:symbol,c2:number,c3:symbol) +.decl lambda_callee_single(c0:symbol,c1:symbol) .decl lambda_delegate_in_source(c0:symbol,c1:symbol) .decl lambda_delegate_signature(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl lambda_param_arg_ref(c0:symbol,c1:symbol,c2:symbol) @@ -509,6 +522,7 @@ .decl lc(c0:symbol,c1:symbol) .decl lc_demand(c0:symbol) .decl lc_step(c0:symbol,c1:number,c2:symbol) +.decl lib_member_inaccessible(c0:symbol) .decl lib_type_named(c0:symbol,c1:symbol) .decl local_bind_best_depth(c0:symbol,c1:symbol,c2:number) .decl local_bind_cand(c0:symbol,c1:symbol,c2:symbol,c3:number) @@ -558,6 +572,7 @@ .decl member_access(c0:symbol,c1:symbol,c2:symbol) .decl member_access_is_callee(c0:symbol,c1:symbol) .decl member_access_resolved(c0:symbol,c1:symbol) +.decl member_access_site_untyped(c0:symbol,c1:symbol) .decl member_best_rank(c0:symbol,c1:symbol,c2:symbol,c3:number) .decl member_cand(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:number) .decl member_is_explicit_impl(c0:symbol,c1:symbol) @@ -843,6 +858,7 @@ .decl serves_destination(c0:symbol,c1:symbol,c2:symbol) .decl site_in_output(c0:symbol) .decl site_leaves_client(c0:symbol,c1:symbol) +.decl site_unresolved_member(c0:symbol,c1:symbol,c2:symbol) .decl site_unresolved_named(c0:symbol,c1:symbol,c2:symbol) .decl site_unresolved_real(c0:symbol) .decl sseg(c0:symbol,c1:number,c2:symbol) @@ -956,6 +972,9 @@ .decl type_struct_heritage_is_class(c0:symbol,c1:symbol,c2:symbol) .decl type_subtype(c0:symbol,c1:symbol,c2:symbol) .decl unbound_ref(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl unresolved_member_access(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl unresolved_member_access_kind(c0:symbol,c1:symbol,c2:symbol) +.decl unresolved_member_accessor(c0:symbol,c1:symbol) .decl url_tmpl(c0:symbol,c1:symbol) .decl using_alias(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl using_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) diff --git a/graph/csharp/souffle/export_manifest.tsv b/graph/csharp/souffle/export_manifest.tsv index 19fc1f9c..56f36d4f 100644 --- a/graph/csharp/souffle/export_manifest.tsv +++ b/graph/csharp/souffle/export_manifest.tsv @@ -40,3 +40,5 @@ type_ancestor_type resolution-type-ancestor-type.csv override_pair resolution-virtual-override.csv framework_edge framework-edge.csv field_access field-access.csv +site_unresolved_member site-unresolved-member.csv +ctor_implicit_type ctor-implicit-type.csv diff --git a/graph/test/csharp/README.md b/graph/test/csharp/README.md index b3af0241..530ecc59 100644 --- a/graph/test/csharp/README.md +++ b/graph/test/csharp/README.md @@ -35,6 +35,10 @@ run-tests.sh [--only NN-slug] [--verbose N] | `16-target-typed-new-external` | a target-typed `new(...)` whose target type is unstaged, in each context `target-typed-new.dl` reads (a field, a property, an explicit local, a return statement, an expression body, an assignment) and with a keyword-alias target (`string s = new('a', 3)`): each is `external:.`, as the explicit `new List()` already is (#1290). Controls: an in-source target type still resolves to its constructor | | `17-lambda-parameters` | a lambda parameter with no written type, typed by the delegate the lambda is converted to: a generic delegate declared in source closed at the field (`ParseFn`), a non-generic one, `Func` and `Action`, through a field, property and local initializer, `=`, `??=` and the branches of `?:`; and a value typed by a constrained type parameter, as a parameter and as the argument closing a delegate (`ParseFn` where `T : ISchema`) (#1283). Controls: two type arguments of different types on a declared delegate and on `Func`, whose parameters call a member of the same name on each, so crossed positions fan; an explicitly typed lambda and method. The calls THROUGH a delegate member (`inst.I.Run = inst.I.Parse`) are in `tools/assigned-delegate-member-test.sh` | | `18-mediator-send` | a mediator's `Send(request)` reaching the handler for the request's type, and `Publish(notification)` reaching every handler for its type and its bases, which no call site names (#1383): the request constructed at the call, through a `var`, through a local declared `object`, a request with no response (`IRequestHandler`), one class handling two requests, a derived request with a handled base, a generic request closed over a type (`GetById`) and one served by an open generic handler, a request or notification declared as its base (the handler of each type in the family), and two classes in two files each declaring its own nested `Ping` and `PingHandler`, where the resolved type and not the simple name decides. The Roslyn score cannot see the hop (it is not the site's own target), so `tools/mediator-dispatch-test.sh` asserts the `event_dispatch` edges exactly, with the stand-in contracts in the source and again with them removed, the package shape. Controls: a handler nothing sends, a sibling of a sent request, the same generic request closed over another type, a notification handler of a sibling and of an unrelated notification, a notification Sent rather than Published, a notification declared as the marker interface, a handler of another `GetOrder` written partly qualified (`Legacy.GetOrder`, which the engine does not resolve) and reached only by the send that writes it that way (that send is in `dispatch-only/`, outside the Roslyn score, because the engine leaves its construction unresolved), and the same request handed to a receiver that is not a mediator | +| `19-generic-method-return` | a call on the result of a generic METHOD returning its own type parameter, `T Dep()`: the type argument written at the call (on an instance, a static, an unqualified and a generic type's method, through a `var` local, and a constrained type parameter passed as the argument) and inferred from the argument passed to a parameter declared `T`, at ordinal 0 and 1 (#1549). Controls: two type parameters that must not cross (`First()` and `Second()` each call `Mark`, declared on both), `Convert` whose argument fills the other parameter, a non-generic overload with the same parameter types, which C# prefers, so the generic's inferred `T` must not add a second result type, a `List` return that must stay `external:List.Clear` beside an in-source `Widget.Clear`, and a non-generic method | +| `20-generic-type-method-return` | a call on the result of a method whose return type is its declaring TYPE's parameter, `T Create()` on `IFactory`: the receiver's own reference (a field, a parameter, a `var` and a typed local, a class implementing the interface under its own parameter name), a generic base the reference passes its parameter to (once, renamed), and a base closed with a concrete argument (twice, and through an unqualified call on the implicit `this`) (#1448). Controls: the property declared `T`, two parameters that must not cross (`IPair` whose `First()` and `Second()` each call `Mark`, declared on both), a base closed with the other type, a `List` return that must stay `external:List.Clear` beside an in-source `Store.Clear`, and a non-generic factory | +| `21-untyped-receiver-member` | a member access whose receiver is a SITE with no type (`lazy.Value.MaxCount` on an unstaged `Lazy`, `xs[0].MaxCount`, `xs.Find(p)!.MaxCount`, a chain `lazy.Value.Limits.Floor`): each is an `ambiguous_unknown` site named by its accessor (`get_MaxCount`, `set_MaxCount` for a write, the read for `+=`), so it is counted and offered by name rather than dropped (#1441). Controls: an in-source generic's property still resolves, namespace qualifiers (`System.Environment.NewLine`) are not sites, and a typed external receiver is labelled | +| `22-argument-lambda-parameters` | a lambda with no written parameter type passed as an ARGUMENT to a method with one candidate: its parameter takes the delegate type of the callee's parameter at that position, for `Action`, `Func` and a delegate declared in source, an expression and a block body, the second argument of two (`Both(w => ..., g => ...)`, whose delegates name members of the same name on different types, so crossed positions would fan), a static call, and an extension method called on a receiver, whose argument k fills parameter k + 1 (#1442). Controls: an explicitly typed lambda and a lambda stored in a typed local, typed as before | ### The acceptance bar's own self-test diff --git a/graph/test/csharp/cases/19-generic-method-return/src/19-generic-method-return.csproj b/graph/test/csharp/cases/19-generic-method-return/src/19-generic-method-return.csproj new file mode 100644 index 00000000..38e8124a --- /dev/null +++ b/graph/test/csharp/cases/19-generic-method-return/src/19-generic-method-return.csproj @@ -0,0 +1,7 @@ + + + net8.0 + 13.0 + enable + + diff --git a/graph/test/csharp/cases/19-generic-method-return/src/GenericMethodReturn.cs b/graph/test/csharp/cases/19-generic-method-return/src/GenericMethodReturn.cs new file mode 100644 index 00000000..52cfa7fc --- /dev/null +++ b/graph/test/csharp/cases/19-generic-method-return/src/GenericMethodReturn.cs @@ -0,0 +1,100 @@ +// A call on the result of a generic METHOD whose return type is the method's own +// type parameter (#1549). `T Dep()` returns a T, a reference to a type parameter +// resolves to no type, and the call's type argument was never put in its place, so +// `_p.Dep().Spin()` was labelled external:T.Spin and Widget.Spin lost the +// caller. The type argument is written at the call or inferred from the argument +// passed to a parameter declared `T`; both are asserted. +// +// EVERY ASSERTION IS A CALL on the returned value, so a wrong substitution is a +// disagreement in the headline number rather than an unscored property read. +using System.Collections.Generic; + +namespace Cases.GenericMethodReturn; + +public class Widget +{ + public int Spin() => 1; + public int Mark() => 10; // same name as Gadget.Mark: crossed positions disagree + public void Clear() { } // same name as List.Clear +} + +public class Gadget +{ + public int Mark() => 2; +} + +public class Provider +{ + public T Dep() where T : new() => new T(); + public T Echo(T value) => value; + public T Pick(int n, T value) => value; + public TFirst First() where TFirst : new() => new TFirst(); + public TSecond Second() where TSecond : new() => new TSecond(); + public TOut Convert(TIn input) where TOut : new() => new TOut(); + public List Wrap(T value) => new List { value }; + public Widget Plain() => new Widget(); +} + +public static class Factory +{ + public static T Make() where T : new() => new T(); +} + +// A non-generic twin: same name, same parameter types. C# prefers it over the +// generic one when both are applicable with identical parameter types. +public class Twin +{ + public T Echo(T value) => value; + public Widget Echo(Gadget g) => new Widget(); +} + +// A generic method of a generic TYPE: the method's parameter is position 0 of the +// METHOD's list, not of the type's. +public class Store +{ + public TValue Get(TKey key) where TValue : new() => new TValue(); +} + +public class Runner +{ + private readonly Provider _p = new Provider(); + + // explicit type argument + public int Explicit() => _p.Dep().Spin(); + public int ViaLocal() { var w = _p.Dep(); return w.Spin(); } + public int Static() => Factory.Make().Spin(); + public int OnGenericType(Store s) => s.Get("k").Spin(); + + // inferred from the argument, at ordinal 0 and at ordinal 1 + public int Inferred() => _p.Echo(new Widget()).Spin(); + public int InferredFromLocal() { var g = new Gadget(); return _p.Echo(g).Mark(); } + public int InferredSecond() => _p.Pick(1, new Gadget()).Mark(); + + // a type argument that is itself a constrained type parameter + public int Constrained() where TW : Widget, new() => _p.Dep().Spin(); + + // CONTROL: two type parameters must not cross. Each call names Mark on the + // other type, so a swapped position is a wrong edge, not a missing one. + public int FirstOfTwo() => _p.First().Mark(); + public int SecondOfTwo() => _p.Second().Mark(); + // CONTROL: the return is TOut, the argument fills TIn. Explicit arguments only. + public int Converted(Widget w) => _p.Convert(w).Mark(); + + // CONTROL: the non-generic twin wins, so the result is a Widget. Inferring T + // from the argument would add Gadget.Mark beside Widget.Mark. + public int TwinWins(Twin t) => t.Echo(new Gadget()).Mark(); + + // CONTROL: List HAS the parameter and is not it. Typing the result as the + // element would resolve this to Widget.Clear. + public void ListReturn() => _p.Wrap(new Widget()).Clear(); + + // CONTROL: a non-generic method, which resolved before. + public int Control() => _p.Plain().Spin(); +} + +// An unqualified call from inside the declaring type. +public class SelfUser +{ + private T Build() where T : new() => new T(); + public int Unqualified() => Build().Spin(); +} diff --git a/graph/test/csharp/cases/20-generic-type-method-return/src/20-generic-type-method-return.csproj b/graph/test/csharp/cases/20-generic-type-method-return/src/20-generic-type-method-return.csproj new file mode 100644 index 00000000..38e8124a --- /dev/null +++ b/graph/test/csharp/cases/20-generic-type-method-return/src/20-generic-type-method-return.csproj @@ -0,0 +1,7 @@ + + + net8.0 + 13.0 + enable + + diff --git a/graph/test/csharp/cases/20-generic-type-method-return/src/GenericTypeMethodReturn.cs b/graph/test/csharp/cases/20-generic-type-method-return/src/GenericTypeMethodReturn.cs new file mode 100644 index 00000000..b3ee9ad7 --- /dev/null +++ b/graph/test/csharp/cases/20-generic-type-method-return/src/GenericTypeMethodReturn.cs @@ -0,0 +1,104 @@ +// A call on the result of a METHOD whose return type is its declaring TYPE's +// parameter (#1448). `IFactory.Create()` returns a T, the receiver's +// reference says T is Store, and nothing put Store in T's place, so +// `f.Create().Count()` was labelled external:T.Count while `f.Current.Count()` +// through a property declared `T` resolved. Case 10 pins the property and field +// substitution; this is the same set of shapes for a method's return. +// +// EVERY ASSERTION IS A CALL on the returned value, so a wrong substitution is a +// disagreement in the headline number rather than an unscored property read. +using System.Collections.Generic; + +namespace Cases.GenericTypeMethodReturn; + +public class Store +{ + public int Count() => 0; + public int Mark() => 1; // same name as Other.Mark: crossed positions disagree + public void Clear() { } // same name as List.Clear +} + +public class Other +{ + public int Mark() => 2; +} + +public interface IFactory +{ + T Create(); + T Current { get; } +} + +public interface IPair +{ + TFirst First(); + TSecond Second(); +} + +// A generic base holding the method, passed through, closed at the declaration, +// inherited again, closed with a DIFFERENT type, and RENAMED on the way through. +public abstract class RepoBase where T : new() +{ + public T Get() => new T(); + public List All() => new List(); +} +public sealed class Repo : RepoBase where T : new() { } +public class StoreRepo : RepoBase +{ + // an unqualified call on the implicit `this`, closed by the heritage clause + public int Own() => Get().Count(); +} +public sealed class DeepRepo : StoreRepo { } +public sealed class OtherRepo : RepoBase { } +public sealed class Renamed : RepoBase where U : new() { } + +// A class implementing the interface under its OWN parameter name. +public sealed class Maker : IFactory where U : new() +{ + public U Create() => new U(); + public U Current { get; } = new U(); +} + +// CONTROL: a non-generic factory, which resolved before. +public sealed class StoreFactory +{ + public Store Create() => new Store(); +} + +public class Reader +{ + private readonly IFactory _factory; + public Reader(IFactory f) { _factory = f; } + + // the receiver's own reference: a field, a parameter, locals. A `this.`-qualified + // field has no type reference at all (the property form fails the same way), a + // separate gap, so it is not asserted here. + public int ViaField() => _factory.Create().Count(); + public int ViaParameter(IFactory f) => f.Create().Count(); + public int ViaLocal(IFactory f) { var s = f.Create(); return s.Count(); } + public int ViaTypedLocal() { IFactory f = _factory; return f.Create().Count(); } + public int ViaClass(Maker m) => m.Create().Count(); + + // CONTROL: the property declared `T`, which resolved before. + public int ViaProperty() => _factory.Current.Count(); + + // CONTROL: TWO parameters. Each call names Mark on the other type, so a + // swapped position is a wrong edge, not a missing one. + public int PairFirst(IPair p) => p.First().Mark(); + public int PairSecond(IPair p) => p.Second().Mark(); + + // through a generic base + public int PassedThrough(Repo r) => r.Get().Count(); + public int ClosedHere(StoreRepo r) => r.Get().Count(); + public int TwoLevels(DeepRepo r) => r.Get().Count(); + public int RenamedBase(Renamed r) => r.Get().Count(); + // CONTROL: closed with Other, so this must reach Other.Mark and not Store.Mark. + public int ClosedOther(OtherRepo r) => r.Get().Mark(); + + // CONTROL: List HAS the parameter and is not it. Typing the result as the + // element would resolve this to Store.Clear. + public void ListReturn(Repo r) => r.All().Clear(); + + // CONTROL: a non-generic method. + public int Plain(StoreFactory f) => f.Create().Count(); +} diff --git a/graph/test/csharp/cases/21-untyped-receiver-member/src/21-untyped-receiver-member.csproj b/graph/test/csharp/cases/21-untyped-receiver-member/src/21-untyped-receiver-member.csproj new file mode 100644 index 00000000..38e8124a --- /dev/null +++ b/graph/test/csharp/cases/21-untyped-receiver-member/src/21-untyped-receiver-member.csproj @@ -0,0 +1,7 @@ + + + net8.0 + 13.0 + enable + + diff --git a/graph/test/csharp/cases/21-untyped-receiver-member/src/UntypedReceiverMember.cs b/graph/test/csharp/cases/21-untyped-receiver-member/src/UntypedReceiverMember.cs new file mode 100644 index 00000000..8509a1c1 --- /dev/null +++ b/graph/test/csharp/cases/21-untyped-receiver-member/src/UntypedReceiverMember.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Cases.UntypedReceiverMember +{ + public sealed class Limits { public int Floor { get; set; } } + + public sealed class WidgetOptions + { + public int MaxCount { get; set; } + public Limits Limits { get; } = new Limits(); + public int Clamp() => 1; + } + + public sealed class Box + { + public Box(T value) { Value = value; } + public T Value { get; } + } + + // The receiver is a SITE whose result has no type: Lazy and List are not staged, + // so `lazy.Value` and `xs[0]` are labelled external and nothing says what they return. + public class Use + { + public int Read(Lazy lazy) => lazy.Value.MaxCount; + public void Write(Lazy lazy) { lazy.Value.MaxCount = 3; } + public void Bump(Lazy lazy) { lazy.Value.MaxCount += 1; } + public int Chain(Lazy lazy) => lazy.Value.Limits.Floor; + public int FromIndexer(List xs) => xs[0].MaxCount; + public int FromCall(List xs) => xs.Find(IsBig)!.MaxCount; + static bool IsBig(WidgetOptions w) => true; + } + + // Controls. + public class Controls + { + // an in-source generic: the read resolves, no unresolved row + public int Boxed(Box box) => box.Value.MaxCount; + // namespace qualifiers are untyped member accesses and not sites: no get_Text, no get_Environment + public string Qualified() => System.Environment.NewLine + System.Text.Encoding.UTF8.WebName; + // a typed external receiver is labelled, not unresolved + public int Typed(List xs) => xs.Count; + } +} diff --git a/graph/test/csharp/cases/22-argument-lambda-parameters/src/22-argument-lambda-parameters.csproj b/graph/test/csharp/cases/22-argument-lambda-parameters/src/22-argument-lambda-parameters.csproj new file mode 100644 index 00000000..38e8124a --- /dev/null +++ b/graph/test/csharp/cases/22-argument-lambda-parameters/src/22-argument-lambda-parameters.csproj @@ -0,0 +1,7 @@ + + + net8.0 + 13.0 + enable + + diff --git a/graph/test/csharp/cases/22-argument-lambda-parameters/src/ArgumentLambdaParameters.cs b/graph/test/csharp/cases/22-argument-lambda-parameters/src/ArgumentLambdaParameters.cs new file mode 100644 index 00000000..2fc9ef90 --- /dev/null +++ b/graph/test/csharp/cases/22-argument-lambda-parameters/src/ArgumentLambdaParameters.cs @@ -0,0 +1,45 @@ +using System; + +namespace Cases.ArgumentLambdaParameters +{ + public sealed class WidgetOptions { public void Reset() { } public int Size() => 1; } + public sealed class GadgetOptions { public void Reset() { } public int Size() => 2; } + + public delegate void Configure(T target); + + public sealed class Registry + { + public Registry Setup(Action configure) => this; + public Registry Both(Action w, Action g) => this; + public Registry Measure(Func f) => this; + public Registry Declared(Configure c) => this; + public static Registry Static(int n, Action g) => new Registry(); + } + + public static class RegistryExtensions + { + public static Registry Also(this Registry r, Action g) => r; + } + + // Each lambda below has a parameter with no written type, passed to a method with + // one candidate: the parameter takes the delegate type at that argument's position. + public class Wiring + { + public void Implicit(Registry r) => r.Setup(o => o.Reset()); + public void Block(Registry r) => r.Setup(o => { o.Reset(); }); + public void SecondArgument(Registry r) => r.Both(w => w.Size(), g => g.Reset()); + public void FuncArgument(Registry r) => r.Measure(g => g.Size()); + public void DeclaredDelegate(Registry r) => r.Declared(o => o.Reset()); + public void StaticCall() => Registry.Static(1, g => g.Reset()); + public void Extension(Registry r) => r.Also(g => g.Reset()); + } + + // Controls. + public class Controls + { + // written type: already resolved before this rule + public void Explicit(Registry r) => r.Setup((WidgetOptions o) => o.Reset()); + // a lambda stored in a typed local: typed by the local, as before + public void Stored() { Action a = g => g.Reset(); a(new GadgetOptions()); } + } +} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 26ffe474..25fe782c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -935,7 +935,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 = '52' # 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 is claimed by another branch); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '53' # 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 is claimed by another branch); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1153,6 +1153,11 @@ class Impact: for r in g.q("""SELECT s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s WHERE s.callee_name IS NOT NULL AND s.callee_name <> '' AND NOT 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)""")]) + # `new T()` of a type that writes no constructor: the engine classifies the site known_implicit_ctor with no callee, + # and names the type it constructs in ctor_implicit_type (C#), so the instantiation is not lost (#1473) + W('implicit_new', [(r['caller_id'], r['t'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("""SELECT DISTINCT s.caller_id, x.c1 t, s.file_path, s.start_line FROM ext_ctor_implicit_type x + JOIN call_sites s ON s.id = x.c0""")] if g.has('ext_ctor_implicit_type') else []) refs = []; quals = [] if g.has('refs'): for r in g.q("SELECT name, file, line, kind, entity_kind FROM refs WHERE line > 0"): @@ -2465,6 +2470,8 @@ def main(argv): test_cert = {m: cert_of(m) for m in tests} bad = 0; hops = 0 real = {(r[0], r[1]) for r in g.q("SELECT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IS NOT NULL")} # the engine's own edges, once + if g.has('ext_ctor_implicit_type'): # `new T()` where T writes no constructor: the edge is the type (#1473) + real |= {(r[0], r[1]) for r in g.q("SELECT s.caller_id, x.c1 FROM ext_ctor_implicit_type x JOIN call_sites s ON s.id = x.c0")} callers_real = {a for a, _ in real} for ch in chains.values(): for a, b in zip(ch, ch[1:]): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 16cf8024..2bbd460a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -18,7 +18,7 @@ to --src). The language's IR is read through the ADAPTER table below — header positions — mirroring graph/bundle/languages.ts. When the IR is absent the index degrades: symbols come from methods/types alone, refs/literals/comments are empty, and index_meta says so. """ -import csv, os, re, sqlite3, sys, time, glob, collections +import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) INDEX_VERSION = '6' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; @@ -42,8 +42,10 @@ if not os.path.isdir(IR): cands = glob.glob(os.path.join(GDIR, 'out', '.intermediate', 'ir', LANG)) + glob.glob(os.path.join(GDIR, 'ir')) IR = cands[0] if cands else '' HAVE_IR = bool(IR and os.path.isdir(IR)) +@functools.lru_cache(maxsize=None) def rel(p): - """repo-relative path: the bundle stores Java paths absolute and Python paths relative to --src""" + """repo-relative path: the bundle stores Java paths absolute and Python paths relative to --src. Cached: a file's + path is asked once per row that lives in it, and realpath costs a stat per path component each time""" if not p: return p a = p if os.path.isabs(p) else os.path.join(SRC, p) a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) @@ -156,7 +158,7 @@ A = { 'csharp': dict( modules=dict(file='all-csharp-modules.csv', id='csModuleUniqueHash', filePath='filePath'), # a member's name (`obj.Count`, `this.value`) is in potentialQualifiedName; a bare name is in literalValue - expr=dict(file='all-csharp-expressions.csv', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'csModuleLinkHash'), + expr=dict(file='all-csharp-expressions.csv', id='csExpressionUniqueHash', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'csModuleLinkHash'), refKinds={'NAME_REFERENCE', 'MEMBER_ACCESS'}, entityKind='referencedEntityKind', # `_o.Limit` is a MEMBER_ACCESS row AND a NAME_REFERENCE child `Limit` in the MEMBER_NAME role: one access, # stored once, as the qualified access it is (#1445). Kept twice, the child was a BARE reference, and a bare @@ -165,8 +167,21 @@ A = { # the literal TYPE column is literalKind here, not literalType as in java/typescript/python litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='literalValue'), comments=dict(file='all-csharp-comments.csv', text='commentText', kind='commentKind', line='startLine', fileVia=('modules', 'csModuleLinkHash')), - # type references carry no module link of their own; the owner hash resolves through the bundle's types - typeRefs=dict(file='all-csharp-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('types', 'ownerLinkHash')), + # type references carry no module link of their own. The owner is a type only for a base-list reference; for + # `M()`, `new T()`, a parameter, a return or a field it is the expression, method, parameter or field, so + # the owner hash resolves through every row kind that can own one (see `owners` below) + typeRefs=dict(file='all-csharp-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('owners', 'ownerLinkHash')), + # (file, id column) of every row kind a type reference's ownerLinkHash can name. Each reaches its module in at + # most two hops: its own csModuleLinkHash, or the method, type, attribute or owner that holds it (a parameter, a + # property, an event, a type parameter, the `typeof(T)` argument of an attribute), which has one. An EXPRESSION + # owner (`M()`, `new T()`) is not listed: the expression table is the largest in the IR and the reference pass + # below already reads it, so it records those owners' modules as it goes (`expr_module`) + owners=(('all-csharp-types.csv', 'csTypeUniqueHash'), ('all-csharp-methods.csv', 'csMethodUniqueHash'), + ('all-csharp-method-parameters.csv', 'csMethodParameterUniqueHash'), ('all-csharp-fields.csv', 'csFieldUniqueHash'), + ('all-csharp-properties.csv', 'csPropertyUniqueHash'), ('all-csharp-events.csv', 'csEventUniqueHash'), + ('all-csharp-type-parameters.csv', 'csTypeParameterUniqueHash'), ('all-csharp-variables.csv', 'csVariableUniqueHash'), + ('all-csharp-attributes.csv', 'csAttributeUniqueHash'), + ('all-csharp-usings.csv', 'csUsingUniqueHash'), ('all-csharp-attribute-arguments.csv', 'csAttributeArgumentUniqueHash')), # [Attribute] on a method or type. Arguments live in a second file, which is empty on every fixture # available here, so it is not mapped rather than guessed: a decoration with no argument text is # still the decoration, and a wrong `args` mapping would be silently empty in the same way @@ -506,7 +521,35 @@ def file_of(r, d): if via and via[0] == 'modules': return rel(modules.get(r.get(via[1], ''), '')) if via and via[0] == 'types': t = types.get(r.get(via[1], '')); return rel(t['file_path']) if t else '' + if via and via[0] == 'owners': return rel(modules.get(owner_module().get(r.get(via[1], ''), ''), '')) return '' +_owner_module = None +def owner_module(): + """owner hash -> module hash, over every row kind in A['owners']: the row's own module link, else the module of + the method, type or owner that holds it (C# column names: only C# declares owners). Built once, on first use, + after the reference pass has filled `expr_module`. A row is kept only when a type reference names it, or when it + is a method, a type or an attribute (the second hop's targets).""" + global _owner_module + if _owner_module is not None: return _owner_module + tr = A.get('typeRefs') or {} + via = (tr.get('fileVia') or ('', ''))[1] + wanted = {r.get(via) for r in rows(tr['file'])} if tr else set() + mod, up = dict(expr_module), {} + for fname, idcol in A.get('owners') or (): + hop = idcol in ('csTypeUniqueHash', 'csMethodUniqueHash', 'csAttributeUniqueHash') + for r in rows(fname): + h = r.get(idcol) + if not h or not (hop or h in wanted): continue + if r.get('csModuleLinkHash'): mod[h] = r['csModuleLinkHash'] + else: + p = r.get('csMethodLinkHash') or r.get('csTypeLinkHash') or r.get('ownerLinkHash') or r.get('parentAttributeHash') + if p: up[h] = p + for _ in range(2): # a type parameter's owner is a method or a type: two hops at most + for h, p in list(up.items()): + if p in mod: mod[h] = mod[p]; del up[h] + elif p in up: up[h] = up[p] + _owner_module = mod + return mod for d in A['decls']: for r in rows(d['file']): if d.get('only') and not d['only'](r): continue @@ -533,7 +576,12 @@ mname = e.get('memberName'); child_ek = {} if mname: # the member-name child's entity kind, for the access that stands for it (the parent's own is UNKNOWN until resolved) for r in rows(e['file']): if r.get(mname['role']) == mname['value'] and r.get(mname['parent']): child_ek[r[mname['parent']]] = r.get(e['entityKind'], '') +# the expressions that own a type reference (see A['owners']): their module, taken on this pass rather than a second one +expr_module = {}; tr_exprs = set() +if A.get('owners') and e.get('id'): + _tr = A['typeRefs']; tr_exprs = {r.get(_tr['fileVia'][1]) for r in rows(_tr['file']) if r.get(_tr['ownerKind']) == 'EXPRESSION'} for r in rows(e['file']): + if tr_exprs and r.get(e['id']) in tr_exprs: expr_module[r[e['id']]] = r.get(e['fileVia'][1], '') k = r.get(e['kind'], '') if mname and r.get(mname['role']) == mname['value'] and r.get(mname['parent']): continue if k in e['refKinds']: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index d9923aa2..a19efb6c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -47,6 +47,8 @@ .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 +// a `new T()` of a type with no written constructor (C#): no constructor to call, so no `calls` row, but the engine knows the type (#1473) +.decl implicit_new(c:symbol, t:symbol, f:symbol, l:number) .input implicit_new .decl member(t:symbol, s:symbol, n:symbol, k:symbol) .input member .decl owner(c:symbol, t:symbol) .input owner .decl typ(t:symbol, n:symbol, k:symbol) .input typ @@ -610,6 +612,7 @@ direct(q, c, "produces", why, cert, f, l) :- produces(q, c, why, cert, f, l), !i .decl tmember(q:symbol, m:symbol, n:symbol, k:symbol) tmember(q, m, n, k) :- target(q, "type", t, _), member(t, m, n, k). direct(q, c, "produces", "instantiates it", "resolved", f, l) :- tmember(q, m, _, "constructor"), calls(c, m, _, f, l), !inside_target(q, c). +direct(q, c, "produces", "instantiates it", "resolved", f, l) :- target(q, "type", t, _), implicit_new(c, t, f, l), !inside_target(q, c). direct(q, c, "uses", cat("calls ", n), "resolved", f, l) :- tmember(q, m, n, k), k != "constructor", calls(c, m, t, f, l), t != "multi_inferred", !inside_target(q, c). direct(q, c, "uses", cat("calls ", n), "one of a set", f, l) :- tmember(q, m, n, k), k != "constructor", calls(c, m, "multi_inferred", f, l), !inside_target(q, c). direct(q, c, "produces", "instantiates it (unresolved site)", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), unresolved(c, n, k, f, l), ctor_kind(k), !inside_target(q, c). @@ -763,6 +766,8 @@ direct_edge(q, c, a) :- target(q, "field", fl, _), field(fl, t, _, _, _), access 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, _, _, _). direct_edge(q, c, m) :- tmember(q, m, _, _), calls(c, m, _, _, _). +// an instantiation of a type that writes no constructor lands on the type itself: the engine's ctor_implicit_type row (#1473) +direct_edge(q, c, t) :- target(q, "type", t, _), implicit_new(c, t, _, _). direct_edge(q, c, m) :- target(q, "param", m, _), calls(c, m, _, _, _). direct_edge(q, c, m) :- target(q, "typeparam", m, _), calls(c, m, _, _, _). .output direct_edge diff --git a/tests/cases/csharp/a-located-service-is-typed-by-its-argument/case.json b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/case.json new file mode 100644 index 00000000..67eda4ce --- /dev/null +++ b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/case.json @@ -0,0 +1,16 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a call on what GetRequiredService() returns is a call on T, though the locator is in a package the run was not given (#1471)", + "run": ["impact", "Worker.Start"], + "want": ["[resolved] Program.
    $ src/Program.cs:11 — calls it"], + "avoid": ["[by name] Program.
    $ src/Program.cs:11", "change is local"]}, + {"why": "GetService() is typed the same way", + "run": ["impact", "Worker.Stop"], + "want": ["[resolved] Program.
    $ src/Program.cs:13 — calls it"]}, + {"why": "a local holding the located interface reaches its implementation", + "run": ["impact", "NightlyJob.Run"], + "want": ["Program.
    $ src/Program.cs:10"], + "avoid": ["[by name] Program.
    $ src/Program.cs:10"]}, + {"why": "control: a type the locator was not asked for is not reached by the call on Worker", + "run": ["impact", "Other.Start"], + "avoid": ["[resolved] Program.
    $"]}]} diff --git a/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/App.csproj b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/App.csproj new file mode 100644 index 00000000..8e63988c --- /dev/null +++ b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/App.csproj @@ -0,0 +1,9 @@ + + + net8.0 + enable + + + + + diff --git a/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Orders.cs b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Orders.cs new file mode 100644 index 00000000..51b7585e --- /dev/null +++ b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Orders.cs @@ -0,0 +1,6 @@ +namespace App.Orders; + +public interface IOrderJob { void Run(); } +public class NightlyJob : IOrderJob { public void Run() { } } +public class Worker { public void Start() { } public void Stop() { } } +public class Other { public void Start() { } } diff --git a/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Program.cs b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Program.cs new file mode 100644 index 00000000..7d9a5063 --- /dev/null +++ b/tests/cases/csharp/a-located-service-is-typed-by-its-argument/src/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using App.Orders; + +var services = new ServiceCollection(); +services.AddScoped(); +services.AddSingleton(); +using var sp = services.BuildServiceProvider(); + +var job = sp.GetRequiredService(); +job.Run(); +sp.GetRequiredService().Start(); +var maybe = sp.GetService(); +maybe.Stop(); diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/case.json b/tests/cases/csharp/a-type-named-outside-a-base-list/case.json new file mode 100644 index 00000000..efca3b4f --- /dev/null +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/case.json @@ -0,0 +1,42 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a type registered only as a generic method's type argument (AddSingleton()) is named by the statement that registers it, and a registration of another type on the next line is not a use of it", + "run": ["impact", "WidgetStore", "--kind", "type"], + "want": ["Program.
    $ src/Program.cs:2 — names it (METHOD_TYPE_ARGUMENT)"], + "avoid": ["src/Program.cs:3", "src/Program.cs:4"]}, + {"why": "both type arguments of AddScoped() name their types, the implementation too", + "run": ["impact", "GadgetRepository", "--kind", "type"], + "want": ["Program.
    $ src/Program.cs:3 — names it (METHOD_TYPE_ARGUMENT)"], + "avoid": ["src/Program.cs:2"]}, + {"why": "AddHostedService() names the hosted service it registers", + "run": ["impact", "WidgetSweeper", "--kind", "type"], + "want": ["Program.
    $ src/Program.cs:4 — names it (METHOD_TYPE_ARGUMENT)"], + "avoid": ["src/Program.cs:6"]}, + {"why": "UseMiddleware() names the middleware type it registers", + "run": ["impact", "WidgetMiddleware", "--kind", "type"], + "want": ["Program.
    $ src/Program.cs:6 — names it (METHOD_TYPE_ARGUMENT)"], + "avoid": ["src/Program.cs:4"]}, + {"why": "a type written as a nested generic argument, a parameter, a return, a new, a property type and a type-parameter constraint is named in each of those places, in the file that writes it", + "run": ["impact", "Gadget", "--kind", "type", "--limit", "50"], + "want": ["Program.
    $ src/Program.cs:3 — names it (METHOD_TYPE_ARGUMENT)", + "Setup.Lookup src/Gadgets.cs:29 — calls get_Id; also: names it (METHOD_PARAMETER)", + "GadgetRepository.Find src/Gadgets.cs:15 — names it (METHOD_RETURN), (OBJECT_CREATION)", + "[resolved] GadgetRepository.Find src/Gadgets.cs:15 — instantiates it", + "src/Gadgets.cs:36 — names it (PROPERTY_TYPE)", + "Setup.First src/Gadgets.cs:31 — names it (TYPE_PARAMETER_CONSTRAINT)", + "GadgetRepository src/Gadgets.cs:13 — names it (BASE_LIST)"], + "avoid": ["src/Program.cs:2", "[by name] Setup.Make", "[resolved] Setup.Make", "✗"]}, + {"why": "a class with no written constructor is named where it is returned, constructed and taken as a parameter", + "run": ["impact", "GadgetStore", "--kind", "type"], + "want": ["Setup.Make src/Gadgets.cs:25 — names it (METHOD_RETURN), (OBJECT_CREATION)", + "[resolved] Setup.Make src/Gadgets.cs:25 — instantiates it", + "Setup.Report src/Gadgets.cs:27 — calls Count; also: names it (METHOD_PARAMETER)"], + "avoid": ["Program.
    $"]}, + {"why": "control: a type named only in a comment and a string is named nowhere, so nothing uses it", + "run": ["impact", "Unused", "--kind", "type"], + "want": ["change is local"], + "avoid": ["names it", "Program.
    $"]}, + {"why": "control: a base-list reference reads as it did, and a type only derived from is not registered anywhere", + "run": ["impact", "BaseThing", "--kind", "type"], + "want": ["DerivedThing src/Widgets.cs:28 — extends / implements it", "DerivedThing src/Widgets.cs:28 — names it (BASE_LIST)"], + "avoid": ["Program.
    $"]}]} diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/src/App.csproj b/tests/cases/csharp/a-type-named-outside-a-base-list/src/App.csproj new file mode 100644 index 00000000..a9711c73 --- /dev/null +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/src/App.csproj @@ -0,0 +1,9 @@ + + + net8.0 + enable + + + + + diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/src/Gadgets.cs b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Gadgets.cs new file mode 100644 index 00000000..448a83b2 --- /dev/null +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Gadgets.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; + +public class Gadget +{ + public int Id { get; set; } +} + +public interface IRepository +{ + T Find(int id); +} + +public class GadgetRepository : IRepository +{ + public Gadget Find(int id) => new Gadget(); +} + +public class GadgetStore +{ + public int Count() => 2; +} + +public static class Setup +{ + public static GadgetStore Make() => new GadgetStore(); + + public static int Report(GadgetStore g) => g.Count(); + + public static int Lookup(IRepository repo) => repo.Find(1).Id; + + public static T First(IRepository repo) where T : Gadget => repo.Find(0); +} + +public class ShopDb : DbContext +{ + public DbSet Gadgets { get; set; } = null!; +} diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/src/Program.cs b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Program.cs new file mode 100644 index 00000000..51498735 --- /dev/null +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Program.cs @@ -0,0 +1,7 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddScoped, GadgetRepository>(); +builder.Services.AddHostedService(); +var app = builder.Build(); +app.UseMiddleware(); +app.Run(); diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/src/Widgets.cs b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Widgets.cs new file mode 100644 index 00000000..60f2337d --- /dev/null +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/src/Widgets.cs @@ -0,0 +1,30 @@ +public class WidgetStore +{ + public int Count() => 0; +} + +public class WidgetSweeper : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken ct) => Task.CompletedTask; +} + +public class WidgetMiddleware +{ + private readonly RequestDelegate _next; + public WidgetMiddleware(RequestDelegate next) { _next = next; } + public Task InvokeAsync(HttpContext ctx) => _next(ctx); +} + +// Unused is named in this comment and in the string below, never as a type +public class Unused +{ + public string Label() => "Unused"; +} + +public class BaseThing +{ +} + +public class DerivedThing : BaseThing +{ +} diff --git a/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/case.json b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/case.json new file mode 100644 index 00000000..0aab133b --- /dev/null +++ b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a class that writes no constructor is instantiated where `new` names it, and a target-typed `new()` for a field of that type is one too (#1473)", + "run": ["impact", "SystemClock", "--kind", "type"], + "want": ["Wiring.Clock src/Orders.cs:13 — instantiates it", "src/Orders.cs:12 — instantiates it"], + "avoid": ["change is local", "src/Orders.cs:14 — instantiates it"]}, + {"why": "a partial class with no constructor is instantiated through either of its declarations", + "run": ["impact", "Ledger", "--kind", "type"], + "want": ["Wiring.Book src/Orders.cs:15 — instantiates it"], + "avoid": ["src/Orders.cs:13 — instantiates it"]}, + {"why": "control: a class with a written constructor is instantiated through the constructor, as before", + "run": ["impact", "GroundShipper", "--kind", "type"], + "want": ["Wiring.Shipper src/Orders.cs:14 — instantiates it"], + "avoid": ["src/Orders.cs:13 — instantiates it"]}, + {"why": "control: a type only taken as a parameter is never instantiated", + "run": ["impact", "NeverBuilt", "--kind", "type"], + "want": ["Wiring.Named"], + "avoid": ["instantiates it"]}]} diff --git a/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/App.csproj b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/App.csproj new file mode 100644 index 00000000..ad1b47cd --- /dev/null +++ b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/App.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/Orders.cs b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/Orders.cs new file mode 100644 index 00000000..6d89925b --- /dev/null +++ b/tests/cases/csharp/a-type-without-a-constructor-is-instantiated/src/Orders.cs @@ -0,0 +1,17 @@ +namespace App.Orders; + +public interface IClock { DateTime Now(); } +public class SystemClock : IClock { public DateTime Now() => DateTime.UtcNow; } +public class GroundShipper { public GroundShipper() { } public void Ship() { } } +public class NeverBuilt { public int Size() => 0; } +public partial class Ledger { public int Total() => 0; } +public partial class Ledger { public int Count() => 0; } + +public class Wiring +{ + private readonly SystemClock _clock = new(); + public IClock Clock() => new SystemClock(); + public GroundShipper Shipper() => new GroundShipper(); + public Ledger Book() => new Ledger(); + public int Named(NeverBuilt n) => n.Size(); +} diff --git a/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/case.json b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/case.json new file mode 100644 index 00000000..b0264f07 --- /dev/null +++ b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/case.json @@ -0,0 +1,13 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an implicitly typed lambda passed to a method with one candidate takes that parameter's delegate type (#1442)", + "run": ["impact", "WidgetOptions.Reset"], + "want": ["[resolved] Wiring. src/Widgets.cs:18", "[resolved] Wiring. src/Widgets.cs:19"], + "avoid": ["[by name] Wiring. src/Widgets.cs:18", "src/Widgets.cs:20"]}, + {"why": "the second argument lambda takes the second parameter's type, not the first", + "run": ["impact", "GadgetOptions.Reset"], + "want": ["[resolved] Wiring. src/Widgets.cs:20"], + "avoid": ["src/Widgets.cs:18", "src/Widgets.cs:19"]}, + {"why": "control: a lambda passed to an overloaded method is not typed from either overload", + "run": ["impact", "GadgetOptions.Reset"], + "avoid": ["[resolved] Wiring. src/Widgets.cs:21"]}]} diff --git a/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/App.csproj b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/App.csproj new file mode 100644 index 00000000..ec2cce14 --- /dev/null +++ b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/App.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/Widgets.cs b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/Widgets.cs new file mode 100644 index 00000000..a92ebb3c --- /dev/null +++ b/tests/cases/csharp/an-argument-lambda-takes-the-callee-parameter-type/src/Widgets.cs @@ -0,0 +1,22 @@ +using System; + +namespace App.Widgets; + +public sealed class WidgetOptions { public void Reset() { } } +public sealed class GadgetOptions { public void Reset() { } } + +public sealed class Registry +{ + public Registry Setup(Action configure) => this; + public Registry Both(Action w, Action g) => this; + public Registry Pick(Action w) => this; + public Registry Pick(Action g) => this; +} + +public static class Wiring +{ + public static void Implicit(Registry r) => r.Setup(o => o.Reset()); + public static void Explicit(Registry r) => r.Setup((WidgetOptions o) => o.Reset()); + public static void Second(Registry r) => r.Both(w => { }, g => g.Reset()); + public static void Overloaded(Registry r) => r.Pick(x => x.Reset()); +} diff --git a/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.cs b/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.cs new file mode 100644 index 00000000..696ac525 --- /dev/null +++ b/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.cs @@ -0,0 +1,9 @@ +using Example.Kit; +namespace App.Widgets; +public static class Limits { public static bool Allow(int n) => n > 0; } +public static class Quotas { public static bool Allow(int n) => n > 1; } +public class Gadget : WidgetBase +{ + public bool Check(int n) => Limits.Allow(n); + public bool Other(int n) => Quotas.Allow(n); +} diff --git a/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.csproj b/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.csproj new file mode 100644 index 00000000..41ae612e --- /dev/null +++ b/tests/cases/csharp/an-internal-library-member-hides-nothing/app/App.csproj @@ -0,0 +1,8 @@ + + + net8.0 + + + + + diff --git a/tests/cases/csharp/an-internal-library-member-hides-nothing/case.json b/tests/cases/csharp/an-internal-library-member-hides-nothing/case.json new file mode 100644 index 00000000..cefff6c8 --- /dev/null +++ b/tests/cases/csharp/an-internal-library-member-hides-nothing/case.json @@ -0,0 +1,9 @@ +{"lang": "csharp", "src": "app", "library": "lib/Example.Kit", + "checks": [ + {"why": "an internal member of a library base is not accessible from the client, so the simple name is the client's own class (#1538)", + "run": ["path", "Gadget.Check", "Limits.Allow"], + "want": ["App.cs:7"], + "avoid": ["no chain of resolved calls"]}, + {"why": "control: a protected member of the library base is accessible to the derived type and still wins the simple name, so the client class of that name is not called", + "run": ["impact", "Quotas.Allow"], + "avoid": ["[resolved] Gadget.Other"]}]} diff --git a/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Example.Kit.csproj b/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Example.Kit.csproj new file mode 100644 index 00000000..ec2cce14 --- /dev/null +++ b/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Example.Kit.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Kit.cs b/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Kit.cs new file mode 100644 index 00000000..f0db2c79 --- /dev/null +++ b/tests/cases/csharp/an-internal-library-member-hides-nothing/lib/Example.Kit/Kit.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +namespace Example.Kit; +public abstract class WidgetBase +{ + internal List Limits { get; } = new(); + protected Counter Quotas { get; } = new(); +} +public class Counter { public bool Allow(int n) => n > 2; } diff --git a/tests/cases/csharp/an-untyped-property-read-is-a-lead/case.json b/tests/cases/csharp/an-untyped-property-read-is-a-lead/case.json new file mode 100644 index 00000000..685f0350 --- /dev/null +++ b/tests/cases/csharp/an-untyped-property-read-is-a-lead/case.json @@ -0,0 +1,16 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a property read on a receiver the engine could not type is still a site, offered by name, as a method call on the same receiver is (#1441)", + "run": ["impact", "WidgetOptions.get_MaxCount"], + "want": ["[resolved] WidgetService.BoxLimit src/Widgets.cs:13", "[by name] WidgetService.Limit src/Widgets.cs:11"], + "avoid": ["src/Widgets.cs:14"]}, + {"why": "a write to it is a setter lead, not a getter one", + "run": ["impact", "WidgetOptions.set_MaxCount"], + "want": ["[by name] WidgetService.Raise src/Widgets.cs:14"], + "avoid": ["src/Widgets.cs:11"]}, + {"why": "the untyped read is a path lead too", + "run": ["path", "WidgetService.Limit", "WidgetOptions.get_MaxCount"], "expect_error": true, + "want": ["`get_MaxCount` called in WidgetService.Limit at src/Widgets.cs:11"]}, + {"why": "control: a property of another name is not reached by the read of MaxCount", + "run": ["impact", "WidgetOptions.get_MinCount"], + "avoid": ["WidgetService.Limit", "WidgetService.BoxLimit"]}]} diff --git a/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/App.csproj b/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/App.csproj new file mode 100644 index 00000000..cb4730f9 --- /dev/null +++ b/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/App.csproj @@ -0,0 +1,8 @@ + + + net8.0 + + + + + diff --git a/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/Widgets.cs b/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/Widgets.cs new file mode 100644 index 00000000..31cccbad --- /dev/null +++ b/tests/cases/csharp/an-untyped-property-read-is-a-lead/src/Widgets.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Options; + +namespace App.Widgets; + +public sealed class WidgetOptions { public int MaxCount { get; set; } public int MinCount { get; set; } public int Clamp() => 1; } + +public sealed class Box(T value) { public T Value { get; } = value; } + +public sealed class WidgetService(IOptions options, Box box) +{ + public int Limit() => options.Value.MaxCount; + public int Clamped() => options.Value.Clamp(); + public int BoxLimit() => box.Value.MaxCount; + public void Raise() { options.Value.MaxCount = 9; } + public string Line() => System.Environment.NewLine; +} diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index 51012844..c58a7c7c 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -4,7 +4,7 @@ { "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE TO ASSUME", "a lifecycle callback (above) — the framework calls it back"], + "want": ["NOT SAFE", "it extends / implements BackgroundService, which the graph does not contain", "Program.
    $ src/App/Program.cs:2 — names it (METHOD_TYPE_ARGUMENT)"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { From af23922f89a68f54208d2c7854f198e70d87e74e Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:17:47 -0700 Subject: [PATCH 054/258] impact, csharp: a name the engine tied to another type of that name is not a by-name use Two interactions of the C# type-reference change (#1434, which gives every type_refs row a file) with work stacked below it. 1. same-name-type-keeps-its-own-base (from the commit "impact, path: a member's base types come from its own declaring type", in fix/batch-0929-b5) went red. Its Program.cs writes `new App.Entities.Basket()`. Before #1434 that OBJECT_CREATION type_ref had no file, so impact.dl's typeref rule never saw it. With a file it matched by the simple name `Basket`, and impact --delete on the view component App.Web.ViewComponents.Basket listed Program as a [by name] reader and said NOT SAFE instead of NOT SAFE TO ASSUME. The engine already knows which type is built there: the implicit construction (ctor_implicit_type, #1473) names the entity. impact.dl gains typeref_other: where the same caller, at the same line, constructs another type of the target's name (a resolved constructor call or an implicit `new T()`) and not the target, the name at that line is that other type's, and the typeref row is not emitted for the target. The SQL port (graph_sql rule 273) skips the same rows through _constructs_other. Impact on the entity still lists the construction ("instantiates it") and the OBJECT_CREATION name. 2. a-type-named-outside-a-base-list and unmodelled-entry-not-local expected the AddHostedService() row to read exactly "names it (METHOD_TYPE_ARGUMENT)". On the release branch that line is also a resolved call of the service's ExecuteAsync, so the one row reads "calls ExecuteAsync; also: names it (METHOD_TYPE_ARGUMENT)". The checks now want the row's location and the "names it (METHOD_TYPE_ARGUMENT)" words separately. The rest of unmodelled-entry-not-local still fails as it does on the release branch (#1446). tests/run.py on those cases: 27 of 29, the 2 failures being #1446's. tests/query_rules.py: 15 passed. --- .../skills/axiomcode/scripts/dl/impact.dl | 13 ++++++++++- .../skills/axiomcode/scripts/graph_sql.py | 22 +++++++++++++++++++ .../case.json | 2 +- .../unmodelled-entry-not-local/case.json | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index a19efb6c..71b675eb 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -618,7 +618,18 @@ direct(q, c, "uses", cat("calls ", n), "one of a set", f, l) :- tmember(q, m, n, direct(q, c, "produces", "instantiates it (unresolved site)", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), unresolved(c, n, k, f, l), ctor_kind(k), !inside_target(q, c). 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). +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), !typeref_other(q, c, f, l). +// A NAME THE ENGINE ALREADY TIED TO ANOTHER TYPE OF THAT NAME IS THAT TYPE'S. `new App.Entities.Basket()` builds the +// entity; the type_ref row keeps only the simple name `Basket`, so read by name it was also a use of a view component +// `Basket` in another namespace, and --delete on the component said NOT SAFE for a by-name row that constructs the +// entity. Where the same caller, at the same line, constructs another type of the name (a resolved constructor call, or +// an implicit `new T()`, #1473) and not the target, the name at that line is that other type's. +.decl typeref_own_new(q:symbol, c:symbol, f:symbol, l:number) +typeref_own_new(q, c, f, l) :- target(q, "type", t, _), implicit_new(c, t, f, l). +typeref_own_new(q, c, f, l) :- target(q, "type", t, _), member(t, k, _, "constructor"), calls(c, k, _, f, l). +.decl typeref_other(q:symbol, c:symbol, f:symbol, l:number) +typeref_other(q, c, f, l) :- target(q, "type", t, _), typ(t, n, _), typ(t2, n, _), t2 != t, implicit_new(c, t2, f, l), !typeref_own_new(q, c, f, l). +typeref_other(q, c, f, l) :- target(q, "type", t, _), typ(t, n, _), typ(t2, n, _), t2 != t, member(t2, k, _, "constructor"), calls(c, k, _, f, l), !typeref_own_new(q, c, 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. .decl alias_over(q:symbol, a:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index f3932ab7..b781a96c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -2278,6 +2278,26 @@ def type_aliases(q): return spans, names +def _constructs_other(q, t, n, by_tid, rel): + """{(caller, file, line)} where the caller constructs ANOTHER type named n and not t: a resolved constructor call, or an + implicit `new T()` the engine names (ext_ctor_implicit_type, #1473). impact.dl's typeref_other: the type_ref row there + keeps only the simple name, so `new App.Entities.Basket()` was also a by-name use of a view component `Basket`.""" + same = {r[0] for r in q("SELECT id FROM symbols WHERE name = ? AND type_id IS NOT NULL AND method_id IS NULL", n)} + built = collections.defaultdict(set) # (caller, file, line) -> the types built there + ctor = {m: t2 for t2 in same for (m, _n, k) in by_tid.get(t2, ()) if k == 'constructor'} + if ctor and _has(q, 'call_edges') and _has(q, 'call_sites'): + ph = ','.join('?' * len(ctor)) + for c, m, f, l in q(f"""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line FROM call_edges e + JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_method_id IN ({ph})""", *ctor): + built[(c, rel(f) if f else '', l or 0)].add(ctor[m]) + if same and _has(q, 'ext_ctor_implicit_type') and _has(q, 'call_sites'): + ph = ','.join('?' * len(same)) + for c, t2, f, l in q(f"""SELECT s.caller_id, x.c1, s.file_path, s.start_line FROM ext_ctor_implicit_type x + JOIN call_sites s ON s.id = x.c0 WHERE x.c1 IN ({ph})""", *same): + built[(c, rel(f) if f else '', l or 0)].add(t2) + return {k for k, ts in built.items() if t not in ts} + + 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 @@ -2509,8 +2529,10 @@ def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE li 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'): + other = _constructs_other(q, t, n, by_tid, rel) for nm, f, l, ctx in trefs(n): c = holder(f, l) + if (c, f, l) in other: continue # typeref_other: the name at that line builds another type of it 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) # 273b — a signature the engine RESOLVED to this type (#1422): `type_use` holds each parameter, return and diff --git a/tests/cases/csharp/a-type-named-outside-a-base-list/case.json b/tests/cases/csharp/a-type-named-outside-a-base-list/case.json index efca3b4f..0709bfeb 100644 --- a/tests/cases/csharp/a-type-named-outside-a-base-list/case.json +++ b/tests/cases/csharp/a-type-named-outside-a-base-list/case.json @@ -10,7 +10,7 @@ "avoid": ["src/Program.cs:2"]}, {"why": "AddHostedService() names the hosted service it registers", "run": ["impact", "WidgetSweeper", "--kind", "type"], - "want": ["Program.
    $ src/Program.cs:4 — names it (METHOD_TYPE_ARGUMENT)"], + "want": ["Program.
    $ src/Program.cs:4 — ", "names it (METHOD_TYPE_ARGUMENT)"], "avoid": ["src/Program.cs:6"]}, {"why": "UseMiddleware() names the middleware type it registers", "run": ["impact", "WidgetMiddleware", "--kind", "type"], diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index c58a7c7c..02c1e1b1 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -4,7 +4,7 @@ { "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE", "it extends / implements BackgroundService, which the graph does not contain", "Program.
    $ src/App/Program.cs:2 — names it (METHOD_TYPE_ARGUMENT)"], + "want": ["NOT SAFE", "it extends / implements BackgroundService, which the graph does not contain", "Program.
    $ src/App/Program.cs:2 — ", "names it (METHOD_TYPE_ARGUMENT)"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { From 75aca97c5c626a6645a82e365db992c0f2c1737a Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:50:42 -0700 Subject: [PATCH 055/258] impact: a C# test class member serves the tests the runner or a call links it to Fixes #1498, #1499 Refs #1416, #1556, #1514, #1516, #1526, #1454 What was wrong - dl/impact.dl carried any reached callable a C# test class owns to every test of that class. A named helper that one test calls was listed as a [fixture] of every sibling test. So was a [MemberData] source on the test's own class, though the runner calls it only for the test that names it. (A lambda written inside a test already has its own rule, which serves that test only.) - Nothing read the links an xUnit runner makes from attribute arguments and base lists. A change reached only from an IClassFixture or ICollectionFixture fixture reached no test (#1498), and a [MemberData] source on another type (MemberType = typeof(...)) reached no test (#1499). The graph keeps a C# decoration as its name only, without its arguments, so neither link was in any fact. The change - dl/impact.dl: the class-scope helper rule no longer applies to C#. A C# test class member now reaches a test when: - a test calls it (a recorded edge); - it is written inside the test (the existing rule for a lambda); - it is a set-up or tear-down fixture, or a constructor, as before; - it is Dispose, DisposeAsync or InitializeAsync, which xUnit calls around every test of the class through IDisposable or IAsyncLifetime and no decoration marks; the old helper rule carried these, and they now have their own rule; - a test's data attribute names it: [MemberData] (own class or MemberType), [TestCaseSource] and [DynamicData] with or without a type, and [ClassData(typeof(C))] / [TestCaseSource(typeof(C))], where every callable of C serves that test. The stub listing (test_stub) is scoped the same way. - dl/impact.dl: xUnit class and collection fixtures. A test class that names IClassFixture, or sits in a [Collection("n")] whose [CollectionDefinition("n")] class names ICollectionFixture, has X's constructors, its lifecycle members, and its members nothing in the graph calls credited to its tests as [fixture]. The last covers an override the framework base calls, such as a WebApplicationFactory's CreateHost. A member of X that a test calls is carried by that call instead. A fixture type no test class names reaches no test. - axiomcode-impact: three new facts read from the source, since the decoration text carries no arguments: cs_data_source, cs_data_type and cs_fixture_type. IMPACT_VERSION 51. - New case tests/cases/csharp/test-class-member-serves-its-tests, 12 checks. It covers a helper one test calls, a class fixture's constructor and its framework-called override, a collection fixture, and [MemberData] on another type and on the own class. Near-miss controls: a helper no test calls credits no test; a fixture type no test class names credits none; a member of the class fixture that one test calls is credited to that test only; a direct call names one test; a lambda in one test still serves that test only; a test class's Dispose still serves every test of the class. On the release branch 7 checks fail and the 5 controls pass. Java is unchanged here; its helper-scoping change is on its own branch and edits the same rule line, so whichever lands second resolves that one line. Python is unchanged. What still reproduces on this branch, left for its own change: a fixture requested with request.getfixturevalue("name") (#1514), a fixture from a pytest11 entry-point plugin (#1516), a decorator factory's application site read as a by-name call (#1526), and serializer-read accessors (#1454). #1384, #1512, #1542 and #1500 no longer reproduce on the release branch. Suites on the rebased tree, the release branch -> this change: - tests/run.py --lang csharp: 121 of 123, 2 FAILED -> 133 of 135, the same 2 FAILED (unmodelled-entry-not-local, #1446) - tests/run.py --lang python: 233 of 234, 1 FAILED -> the same, the same FAIL line - tests/run.py --lang java: 238 of 238 -> 238 of 238 - tests/fastpath.py --lang csharp|python|java: 5 of 6 each on both, the same check failing on both (no alongside row on any shape) - tests/query_rules.py: 15 passed Smoke, the installed release branch vs this change, each on a fresh index of a real copy, impact --tests-only on production methods that test code calls: - a 154-file C# library, 35 targets: 1343 (target, test) pairs -> 1338. The 5 dropped pairs are tests credited through a [MemberData] source they do not name. None was gained. - a 216-file C# library, 40 targets: 3689 pairs, 863 [fixture] -> 3588 pairs, 316 [fixture]. The 101 dropped pairs came through two named helpers of the test class, and a source check found no dropped test that calls the helper. 446 kept pairs now show the test's own route (406 one of a set, 28 sound, 12 registered) where the nearer helper carrier route hid it. - a 254-file C# web application whose functional tests share a WebApplicationFactory fixture through IClassFixture: impact on the fixture's CreateHost goes from 0 of 62 tests to 12 tests in 7 files, the classes that name it. Its second fixture type is named only in commented out code and still reaches no test of its own. Its CreateHost is listed with those 12 tests too, through a by-name row: base.CreateHost has an untyped receiver in the graph, and the answer says these routes are "not an exact edge". 16 other targets: 86 pairs before and after. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/axiomcode-impact | 88 ++++++++++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 53 ++++++++++- .../case.json | 50 +++++++++++ .../src/App.Tests/ApiTests.cs | 25 ++++++ .../src/App.Tests/App.Tests.csproj | 5 ++ .../src/App.Tests/DataTests.cs | 23 +++++ .../src/App.Tests/FixtureTests.cs | 56 ++++++++++++ .../src/App.Tests/ShapeTests.cs | 30 +++++++ .../src/App/App.csproj | 3 + .../src/App/Calc.cs | 17 ++++ 10 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/case.json create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ApiTests.cs create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/App.Tests.csproj create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/DataTests.cs create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/FixtureTests.cs create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ShapeTests.cs create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App/App.csproj create mode 100644 tests/cases/csharp/test-class-member-serves-its-tests/src/App/Calc.cs diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 25fe782c..4343911f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -934,8 +934,93 @@ class Impact: L = self.code(s['file']); pat = re.compile(rf'\b{re.escape(name)}\b') return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] + # ── C# TEST LINKS THE RUNNER MAKES, which no call site records ────────────────────────────────────────────── + # An xUnit / NUnit / MSTest test class runs only its lifecycle members before or after each test; any other + # member runs when something calls it (a recorded edge) or when a test's DATA attribute names it. Two links are + # written in attribute arguments and base lists, which the graph keeps as names only (a C# decoration's text is + # `@MemberData`, without its arguments), so they are read from the source here and joined as rules: + # cs_data_source(m, tn, n) test m's [MemberData] / [TestCaseSource] / [DynamicData] names member n, of type tn + # ("" when the attribute names no type: the test's own class and its bases) + # cs_data_type(m, tn) test m's [ClassData(typeof(C))] or [TestCaseSource(typeof(C))]: C supplies the rows + # cs_fixture_type(t, xn) xUnit builds xn before the tests of class t: `t : IClassFixture`, or t is in a + # [Collection("n")] whose [CollectionDefinition("n")] class is `ICollectionFixture` + # Types are written as simple names and joined to `typ` by name in the rules. + CS_DATA_ATTR = re.compile(r'^(MemberData|TestCaseSource|DynamicData|ClassData)(Attribute)?$') + CS_FIXTURE_BASE = re.compile(r'\bI(Class|Collection)Fixture\s*<\s*([A-Za-z_][\w.]*)') + + def cs_attr_args(self, file, line, name): + """the argument text of attribute `name` written at file:line, read to its closing parenthesis (at most 8 + lines on), or None when the line does not hold it""" + L = self.lines(file) + if not (0 < line <= len(L)): return None + text = '\n'.join(L[line - 1:line + 7]) + m = re.search(rf'\b{re.escape(name)}(?:Attribute)?\s*\(', text) + if not m: return None + depth, i = 1, m.end() + while i < len(text) and depth: + depth += {'(': 1, ')': -1}.get(text[i], 0); i += 1 + return text[m.end():i - 1] if not depth else None + + @staticmethod + def cs_split_args(s): + out, depth, cur = [], 0, '' + for ch in s: + if ch in '(<[{': depth += 1 + elif ch in ')>]}': depth -= 1 + if ch == ',' and depth == 0: out.append(cur.strip()); cur = '' + else: cur += ch + if cur.strip(): out.append(cur.strip()) + return out + + def cs_test_links(self, g, tm, W): + simple = lambda s: re.sub(r'<.*$', '', s.strip()).split('.')[-1] + src, typ_, fixt = set(), set(), set() + decs = collections.defaultdict(list) + for r in (g.q("SELECT owner_id, name, file, line FROM decorations WHERE file LIKE '%.cs'") if g.has('decorations') else []): + if r['owner_id'] in g.sym and r['file'] and r['line']: + decs[r['owner_id']].append((simple(r['name'] or ''), r['file'], r['line'])) + for m in tm: + for n, f, l in decs.get(m, ()): + if not self.CS_DATA_ATTR.match(n): continue + args = self.cs_attr_args(f, l, n) + if args is None: continue + name, tn = None, '' + for a in self.cs_split_args(args): + kv = re.match(r'^(\w+)\s*=\s*(.*)$', a, re.S) + if kv and kv.group(1) != 'MemberType': continue # DisableDiscoveryEnumeration = true, ... + v = kv.group(2) if kv else a + t = re.fullmatch(r'typeof\s*\(\s*([\w.<>, ]+?)\s*\)', v.strip()) + s = re.fullmatch(r'nameof\s*\(\s*([\w.]+)\s*\)', v.strip()) or re.fullmatch(r'@?"([A-Za-z_]\w*)"', v.strip()) + if t and not tn: tn = simple(t.group(1)) + elif s and name is None: name = s.group(1).split('.')[-1] + if name: src.add((m, tn, name)) + elif tn: typ_.add((m, tn)) + # fixture types: a class's own base list, and a collection's definition joined by the collection's name + def header(i): + sy = g.sym[i]; L = self.lines(sy['file']) + a = sy['line']; b = min(len(L), a + 8, sy.get('end_line') or a + 8) + return re.split(r'[{;]', '\n'.join(L[a - 1:b]), 1)[0] if 0 < a <= len(L) else '' + coll_def, coll_of = collections.defaultdict(set), collections.defaultdict(set) + for i, sy in g.sym.items(): + if not (sy.get('type_id') and not sy.get('method_id') and (sy.get('file') or '').endswith('.cs') and sy.get('line')): continue + h = header(i); ds = decs.get(i, ()) + for n, f, l in ds: + if n in ('Collection', 'CollectionDefinition'): + args = self.cs_attr_args(f, l, n) + k = re.search(r'"([^"]+)"', args or '') or re.search(r'nameof\s*\(\s*([\w.]+)\s*\)', args or '') + if k: (coll_def if n == 'CollectionDefinition' else coll_of)[k.group(1).split('.')[-1] if 'nameof' in k.group(0) else k.group(1)].add(i) + for kind, xn in self.CS_FIXTURE_BASE.findall(h): + if kind == 'Class': fixt.add((i, simple(xn))) + else: fixt.add(('collection:' + i, simple(xn))) + for key, ds in coll_def.items(): + for d in ds: + for (h, xn) in [x for x in fixt if x[0] == 'collection:' + d]: + for t in coll_of.get(key, ()): fixt.add((t, xn)) + W('cs_data_source', sorted(src)); W('cs_data_type', sorted(typ_)) + W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) + # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '53' # 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 is claimed by another branch); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '55' # 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1335,6 +1420,7 @@ class Impact: fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or _gs.is_fixture_callable(s['name'], decs.get(i, ())))] W('test_method', [(m,) for m in tm]); W('fixture', [(m,) for m in fx]) W('runs_before', sorted(self.wide_fixtures(tm, decs))) + self.cs_test_links(g, set(tm), W) # ── which fixture runs before which test, when nothing calls it ──────────────────────────────────────── # pytest hands a fixture to a test BY THE NAME OF A PARAMETER, and the fixture that serves a whole directory # is declared in `conftest.py`, which is not the test's file and is imported by nothing. Neither end is a call diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 71b675eb..600cf0fe 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -113,6 +113,14 @@ // usefixtures_scope(p, n) a usefixtures marker no declaration carries: a module's `pytestmark` (p is that // file) or the ini option (p is the directory holding the ini file) .decl usefixtures_scope(p:symbol, n:symbol) .input usefixtures_scope +// cs_data_source(m, tn, n) C# test m's [MemberData] / [TestCaseSource] / [DynamicData] names member n of type +// tn ("" = the test's own class and its bases) +// cs_data_type(m, tn) C# test m's [ClassData(typeof(tn))] / [TestCaseSource(typeof(tn))] +// cs_fixture_type(t, xn) xUnit builds type xn before each test of class t (IClassFixture, or a +// collection whose definition is ICollectionFixture) +.decl cs_data_source(m:symbol, tn:symbol, n:symbol) .input cs_data_source +.decl cs_data_type(m:symbol, tn:symbol) .input cs_data_type +.decl cs_fixture_type(t:symbol, xn:symbol) .input cs_fixture_type .decl kind(s:symbol, k:symbol) .input kind .decl decl_file(s:symbol, f:symbol) .input decl_file .decl lex_parent(inner:symbol, outer:symbol) .input lex_parent @@ -1022,7 +1030,8 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // ...except a callable written INSIDE a test method (a C# or Java lambda, a local function): it belongs to that test, // not to its type. Owned by the class, a `mock.Setup(s => ...)` or `Func f = () => ...` in one test carried its // callees to every test of the class as [fixture], and the test holding it lost its own route (#1556). -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), !in_test_body(c), decl_file(c, f), is_test_file(f), !java_decl(c). +// Java (#1416) and C# (#1499) test-class members are scoped by their own rules below, not by their class. +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), !in_test_body(c), decl_file(c, f), is_test_file(f), !java_decl(c), !cs_decl(c). .decl in_test_body(c:symbol) in_test_body(c) :- lex_in(m, c), test_method(m), !test_method(c). test_hit(q, m, d, c) :- reach(q, c, d), owner(c, _), in_test_body(c), lex_in(m, c), test_method(m). @@ -1047,6 +1056,35 @@ java_decl(c) :- decl_file(c, f), match(".*[.]java", f). java_teardown(c) :- java_decl(c), decorated(c, n), match("After.*", n), owner(c, _), !test_method(c). java_teardown(c) :- java_decl(c), member(_, c, "tearDown", _), owner(c, _), !test_method(c). test_hit(q, m, d, c) :- reach(q, c, d), java_teardown(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). +// C#: A MEMBER OF A TEST CLASS SERVES THE TESTS THE RUNNER OR A CALL LINKS IT TO, NOT EVERY TEST OF THE CLASS (#1499). +// xUnit, NUnit and MSTest run a test class's constructor and its set-up and tear-down members (the fixture rule +// above), its IDisposable / IAsyncLifetime members (cs_lifecycle below), the data source a test's attribute names +// (uses_fixture), and a callable written inside the test (in_test_body above). Any other member runs when something +// calls it, and C# calls are recorded edges, so a test that calls a helper reaches the change through it on its own +// route. Crediting a helper to every test of its class listed tests that never call it, as [fixture]: a [MemberData] +// source on the test's own class was credited to every test of the class, not to the one that names it. A member +// nothing calls or names credits no test. +.decl cs_decl(c:symbol) +cs_decl(c) :- decl_file(c, f), match(".*[.]cs", f). +// the members an xUnit runner calls around every test of their class by the interface the class implements, which no +// decoration marks: IAsyncLifetime's InitializeAsync / DisposeAsync and IDisposable.Dispose. A change one of them +// reaches fails each of those tests. The decorated set-up and tear-down members are `fixture` already. +.decl cs_lifecycle(c:symbol) +cs_lifecycle(c) :- cs_decl(c), owner(c, t), !test_method(c), member(t, c, n, _), (n = "InitializeAsync" ; n = "DisposeAsync" ; n = "Dispose"). +test_hit(q, m, d, c) :- reach(q, c, d), cs_lifecycle(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). +// xUnit builds a class fixture (IClassFixture) or a collection fixture (ICollectionFixture, joined through the +// collection's name) before the tests of each class that names it, and disposes it after: X's constructors and +// lifecycle members run for those tests, though X declares no test and no call site names it (#1498). So does a +// member of X that nothing in the graph calls: an override the framework base calls (a WebApplicationFactory's +// CreateHost / ConfigureWebHost, which builds the application every such test talks to). A member of X that a test +// calls on the injected instance is carried by that call instead. A fixture type no test class names reaches no test. +.decl cs_fixture_member(c:symbol, x:symbol) +cs_fixture_member(c, x) :- cs_decl(c), owner(c, x), kind(c, "constructor"). +cs_fixture_member(c, x) :- cs_decl(c), owner(c, x), member(x, c, n, _), (n = "InitializeAsync" ; n = "DisposeAsync" ; n = "Dispose"). +cs_fixture_member(c, x) :- cs_decl(c), owner(c, x), member(x, c, _, _), !cs_called(c). +.decl cs_called(c:symbol) +cs_called(c) :- edge(a, c, t), t != "defines", a != c. +test_hit(q, m, d, c) :- reach(q, c, d), cs_fixture_member(c, x), typ(x, xn, _), cs_fixture_type(t, xn), cs_decl(t), scope(t, s), member(s, m, _, _), test_method(m). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -1122,6 +1160,14 @@ uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m uses_fixture(m, fx) :- dec_literal(m, "MethodSource", n, _, _), test_method(m), java_decl(m), owner(m, u), scope(t, u), member(t, fx, n, _), fx != m, !test_method(fx). uses_fixture(m, fx) :- decorated(m, "MethodSource"), !dec_literal(m, "MethodSource", _, _, _), test_method(m), java_decl(m), member(_, m, n, _), owner(m, u), scope(t, u), member(t, fx, n, _), fx != m, !test_method(fx). +// A C# data attribute names the member that supplies a parameterized test's rows, and the runner calls it for that +// test only (#1499): [MemberData(nameof(Rows))] in the test's class or a class it extends or is nested in, and +// [MemberData(nameof(Cases.Rows), MemberType = typeof(Cases))] (NUnit's [TestCaseSource(typeof(C), nameof(X))] and +// MSTest's [DynamicData(nameof(X), typeof(C))] alike) in the type it names. [ClassData(typeof(C))] builds C and +// enumerates it: every callable C declares serves that test. +uses_fixture(m, fx) :- cs_data_source(m, "", n), test_method(m), owner(m, u), scope(t, u), member(t, fx, n, _), fx != m, !test_method(fx). +uses_fixture(m, fx) :- cs_data_source(m, tn, n), tn != "", test_method(m), typ(t, tn, _), cs_decl(t), member(t, fx, n, _), fx != m, !test_method(fx). +uses_fixture(m, c) :- cs_data_type(m, tn), test_method(m), typ(t, tn, _), cs_decl(t), owner(c, t), c != m, !test_method(c). // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. test_hit(q, m, d, fx) :- reach(q, fx, d), uses_fixture(m, fx), test_method(m). @@ -1153,10 +1199,13 @@ 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), !in_test_body(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f), !java_decl(c). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), !in_test_body(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f), !java_decl(c), !cs_decl(c). // a Java helper holding the stub is walked up to its callers by the edge rule, and a lambda in a test body reaches its // test through stub_near's lex_in step (#1416, #1556); a teardown is credited to every test of its class test_stub(q, m) :- stub_near(q, c), java_teardown(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). +// C#: a stub written in a helper or a lambda is walked up to the test that calls or holds it by the rules above; only a +// lifecycle member is credited to every test of its class, as the test_hit rules do (#1556) +test_stub(q, m) :- stub_near(q, c), cs_lifecycle(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m). .output test_stub // ── bound from outside the source ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/case.json b/tests/cases/csharp/test-class-member-serves-its-tests/case.json new file mode 100644 index 00000000..df29dee2 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/case.json @@ -0,0 +1,50 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "control (#1556): a lambda written inside one test serves that test and not a sibling, with the C# helper rule narrowed", + "run": ["impact", "Calc.Area", "--tests-only", "--why"], + "want": ["ShapeTests::AreaThroughALambda"], + "avoid": ["ShapeTests::PerimeterOnly", "ShapeTests::UsesHelper"]}, + {"why": "control: a direct call from one test names that test only", + "run": ["impact", "Calc.Perimeter", "--tests-only"], + "want": ["ShapeTests::PerimeterOnly", "[sound]"], + "avoid": ["ShapeTests::AreaThroughALambda", "ShapeTests::UsesHelper"]}, + {"why": "a helper of the test class serves the test that calls it, on that test's own resolved route", + "run": ["impact", "Calc.Scale", "--tests-only", "--why"], + "want": ["ShapeTests::UsesHelper", "ShapeTests.UsesHelper → ShapeTests.Scaled → Calc.Scale"], + "avoid": ["ShapeTests::PerimeterOnly", "ShapeTests::AreaThroughALambda", "[fixture]"]}, + {"why": "near-miss control: a helper of the test class that no test calls or names is credited to no test", + "run": ["impact", "Calc.Unused", "--tests-only"], + "want": ["0 of"], + "avoid": ["ShapeTests::"]}, + {"why": "xUnit builds an IClassFixture before the tests of the class that names it: T's constructor serves them (#1498)", + "run": ["impact", "Calc.Open", "--tests-only", "--why"], + "want": ["StoreTests::Count", "via ConnectionFixture.", "[fixture]"], + "avoid": ["PlainTests::Nothing", "QueryTests::Query"]}, + {"why": "near-miss control: a fixture class no test class names reaches no test (#1498)", + "run": ["impact", "Calc.Orphan", "--tests-only"], + "want": ["0 of"], + "avoid": ["StoreTests::", "PlainTests::"]}, + {"why": "an override of a class fixture's framework base (a WebApplicationFactory's ConfigureWebHost) runs for every test of the class that names the fixture, though nothing in the graph calls it (#1498)", + "run": ["impact", "Calc.Host", "--tests-only", "--why"], + "want": ["ApiTests::UsesHelper", "ApiTests::Plain", "via ApiFactory.ConfigureWebHost"], + "avoid": ["StoreTests::Count", "PlainTests::Nothing"]}, + {"why": "near-miss control: a member of the class fixture that a test calls is carried by that call, to that test only", + "run": ["impact", "Calc.Helped", "--tests-only", "--why"], + "want": ["ApiTests::UsesHelper", "ApiTests.UsesHelper → ApiFactory.Helper → Calc.Helped"], + "avoid": ["ApiTests::Plain"]}, + {"why": "a collection fixture serves the tests of each class in the [Collection] its [CollectionDefinition] names", + "run": ["impact", "Calc.Shared", "--tests-only"], + "want": ["QueryTests::Query"], + "avoid": ["StoreTests::Count", "PlainTests::Nothing"]}, + {"why": "a test class's Dispose runs after each of its tests, so what it reaches is credited to every test of that class", + "run": ["impact", "Calc.Close", "--tests-only"], + "want": ["CleanupTests::First", "CleanupTests::Second"], + "avoid": ["PlainTests::Nothing", "StoreTests::Count"]}, + {"why": "a [MemberData] source on another type (MemberType = typeof(...)) serves the test that names it (#1499)", + "run": ["impact", "Calc.External", "--tests-only", "--why"], + "want": ["DataTests::FromOtherType", "via ExternalCases.Rows"], + "avoid": ["DataTests::FromOwnType"]}, + {"why": "a [MemberData] source on the test's own class serves the test that names it, not every test of the class (#1499)", + "run": ["impact", "Calc.Local", "--tests-only", "--why"], + "want": ["DataTests::FromOwnType", "via DataTests.LocalRows"], + "avoid": ["DataTests::FromOtherType"]}]} diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ApiTests.cs b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ApiTests.cs new file mode 100644 index 00000000..4cf71870 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ApiTests.cs @@ -0,0 +1,25 @@ +using App; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace App.Tests; + +public class ApiFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(object builder) { Calc.Host(1); } + + public int Helper() => Calc.Helped(1); +} + +public class ApiTests : IClassFixture +{ + private readonly ApiFactory _factory; + + public ApiTests(ApiFactory factory) { _factory = factory; } + + [Fact] + public void UsesHelper() => Assert.Equal(18, _factory.Helper()); + + [Fact] + public void Plain() => Assert.True(true); +} diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/App.Tests.csproj b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/App.Tests.csproj new file mode 100644 index 00000000..59186e7d --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/App.Tests.csproj @@ -0,0 +1,5 @@ + + net8.0 + + + diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/DataTests.cs b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/DataTests.cs new file mode 100644 index 00000000..1ce59f27 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/DataTests.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using App; +using Xunit; + +namespace App.Tests; + +public static class ExternalCases +{ + public static IEnumerable Rows() { Calc.External(1); return new[] { new object[] { 1 } }; } +} + +public class DataTests +{ + public static IEnumerable LocalRows() { Calc.Local(1); return new[] { new object[] { 1 } }; } + + [Theory] + [MemberData(nameof(ExternalCases.Rows), MemberType = typeof(ExternalCases))] + public void FromOtherType(int q) => Assert.True(q > 0); + + [Theory] + [MemberData(nameof(LocalRows))] + public void FromOwnType(int q) => Assert.True(q > 0); +} diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/FixtureTests.cs b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/FixtureTests.cs new file mode 100644 index 00000000..ee48b6a0 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/FixtureTests.cs @@ -0,0 +1,56 @@ +using System; +using App; +using Xunit; + +namespace App.Tests; + +public class ConnectionFixture : IDisposable +{ + public ConnectionFixture() { Calc.Open(1); } + public void Dispose() { } +} + +public class OrphanFixture +{ + public OrphanFixture() { Calc.Orphan(1); } +} + +public class StoreTests : IClassFixture +{ + public StoreTests(ConnectionFixture fx) { } + + [Fact] + public void Count() => Assert.True(true); +} + +public class PlainTests +{ + [Fact] + public void Nothing() => Assert.True(true); +} + +public class DatabaseFixture +{ + public DatabaseFixture() { Calc.Shared(1); } +} + +[CollectionDefinition("db")] +public class DatabaseCollection : ICollectionFixture { } + +[Collection("db")] +public class QueryTests +{ + [Fact] + public void Query() => Assert.True(true); +} + +public class CleanupTests : IDisposable +{ + public void Dispose() { Calc.Close(1); } + + [Fact] + public void First() => Assert.True(true); + + [Fact] + public void Second() => Assert.True(true); +} diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ShapeTests.cs b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ShapeTests.cs new file mode 100644 index 00000000..a2304f18 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App.Tests/ShapeTests.cs @@ -0,0 +1,30 @@ +using System; +using App; +using Xunit; + +namespace App.Tests; + +public class ShapeTests +{ + static int Scaled() => Calc.Scale(2); + static int NeverCalled() => Calc.Unused(2); + + [Fact] + public void AreaThroughALambda() + { + Func area = () => Calc.Area(2, 3); + Assert.Equal(6, area()); + } + + [Fact] + public void PerimeterOnly() + { + Assert.Equal(10, Calc.Perimeter(2, 3)); + } + + [Fact] + public void UsesHelper() + { + Assert.Equal(6, Scaled()); + } +} diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App/App.csproj b/tests/cases/csharp/test-class-member-serves-its-tests/src/App/App.csproj new file mode 100644 index 00000000..c3c35549 --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App/App.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/test-class-member-serves-its-tests/src/App/Calc.cs b/tests/cases/csharp/test-class-member-serves-its-tests/src/App/Calc.cs new file mode 100644 index 00000000..02edbfbd --- /dev/null +++ b/tests/cases/csharp/test-class-member-serves-its-tests/src/App/Calc.cs @@ -0,0 +1,17 @@ +namespace App; + +public static class Calc +{ + public static int Area(int w, int h) => w * h; + public static int Perimeter(int w, int h) => 2 * (w + h); + public static int Scale(int q) => q * 3; + public static int Unused(int q) => q - 1; + public static int Open(int q) => q + 10; + public static int Orphan(int q) => q + 11; + public static int Shared(int q) => q + 12; + public static int External(int q) => q + 13; + public static int Local(int q) => q + 14; + public static int Close(int q) => q + 15; + public static int Host(int q) => q + 16; + public static int Helped(int q) => q + 17; +} From e673f60e00adafa699b4fd530eb0f40ec1813038 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:20:27 -0700 Subject: [PATCH 056/258] tests: the derived-attribute near miss expects no test through an uncalled C# test-class member The derived-test-attribute case (#1497) was written before the C# test-class scoping change (#1499) and expected its near miss, a method under [Audited] that no test calls, to reach a sibling test through the class-scope helper route ('via PricerTests.AuditTrail'). With #1499 a C# test-class member serves only the tests the runner or a call links it to, and nothing calls AuditTrail, so impact on Pricer.Audit now reports 0 of 3 test methods and lists AuditTrail as an uncredited caller. The near miss still holds: AuditTrail is not a test and the count stays 3. This mirrors the Java composed-annotation case after #1416. --- tests/cases/csharp/derived-test-attribute/case.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cases/csharp/derived-test-attribute/case.json b/tests/cases/csharp/derived-test-attribute/case.json index d16f6ff6..adaaec04 100644 --- a/tests/cases/csharp/derived-test-attribute/case.json +++ b/tests/cases/csharp/derived-test-attribute/case.json @@ -8,10 +8,10 @@ "run": ["impact", "Pricer.Sweep", "--tests"], "want": ["1 of 3 test method(s)", "PricerTests::SweepsNightly"], "avoid": ["PricerTests::TaxesPlainly", "[fixture]"]}, - {"why": "near miss: an attribute derived from Attribute alone does not make its method a test", + {"why": "near miss: an attribute derived from Attribute alone does not make its method a test; it stays a helper, and a C# test-class member nothing calls credits no test (#1499), so no sibling is listed through it", "run": ["impact", "Pricer.Audit", "--tests"], - "want": ["of 3 test method(s)", "via PricerTests.AuditTrail"], - "avoid": ["PricerTests::AuditTrail", "of 4 test method(s)"]}, + "want": ["0 of 3 test method(s)"], + "avoid": ["PricerTests::AuditTrail", "of 4 test method(s)", "via PricerTests.AuditTrail"]}, {"why": "control: a direct [Fact] is a test as before", "run": ["impact", "Pricer.Tax", "--tests"], "want": ["1 of 3 test method(s)", "[sound] 1 test(s)", "PricerTests::TaxesPlainly"], From e12e5a290804195e41d3de99f2a1c590ffb4eaec Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:41:58 -0700 Subject: [PATCH 057/258] tests: bless the handed-over filter edge in 67-framework-registered-entry-points The Java engine suite failed 67 since the handed-over-callbacks rule (call-edge-generation/callback_dispatch.dl) landed: it adds callback_registered callback AppInit#onStartup(Set,ServletContext) -> InitFilter#doFilter(ServletRequest,ServletResponse,FilterChain) The edge is true. AppInit.onStartup calls ctx.addFilter("audit", new InitFilter()): a client object created for a call that leaves the client, handed to the servlet container, which keeps the instance and calls its doFilter for the requests the filter serves. No client call site names doFilter; the container makes that call. This is the shape the rule is for. InitFilter writes no @Override and names the external Filter interface itself, so its public doFilter is the method the library can call back (rule branch c). The case's config golden already lists InitFilter#doFilter as a web_filter entry point from this same registration; the new row ties that entry to the method that registers it. The tier says the library may call it, not that it does, so the absent URL mapping on this registration does not make it wrong. The case's controls still hold: ctx.addServlet(.., OrderServlet.class) and ctx.addListener(StartupListener.class) pass a class literal, not an object, and bus.addListener(BusListener.class) on a client receiver resolves to client code, so none of them gains a callback row. Suites: Java engine suite (run-suite.sh java --oracle --no-torture), passed 77 failed 2 before these two commits, passed 79 failed 0 after (with the XML id fix that follows). tests/run.py --lang java 298 of 298 and tests/fastpath.py --lang java 8 of 8, before and after. --- .../java/expected/67-framework-registered-entry-points.edges | 1 + 1 file changed, 1 insertion(+) diff --git a/graph/test/java/expected/67-framework-registered-entry-points.edges b/graph/test/java/expected/67-framework-registered-entry-points.edges index 84af5fa4..7b81a0c8 100644 --- a/graph/test/java/expected/67-framework-registered-entry-points.edges +++ b/graph/test/java/expected/67-framework-registered-entry-points.edges @@ -4,6 +4,7 @@ ambiguous_unknown new app.init.BootConfig#bootServlet() -> - boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addFilter boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addListener boundary_lib method app.init.AppInit#onStartup(Set,ServletContext) -> external:jakarta.servlet.ServletContext.addServlet +callback_registered callback app.init.AppInit#onStartup(Set,ServletContext) -> app.init.InitFilter#doFilter(ServletRequest,ServletResponse,FilterChain) known_edge method app.init.AppInit#wire(EventBus) -> app.init.EventBus#addListener(Class) known_edge new app.beans.AppConfig#plain() -> app.beans.Plain#() known_edge new app.beans.AppConfig#pool() -> app.beans.Pool#() From d88379057a6dce5fe5aafea4a568540a105ec8e2 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:41:58 -0700 Subject: [PATCH 058/258] parser: XML ids hash the path relative to the analysis root, not the checkout path What was wrong 70-persistence-derived-and-mapping-file failed in every checkout except the one its golden was blessed in, CI included. Its config golden prints a declared unknown (config_unresolved spel_expression), and that row is named by the value reference's id, XML_VALUE_REFERENCE_. XmlValueReference hashed the file's absolute path, and its owner element's id, which it also hashes, hashed the absolute file path and the absolute project path. XmlAttribute hashed the absolute path the same way. So every XML element, attribute and value-reference id moved with the directory the tree was parsed from. The change - XmlElement, XmlAttribute and XmlValueReference take an idPath (withIdPath) and hash it in place of the absolute filePath; an element no longer hashes the absolute project path when it has one. The absolute paths stay as payload columns, so the CSV layout and every reader are unchanged. A builder given no idPath hashes as before. - XmlProjectAnalyzer.analyzeXmlFiles takes the analysis root, and extract.ts passes the directory the run was asked to parse. idPath is the file's path relative to it, '/'-separated (toIdPath in xml-parser.ts). Relative to the root rather than to the owning project, so two modules that each hold an identical beans.xml keep distinct ids. A file outside the root keeps its absolute path. - XmlParser.parse threads idPath to every element, attribute and value-reference builder, including the nested references in a placeholder default. - 70's golden is re-blessed with the portable id. No other golden prints an XML id. Tests - New preflight graph/test/tools/xml-id-portable-test.sh, run by the Java engine suite beside xml-skip: one tree parsed from two directories gives the same element, attribute and value-reference ids; control, two byte-identical files at different paths in one tree still get different ids; neither run may be empty. On the parser before this change it fails (10 element, 12 attribute and 4 value-reference ids moved); after, it passes. - 70 passes from this worktree and from a copy of it at a different, deeper path. - Java engine suite (run-suite.sh java --oracle --no-torture): passed 77 failed 2 before (67 and 70), passed 79 failed 0 after, with the new preflight passing. tests/run.py --lang java: 298 of 298 checks before and after. tests/fastpath.py --lang java: 8 of 8 before and after. --- ...ersistence-derived-and-mapping-file.config | 2 +- graph/test/java/run-tests.sh | 6 ++ graph/test/tools/xml-id-portable-test.sh | 59 +++++++++++++++++++ parser/src/analysis-types/xml/XmlAttribute.ts | 16 ++++- parser/src/analysis-types/xml/XmlElement.ts | 19 +++++- .../analysis-types/xml/XmlValueReference.ts | 17 +++++- parser/src/extract.ts | 2 +- parser/src/parsers/xml/xml-parser.ts | 43 ++++++++++++-- .../src/workflows/xml/xml-project-analyzer.ts | 16 +++-- 9 files changed, 163 insertions(+), 17 deletions(-) create mode 100755 graph/test/tools/xml-id-portable-test.sh diff --git a/graph/test/java/expected/70-persistence-derived-and-mapping-file.config b/graph/test/java/expected/70-persistence-derived-and-mapping-file.config index 46de6715..368f269b 100644 --- a/graph/test/java/expected/70-persistence-derived-and-mapping-file.config +++ b/graph/test/java/expected/70-persistence-derived-and-mapping-file.config @@ -14,7 +14,7 @@ ── config_entry_point (0) ── ── bean_condition (0) ── ── config_unresolved [DECLARED UNKNOWNS] (1) ── - spel_expression xml XML_VALUE_REFERENCE_dc1d72626b3bf150d11939c7baf98c85 "systemProperties['user.timezone']" + spel_expression xml XML_VALUE_REFERENCE_ae976e37adac6d6e762a1706dacfcc43 "systemProperties['user.timezone']" ── remote_edge (0) ── ── remote_unserved [SENT, NO CONSUMER HERE] (0) ── ── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index 964ecb5e..18db81e1 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -187,6 +187,12 @@ if ! bash "$ROOT/graph/test/tools/xml-skip-test.sh"; then echo "aborting: a file the extractor failed on left no skip row" exit 1 fi +# An XML id is printed by the config golden (a declared unknown names its value reference), +# so an id that hashes the checkout path fails every checkout but the one that blessed it. +if ! bash "$ROOT/graph/test/tools/xml-id-portable-test.sh"; then + echo "aborting: XML ids depend on the directory the tree was parsed from" + exit 1 +fi if ! bash "$HERE/tools/synthetic-callee-test.sh"; then echo "aborting: the class-file oracle emits compiler-generated callees as ground truth" diff --git a/graph/test/tools/xml-id-portable-test.sh b/graph/test/tools/xml-id-portable-test.sh new file mode 100755 index 00000000..63479158 --- /dev/null +++ b/graph/test/tools/xml-id-portable-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# AN XML ID MUST NOT DEPEND ON WHERE THE TREE IS CHECKED OUT. +# +# Every XML element, attribute and value-reference id hashed the file's ABSOLUTE path +# (an element's also the absolute project path). A value reference's id is what a +# declared unknown names (config_unresolved spel_expression), so the Java golden that +# printed one passed only in the checkout it was blessed in, CI included. +# +# The ids now hash the path relative to the analysis root. Checked here: +# 1. one tree parsed from two directories gives the same element, attribute and +# value-reference ids; +# 2. CONTROL: two byte-identical files at different relative paths in one tree still +# get different ids, so the key did not simply stop naming the file; +# 3. neither run is empty, so (1) cannot pass on two empty sets. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$HERE"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +[ -f "$PARSER" ] || { echo "xml-id-portable: SKIP (no parser at $PARSER)"; exit 0; } +command -v node >/dev/null 2>&1 || { echo "xml-id-portable: SKIP (no node)"; exit 0; } + +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +mk() { # mk + local p="$1" + mkdir -p "$p/a/src/main/resources" "$p/b/src/main/resources" + printf '4.0.0\n' > "$p/pom.xml" + local x='\n \n \n \n \n\n' + printf "$x" > "$p/a/src/main/resources/beans.xml" + printf "$x" > "$p/b/src/main/resources/beans.xml" +} +mk "$W/one/proj" +mk "$W/two/deeper/checkout/proj" +node "$PARSER" "$W/one/proj" xmlid false "$W/ir1" >"$W/p1.log" 2>&1 +node "$PARSER" "$W/two/deeper/checkout/proj" xmlid false "$W/ir2" >"$W/p2.log" 2>&1 + +fail=0; bad(){ echo " ✗ $*"; fail=$((fail+1)); } +# The row's own id is the last column of each table. +ids() { [ -f "$1" ] && awk -F'\t' 'NR>1 { print $NF }' "$1" | sort; } +# The ids of the rows of one module's file, by the file path column ($2 = a|b). +ids_of() { [ -f "$1" ] && awk -F'\t' -v m="/$2/src/main/resources/beans.xml" 'NR>1 && index($0, m) { print $NF }' "$1" | sort; } + +for t in all-xml-elements all-xml-attributes all-xml-value-references; do + n1=$(ids "$W/ir1/$t.csv" | grep -c . || true); n2=$(ids "$W/ir2/$t.csv" | grep -c . || true) + [ "${n1:-0}" -ge 2 ] || bad "$t: the first run wrote ${n1:-0} rows, expected at least 2" + [ "${n1:-0}" = "${n2:-0}" ] || bad "$t: ${n1:-0} rows from one directory, ${n2:-0} from the other" + if ! diff -q <(ids "$W/ir1/$t.csv") <(ids "$W/ir2/$t.csv") >/dev/null; then + bad "$t: the same tree got different ids in two directories ($(comm -23 <(ids "$W/ir1/$t.csv") <(ids "$W/ir2/$t.csv") | grep -c .) moved)" + fi + a=$(ids_of "$W/ir1/$t.csv" a); b=$(ids_of "$W/ir1/$t.csv" b) + [ -n "$a" ] && [ -n "$b" ] || bad "$t: no rows for one of the two identical files" + [ -z "$(comm -12 <(printf '%s\n' "$a") <(printf '%s\n' "$b") | grep .)" ] \ + || bad "$t: two identical files at different paths share an id" +done +[ "$(awk -F'\t' 'NR>1 && $2=="SPEL_EXPRESSION"' "$W/ir1/all-xml-value-references.csv" 2>/dev/null | grep -c .)" -ge 2 ] \ + || bad "no SPEL_EXPRESSION value reference was extracted, so the id a declared unknown prints was not checked" + +if [ "$fail" -eq 0 ]; then echo "xml-id-portable: ok (same ids from two directories, identical files at two paths still distinct)"; else echo "xml-id-portable: $fail failure(s)"; exit 1; fi diff --git a/parser/src/analysis-types/xml/XmlAttribute.ts b/parser/src/analysis-types/xml/XmlAttribute.ts index ab0d4aac..dbe55d39 100644 --- a/parser/src/analysis-types/xml/XmlAttribute.ts +++ b/parser/src/analysis-types/xml/XmlAttribute.ts @@ -26,6 +26,7 @@ export class XmlAttribute implements EntityIdentifiable { private endLine: number; private parentElementHash: string; private serviceVersionLinkHash: string; + private idPath: string; private xmlAttributeUniqueHash: string = ''; private constructor(builder: XmlAttributeBuilder) { @@ -38,6 +39,7 @@ export class XmlAttribute implements EntityIdentifiable { this.endLine = builder.endLine; this.parentElementHash = builder.parentElementHash; this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.idPath = builder.idPath; this.generateHash(); } @@ -77,7 +79,7 @@ export class XmlAttribute implements EntityIdentifiable { this.name + '||' + this.value + '||' + this.parentElementHash + - '||' + this.filePath + + '||' + (this.idPath || this.filePath) + '||' + this.startLine + '||' + this.serviceVersionLinkHash; @@ -132,6 +134,7 @@ class XmlAttributeBuilder { endLine: number; parentElementHash: string; serviceVersionLinkHash: string; + idPath: string = ''; constructor( name: string, @@ -158,6 +161,17 @@ class XmlAttributeBuilder { return this; } + /** + * The file's path as its key sees it: relative to the analysis root, '/'-separated. + * The absolute filePath stays a payload column, but it moves with the directory the + * analysis ran in, so a key built from it differed in every checkout of one tree. + * Unset (a caller that never says), the key falls back to filePath as before. + */ + withIdPath(idPath: string): XmlAttributeBuilder { + this.idPath = idPath; + return this; + } + build(): XmlAttribute { return new (XmlAttribute as any)(this); } diff --git a/parser/src/analysis-types/xml/XmlElement.ts b/parser/src/analysis-types/xml/XmlElement.ts index e1bb6714..4d29f5aa 100644 --- a/parser/src/analysis-types/xml/XmlElement.ts +++ b/parser/src/analysis-types/xml/XmlElement.ts @@ -36,6 +36,7 @@ export class XmlElement implements EntityIdentifiable { private endLine: number; private parentElementHash: string; private serviceVersionLinkHash: string; + private idPath: string; private xmlElementUniqueHash: string = ''; private constructor(builder: XmlElementBuilder) { @@ -53,6 +54,7 @@ export class XmlElement implements EntityIdentifiable { this.endLine = builder.endLine; this.parentElementHash = builder.parentElementHash; this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.idPath = builder.idPath; this.generateHash(); } @@ -96,8 +98,9 @@ export class XmlElement implements EntityIdentifiable { const content = this.tagName + '||' + this.xPath + - '||' + this.filePath + - '||' + this.baseMservPath + + // A root-relative idPath names the file on its own; the absolute file and + // project paths are hashed only where no idPath was given. + (this.idPath ? '||' + this.idPath : '||' + this.filePath + '||' + this.baseMservPath) + '||' + this.startLine + '||' + this.serviceVersionLinkHash; @@ -167,6 +170,7 @@ class XmlElementBuilder { endLine: number; parentElementHash: string = ''; serviceVersionLinkHash: string; + idPath: string = ''; constructor( tagName: string, @@ -218,6 +222,17 @@ class XmlElementBuilder { return this; } + /** + * The file's path as its key sees it: relative to the analysis root, '/'-separated. + * The absolute filePath stays a payload column, but it moves with the directory the + * analysis ran in, so a key built from it differed in every checkout of one tree. + * Unset (a caller that never says), the key falls back to filePath as before. + */ + withIdPath(idPath: string): XmlElementBuilder { + this.idPath = idPath; + return this; + } + build(): XmlElement { return new (XmlElement as any)(this); } diff --git a/parser/src/analysis-types/xml/XmlValueReference.ts b/parser/src/analysis-types/xml/XmlValueReference.ts index 32f172be..1c65b7d6 100644 --- a/parser/src/analysis-types/xml/XmlValueReference.ts +++ b/parser/src/analysis-types/xml/XmlValueReference.ts @@ -32,6 +32,7 @@ export class XmlValueReference implements EntityIdentifiable { private startLine: number; private endLine: number; private serviceVersionLinkHash: string; + private idPath: string; private xmlValueReferenceUniqueHash: string = ''; private constructor(builder: XmlValueReferenceBuilder) { @@ -47,6 +48,7 @@ export class XmlValueReference implements EntityIdentifiable { this.startLine = builder.startLine; this.endLine = builder.endLine; this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.idPath = builder.idPath; this.generateHash(); } @@ -80,6 +82,7 @@ export class XmlValueReference implements EntityIdentifiable { getStartLine(): number { return this.startLine; } getEndLine(): number { return this.endLine; } getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + getIdPath(): string { return this.idPath; } getHash(): string { return this.xmlValueReferenceUniqueHash; @@ -93,7 +96,7 @@ export class XmlValueReference implements EntityIdentifiable { '||' + this.ownerElementHash + '||' + this.ownerAttributeName + '||' + this.depth + - '||' + this.filePath + + '||' + (this.idPath || this.filePath) + '||' + this.startLine + '||' + this.serviceVersionLinkHash; @@ -157,6 +160,7 @@ class XmlValueReferenceBuilder { startLine: number; endLine: number; serviceVersionLinkHash: string; + idPath: string = ''; constructor( referenceExpression: string, @@ -195,6 +199,17 @@ class XmlValueReferenceBuilder { return this; } + /** + * The file's path as its key sees it: relative to the analysis root, '/'-separated. + * The absolute filePath stays a payload column, but it moves with the directory the + * analysis ran in, so a key built from it differed in every checkout of one tree. + * Unset (a caller that never says), the key falls back to filePath as before. + */ + withIdPath(idPath: string): XmlValueReferenceBuilder { + this.idPath = idPath; + return this; + } + build(): XmlValueReference { return new (XmlValueReference as any)(this); } diff --git a/parser/src/extract.ts b/parser/src/extract.ts index 3a0029f4..20037524 100644 --- a/parser/src/extract.ts +++ b/parser/src/extract.ts @@ -311,7 +311,7 @@ export async function extractProject(opts: ExtractOptions): Promise { = await Promise.all([ javaAnalyzer.analyzeJavaProjects(javaProjects, opts.versionLink, excludeTests), propertiesAnalyzer.analyzePropertiesFiles(scanTargets, opts.versionLink), - xmlAnalyzer.analyzeXmlFiles(scanTargets, opts.versionLink), + xmlAnalyzer.analyzeXmlFiles(scanTargets, opts.versionLink, absolutePath), yamlAnalyzer.analyzeYamlFiles(scanTargets, opts.versionLink), gradleAnalyzer.analyzeGradleFiles(scanTargets, opts.versionLink), // META-INF/services is given the same scan targets as the other file-type diff --git a/parser/src/parsers/xml/xml-parser.ts b/parser/src/parsers/xml/xml-parser.ts index 782720dd..0b5b5b92 100644 --- a/parser/src/parsers/xml/xml-parser.ts +++ b/parser/src/parsers/xml/xml-parser.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import * as sax from 'sax'; import { XmlAttribute } from '@/analysis-types/xml/XmlAttribute'; @@ -5,6 +6,22 @@ import { XmlElement } from '@/analysis-types/xml/XmlElement'; import { XmlValueReference } from '@/analysis-types/xml/XmlValueReference'; import { XmlValueReferenceType } from '@/enums/xml/XmlValueReferenceType'; +/** + * The path an XML id hashes: `filePath` relative to `root`, '/'-separated. + * + * Every XML element, attribute and value-reference id used to hash the absolute file path + * (and an element's the absolute project path too), so the same file analysed from two + * directories got two sets of ids. A value reference's id is what a declared unknown names + * (config_unresolved), so it differed in every checkout. Relative to the analysis root it + * is still unique within a run, since each file is analysed once, under one owner. + * A file outside `root` keeps its absolute path rather than a `../` path. + */ +export function toIdPath(root: string, filePath: string): string { + const rel = path.relative(root, filePath); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath; + return rel.split(path.sep).join('/'); +} + /** * Internal state for tracking an open element during SAX parsing. */ @@ -38,13 +55,17 @@ export class XmlParser { * @param filePath Absolute path to the XML file * @param baseMservPath Project root path * @param serviceVersionLinkHash Service version hash + * @param idPath The file's path relative to the analysis root, which every id hashes in + * place of the absolute filePath (see XmlElement.withIdPath). Defaults to the path + * relative to the project path. * @returns Tuple of [XmlElement[], XmlAttribute[], XmlValueReference[]] */ parse( content: string, filePath: string, baseMservPath: string, - serviceVersionLinkHash: string + serviceVersionLinkHash: string, + idPath: string = toIdPath(baseMservPath, filePath) ): [XmlElement[], XmlAttribute[], XmlValueReference[]] { const elements: XmlElement[] = []; const attributes: XmlAttribute[] = []; @@ -118,6 +139,7 @@ export class XmlParser { ) .withNamespace(resolvedUri) .withNamespacePrefix(prefix) + .withIdPath(idPath) .withParentElementHash( elementStack.length > 0 ? elementStack[elementStack.length - 1]!.elementHash @@ -170,6 +192,7 @@ export class XmlParser { serviceVersionLinkHash ) .withNamespace(attrNsUri) + .withIdPath(idPath) .build(); attributes.push(xmlAttr); @@ -183,7 +206,8 @@ export class XmlParser { baseMservPath, startLine, startLine, - serviceVersionLinkHash + serviceVersionLinkHash, + idPath ); valueReferences.push(...attrRefs); } @@ -222,6 +246,7 @@ export class XmlParser { ) .withNamespace(state.namespace) .withNamespacePrefix(state.prefix) + .withIdPath(idPath) .withTextContent(trimmedText) .withIsSelfClosing(isSelfClosing) .withChildCount(state.childCount) @@ -240,7 +265,8 @@ export class XmlParser { baseMservPath, state.startLine, endLine, - serviceVersionLinkHash + serviceVersionLinkHash, + idPath ); valueReferences.push(...textRefs); } @@ -283,7 +309,8 @@ export class XmlParser { baseMservPath: string, startLine: number, endLine: number, - serviceVersionLinkHash: string + serviceVersionLinkHash: string, + idPath: string ): XmlValueReference[] { const references: XmlValueReference[] = []; let pos = 0; @@ -307,6 +334,7 @@ export class XmlParser { serviceVersionLinkHash ) .withOwnerAttributeName(ownerAttributeName) + .withIdPath(idPath) .build(); references.push(ref); @@ -347,7 +375,8 @@ export class XmlParser { serviceVersionLinkHash ) .withOwnerAttributeName(ownerAttributeName) - .withDefaultValue(defaultValue); + .withDefaultValue(defaultValue) + .withIdPath(idPath); references.push(refBuilder.build()); @@ -361,7 +390,8 @@ export class XmlParser { baseMservPath, startLine, endLine, - serviceVersionLinkHash + serviceVersionLinkHash, + idPath ); // Set depth on nested refs for (const nested of nestedRefs) { @@ -379,6 +409,7 @@ export class XmlParser { .withOwnerAttributeName(ownerAttributeName) .withDefaultValue(nested.getDefaultValue()) .withDepth(nested.getDepth() + 1) + .withIdPath(idPath) .build(); references.push(nestedWithDepth); } diff --git a/parser/src/workflows/xml/xml-project-analyzer.ts b/parser/src/workflows/xml/xml-project-analyzer.ts index 9296f83e..ca3a1f74 100644 --- a/parser/src/workflows/xml/xml-project-analyzer.ts +++ b/parser/src/workflows/xml/xml-project-analyzer.ts @@ -7,7 +7,7 @@ import { XmlValueReference } from '@/analysis-types/xml/XmlValueReference'; import { EXCLUDED_DIRS, ANALYSIS_OUTPUT_DIR, OUTPUT_XML_ELEMENT_CSV_FILENAME, OUTPUT_XML_ATTRIBUTE_CSV_FILENAME, OUTPUT_XML_VALUE_REFERENCE_CSV_FILENAME, OUTPUT_SKIPPED_XML_FILES_CSV_FILENAME, FILE_EXTENSIONS, LARGE_FILE_LINE_THRESHOLD, LARGE_FILE_BYTE_THRESHOLD } from '@/constants/consts'; import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; import { SkippedFileReason } from '@/enums/SkippedFileReason'; -import { XmlParser } from '@/parsers/xml/xml-parser'; +import { XmlParser, toIdPath } from '@/parsers/xml/xml-parser'; import { ProjectInfo } from '@/types/ProjectInfo'; import { EntityUtils } from '@/utils/entity-utils'; import { groupOwnedFiles, resolveFileOwners } from '@/utils/file-ownership'; @@ -38,10 +38,14 @@ export class XmlProjectAnalyzer { * * @param projects Array of projects to scan for XML files * @param serviceVersionLink Service version identifier string + * @param analysisRoot The directory the analysis was asked to run on. Ids hash each + * file's path relative to it, so they do not move with the checkout. Without it, a + * file's path relative to its own project is used. */ async analyzeXmlFiles( projects: ProjectInfo[], - serviceVersionLink: string + serviceVersionLink: string, + analysisRoot?: string ): Promise { const startTime = Date.now(); @@ -55,7 +59,7 @@ export class XmlProjectAnalyzer { await Promise.all( [...groupOwnedFiles( await resolveFileOwners(projects, (root) => this.findXmlFiles(root)) - )].map(([project, files]) => this.analyzeProject(project, files, serviceVersionHash)) + )].map(([project, files]) => this.analyzeProject(project, files, serviceVersionHash, analysisRoot ?? project.path)) ); await this.exportElementsCsv(); @@ -78,7 +82,8 @@ export class XmlProjectAnalyzer { private async analyzeProject( project: ProjectInfo, xmlFiles: ReadonlyArray, - serviceVersionHash: string + serviceVersionHash: string, + analysisRoot: string ): Promise { if (xmlFiles.length === 0) { return; @@ -131,7 +136,8 @@ export class XmlProjectAnalyzer { content, filePath, project.path, - serviceVersionHash + serviceVersionHash, + toIdPath(analysisRoot, filePath) ); this.allElements.push(...elements); From 9e17e219be4d0b6ecabb6637c0adf46ba55eec0c Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:30:03 -0700 Subject: [PATCH 059/258] path: a qualified name whose qualifier is a declared type is a miss, not a use of a same-named undeclared type What was wrong - `path OrderController.Panel OrderHandler.Handle`, where OrderController is a client type that declares no Panel, answered "type OrderController.Panel (not declared here; matched: referenced by name in 1 method(s))" and then "the two are independent", instead of "nothing named 'OrderController.Panel' ... close names". A typo read as a type use. - G.type_use (unchanged on this branch) matches a type the code uses without declaring by its LAST segment only. Its guard refused a qualified name whose last segment the client declares, but not one whose qualifier is a client type. Until now the C# graph hid this: a type reference outside a base list had no file, so type_use found no enclosing method and gave up. The C# change on this branch that files every type reference (#1434) gave such rows a file, so a property typed by an undeclared type named Panel made any `X.Panel` a type use. The change - type_use returns nothing when the qualifier's last segment names a type the client declares: that name asks for a member of that type, and the resolver goes on to its "nothing named ... close names" answer. A bare name, or a name qualified by a namespace or package (`Vendor.Ui.Panel`, `java.io.File`), still resolves as a type use. - New case csharp/qualified-typo-is-not-a-type-use: the qualified typo on a declared type (fails before the change, passes after), and three controls: the bare name, the namespace-qualified name, and the qualifier's real member. Tests - tests/run.py: python 260/261, java 298/298, csharp 195/197 (tip 191/193; the four new checks pass). The FAIL lines are the same three as on the branch tip before the change (python lambda-is-named-by-its-place, csharp unmodelled-entry-not-local twice), none new. - tests/fastpath.py: 8/8 for python, java and csharp, as on the tip. - The path probe that regressed now answers "nothing named"; the six probes this branch fixes still pass. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/axiomcode-path | 5 +++++ .../case.json | 18 +++++++++++++++ .../src/App.csproj | 6 +++++ .../src/Orders.cs | 22 +++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 tests/cases/csharp/qualified-typo-is-not-a-type-use/case.json create mode 100644 tests/cases/csharp/qualified-typo-is-not-a-type-use/src/App.csproj create mode 100644 tests/cases/csharp/qualified-typo-is-not-a-type-use/src/Orders.cs diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 6bcbdf70..3fbea52f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -641,6 +641,11 @@ class G: # prefix, not a type from outside: answering for every reference to that short name is a different question if '.' in s and self.q("SELECT 1 FROM symbols WHERE name = ? AND (method_id IS NOT NULL OR type_id IS NOT NULL) AND kind NOT IN ('library', 'written') LIMIT 1", name): return None + # nor is one whose QUALIFIER is a type the client declares: `Controller.Details` asks for a member of that type, + # and the type has none of that name. The uses of some other `Details` (a component, a library class written + # bare) are not uses of it, and answering for them turned a typo into "the two are independent" + if '.' in s and self.q("SELECT 1 FROM symbols WHERE name = ? AND type_id IS NOT NULL AND method_id IS NULL AND kind NOT IN ('library', 'written') LIMIT 1", s.split('.')[-2]): + return None w = self.written('new ' + name) if w: ids += w[1]; parts.append(f"new {name} at {len(self.SITES[w[1][0]])} site(s)") lib = self.library(s + '.*') if '.' in s else self.library(name + '.*') diff --git a/tests/cases/csharp/qualified-typo-is-not-a-type-use/case.json b/tests/cases/csharp/qualified-typo-is-not-a-type-use/case.json new file mode 100644 index 00000000..dd79161c --- /dev/null +++ b/tests/cases/csharp/qualified-typo-is-not-a-type-use/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a qualified name whose qualifier is a declared type that has no such member is a miss, not a use of a same-named type declared outside the graph", + "run": ["path", "OrderController.Panel", "OrderHandler.Handle"], "expect_error": true, + "want": ["nothing named 'OrderController.Panel'"], + "avoid": ["not declared here; matched", "the two are independent"]}, + {"why": "control: the bare name is still the undeclared type, found where it is used", + "run": ["path", "Panel", "OrderHandler.Handle"], "expect_error": true, + "want": ["type Panel (not declared here; matched: referenced by name in 1 method(s))"], + "avoid": ["nothing named"]}, + {"why": "control: qualified by the namespace it is imported from, it is the same undeclared type", + "run": ["path", "Vendor.Ui.Panel", "OrderHandler.Handle"], "expect_error": true, + "want": ["type Vendor.Ui.Panel (not declared here; matched: referenced by name in 1 method(s))"], + "avoid": ["nothing named"]}, + {"why": "control: the member the qualifier does declare resolves and reaches the handler", + "run": ["path", "OrderController.Detail", "OrderHandler.Handle"], + "want": ["OrderHandler.Handle"], + "avoid": ["nothing named", "the two are independent"]}]} diff --git a/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/App.csproj b/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/App.csproj new file mode 100644 index 00000000..a7a09e8a --- /dev/null +++ b/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/App.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/Orders.cs b/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/Orders.cs new file mode 100644 index 00000000..1abf44f8 --- /dev/null +++ b/tests/cases/csharp/qualified-typo-is-not-a-type-use/src/Orders.cs @@ -0,0 +1,22 @@ +using Vendor.Ui; + +namespace App; + +public class OrderHandler +{ + public void Handle() { } +} + +public class OrderController +{ + private readonly OrderHandler _h = new OrderHandler(); + + public void Detail() { _h.Handle(); } +} + +public class OrderPage +{ + private Panel Summary { get; set; } = null!; + + public void Show() { Summary.Open(); } +} From a288a86d2d45d7804e03daf22a4200c314f3a1eb Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:02:05 -0700 Subject: [PATCH 060/258] hooks, changed: an edit report names only that edit after a rebase, a pull or a checkout What was wrong - The PostToolUse edit report (enrich.py) read the edited file against the baseline, the tree the graph was built from. The baseline moves to a new HEAD only when the background refresher has rebuilt HEAD's text: minutes on a real tree, never with refresh off. So after `git rebase` or `git pull`, every change the new commits made to the file came back as "this edit changed", including signature diffs for functions the agent never touched. - `changed` and `test-impact` without a range read the same stale baseline, and so counted the new commits as the agent's own. The Bash and prompt hooks (changes.py) already ask `changed --against-head` once HEAD has left the baseline, but set the kept baseline graph even then, and never said that the base had moved. - `changed --range ..HEAD`, with the local branch left behind the remote the branch was rebased onto, read from the old fork point and counted the remote's commits as the branch's own. The change - enrich.py reads one edit as the file just before the tool call against the file after it: the host's originalFile, else a copy the PreToolUse hook keeps, else the edit undone (_graphline.edit_before, snapshot_before). The baseline is asked only when none of these exists. - A move of HEAD (a rebase, a pull, a checkout, a reset or a commit) is said once per session in the next report, with the number of commits the old base did not have (_graphline.base_moved_line), and is never listed as an edit. - axiomcode-changed: when HEAD is not the commit the baseline was set at, the base is HEAD's tree at once, and the graph's spans are carried onto it (moved_base, Changed.__init__, main, with a `note: the base moved` line and a `base_moved` JSON field, also under `--against-head`). The spans are carried by the existing anchor mapping; this change adds no second mapper. resolve_range reads from the remote's fork when the named local branch is behind it (fresher_base), with a note; a commit sha is read as written. branch_suggestion also considers the branch HEAD tracks. - changes.py keeps the before copy on PreToolUse, does not set the kept baseline graph once HEAD has left it, keeps `--against-head` on Bash and prompt events, and names the move once there, with the new HEAD in the header. Functions touched: axiomcode-changed resolve_range, branch_suggestion, Changed.__init__, main (baseline graph choice, the note and JSON field); new fresher_base, moved_base, moved_note. hooks/enrich.py: the Edit block's `changed` call and the once-per-session filter, not its impact() helper. hooks/changes.py: the PreToolUse entry, the baseline graph line, the Bash and prompt branches, not summarize(). ax_fresh.py: the wait_baseline message. Tests - New tests/hook_rebase.py (18 checks): a rebase bringing upstream edits then one local edit (only the local edit, the move said once); the same without a host payload (the PreToolUse copy); a multi-line local edit (signature and body both reported); a checkout then an edit; `changed --range` from a branch left behind its remote. Controls: an edit before any move names no base move; an explicit commit range is read as written; a range from an up-to-date remote gets no note. On the release branch without this change 9 of the 18 checks fail and every control passes; with it all 18 pass. - Suites on this tree and on the release branch tip without it (9e17e219), FAIL lines identical: tests/run.py --lang python 260 of 261, 1 FAILED on both (lambda-is-named-by-its-place) tests/run.py --lang java 298 of 298 on both tests/run.py --lang csharp 195 of 197, 2 FAILED on both (unmodelled-entry-not-local) tests/fastpath.py python, java, csharp: 8 of 8 each on both hook_languages 7/7, enrich_lines 45/45, edit_stale_spans 37/37, freshness 118/118, changed_range ok, case_runner ok, mcp ok, surfaces ok, hooks_from_path 26/26, hosts ok, enrich_budget 17/17, on both. Smoke, one rebase that inserts a declaration and edits a body in a file, then one local body edit in the same file, refresh off (declarations reported by the edit hook / by `changed`), measured before the rebase onto the current release branch: Python, a 133-file CLI project: before 3 declarations from `changed` (2 of them upstream's) and an edit report naming a module body upstream touched; after 1 and 1, the edited function only, with one "the base moved" line. Java, a 172-file examples project: before 3 from `changed` (a constructor "decoration changed" and an added method, both upstream's) and the same constructor in the edit report; after 1 and 1, the edited method's body only. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/hooks/_graphline.py | 68 ++++++++ plugins/axiomcode/hooks/changes.py | 31 +++- plugins/axiomcode/hooks/enrich.py | 23 ++- .../axiomcode/reference/changed-and-tests.md | 3 + .../skills/axiomcode/scripts/ax_fresh.py | 4 +- .../axiomcode/scripts/axiomcode-changed | 68 +++++++- .../axiomcode/reference/changed-and-tests.md | 3 + tests/README.md | 3 + tests/hook_rebase.py | 154 ++++++++++++++++++ 9 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 tests/hook_rebase.py diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index b1c0821d..7755c85d 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -135,3 +135,71 @@ def body_line(db, results, 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}" + + +# ── what ONE edit changed, and a base that moved under it ───────────────────────────────────────────────────────────── +# The edit hook read the file against the BASELINE (the commit the graph was built from), which only moves when the +# background refresher has rebuilt HEAD's text. After a rebase or a pull that is minutes, or never with refresh off, and +# every change the new commits made to the file came back as "this edit changed", with signature diffs garbled by the +# graph's lines landing on another text. An edit is now read against the file as it was just before the tool call. + +def _snap_path(repo, session, fp): + import hashlib + return os.path.join(repo, '.axiomcode', f"hooks-before-{session or 'x'}", hashlib.sha1(os.path.realpath(fp).encode()).hexdigest()) + + +def snapshot_before(repo, session, fp): + """PreToolUse on an edit: keep the file as it is, for the PostToolUse report to diff against ('' when it is new)""" + try: + cur = open(fp, errors='replace').read() if os.path.exists(fp) else '' + p = _snap_path(repo, session, fp); os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, 'w') as f: f.write(cur) + except OSError: + pass + + +def edit_before(tool, inp, resp, fp, repo, session): + """the text of `fp` just before this Edit / Write / MultiEdit, or None when it cannot be known. In order: what the + host reports (Claude Code's `originalFile`), the PreToolUse snapshot, the edit itself undone (each new_string that + occurs exactly once put back); never the baseline, which predates a rebase or a pull""" + snap = _snap_path(repo, session, fp); kept = None + try: + kept = open(snap, errors='replace').read(); os.unlink(snap) + except OSError: + pass + if isinstance(resp, dict): + o = resp.get('originalFile') + if isinstance(o, str): return o + if tool == 'Write' and resp.get('type') == 'create': return '' + if kept is not None: return kept + if tool not in ('Edit', 'MultiEdit'): return None + try: t = open(fp, errors='replace').read() + except OSError: return None + edits = inp.get('edits') or ([inp] if 'new_string' in inp else []) + if not edits: return None + for e in reversed(edits): + o, n = str(e.get('old_string', '')), str(e.get('new_string', '')) + if e.get('replace_all') or not n or t.count(n) != 1: return None + t = t.replace(n, o, 1) + return t + + +def base_moved_line(repo, st): + """one line, once per move of HEAD in a session: `st['head']` is the HEAD this session last spoke for (at first, the + commit the baseline was set at). A rebase, a pull, a checkout, a reset or a commit moves it; what those commits + changed is then never reported as an edit, and this says so. '' when HEAD has not moved.""" + import subprocess + git = lambda *a: subprocess.run(['git', *a], cwd=repo, capture_output=True, text=True) + try: h = git('rev-parse', '-q', '--verify', 'HEAD').stdout.strip() + except Exception: return '' + if not h: return '' + prev = st.get('head') + if prev is None: + try: prev = open(os.path.join(repo, '.axiomcode', 'out', 'base-commit')).read().strip() + except OSError: prev = h + st['head'] = h + if not prev or prev == h or prev == 'nogit': return '' + n = git('rev-list', '--count', '--right-only', '--cherry-pick', f'{prev}...{h}').stdout.strip() + return (f"graph: the base moved: HEAD is {h[:10]}, was {prev[:10]}" + (f" ({n} commit(s) it did not have)" if n.isdigit() else '') + + " — a rebase, a pull, a checkout, a reset or a commit. What those commits changed is not reported as an edit;" + " edits are read against the file before each one. `axiomcode changed --range ..HEAD` reads committed work.") diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 13fbc350..44fba37d 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -126,7 +126,9 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} lines = [] if event == 'PreToolUse' and tool in ('Edit', 'Write', 'MultiEdit'): fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - if not _where.is_source(fp) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) + if not _where.is_source(fp) or TEST.search(rel): sys.exit(0) + _graphline.snapshot_before(cwd, ev.get('session_id'), fp) # what the PostToolUse report diffs this one edit against + if not os.path.exists(fp): sys.exit(0) cur = open(fp, errors='replace').read(); new = cur if tool == 'Write': new = str(inp.get('content', '')) else: @@ -150,30 +152,43 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} try: ax_fresh.wait_baseline(cwd, 8, hook=True) # never a rebuild of a graph another axiomcode built except Exception: pass bg = ax_fresh.baseline_graph(cwd) - if bg: os.environ['AXIOMCODE_GRAPH'] = bg # HEAD MOVED AND THE BASELINE HAS NOT FOLLOWED YET (a rebase, a pull, a checkout; the wait above ran out). Measured # against the baseline, every declaration the incoming commits changed was reported as this session's edit. Against - # HEAD it is the working tree's own edits only; the commits that came in are nobody's edit here + # HEAD it is the working tree's own edits only; the commits that came in are nobody's edit here. The kept baseline + # graph describes the old base then, so `changed` reads with the current graph, its lines carried onto HEAD's text try: behind = ax_fresh.base_moved(cwd) except Exception: behind = False + if bg and not behind: os.environ['AXIOMCODE_GRAPH'] = bg AGAINST = ['--against-head'] if behind else [] if event == 'PostToolUse' and tool == 'Bash': c = str(inp.get('command', '')) if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(' + _where.SOURCE_ALT + r')\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) j = changed(AGAINST, timeout=18) st = load_state(); seen = set(st.get('reported', [])) + # a rebase, a pull, a checkout or a reset moved HEAD: said once, and `changed` reads against the new HEAD, so what the + # new commits changed is never listed as the agent's + moved = _graphline.base_moved_line(cwd, st) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] + head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) + since = f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) else f"the graph's commit {(j.get('built_at') or '')[:10]}" if new: - lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against " + ("HEAD: the commits that came in are not counted" if AGAINST else f"the graph's commit {(j.get('built_at') or '')[:10]}") + ") —") - st['reported'] = list(seen | {key(d) for d in new}); save_state(st) + lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against {since}) —") + st['reported'] = list(seen | {key(d) for d in new}) + if moved: lines = [moved] + lines + if new or moved: save_state(st) elif event == 'UserPromptSubmit': j = changed(AGAINST, timeout=18) st = load_state(); seen = set(st.get('reported', [])) + moved = _graphline.base_moved_line(cwd, st) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] + head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) + since = (f"against {head_at} (the commits that came in are not counted)" if (AGAINST or j.get('base_moved')) + else f"since the graph's commit {(j.get('built_at') or '')[:10]}") if new: - lines = summarize(new, (f"graph: {{n}} declaration(s) changed in the working tree against HEAD (the commits that came in are not counted) and were not reported yet —" if AGAINST - else f"graph: {{n}} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —")) - st['reported'] = list(seen | {key(d) for d in new}); save_state(st) + lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree {since} and were not reported yet —") + st['reported'] = list(seen | {key(d) for d in new}) + if moved: lines = [moved] + lines + if new or moved: save_state(st) try: with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: f.write(json.dumps({'event': event, 'tool': tool, 'lines': len(lines), 'chars': sum(len(l) for l in lines), 'input': {k: v for k, v in inp.items() if k in ('file_path', 'command', 'old_string', 'new_string')}, 'text': '\n'.join(lines)}) + '\n') except OSError: pass diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index bf95aadf..6bd2d80a 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -162,8 +162,21 @@ def context_ids(): fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) if not _where.is_source(fp) or _where.is_test(rel): sys.exit(0) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') - try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), cwd, rel, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') + # WHAT THIS EDIT CHANGED: the file just before the tool call against the file now. Against the baseline, as this read + # before, a rebase or a pull the refresher had not caught up with made every change the new commits brought into the + # file "this edit changed" (_graphline.edit_before). Only when the before-text cannot be known is the baseline asked, + # and `changed` then reads against HEAD once HEAD has moved from it. + before = _graphline.edit_before(tool, inp, ev.get('tool_response'), fp, cwd, ev.get('session_id')) + args = [cwd, rel] + if before is not None: + import tempfile + with tempfile.NamedTemporaryFile('w', suffix=os.path.splitext(rel)[1], delete=False) as f: f.write(before); tmp = f.name + args = [cwd, '--old', tmp, '--new', fp, '--file', rel] + try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), *args, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') except Exception: ch = {} + if before is not None: + try: os.unlink(tmp) + except OSError: pass decls = [d for d in ch.get('changed', []) if d.get('target')] # AN EDIT THAT CHANGED NO DECLARATION SAYS NOTHING. It printed "this edit changed 0 declaration(s) -- added: 1 new # line(s)", which restates the edit the agent just made; 28 of 30 of these blocks went unused. @@ -172,7 +185,11 @@ def context_ids(): key = lambda d: f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" st = load_state(); done = set(st.get('reported', [])) decls = [d for d in decls if key(d) not in done] - if not decls: sys.exit(0) + moved = _graphline.base_moved_line(cwd, st) # once per move of HEAD: the base moved, and is not this edit + if not decls: + if moved: save_state(st); _host.emit('PostToolUse', moved) + sys.exit(0) + if moved: lines.append(moved) st['reported'] = list(dict.fromkeys(st.get('reported', []) + [key(d) for d in decls])); save_state(st) # once per session (changes.py reads this) # A BODY EDIT BREAKS NO CALLER, so its readers are not a blast radius: 10-42 `reads / uses it` rows nobody could act on. # What it is worth is the tests that reach it and the command that runs them, on one line. @@ -191,7 +208,7 @@ def impact(d): with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) base = (ch.get('built_at') or '')[:10] - if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base else '') + " —") + if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base and before is None else '') + " —") for d, j in results: head = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(head + " (impact unavailable)"); continue diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 4b16dde9..cb924da2 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -35,6 +35,7 @@ What to pass, and what the answer says when the question cannot be answered the |---|---|---| | uncommitted edits | `changed` · `test-impact` | the edits against the baseline | | your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | | committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | | a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | | named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | @@ -80,6 +81,8 @@ the unresolved-call bound). That is where the agent that changed `String zipCode lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and the four repositories that deserialize a holder. +**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. + **Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under `tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index ea63bb67..415c183d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -1146,8 +1146,8 @@ def wait_baseline(repo, seconds, hook=False): if not base_moved(repo): return '' if read_state(repo).get('state') == 'failed': break kick(repo, 'a query') - return (f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}); the baseline is still being moved, " - "so this answer also counts what the new commits changed") + return (f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}); the baseline graph is still being built, " + "so edits are read against HEAD's text with the current graph's lines carried onto it") def last_update(repo): """the last time anything looked at this graph's freshness or rebuilt it: a check, a refresh, a build""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 84e2b8c6..ad902e51 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -59,12 +59,53 @@ def resolve_range(repo, rng): mb = (sh('git', 'merge-base', ha, hb, cwd=repo) or '').strip() if not mb: return ha, hb, f"range: {a} and {b} share no history, so this compares the two tips" + # A BRANCH BEHIND THE ONE b WAS REBASED ONTO. After `git rebase origin/x` (or a pull) with the local x never updated, the + # merge-base with x is the OLD fork point, and every commit origin/x brought in between counted as b's own. When x's + # upstream (or origin/x) holds more of b's history than x does, the fork is read from it, and the note says so; a + # commit named by its sha, or a branch that is not behind, is read exactly as written. + fr = fresher_base(repo, a, hb, mb) + if fr: + u, mu = fr + return mu, hb, (f"range base: merge-base {mu[:10]} of {u} and {b}, not of {a} — {a} is behind {u}, and {b} already holds " + f"{u}'s commits up to {mu[:10]} (a rebase or a pull onto {u}), which are not {b}'s own; " + f"name a commit instead of {a} to read from {a} itself") if mb == ha: return ha, hb, None # a is an ancestor of b: exactly the old answer moved = (sh('git', 'rev-list', '--count', f'{mb}..{ha}', cwd=repo) or '?').strip() return mb, hb, (f"range base: merge-base {mb[:10]} of {a} and {b}, not {a} itself — {a} has moved {moved} commit(s) since " f"{b} forked from it, and those are not changes of {b}'s; this answers for {b}'s own commits" + (" (a...b already means this)" if dots == 3 else '')) +def fresher_base(repo, a, hb, mb): + """(ref, merge-base) when `a` is a local branch and its upstream, or origin/, forked from b LATER than `a` did: b was + rebased onto (or pulled from) that remote ref while `a` stayed behind. None otherwise.""" + if sh('git', 'show-ref', '--verify', '-q', f'refs/heads/{a}', cwd=repo) is None: return None + ups = [(sh('git', 'rev-parse', '--abbrev-ref', '-q', f'{a}@{{upstream}}', cwd=repo) or '').strip(), f'origin/{a}'] + for u in dict.fromkeys(x for x in ups if x): + hu = (sh('git', 'rev-parse', '--verify', '-q', f'{u}^{{commit}}', cwd=repo) or '').strip() + mu = (sh('git', 'merge-base', hu, hb, cwd=repo) or '').strip() if hu else '' + if mu and mu != mb and sh('git', 'merge-base', '--is-ancestor', mb, mu, cwd=repo) is not None: return u, mu + return None + +def moved_base(repo): + """{'old', 'new', 'tree', 'commits'} when HEAD is no longer the commit the baseline was set at (.axiomcode/out/base-commit, + written by every build): a rebase, a pull, a checkout, a reset or a commit since. `commits` is how many commits HEAD has + that the old base did not, patch-equivalent ones (a rebased commit of the agent's own) left out. None when it has not moved.""" + try: bc = open(os.path.join(repo, '.axiomcode', 'out', 'base-commit')).read().strip() + except OSError: return None + h = (sh('git', 'rev-parse', '-q', '--verify', 'HEAD', cwd=repo) or '').strip() + if not bc or bc == 'nogit' or not h or bc == h: return None + t = (sh('git', 'rev-parse', '-q', '--verify', 'HEAD^{tree}', cwd=repo) or '').strip() + if not t: return None + n = (sh('git', 'rev-list', '--count', '--right-only', '--cherry-pick', f'{bc}...{h}', cwd=repo) or '').strip() + return dict(old=bc, new=h, tree=t, commits=int(n) if n.isdigit() else None) + +def moved_note(mv): + """one line for a baseline HEAD has moved from: what it is read against now, and what is left out""" + n = mv.get('commits') + return (f"the base moved: HEAD is {mv['new'][:10]}, not {mv['old'][:10]} where the baseline was set" + + (f" ({n} commit(s) it did not have: a rebase, a pull, a checkout or a commit)" if n is not None else '') + + "; edits are read against HEAD, so what those commits changed is not counted as an edit") + def branch_suggestion(repo): """when the working tree matches the baseline but HEAD holds commits of its own: the nearest ref HEAD forked from, and how many commits HEAD has since, so the answer can say `--range ..HEAD` instead of a bare 'no change'. The @@ -78,6 +119,10 @@ def branch_suggestion(repo): rows = (sh('git', 'for-each-ref', fmt, *usual, cwd=repo) or '').split('\n') rows += (sh('git', 'for-each-ref', '--sort=-committerdate', '--count=40', fmt, 'refs/heads', 'refs/remotes', cwd=repo) or '').split('\n') cur = (sh('git', 'rev-parse', '--abbrev-ref', 'HEAD', cwd=repo) or '').strip() + # the branch HEAD tracks, as it is now (a branch cut with `-b x origin/rel` tracks rel): a candidate even when it is + # neither a usual base name nor among the recent refs + up = (sh('git', 'rev-parse', '--abbrev-ref', '-q', '@{upstream}', cwd=repo) or '').strip() + if up: rows.insert(0, f"{up} {(sh('git', 'rev-parse', '-q', '--verify', up, cwd=repo) or '').strip()}") # on a base branch itself (or its remote's default) there is no fork to name: its commits are the base's own dflt = (sh('git', 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD', cwd=repo) or '').strip() if cur in ('main', 'master', 'dev', 'develop', 'trunk') or (dflt and cur == dflt.split('/', 1)[-1]): return None @@ -157,6 +202,15 @@ class Changed: b = os.path.join(self.repo, '.axiomcode', 'out', 'base-tree') b = open(b).read().strip() if os.path.exists(b) else '' self.base_tree = b if b and sh('git', 'cat-file', '-e', b, cwd=self.repo) is not None else self.indexed_tree + # THE BASE MOVED UNDER THE BASELINE: a rebase, a pull, a checkout, a reset or a commit since it was set, and the + # refresher has not caught up (it builds HEAD's text first, which takes minutes on a dirty tree, and never runs + # with refresh off). Read against the old baseline, every change the new commits brought in (upstream's) came + # back as the agent's edit, and a graph's lines laid on the new text garbled the signatures they fell on. The + # baseline is where the refresher will put it, HEAD's tree, now; the graph's spans are carried onto it (decl_spans). + self.moved = moved_base(self.repo) + if self.moved: + if baseline: self.indexed_tree = self.base_tree # the kept graph describes the OLD baseline + self.base_tree = self.moved['tree']; baseline = False self.tree_differs = bool(self.base_tree) and (sh('git', 'rev-parse', 'HEAD^{tree}', cwd=self.repo) or '').strip() != self.base_tree if baseline: self.indexed_tree = self.base_tree # reading the baseline's own graph: its spans ARE the baseline's self.base_absorbed = self._absorbed() if self.tree_differs else [] @@ -1136,7 +1190,11 @@ def main(argv): # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a # graph chosen for this edit, so the language's baseline is still looked for, as it is for the main one if given and os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(rrepo)): given = None - if bg and (not given or os.path.realpath(given) == os.path.realpath(bg)): + if moved_base(rrepo): + # the kept graph describes the baseline HEAD has left (a rebase, a pull, a checkout): the current graph is read, + # its lines carried onto HEAD's text (Changed), and --impact asks it too + if bg and given and os.path.realpath(given) == os.path.realpath(bg): os.environ['AXIOMCODE_GRAPH'] = ax_fresh.graph_dir(rrepo) + elif bg and (not given or os.path.realpath(given) == os.path.realpath(bg)): baseline = bg; os.environ['AXIOMCODE_GRAPH'] = bg # also for --impact, which runs impact below C = Changed(repo, baseline=bool(baseline)); C.mode = 'files' if (old_f or new_f) else mode range_note = None @@ -1208,18 +1266,22 @@ def main(argv): f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None + mv = C.moved if mode in ('worktree', 'head') and not (old_f or new_f) else None + moved_json = dict(mv, note=moved_note(mv)) if mv else None if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, 'baseline_note': base_note, - 'suggest_range': suggest, 'suggest_note': sugg_line}, indent=1)) + 'suggest_range': suggest, 'suggest_note': sugg_line, 'base_moved': moved_json}, indent=1)) return 3 if fanout_empty else 0 if range_note: print(f"note: {range_note}") + if mv: print(f"note: {moved_note(mv)}") if base_note: print(f"note: {base_note}") if not results and not notes: print(f"no change to a declaration the graph knows ({base_desc})") if sugg_line: print(f"next: {sugg_line}") return 3 if fanout_empty else 0 - against = (f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs + against = (f" — against HEAD {mv['new'][:10]} (the base moved: see the note)" if mv else + f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] print(f"changed declarations ({len(results)})" + (" — the two texts given" if (old_f or new_f) else against if C.built_at and mode == 'worktree' and not named else (f" — {rng}" if rng else '')) + ":") diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 4b16dde9..cb924da2 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -35,6 +35,7 @@ What to pass, and what the answer says when the question cannot be answered the |---|---|---| | uncommitted edits | `changed` · `test-impact` | the edits against the baseline | | your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | | committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | | a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | | named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | @@ -80,6 +81,8 @@ the unresolved-call bound). That is where the agent that changed `String zipCode lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and the four repositories that deserialize a holder. +**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. + **Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under `tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a diff --git a/tests/README.md b/tests/README.md index 1dd58778..abe38c76 100644 --- a/tests/README.md +++ b/tests/README.md @@ -21,6 +21,9 @@ One check needs no graph and is its own script: python3 tests/hook_languages.py the edit hooks speak for C# as for Java and Python, from one extension table, and a body edit's command runs the classes that extend an abstract test base (indexes a small C# project, so it needs the engine) + python3 tests/hook_rebase.py after a rebase, a pull or a checkout, an edit report names only that edit and says + the base moved once; `changed` reads against the new HEAD; `--range` from a branch + left behind its remote reads from the remote's fork (indexes a small project) python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) diff --git a/tests/hook_rebase.py b/tests/hook_rebase.py new file mode 100644 index 00000000..a1ff5fc4 --- /dev/null +++ b/tests/hook_rebase.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""tests/hook_rebase.py: after a rebase, a pull or a checkout, an edit report names only what that edit changed. + +The edit hook read the edited file against the baseline (the commit the graph was built from), which moves only when the +background refresher has rebuilt HEAD's text: minutes on a real tree, never with refresh off. So after `git rebase` every +change the new commits made to the file came back as "this edit changed", with signature diffs garbled by the graph's +lines landing on another text, and `changed` counted the new commits as the agent's own. Checked here with the refresher +off, which is the moment between the rebase and the refresh: + + a rebase brings upstream edits, then one local edit only the local edit is reported, the base move is said once, + and `changed` reads against the new HEAD + control an edit before any move says nothing about a base + a real multi-line local edit (a Write, no host payload) the signature and the body it changed are both reported + a checkout to another branch, then an edit only that edit, and the move is said + `changed --range` a local branch left behind the remote it was rebased onto + reads from the remote's fork, with a note + control an explicit commit range is read exactly as written + +It indexes a small Python project, so it needs the engine. + + python3 tests/hook_rebase.py +""" +import json, os, shutil, subprocess, sys, tempfile + +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hook-rebase-') +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'bin', 'axiomcode') +ENV = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') +G = ('git', '-c', 'user.email=t@t', '-c', 'user.name=t') + +ENG = ('def _engine_hash(a, b):\n return a + b\n\n\n' + 'def run(x):\n y = x + 1\n return _engine_hash(y, 2)\n\n\n' + 'def stop(x):\n return x\n') +UPSTREAM = ('import os\n\n\ndef helper():\n return 1\n\n\n' + + ENG.replace('_engine_hash(a, b)', '_engine_hash(a, b, c=0)').replace('a + b', 'a + b + c')) + +fails = [] +def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + str(detail).strip().replace('\n', '\n '))) + if not ok: fails.append(why) + +def sh(cwd, *a): + return subprocess.run(a, cwd=cwd, capture_output=True, text=True, env=ENV) + +def fire(repo, sid, hook, event, tool, inp, resp=None): + ev = dict(hook_event_name=event, tool_name=tool, session_id=sid, cwd=repo, tool_input=inp) + if resp is not None: ev['tool_response'] = resp + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120, env=ENV) + try: return json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] + except Exception: return r.stdout + r.stderr + +def edit(repo, sid, rel, old, new, payload=True): + """an Edit as the host runs it: PreToolUse, the change, PostToolUse (with the host's originalFile when `payload`)""" + p = os.path.join(repo, rel); inp = dict(file_path=p, old_string=old, new_string=new) + fire(repo, sid, 'changes.py', 'PreToolUse', 'Edit', inp) + before = open(p).read(); assert before.count(old) == 1, old + open(p, 'w').write(before.replace(old, new, 1)) + return fire(repo, sid, 'enrich.py', 'PostToolUse', 'Edit', inp, + dict(filePath=p, oldString=old, newString=new, originalFile=before) if payload else None) + +def write(repo, rel, text): + p = os.path.join(repo, rel); os.makedirs(os.path.dirname(p), exist_ok=True); open(p, 'w').write(text) + +def commit(repo, msg): + sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', msg) + + +def main(): + work = tempfile.mkdtemp(prefix='axiomcode-hook-rebase-') + try: + origin = os.path.join(work, 'origin.git'); repo = os.path.join(work, 'repo') + sh(work, 'git', 'init', '-q', '--bare', '-b', 'main', origin) + os.makedirs(repo) + for rel, text in {'app/__init__.py': '', 'app/eng.py': ENG, 'app/other.py': 'def other(q):\n return q\n', + 'tests/__init__.py': '', 'tests/test_eng.py': 'from app.eng import run\n\n\ndef test_run():\n assert run(1)\n', + '.gitignore': '.axiomcode/\n'}.items(): write(repo, rel, text) + sh(repo, 'git', 'init', '-q', '-b', 'main'); commit(repo, 'base') + sh(repo, 'git', 'remote', 'add', 'origin', origin); sh(repo, 'git', 'push', '-q', 'origin', 'main') + sh(repo, 'git', 'fetch', '-q', 'origin') + sh(repo, 'git', 'checkout', '-qb', 'feature', '--track', 'origin/main') + write(repo, 'app/other.py', 'def other(q):\n return q + 1\n'); commit(repo, 'mine') + built = sh(repo, AX, 'index', '.', '--lang', 'python') + check(built.returncode == 0 and os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')), 'the graph builds', built.stdout + built.stderr) + + # control: an edit before anything moved says nothing about a base + out = edit(repo, 's0', 'app/eng.py', 'return x\n', 'return x - 0\n') + check('stop' in out and 'base moved' not in out, 'control: an edit with HEAD unmoved reports it and names no base move', out) + sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') + + # upstream moves on (through the remote; the local main is never updated), and feature is rebased onto it + up = os.path.join(work, 'up'); sh(work, 'git', 'clone', '-q', origin, up) + write(up, 'app/eng.py', UPSTREAM); commit(up, 'upstream'); sh(up, 'git', 'push', '-q', 'origin', 'main') + sh(repo, 'git', 'fetch', '-q', 'origin') + r = sh(repo, 'git', 'rebase', 'origin/main') + check(r.returncode == 0, 'the branch rebases onto the moved remote', r.stderr) + + # one local edit, to run's body + out = edit(repo, 's1', 'app/eng.py', 'y = x + 1', 'y = x + 5') + check('body edit of run:' in out, 'after a rebase, the local edit to run is reported', out) + check('_engine_hash' not in out and 'helper' not in out, "upstream's _engine_hash and helper are not reported as this edit", out) + check(out.count('the base moved') == 1 and '1 commit(s)' in out, 'the base move is said once, with the upstream commit count', out) + out2 = edit(repo, 's1', 'app/eng.py', 'return x\n', 'return x * 1\n') + check('stop' in out2 and 'base moved' not in out2, 'the next edit reports itself and does not repeat the base move', out2) + rc = sh(repo, AX, 'changed', '.') + check('run' in rc.stdout and 'stop' in rc.stdout and '_engine_hash' not in rc.stdout and 'helper' not in rc.stdout, + '`changed` after the rebase lists the uncommitted edits only, not what upstream changed', rc.stdout + rc.stderr) + check('the base moved' in rc.stdout, '`changed` says the base moved', rc.stdout) + # the same edit with no host payload: the before-text comes from the PreToolUse snapshot + out3 = edit(repo, 's1b', 'app/eng.py', 'y = x + 5', 'y = x + 6', payload=False) + check('run' in out3 and '_engine_hash' not in out3 and 'helper' not in out3, 'without the host payload, the PreToolUse snapshot keeps the report to this edit', out3) + sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') + + # a real multi-line local edit, written whole with no host payload: a signature and a body, both reported + p = os.path.join(repo, 'app/eng.py'); cur = open(p).read() + new = cur.replace('_engine_hash(a, b, c=0)', '_engine_hash(a, b, c=0, d=1)').replace('a + b + c', 'a + b + c + d').replace('y = x + 1', 'y = x + 7') + inp = dict(file_path=p, content=new) + pre = fire(repo, 's2', 'changes.py', 'PreToolUse', 'Write', inp); open(p, 'w').write(new) + out = fire(repo, 's2', 'enrich.py', 'PostToolUse', 'Write', inp) + check('signature _engine_hash' in pre and '+d' in pre and 'helper' not in pre, + 'a multi-line local edit: the signature it changes is reported before it lands, with the parameter, and nothing upstream did', pre) + check('run' in out and 'helper' not in out, 'a multi-line local edit: the body it changed is reported, and nothing upstream did', out) + sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') + + # a checkout to another branch, then an edit (no host payload, no PreToolUse: the edit is undone to find the before) + sh(repo, 'git', 'checkout', '-qb', 'side', 'main') + write(repo, 'app/eng.py', ENG + '\n\ndef side(z):\n return z\n'); commit(repo, 'side') + p = os.path.join(repo, 'app/eng.py'); before = open(p).read(); open(p, 'w').write(before.replace('return x\n', 'return x + 2\n')) + out = fire(repo, 's3', 'enrich.py', 'PostToolUse', 'Edit', dict(file_path=p, old_string='return x\n', new_string='return x + 2\n')) + check('body edit of stop:' in out and '_engine_hash' not in out and 'side' not in out.replace('checkout', ''), + 'after a checkout, only the edit to stop is reported, not what the other branch holds', out) + check(out.count('the base moved') == 1, 'the checkout is said once as a base move', out) + sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py'); sh(repo, 'git', 'checkout', '-q', 'feature') + + # `changed --range`: the local main is behind origin/main, which feature was rebased onto + rc = sh(repo, AX, 'changed', '.', '--range', 'main..HEAD') + check('other' in rc.stdout and '_engine_hash' not in rc.stdout and 'helper' not in rc.stdout, + "--range main..HEAD, main left behind origin/main: only the branch's own commit", rc.stdout + rc.stderr) + check('main is behind origin/main' in rc.stdout, 'the note says which fork it read from and why', rc.stdout) + base = sh(repo, 'git', 'rev-parse', 'main').stdout.strip() + rc = sh(repo, AX, 'changed', '.', '--range', f'{base}..HEAD') + check('other' in rc.stdout and ('_engine_hash' in rc.stdout or 'helper' in rc.stdout) and 'behind' not in rc.stdout, + 'control: an explicit commit range is read as written, upstream commit included', rc.stdout + rc.stderr) + rc = sh(repo, AX, 'changed', '.', '--range', 'origin/main..HEAD') + check('other' in rc.stdout and '_engine_hash' not in rc.stdout and 'range base' not in rc.stdout, + 'control: a range from the up-to-date remote needs no note', rc.stdout + rc.stderr) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'ok' if not fails else f'{len(fails)} FAILED'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) From c08f3b173b5f95b7ad13f571bd7d083f8e45d465 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:23:55 -0700 Subject: [PATCH 061/258] path, impact: --why says how each endpoint name was resolved When path or impact turned a name into an endpoint, the answer gave the label and nothing else. A qualified typo that became "type X.Details (not declared here; matched: referenced by name in 1 method(s))", or an endpoint that matched a same-named declaration elsewhere, could only be explained by querying graph.sqlite by hand. --why (CLI) and why=True (MCP path; the existing why=True of impact) now print, right after the endpoint line (or after the refusal when a name matched nothing), a block of at most eight lines per endpoint: - the resolver step that matched, in the order the resolver tries them: a file and line, a decoration, a file, a lambda at a line, then for a name: exact declaration (including a full qualified name), qualified suffix (a leading segment that matched nothing dropped, the last segments of a longer qualified name, or other separators), simple name, library method, call as written at unresolved sites, type used by name, fragment; impact adds field, a type by its own lookup, inherited member, signature, parameter, local, type parameter, string and configuration key; - the steps that ran before it and found nothing; - up to five candidates it weighed, with file:line; - why the winner won, or why the name fell to "nothing named" (a qualifier that is a declared type with no such member, a last segment declared under another owner, a leading segment that matches no package). The resolver records a note at each return; nothing reads it without --why, and no answer depends on it. The pager keeps the block under the change: lines of impact, so the page header does not split them. --json gains an optional "why" list only with --why. Documented in both SKILL.md copies, reference/path.md, reference/impact.md, the script usage lines and the MCP tool descriptions; packaging/copies.py was rerun, which also brings the skill copy's [approx] row up to date. Tests: tests/cases/{python,java,csharp}/why-names-the-resolver-step, each with an exact declaration, a looser step (simple name, dropped prefix, full qualified name, type used by name) and a qualified typo that falls to "nothing named", asserting the step names, plus controls without --why. Byte-identical without --why: every path and impact check in the python, java and csharp case suites (619 runs) was run with the 0.1.9 scripts and with this change on the same indexed copies, and the full stdout, stderr and exit codes were diffed. 604 are identical; the 12 checks that already pass --why (impact --tests-only --why) differ only by the added block; 3 differ only in the order of two [text] rows on one file line, and that order also changes between two runs of the 0.1.9 scripts alone. Suites, on this tree and on 0.1.9 (the FAIL lines are the same on both): tests/run.py python 268 of 269 (1 FAIL, also on 0.1.9), java 304 of 304, csharp 201 of 203 (2 FAIL, also on 0.1.9); tests/fastpath.py python, java and csharp 8 of 8 each; tests/surfaces.py, mcp_docs.py, mcp.py, graph_verb.py and manifests.py pass. Smoke: one Python project copy, freshly indexed. Five path and impact queries without --why print the same bytes as the 0.1.9 scripts; with --why a qualified typo names the declared qualifier type and its line, a bare name reports its simple-name step, and an exact name reports how many declarations only ending in it were not taken. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- plugins/axiomcode/mcp/server.py | 10 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 10 +- .../skills/axiomcode/reference/impact.md | 4 +- .../skills/axiomcode/reference/path.md | 8 + .../skills/axiomcode/scripts/ax_pages.py | 9 +- .../skills/axiomcode/scripts/axiomcode | 6 +- .../skills/axiomcode/scripts/axiomcode-impact | 86 ++++++- .../skills/axiomcode/scripts/axiomcode-path | 230 ++++++++++++++++-- skills/axiomcode/SKILL.md | 13 +- skills/axiomcode/reference/impact.md | 4 +- skills/axiomcode/reference/path.md | 8 + .../why-names-the-resolver-step/case.json | 23 ++ .../src/App.csproj | 6 + .../why-names-the-resolver-step/src/Orders.cs | 22 ++ .../why-names-the-resolver-step/case.json | 22 ++ .../src/shop/Checkout.java | 5 + .../src/shop/Order.java | 7 + .../app/__init__.py | 0 .../app/billing.py | 10 + .../why-names-the-resolver-step/case.json | 29 +++ 20 files changed, 458 insertions(+), 54 deletions(-) create mode 100644 tests/cases/csharp/why-names-the-resolver-step/case.json create mode 100644 tests/cases/csharp/why-names-the-resolver-step/src/App.csproj create mode 100644 tests/cases/csharp/why-names-the-resolver-step/src/Orders.cs create mode 100644 tests/cases/java/why-names-the-resolver-step/case.json create mode 100644 tests/cases/java/why-names-the-resolver-step/src/shop/Checkout.java create mode 100644 tests/cases/java/why-names-the-resolver-step/src/shop/Order.java create mode 100644 tests/cases/python/why-names-the-resolver-step/app/__init__.py create mode 100644 tests/cases/python/why-names-the-resolver-step/app/billing.py create mode 100644 tests/cases/python/why-names-the-resolver-step/case.json diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 0228e834..53ab0eb7 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -285,15 +285,15 @@ def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int return run(a + NOREF(refresh)) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, refresh: bool = True) -> 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - paged = _paged(page) - 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 []) +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True) -> 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. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + paged = _paged(page); full = full or why + a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--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 + NOREF(refresh)) @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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> 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 (the CLI's --tests-only); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + """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 (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" prose = full or why or delete or _paged(page) 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 []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) return run(a + NOREF(refresh)) diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 36fa651f..6a7b7a22 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -88,13 +88,13 @@ does not restrict. Detail: `reference/context.md`. ## impact — what a change to a declaration reaches -`axiomcode impact … [--depth N] [--in ] [--delete]`. Targets as written in the code: +`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: `Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or `file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, `src.util.square` and `src/util#square` are one name. **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. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config +(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. ## changed · test-impact — from an edit @@ -108,9 +108,11 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph -`axiomcode path [--every] [--in ]`: one shortest verified chain per target, or why there is none +`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: +`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with +file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. ## diff: two graphs of the same tree diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 09d2022f..026784d9 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -176,7 +176,9 @@ line and names the constructor query to run; take that suggestion before acting file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change, and `--tests-in ` narrows + file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: + the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a + `why` list), and `--tests-in ` narrows the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md index f17b0484..e20eeeef 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ b/plugins/axiomcode/skills/axiomcode/reference/path.md @@ -20,6 +20,14 @@ says so on the answer's first line, with the reason. written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python and C#; JavaScript works but the engine's JavaScript output is still moving. +- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, + right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, + in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix + (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, + library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and + found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" + (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when + an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. - **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index b076f4fc..94915f7f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -48,7 +48,14 @@ def _parse(lines): # a field and a method): it stays on top of every page instead of sinking into the footer with the qualifiers while lines and lines[0].startswith('note:'): head.append(lines.pop(0)) - while lines and (lines[0].startswith('change:') or (head and lines[0].startswith(' ') and not lines[0].startswith(' '))): + # impact --why prints, under each `change:` line, a block that says how the name was resolved: its first line is + # indented like the other lines under `change:`, its detail deeper, and the whole block stays in the head + why = False + while lines: + l = lines[0] + if l.startswith('change:'): why = False + elif head and l.startswith(' ') and not l.startswith(' '): why = l.startswith(" why '") + elif not (why and l.startswith(' ')): break head.append(lines.pop(0)) quals = [l for l in lines if QUALIFIER.match(l)] sections, cur = [], None # a line at column 0 opens a section diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 5e4ae000..bd3ce618 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -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] [--no-refresh] [--grep] +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--why] [--fresh] [--no-refresh] [--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,9 @@ # 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] [--no-refresh] [--grep] +# --why (path and impact) says after the endpoint line how each name was resolved: the lookup step that matched, +# up to five candidates with file:line, and why that one won or why the name matched nothing. +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--why] [--fresh] [--no-refresh] [--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] / diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4343911f..b8a00775 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--page N|all] [--budget N] [--json] [--fresh] [--no-refresh] +"""axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--tests-in ]] [--why] [--limit N] [--page N|all] [--budget N] [--json] [--fresh] [--no-refresh] what has to be looked at again when a declaration changes — and how sure each entry is. --no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the @@ -50,6 +50,9 @@ The answer has the same shape for every kind and every language: COUNTED, by rung, with the strong-route ones named and the top test files; --tests lists them all by rung and test file, --tests-only prints only that, --why adds each route, and --tests-in narrows the listing (not the closure) to test files containing it +--why also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact +declaration, qualified suffix, simple name, field, type used by name, ...), up to five declarations it weighed with +file:line, and why that one won, or why the name matched nothing. Without --why the answer is unchanged; --json gains `why`. bound the unresolved calls inside the impacted set: the graph cannot see what those reach, so the set is a lower bound on the real one Every closure is cross-checked against a second, independent traversal over the same facts (the `verified:` line). The tool infers nothing @@ -371,8 +374,38 @@ class Impact: "\n (the language's own words are accepted too: " + ', '.join(sorted(self.KIND_ALIAS)) + ")") return k + # --why: how each target name was read. _resolve notes, per kind it answers with, the step that matched and the + # declarations it weighed (WHY); methods() and types() keep the path finder's note for each name they looked up. + # Notes only: nothing reads them unless --why asked, and no answer depends on them. + WHY = {}; WHY_MISS = None + GENERIC_WHY = {'string': ('string literal', "a quoted name is looked up as text: the literals and decorations that write it"), + 'config': ('configuration key', "a dotted lower-case name, or Section:Key, read as a configuration key"), + 'decoration': ('decoration', "every declaration that carries it"), + 'var': ('local variable', "a local of that method, found where the method's body uses it"), + 'param': ('parameter', "a parameter of that method, found in its header or body"), + 'typeparam': ('type parameter', "a type parameter used in that declaration's body"), + 'clinit': ('static initialization', "the type's static initializer and field initializers"), + 'newconst': ('a member to be added', "a constant that does not exist yet: the switches over the type")} + def why_of(self, sel, k, lab, pay): + """the --why note for one (kind, label, payload) target, in the path finder's shape""" + n = self.WHY.get(k) + if n is None and k == 'name match' and self.g.WHY_LOG: n = self.g.WHY_LOG[-1] + if n is None: + step, won = self.GENERIC_WHY.get(k, (k, '')) + if k == 'method' and '(' in sel: step, won = 'signature', "the method, narrowed to the overloads whose parameters match what was written" + ids = [i for i in (pay if isinstance(pay, list) else []) if isinstance(i, str)] + n = {'step': step, 'won': won, 'ids': ids, 'rows': None} + return dict(n, typed=sel, label=lab) + def why_miss(self, sel): + """the --why note when a target resolved to nothing: the prefix check's, else the path finder's last refusal of it""" + if self.WHY_MISS: return dict(self.WHY_MISS, typed=sel, label=None) + base = re.sub(r'\(.*\)$', '', sel.strip().strip('`"\'')).replace('#', '.').strip('.') + last = self.g.WHY_LAST + if last and last.get('step') == 'nothing named' and last.get('asked') in (base, base.replace('$', '.').strip('.')): return dict(last, typed=sel, label=None) + return {'step': 'no match', 'won': 'nothing in the graph answers this target (the message above says why)', 'ids': [], 'rows': None, 'typed': sel, 'label': None} def resolve(self, sel, kind=None): kind = self.kind_arg(kind) + self.WHY = {}; self.WHY_MISS = None out = self._resolve(sel, kind) # a target whose leading segments match nothing is not the declaration it happens to end with pre = self.prefix_note(sel, out) if out else '' @@ -383,6 +416,8 @@ class Impact: if self.CONFIG_RE.match(key) and (self.has_config() or not self.declared_name(key.rsplit('.', 1)[-1])): # a lowercase dotted key reaching a declaration only by its tail is the key, not the code return self.config_target(re.sub(r'\(.*\)$', '', sel).strip()) + self.WHY_MISS = {'step': 'nothing named', 'won': f"'{pre}' in front matches no package, type or declaration; only the trailing '{short}' matched, so the target is refused", + 'tried': ['exact declaration'], 'ids': [], 'rows': [(self.g.disp(i), self.g.loc(i)) for _k, _l, p_ in out for i in (p_ if isinstance(p_, list) else []) if isinstance(i, str) and i in self.g.sym]} die(f"'{pre}' matches no package, type or declaration in this graph; '{sel}' was matched only by its\n" f" trailing segments, which would answer for '{short}' as if the prefix had been checked. If that is\n" f" the declaration you mean, ask for it as '{short}'; if the prefix is real, re-index the tree that\n" @@ -426,8 +461,10 @@ class Impact: 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] + self.WHY['field'] = {'step': 'file and line: a field declared there', 'won': f"the line declares {row['display']} and no callable of its own", 'ids': [], 'rows': [(row['display'], f"{row['file']}:{row['line']}")]} return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] if d and d[0] == 'type' and kind in (None, 'type'): + self.WHY['type'] = {'step': 'file and line: a type declared there', 'won': f"the line is the header of type {d[1]['display']}", 'ids': [d[1]['id']], 'rows': None} 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 @@ -512,9 +549,11 @@ class Impact: return [('method', f"construction of {self.g.disp(tids[0])} ({len(ids)} callable(s))", ids)] if kind in (None, 'field'): rows = self.field_rows(base) + if rows: self.WHY['field'] = self.why_field(base, rows) if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) if kind in (None, 'type'): tids = self.types(base, soft=True) + if tids: self.WHY['type'] = self._why_t.get(base) 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 @@ -544,6 +583,7 @@ class Impact: one = len(mids) == 1 or (len(bodied) == 1 and ':' in s) # 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 and self._why_m.get(s): self.WHY['method'] = dict(self._why_m[s], ids=mids, rows=self._why_m[s].get('rows')) 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 @@ -556,6 +596,9 @@ class Impact: for r in anc: inh = self.resolve(f"{r['d']}.{m.group(2)}", kind) if (self.field_rows(f"{r['d']}.{m.group(2)}") or self.methods(f"{r['d']}.{m.group(2)}", soft=True)) else [] if inh: + self.WHY = {k_: {'step': 'inherited member', 'won': f"{s} is not declared in {self.g.disp(tid)}; it is inherited from {r['d']}, and that declaration is the target", + 'ids': [i for i in (p_ if isinstance(p_, list) else []) if isinstance(i, str)], 'rows': None, + 'tried': ['exact declaration', 'qualified suffix']} for k_, _l, p_ in inh} print(f"note: {s} is not declared in {self.g.disp(tid)}; it is inherited from {r['d']}, and this is the answer for that declaration", file=sys.stderr) return inh if kind: die(f"nothing of kind {kind} named {s} in the graph") @@ -582,10 +625,12 @@ class Impact: """the path finder's resolver, restricted to method declarations whose NAME is the last segment written (a type name expands to its methods there; SQLite's LIKE folds case — neither is a method of that name)""" try: + self.g.WHY_LAST = None with contextlib.redirect_stdout(io.StringIO()) as buf: _, ids = self.g._resolve(s) except SystemExit: if soft: return [] print(buf.getvalue(), end=''); raise + if self.g.WHY_LAST: self._why_m[s] = self.g.WHY_LAST written = re.sub(r'\(.*\)$', '', s) # a computed key (`Tagged.[Symbol.hasInstance]`) is one segment, dots and all key = re.search(r'\.(\[[^\[\]]+\])$', written) @@ -598,14 +643,33 @@ class Impact: parts = s.split('.') for k in range(len(parts)): cand = '.'.join(parts[k:]) - r = self.g.q("SELECT id FROM symbols WHERE type_id IS NOT NULL AND (display = ? OR qualified_name = ?)", cand, cand) or \ - [x for x in self.g.q("SELECT id, display FROM symbols WHERE type_id IS NOT NULL AND display LIKE ?", f"%.{cand}") if x['display'].endswith('.' + cand)] + r = self.g.q("SELECT id FROM symbols WHERE type_id IS NOT NULL AND (display = ? OR qualified_name = ?)", cand, cand); how = 'exact' + if not r: r = [x for x in self.g.q("SELECT id, display FROM symbols WHERE type_id IS NOT NULL AND display LIKE ?", f"%.{cand}") if x['display'].endswith('.' + cand)]; how = 'suffix' if not r: # the dotted spelling, only after the written one - r = self.g.q("SELECT id, qualified_name, file, line FROM symbols WHERE type_id IS NOT NULL AND qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand) + r = self.g.q("SELECT id, qualified_name, file, line FROM symbols WHERE type_id IS NOT NULL AND qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand); how = 'separators' if r: self.g.separator_collision(s, r) - if r: return [x[0] for x in r] + if r: + self._why_t[s] = self.why_type(s, cand, how, [x[0] for x in r]) + return [x[0] for x in r] if soft: return [] die(f"no type named {s}") + _why_m = {}; _why_t = {} + def why_type(self, s, cand, how, ids): + if cand != s: + step, won = 'qualified suffix', f"'{s[:len(s) - len(cand) - 1]}' in front matched no type, so it was dropped and the type '{cand}' was looked up" + elif how == 'exact': + step, won = 'exact declaration', (f"a type is named exactly '{s}'" if len(ids) == 1 else f"{len(ids)} types are named exactly '{s}': all are the target") + elif how == 'suffix': + step, won = 'qualified suffix', f"no type is named '{s}' in full; {len(ids)} end in '.{s}'" + else: + step, won = 'qualified suffix', f"no type is spelled '{s}'; one is, once / # and . are read as one separator" + return {'step': step, 'won': won, 'ids': ids, 'rows': None, 'tried': [] if step == 'exact declaration' else ['exact declaration']} + def why_field(self, s, rows): + parts = s.split('.'); owner = '.'.join(parts[:-1]) + if not owner: won = f"no owner was written: every field named '{s}' ({len(rows)})" if len(rows) > 1 else f"no owner was written: the one field named '{s}'" + elif all((f['owner'] or '') == owner for f in rows): won = f"a field '{parts[-1]}' declared on {owner}" + else: won = f"a field '{parts[-1]}' on a type whose name ends in '{owner}'" + return {'step': 'field', 'won': won, 'ids': [], 'rows': [(f['display'], f"{f['file']}:{f['line']}") for f in rows]} 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 @@ -2179,10 +2243,12 @@ def main(argv): # a scope this graph holds no file of: in a repository in several languages it is another language's # (#1584), and the dispatcher leaves a graph that says this out when any other graph holds the scope die(f"no indexed file has '{IN}' in its path.") - targets = []; scoped_in = False + targets = []; scoped_in = False; target_why = []; g.WHY_ON = want_why for a in args: try: rs = I.resolve(a, kind) except SystemExit as e: + # --why: how the name was looked up, and why it matched nothing, right after the refusal + if e.code and want_why and not as_json: print('\n'.join(g.why_lines(I.why_miss(a)))) # nothing in the graph answers for it: say what a search by hand would find, labelled [text] (ax_text.py) if e.code and not as_json: sys.stdout.flush(); ax_text.emit(repo, [a], IN) raise @@ -2217,6 +2283,7 @@ def main(argv): where = ', '.join(dict.fromkeys(g.loc(i) for i in sorted(ids, key=lambda i: (g.sym[i]['file'], g.sym[i].get('line') or 0)))) print(f"note: {a} names {len(ids)} declarations in {len(files)} files ({where}) — this answer is for all of them; " f"target one by file:line (e.g. `impact {g.loc(ids[0])}`)", file=sys.stderr if as_json else sys.stdout) + if want_why: target_why += [I.why_of(a, k, lab, pay) for k, lab, pay in rs] targets += rs if IN and os.environ.get('AXIOMCODE_FANOUT') == '1': # for ax_langs: a graph whose declarations of the name lie under --in outranks one whose lie elsewhere (#1584) @@ -2616,9 +2683,12 @@ def main(argv): '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', [])})], - 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) + 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, + **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}, indent=1)) return 0 - for k, lab, _ in targets: print(f"change: {lab} [{k}]") + for i, (k, lab, _) in enumerate(targets): + print(f"change: {lab} [{k}]") + if want_why and i < len(target_why): print('\n'.join(g.why_lines(target_why[i]))) # --tests-only: every section is still COMPUTED (the tests are derived from them) but only the tests section, # and the verified / bound lines that qualify it, are printed _stdout = sys.stdout diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f..0d0c9cec 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] [--no-refresh] is there a chain of calls from A to B, and through what? +"""axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--why] [--fresh] [--no-refresh] is there a chain of calls from A to B, and through what? axiomcode path --selftest the engine's own expected edges, replayed through this tool --no-refresh (MCP refresh=false; AXIOMCODE_NO_REFRESH=1 for a whole shell) is a read-only query: it answers from the @@ -11,6 +11,10 @@ An endpoint is anything that names code, written the way it appears in the code Owner.method Owner.Inner.method method (a free function, or that name under any owner) Type (every method it declares) file.ts:123 (the method containing that line) file.py / dir/file.ts (every method in the file) A miss lists the exact names that are close and stops. It does not answer a question about a name that does not exist. +--why adds, right after the endpoint line (or the refusal), how each endpoint name was resolved: the lookup step that +matched (exact declaration, qualified suffix, simple name, library method, call as written, type used by name, +fragment; or a file:line, a decoration, a file), the steps tried before it, up to five candidates with file:line, and +why the winner won or why the name fell to "nothing named". Without it the answer is unchanged; --json gains `why`. `*` as one endpoint asks for a closure instead of a chain: path '*' X = everything that can reach X (upstream, by hop, with the entry points among them: tests, main, decorated handlers, and methods nothing resolved calls); path X '*' = everything X reaches (--depth N bounds either; the library calls at the ends are listed, never traversed). @@ -293,13 +297,68 @@ class G: a FRAGMENT and every declaration containing it is the endpoint. That is what this tool tells a reader to do when a name misses — and until now the advice was circular, because the same lookup rejected the fragment too. """ - label, ids = self._resolve(sel, fragment) + self.WHY_LAST = None + try: label, ids = self._resolve(sel, fragment) + except SystemExit: + self.why_log(sel); raise if self.IN and not all(self.sym[i]['kind'] in ('library', 'written') for i in ids): # a library endpoint has no file; --in applies to the other end kept = [i for i in ids if self.under_in(self.sym[i]['file'])] - if not kept: die(f"{label}: none of its {len(ids)} declaration(s) is under *{self.IN}*") - if len(kept) < len(ids): label += f" [{len(kept)} under *{self.IN}*]" + if not kept: + self.why_log(sel, label); die(f"{label}: none of its {len(ids)} declaration(s) is under *{self.IN}*") + if len(kept) < len(ids): + label += f" [{len(kept)} under *{self.IN}*]" + if self.WHY_LAST: self.WHY_LAST['scope'] = f"--in {self.IN} kept {len(kept)} of its {len(ids)} declarations" ids = kept + self.why_log(sel, label) return label, ids + + # ── --why: how each endpoint name was read ──────────────────────────────────────────────────────────────── + # _resolve notes the step that answered, the declarations it weighed and why the answer is the one it is, or, on a + # miss, the steps that all found nothing. It is a note beside the answer: nothing reads it unless --why asked, + # and it changes no answer. WHY_LOG holds one note per endpoint, in the order they were resolved. + WHY_LAST = None + WHY_LOG = [] + WHY_ON = False # --why asked: the notes may cost a query or two more + # the lookups a plain name goes through, in order (a file:line, a decoration, a file and a lambda are shapes of their own) + NAME_STEPS = ('exact declaration', 'qualified suffix', 'simple name', 'library method', 'call as written', 'type used by name', 'fragment') + def why_note(self, step, ids=(), won='', rows=None, **kw): + """record how the name being resolved was matched; rows: [(name, file:line)] the step weighed (else the ids)""" + self.WHY_LAST = dict(kw, step=step, ids=list(ids), won=won, rows=rows) + def why_log(self, typed, label=None): + n = dict(self.WHY_LAST or {'step': 'no match', 'won': 'nothing in the graph answers this shape (the message above says why)'}) + n['typed'] = typed; n['label'] = label + self.WHY_LOG.append(n) + def why_tried(self, step, s, fragment=False): + """the name lookups that ran, and found nothing, before `step`""" + steps = [x for x in self.NAME_STEPS if not (x == 'simple name' and '.' in s) and not (x == 'fragment' and not fragment)] + return steps[:steps.index(step)] if step in steps else steps + def why_rows(self, rows_or_ids, cap=5): + """(name, file:line) for up to `cap` declarations, and how many there were""" + out = [] + for x in rows_or_ids: + if isinstance(x, tuple): out.append(x) + else: out.append((self.name(x) if x in self.sym or x in self.all_sym else str(x), self.loc(x))) + out = list(dict.fromkeys(out)) + return out[:cap], len(out) + def why_lines(self, n): + """the --why block for one endpoint: at most eight lines, in plain words""" + rows, total = self.why_rows(n['rows'] if n.get('rows') is not None else n.get('ids') or []) + out = [f" why '{n['typed']}': {n['step']}"] + if n.get('won'): out.append(f" {n['won']}") + if n.get('tried'): out.append(f" tried first, no match: {', '.join(n['tried'])}") + if n.get('scope'): out.append(f" {n['scope']}") + room = 8 - len(out) - 1 + if rows: + out.append(f" considered {total}:" if total > 1 else " considered:") + shown = rows[:max(1, room - (1 if total > room else 0))] + out += [f" {nm} {at}" for nm, at in shown] + if total > len(shown): out.append(f" … +{total - len(shown)} more") + return out[:8] + def why_json(self, n): + rows, total = self.why_rows(n['rows'] if n.get('rows') is not None else n.get('ids') or [], cap=20) + return {'endpoint': n['typed'], 'step': n['step'], 'reason': n.get('won') or '', 'tried': n.get('tried') or [], + 'candidates': [{'name': nm, 'at': at} for nm, at in rows], 'candidates_total': total, + **({'scope': n['scope']} if n.get('scope') else {})} def separator_collision(self, typed, rows): """two declarations whose names differ only in separators (`a/b#c`, `a.b/c`) are the same dotted name. Neither is the answer: the caller is told both, and the exact spelling of each still resolves to that one alone.""" @@ -370,6 +429,7 @@ class G: # A LINE OUTSIDE THE FILE IS REFUSED (#1601). Line 0, or a line past the end — a number copied before the file # was edited, or from an old stack trace — spans no callable, so it fell through to the module node and was # answered as the file's top-level code, as if that line existed. The file on disk is what the reader sees. + self.why_note('file and line', won=f"no declaration in the graph spans {s} (the message above says why)") if ln < 1: die(f"line {ln} is not in {f}: lines are numbered from 1") n = self.file_lines(f) if files else None if n is not None and ln > n: die(f"{f} has {n} line{'' if n == 1 else 's'}; line {ln} is not in it") @@ -381,13 +441,19 @@ class G: 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 ids: + self.why_note('file and line: a type declared there', ids, f"the line is the header of type {t['display']}, so its {len(ids)} method(s) are the endpoint", + rows=[(t['display'], f"{f}:{t['line']}")]) + 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) + inner = (self.WHY_LAST or {}).get('step', '') + self.why_note('file and line: a field declared there', ids, f"the line declares {nm}, read as that name ({inner})", + rows=[(nm, f"{f}:{fr['line']}")] + self.why_rows(ids, 4)[0]) return f"{label} (field at {s})", ids - except SystemExit: pass + except SystemExit: self.why_note('file and line', won=f"no declaration in the graph spans {s} (the message above says why)") 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 @@ -401,12 +467,18 @@ 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] + self.why_note('file and line: the callable spanning it', ids, + (f"the narrowest callable spanning line {ln}" if len(ids) == 1 else f"{len(ids)} callables share the narrowest span of line {ln}") + + (f"; {len(r) - len(ids)} wider one(s) that also span it were set aside" if len(r) > len(ids) else ''), + rows=self.why_rows(ids + [x['id'] for x in r if x['id'] not in ids], 5)[0]) 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 r: + self.why_note('file and line: top-level code', [r[0]['id']], f"no callable spans line {ln}; the file's top-level code holding it is the endpoint") + 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}") if s.startswith('@') and self.has('decorations'): # @GetMapping / @*Mapping / @Test → every method carrying it @@ -421,14 +493,20 @@ class G: if not (ty.get('file') and ty.get('line') and ty.get('end_line')): continue ids += [m['id'] for m in self.q("SELECT id FROM symbols WHERE file = ? AND line >= ? AND end_line <= ? AND method_id IS NOT NULL AND kind <> 'module'", ty['file'], ty['line'], ty['end_line'])] ids = list(dict.fromkeys(ids)) - if ids: return f"{s} ({len(ids)} methods)", ids + if ids: + self.why_note('decoration', ids, f"every method carrying {s}, or declared in a type carrying it") + return f"{s} ({len(ids)} methods)", ids + self.why_note('decoration', won=f"no method carries {s}") near = self.q("SELECT name, count(*) n FROM decorations GROUP BY name ORDER BY n DESC LIMIT 8") if not near: die(f"no method carries {s}, and this graph records NO decoration at all — not that the code has none." " A front end that does not project decorations cannot answer a decoration endpoint; ask by name instead.") die(f"no method carries {s}. Decorations in this graph: " + ', '.join(f"@{x['name']} ({x['n']})" for x in near)) if re.search(r'\.(ts|tsx|js|mjs|cjs|py|java)$', s): # a file → every method in it r = self.q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND method_id IS NOT NULL AND kind <> 'module'", s.lstrip('/'), f"%/{s.lstrip('/')}") - if r: return f"{s} ({len(r)} methods)", [x['id'] for x in r] + if r: + self.why_note('file name', [x['id'] for x in r], f"every method declared in a file named {s}") + return f"{s} ({len(r)} methods)", [x['id'] for x in r] + self.why_note('file name', won=f"no indexed file named {s} declares a method") die(f"no methods in a file named {s}") # the name as the agent writes it, normalised: Outer$Inner.m, Outer.Inner#m, m(int,String), pkg.Outer.Inner.m are all # Outer.Inner.m. Java's parser names a nested type without its outer (#667) and the index recovers most of them, so the @@ -446,13 +524,17 @@ class G: 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 + if ids: + self.why_note('lambda at a line', ids, f"the <{ml.group(2)}> that starts on line {ml.group(3)}" + (f" inside {ml.group(1)}" if ml.group(1) else '')) + return (s if len(ids) == 1 else f"{s} ({len(ids)} declarations)"), ids + self.why_note('lambda at a line', won=f"no <{ml.group(2)}> starts on line {ml.group(3)}") 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) if hit: ids = [x['id'] for x in hit] + self.why_note('exact declaration', ids, f"a declaration is named exactly '{verbatim}', written with its # as it is in the code") return (verbatim if len(ids) == 1 else f"{verbatim} ({len(ids)} declarations)"), ids s = re.sub(r'\(.*\)$', '', s).replace('#', '.').strip('.') # EXACT SPELLING WINS. Two shapes were unreachable because a heuristic ran before the literal lookup: @@ -464,6 +546,12 @@ class G: exact = self.q("SELECT id FROM symbols WHERE display = ? AND method_id IS NOT NULL", s) if exact: ids = [x['id'] for x in exact] + lost = '' + if self.WHY_ON: + n = self.q("SELECT count(*) n FROM symbols WHERE method_id IS NOT NULL AND display <> ? AND (display LIKE ? OR qualified_name LIKE ?)", s, f"%.{s}", f"%.{s}")[0]['n'] + if n: lost = f"; {n} other declaration(s) only ending in '.{s}' were not taken" + self.why_note('exact declaration', ids, (f"a declaration is named exactly '{s}'" if len(ids) == 1 else f"{len(ids)} declarations are named exactly '{s}': all are the endpoint") + + ", and an exact name is tried before any looser step" + lost) return (s if len(ids) == 1 else f"{s} ({len(ids)} declarations)"), ids # Outer$Inner.m and Outer$1.m: the type is looked up STRUCTURALLY through the nesting table — Inner declared (at any # depth) inside Outer, or the 1st/2nd… anonymous class inside Outer in source order, as javac numbers them @@ -471,53 +559,78 @@ class G: if mn and self.has('nesting'): outer, inner, mem = mn.group(2).split('.')[-1], mn.group(3), mn.group(4) ids = self.nested_methods(outer, inner, mem) - if ids: return f"{s} (nested in {outer}: {len(ids)} declaration(s))", ids + if ids: + self.why_note('nested type', ids, f"{inner} was looked up as a type declared inside {outer}, and {mem} on it") + return f"{s} (nested in {outer}: {len(ids)} declaration(s))", ids + self.why_note('nested type', won=f"no type {inner} is declared inside {outer} with a member {mem}") die(self.nested_miss(outer, inner, mem, s)) # Outer$Inner / Outer$1 with no member: the nested type itself, which is every method it declares tn = re.match(r'^(.*?)([\w.]+)\$([\w$]+)$', s) if tn and self.has('nesting'): outer, inner = tn.group(2).split('.')[-1], tn.group(3) ids = self.nested_methods(outer, inner, None) - if ids: return f"{s} (nested in {outer}: {len(ids)} method(s))", ids + if ids: + self.why_note('nested type', ids, f"{inner} was looked up as a type declared inside {outer}: its methods are the endpoint") + return f"{s} (nested in {outer}: {len(ids)} method(s))", ids + self.why_note('nested type', won=f"no type {inner} is declared inside {outer}") die(self.nested_miss(outer, inner, '*', s)) s = s.replace('$', '.') - parts = s.split('.'); rows = []; used = s + parts = s.split('.'); rows = []; used = s; how = '' # an owner the agent WROTE is never dropped: Owner.m that does not exist is a miss, not a search for any `m` for k in range(max(1, len(parts) - 1)): cand = '.'.join(parts[k:]) - rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display = ? OR qualified_name = ? OR qualified_name LIKE ?", cand, cand, f"%.{cand}") - if not rows: rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display LIKE ? OR display LIKE ?", f"%.{cand}", f"%#{cand}") + rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display = ? OR qualified_name = ? OR qualified_name LIKE ?", cand, cand, f"%.{cand}"); how = 'name' + if not rows: rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display LIKE ? OR display LIKE ?", f"%.{cand}", f"%#{cand}"); how = 'suffix' # the name as written matched nothing: only then is it compared in the one dotted spelling, so a name that # resolved before resolves to the same declaration now, whatever a dotted lookalike elsewhere is called if not rows: # LIKE on the last segment first: C-speed, and a superset (it folds case, `_` is a wildcard), so the Python # comparison sees a handful of rows instead of every symbol (2.3 s → 0.06 s on a million) - rows = self.q("SELECT id, kind, display, type_id, method_id, qualified_name, file, line FROM symbols WHERE qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand) + rows = self.q("SELECT id, kind, display, type_id, method_id, qualified_name, file, line FROM symbols WHERE qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand); how = 'separators' self.separator_collision(sel, rows) if rows: used = cand; break exact = [r for r in rows if r['display'] == used or (r['display'] or '').endswith('.' + used) and r['display'].count('.') == used.count('.') + 1] or rows methods = [r['id'] for r in exact if r['method_id'] and r['kind'] != 'module'] types = [r for r in exact if r['type_id'] and not r['method_id']] via = '' if used == s else f" (matched as {used})" + if rows: self.why_suffix(s, used, how, rows, exact, methods, types) if methods and not types: return (s if len(methods) == 1 else f"{s} ({len(methods)} declarations)") + via, methods if types: # a type → the methods it declares (+ its initializer node if it has one) ids = [] for t in types: ids += [r['id'] for r in self.q("SELECT id FROM symbols WHERE owner = ? AND method_id IS NOT NULL", t['display'])] if t['id'] in self.callers_ids: ids.append(t['id']) - if ids: return f"{s} (type: {len(ids)} methods){via}", ids + if ids: + self.WHY_LAST['ids'] = ids; self.WHY_LAST['won'] += f"; a type, so its {len(ids)} method(s) are the endpoint" + return f"{s} (type: {len(ids)} methods){via}", ids + self.WHY_LAST['won'] += "; a type, but it declares no method in the graph" die(f"{s} is a type with no methods in the graph") if '.' not in s: # a bare name → that method under any owner, or a free function r = self.q("SELECT id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module'", s) - if r: return (s if len(r) == 1 else f"{s} ({len(r)} declarations, any owner)"), [x['id'] for x in r] + if r: + self.why_note('simple name', [x['id'] for x in r], (f"nothing is named '{s}' in full; the one method with that simple name, under its owner" if len(r) == 1 else + f"nothing is named '{s}' in full; every method with that simple name ({len(r)}, any owner) is the endpoint"), + tried=self.why_tried('simple name', s, fragment)) + return (s if len(r) == 1 else f"{s} ({len(r)} declarations, any owner)"), [x['id'] for x in r] # not declared in the client code: a JDK / dependency method the code calls into (staged with --library), or the same # call as the parser wrote it down at an unresolved site (no staging needed) — a client declaration always wins over both lib = self.library(s) - if lib: return lib + if lib: + self.why_note('library method', lib[1], f"no client declaration is named '{s}'; it is a library method the code calls into", + rows=[(self.sym[i]['display'], 'library') for i in lib[1]], tried=self.why_tried('library method', s, fragment)) + return lib w = self.written(s) - if w: return w + if w: + sites = self.SITES.get(w[1][0], []) + self.why_note('call as written', w[1], f"nothing declares '{s}'; it is the name written at {len(sites)} call site(s) the engine could not resolve, matched by name only", + rows=[(self.disp(r['caller_id']), f"{self.site_file(r['file_path'])}:{r['start_line']}") for r in sites], tried=self.why_tried('call as written', s, fragment)) + return w + self._tu_why = ''; self._tu_cands = [] t = self.type_use(s) # a type the code USES but does not declare: File, Path, Socket - if t: return t + if t: + self.why_note('type used by name', t[1], f"nothing declares '{s}'; it is spelled as a type and used by name, so the places that use it are the endpoint", + rows=getattr(self, '_tu_rows', []), tried=self.why_tried('type used by name', s, fragment)) + return t # THE NO-NAME-YET FORM. `path decrypt '*'` — every declaration whose name CONTAINS the word, which is how a # reader gets from a word in an issue to the exact names this tool otherwise insists on. Only with `'*'` at # the other end (a chain between two fragments would be a guess about both ends), only for a word long enough @@ -527,10 +640,15 @@ class G: AND lower(name) LIKE lower(?) ORDER BY length(display), display LIMIT 200""", f"%{s}%") if hits: ids = [r['id'] for r in hits] + self.why_note('fragment', ids, f"no declaration is named '{s}'; with '*' at the other end, every declaration containing the word is the endpoint", + tried=self.why_tried('fragment', s, fragment)) shown = ', '.join(r['display'] for r in hits[:4]) + (' …' if len(hits) > 4 else '') return f"{len(ids)} declaration(s) containing '{s}' ({shown})", ids near = self.q("SELECT DISTINCT display FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL) AND (lower(display) = lower(?) OR lower(name) = lower(?) OR display LIKE ?) LIMIT 6", s, s.split('.')[-1], f"%{s.split('.')[-1]}%") UNRESOLVED.append(s) # each refusal below: the [text] block searches for it (ax_text.py) + tu = getattr(self, '_tu_why', '') + self.why_note('nothing named', won=tu or (f"no step matched '{s}'" + ("; the names below are close, not matches" if near else "; nothing close to it is declared")), + rows=getattr(self, '_tu_cands', []) + [(r['display'], self.decl_loc(r['display'])) for r in near], tried=self.why_tried('nothing named', s, fragment), asked=s) # with candidates the advice is "pick one"; with none it has to be something else, or the line reads # "use one of them" pointing at an empty list # a word the code WRITES but nothing declares (a local, a parameter, a property) is not a target: the methods that @@ -549,6 +667,34 @@ class G: f" a SHORTER fragment is the way in — `path {s.split('.')[-1][:6] or 'name'} '*'` matches every\n" f" declaration containing it — or a file:line from a stack trace or an issue.") + def decl_loc(self, display): + r = self.q("SELECT file, line FROM symbols WHERE display = ? AND (method_id IS NOT NULL OR type_id IS NOT NULL) ORDER BY file, line LIMIT 1", display) + return f"{r[0]['file']}:{r[0]['line']}" if r and r[0]['line'] else '?' + def why_suffix(self, s, used, how, rows, exact, methods, types): + """the --why note for the dotted-name lookup: which spelling matched, and what the narrowing set aside""" + win = set(methods) | {t['id'] for t in types} + cands = sorted(rows, key=lambda r: (r['id'] not in win, r['display'] or '')) + allrows = [(r['display'], self.loc(r['id']) if r['id'] in self.sym else self.decl_loc(r['display'])) for r in cands] + ids = [r['id'] for r in exact] + qn = {x['qualified_name'] for x in self.q("SELECT qualified_name FROM symbols WHERE id IN (%s)" % ','.join('?' * len(ids)), *ids)} if ids else set() + if used != s: + step, won = 'qualified suffix', (f"'{s[:len(s) - len(used) - 1]}' in front matched nothing, so it was dropped and '{used}' was looked up" + f"; the answer is for a declaration of that shorter name, wherever it is") + elif how == 'separators': + step, won = 'qualified suffix', f"no declaration is spelled '{s}'; one is, once / # and . are read as one separator" + elif all((r['display'] or '') == used for r in exact): + step, won = 'exact declaration', (f"a declaration is named exactly '{s}'" if len(exact) == 1 else f"{len(exact)} declarations are named exactly '{s}': all are the endpoint") + elif how == 'name' and any((r['display'] or '') == used for r in rows): + step, won = 'exact declaration', f"a declaration is named exactly '{s}'" + elif how == 'name' and used in qn: + step, won = 'exact declaration', f"'{s}' is the full qualified name of " + (f"{len(exact)} declarations" if len(exact) > 1 else "a declaration") + elif '.' not in s: + step, won = 'simple name', (f"nothing is named '{s}' in full; it is the simple name of one declaration, under its owner" if len(exact) == 1 else + f"nothing is named '{s}' in full; it is the simple name of {len(exact)} declarations (any owner): all are the endpoint") + else: + step, won = 'qualified suffix', f"no declaration is named '{s}' in full; {len(exact)} end in '.{s}' (the last segments of a longer qualified name)" + if len(exact) < len(rows): won += f"; {len(rows) - len(exact)} longer match(es) were set aside for the one(s) at the same depth" + self.why_note(step, list(win), won, rows=allrows, tried={'exact declaration': [], 'simple name': ['exact declaration']}.get(step, self.why_tried(step, s))) def nested_methods(self, outer, inner, mem): """methods named mem on the type(s) `inner` nested inside a type named `outer` — inner = a name (Inner, or Inner$Deeper as a chain) or an ordinal (1, 2 …: the n-th anonymous class in the outer, by source line)""" @@ -640,21 +786,26 @@ class G: # a qualified name whose last segment the client itself declares is a declaration asked for under a wrong # prefix, not a type from outside: answering for every reference to that short name is a different question if '.' in s and self.q("SELECT 1 FROM symbols WHERE name = ? AND (method_id IS NOT NULL OR type_id IS NOT NULL) AND kind NOT IN ('library', 'written') LIMIT 1", name): + self._tu_why = f"'{name}' is declared here, but not under '{s.rsplit('.', 1)[0]}'; a wrong qualifier is a miss, not a use of another '{name}'" + if self.WHY_ON: self._tu_cands = [(r['display'], f"{r['file']}:{r['line']}") for r in self.q("SELECT display, file, line FROM symbols WHERE name = ? AND (method_id IS NOT NULL OR type_id IS NOT NULL) AND kind NOT IN ('library', 'written') ORDER BY file, line LIMIT 5", name)] return None # nor is one whose QUALIFIER is a type the client declares: `Controller.Details` asks for a member of that type, # and the type has none of that name. The uses of some other `Details` (a component, a library class written # bare) are not uses of it, and answering for them turned a typo into "the two are independent" if '.' in s and self.q("SELECT 1 FROM symbols WHERE name = ? AND type_id IS NOT NULL AND method_id IS NULL AND kind NOT IN ('library', 'written') LIMIT 1", s.split('.')[-2]): + self._tu_why = (f"'{s.split('.')[-2]}' is a type declared here and it has no member '{name}'; the uses of some other '{name}'" + f" are not uses of it") + if self.WHY_ON: self._tu_cands = [(r['display'], f"{r['file']}:{r['line']}") for r in self.q("SELECT display, file, line FROM symbols WHERE name = ? AND type_id IS NOT NULL AND method_id IS NULL AND kind NOT IN ('library', 'written') ORDER BY file, line LIMIT 5", s.split('.')[-2])] return None w = self.written('new ' + name) if w: ids += w[1]; parts.append(f"new {name} at {len(self.SITES[w[1][0]])} site(s)") lib = self.library(s + '.*') if '.' in s else self.library(name + '.*') if lib: ids += lib[1]; parts.append(f"{len(lib[1])} library method(s) of {s}") - callers = set(); typed = bool(ids) + callers = set(); typed = bool(ids); self._tu_rows = [] if self.has('type_refs'): for r in self.q("SELECT file, line FROM type_refs WHERE name = ? AND line > 0", name): e = self.enclosing_at(r['file'], r['line']) - if e: callers.add(e); typed = True + if e: callers.add(e); typed = True; self._tu_rows.append((f"used in {self.disp(e)}", f"{r['file']}:{r['line']}")) # A NAME IS NOT A TYPE BECAUSE IT IS WRITTEN. An identifier reference says only that the word occurs: a local # `const labelOf = new Map()`, a parameter, a field. Those uses were each counted as "a use of type labelOf", # so a name no declaration carries came back as a type with exact callers and a passing `verified:` line. A @@ -669,10 +820,12 @@ class G: if (r['entity_kind'] or '') in VALUE_BINDINGS: continue e = self.enclosing_at(r['file'], r['line']) if not e: continue - callers.add(e) + callers.add(e); self._tu_rows.append((f"used in {self.disp(e)}", f"{r['file']}:{r['line']}")) typed = typed or r['entity_kind'] == 'TYPE' or (name[:1].isupper() and (r['kind'] or '') not in MEMBER_REFS and self.unqualified_on(r['file'], r['line'], name)) - if not typed: return None + if not typed: + self._tu_why = f"nothing declares '{s}', and '{name}' is not used as a type in the code" + return None if callers: node = f"t:{s}" self.sym[node] = {'id': node, 'display': f"uses of type {s}", 'file': '', 'line': None, 'end_line': None, 'kind': 'written', 'owner': '', 'is_test': 0, 'name': name, 'method_id': node} @@ -1883,12 +2036,32 @@ def selftest(lang): print(f"{lang}: {ok}/{tot} expected resolved edges found as chains ({len(cases)} cases); {engine_gaps} not in the engine's output (its suite tracks those), {len(tool_bugs)} lost by this tool") return 0 if not tool_bugs else 1 +def why_around(g, query): + """--why: run the query, then put a short block saying how each endpoint name was read right after the line that + names the endpoints (the answer's first line), or after the refusal when a name matched nothing""" + buf = io.StringIO(); code = None; err = None + with contextlib.redirect_stdout(buf): + try: code = query() + except SystemExit as e: err = e + text = buf.getvalue(); notes = [n for n in g.WHY_LOG if n.get('typed') != '*'] + RESULT['why'] = [g.why_json(n) for n in notes] + block = [l for n in notes for l in g.why_lines(n)] + lines = text.split('\n') + if err is not None: at = len(lines) - (1 if text.endswith('\n') else 0) + else: + labels = [n['label'] for n in notes if n.get('label')] + at = next((i + 1 for i, l in enumerate(lines) if any(lb in l for lb in labels)), 0) + sys.stdout.write('\n'.join(lines[:at] + block + lines[at:]) if block else text) + if err is not None: raise err + return code + if __name__ == '__main__': import ax_pages; ax_pages.install('path') # pages and a next step (#1202) args = sys.argv[1:] if not args or args[0] in ('-h', '--help'): print(__doc__); sys.exit(0) if args[0] == '--selftest': sys.exit(selftest(args[1] if len(args) > 1 else 'typescript')) as_json = '--json' in args; args = [a for a in args if a != '--json'] + want_why = '--why' in args; args = [a for a in args if a != '--why'] show_all = '--all' in args; args = [a for a in args if a != '--all'] depth = 40 if '--depth' in args: i = args.index('--depth'); depth = int(args[i + 1]); del args[i:i + 2] @@ -1906,12 +2079,15 @@ if __name__ == '__main__': if IN and not ax_text.held(repo, IN): print(f"{ax_text.SCOPE_GONE}'{IN}' in its path — answering at the repository root instead") IN = None - g = G(repo); g.IN = IN + g = G(repo); g.IN = IN; g.WHY_ON = want_why RESULT['query'] = {'from': args[0], 'to': args[1], 'repo': g.repo} - try: + def query(): if args[0] == '*': return closure(g, args[1], upstream=True, limit=limit if limit != 10 else 40, depth=depth) if args[1] == '*': return closure(g, args[0], upstream=False, limit=limit if limit != 10 else 40, depth=depth) return path(g, args[0], args[1], show_all, limit, every, max_paths) + try: + if not want_why: return query() + return why_around(g, query) except SystemExit as e: # an endpoint nothing in the graph declares: what a search by hand would find, labelled [text] (ax_text.py) if e.code and UNRESOLVED and not as_json: sys.stdout.flush(); ax_text.emit(repo, UNRESOLVED, IN) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index b7e76787..9ded5b67 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -11,7 +11,7 @@ are in your tool list; otherwise run `/../../plugins/axiomcode/skills/ verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own 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. +**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]` / `[approx]` 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 …]`, @@ -75,6 +75,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[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 | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | +| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges connect, not that a test exercises the change. @@ -87,13 +88,13 @@ does not restrict. Detail: `reference/context.md`. ## impact — what a change to a declaration reaches -`axiomcode impact … [--depth N] [--in ] [--delete]`. Targets as written in the code: +`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: `Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or `file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, `src.util.square` and `src/util#square` are one name. **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. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config +(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. ## changed · test-impact — from an edit @@ -107,9 +108,11 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph -`axiomcode path [--every] [--in ]`: one shortest verified chain per target, or why there is none +`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: +`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with +file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. ## diff: two graphs of the same tree diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 09d2022f..026784d9 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -176,7 +176,9 @@ line and names the constructor query to run; take that suggestion before acting file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change, and `--tests-in ` narrows + file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: + the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a + `why` list), and `--tests-in ` narrows the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md index f17b0484..e20eeeef 100644 --- a/skills/axiomcode/reference/path.md +++ b/skills/axiomcode/reference/path.md @@ -20,6 +20,14 @@ says so on the answer's first line, with the reason. written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python and C#; JavaScript works but the engine's JavaScript output is still moving. +- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, + right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, + in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix + (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, + library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and + found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" + (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when + an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. - **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), diff --git a/tests/cases/csharp/why-names-the-resolver-step/case.json b/tests/cases/csharp/why-names-the-resolver-step/case.json new file mode 100644 index 00000000..b28363fa --- /dev/null +++ b/tests/cases/csharp/why-names-the-resolver-step/case.json @@ -0,0 +1,23 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "--why says an exactly named endpoint matched by its exact declaration, with where it is declared", + "run": ["path", "OrderController.Detail", "OrderHandler.Handle", "--why"], + "want": ["why 'OrderController.Detail': exact declaration", "why 'OrderHandler.Handle': exact declaration", "OrderHandler.Handle src/Orders.cs:7"]}, + {"why": "a type nothing declares is matched where the code uses it by name, and --why names the line that uses it", + "run": ["path", "Panel", "OrderHandler.Handle", "--why"], "expect_error": true, + "want": ["why 'Panel': type used by name", "used in OrderPage", "src/Orders.cs:19", "tried first, no match: exact declaration, qualified suffix, simple name"], + "avoid": ["why 'Panel': exact declaration", "why 'Panel': nothing named"]}, + {"why": "a qualified typo whose qualifier is a declared type falls to nothing named, and --why says why the other Panel was not taken", + "run": ["path", "OrderController.Panel", "OrderHandler.Handle", "--why"], "expect_error": true, + "want": ["nothing named 'OrderController.Panel'", "why 'OrderController.Panel': nothing named", "'OrderController' is a type declared here and it has no member 'Panel'", "OrderController src/Orders.cs:10"], + "avoid": ["why 'OrderController.Panel': type used by name"]}, + {"why": "impact --why on the same typo says the same after the refusal", + "run": ["impact", "OrderController.Panel", "--why"], "expect_error": true, + "want": ["why 'OrderController.Panel': nothing named"]}, + {"why": "impact --why on a declared method prints the step under the change line", + "run": ["impact", "Handle", "--why"], + "want": ["change: OrderHandler.Handle", "why 'Handle': simple name"]}, + {"why": "control: without --why the typo is refused with no block", + "run": ["path", "OrderController.Panel", "OrderHandler.Handle"], "expect_error": true, + "want": ["nothing named 'OrderController.Panel'"], + "avoid": ["why 'OrderController.Panel'", "tried first, no match"]}]} diff --git a/tests/cases/csharp/why-names-the-resolver-step/src/App.csproj b/tests/cases/csharp/why-names-the-resolver-step/src/App.csproj new file mode 100644 index 00000000..a7a09e8a --- /dev/null +++ b/tests/cases/csharp/why-names-the-resolver-step/src/App.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/tests/cases/csharp/why-names-the-resolver-step/src/Orders.cs b/tests/cases/csharp/why-names-the-resolver-step/src/Orders.cs new file mode 100644 index 00000000..1abf44f8 --- /dev/null +++ b/tests/cases/csharp/why-names-the-resolver-step/src/Orders.cs @@ -0,0 +1,22 @@ +using Vendor.Ui; + +namespace App; + +public class OrderHandler +{ + public void Handle() { } +} + +public class OrderController +{ + private readonly OrderHandler _h = new OrderHandler(); + + public void Detail() { _h.Handle(); } +} + +public class OrderPage +{ + private Panel Summary { get; set; } = null!; + + public void Show() { Summary.Open(); } +} diff --git a/tests/cases/java/why-names-the-resolver-step/case.json b/tests/cases/java/why-names-the-resolver-step/case.json new file mode 100644 index 00000000..455a79cc --- /dev/null +++ b/tests/cases/java/why-names-the-resolver-step/case.json @@ -0,0 +1,22 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "--why says an exactly named endpoint matched by its exact declaration, with where it is declared", + "run": ["path", "Checkout.settle", "Order.subtotal", "--why"], + "want": ["why 'Checkout.settle': exact declaration", "why 'Order.subtotal': exact declaration", "Order.subtotal src/shop/Order.java:6"]}, + {"why": "a bare name no declaration carries in full is matched by its simple name, and the step says so", + "run": ["path", "settle", "subtotal", "--why"], + "want": ["why 'settle': simple name", "why 'subtotal': simple name", "tried first, no match: exact declaration"], + "avoid": ["why 'subtotal': exact declaration"]}, + {"why": "the package-qualified name is the declaration's full qualified name: an exact declaration, not a suffix", + "run": ["path", "shop.Checkout.settle", "Order.total", "--why"], + "want": ["why 'shop.Checkout.settle': exact declaration", "full qualified name"]}, + {"why": "a qualified typo falls to nothing named, and --why lists the steps that all missed and the close name", + "run": ["path", "Order.subtotl", "Checkout.settle", "--why"], "expect_error": true, + "want": ["nothing named 'Order.subtotl'", "why 'Order.subtotl': nothing named", "tried first, no match: exact declaration, qualified suffix"], + "avoid": ["why 'Order.subtotl': exact declaration", "why 'Order.subtotl': type used by name"]}, + {"why": "impact --why prints the step under the change line", + "run": ["impact", "subtotal", "--why"], + "want": ["change: Order.subtotal", "why 'subtotal': simple name"]}, + {"why": "control: without --why neither verb prints the block", + "run": ["impact", "subtotal"], + "avoid": ["why 'subtotal'", "tried first, no match"]}]} diff --git a/tests/cases/java/why-names-the-resolver-step/src/shop/Checkout.java b/tests/cases/java/why-names-the-resolver-step/src/shop/Checkout.java new file mode 100644 index 00000000..511e6fcd --- /dev/null +++ b/tests/cases/java/why-names-the-resolver-step/src/shop/Checkout.java @@ -0,0 +1,5 @@ +package shop; + +public class Checkout { + public int settle() { return new Order().total(); } +} diff --git a/tests/cases/java/why-names-the-resolver-step/src/shop/Order.java b/tests/cases/java/why-names-the-resolver-step/src/shop/Order.java new file mode 100644 index 00000000..ce79c3af --- /dev/null +++ b/tests/cases/java/why-names-the-resolver-step/src/shop/Order.java @@ -0,0 +1,7 @@ +package shop; + +public class Order { + public int total() { return subtotal() + 1; } + + int subtotal() { return 2; } +} diff --git a/tests/cases/python/why-names-the-resolver-step/app/__init__.py b/tests/cases/python/why-names-the-resolver-step/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/why-names-the-resolver-step/app/billing.py b/tests/cases/python/why-names-the-resolver-step/app/billing.py new file mode 100644 index 00000000..a5545bfc --- /dev/null +++ b/tests/cases/python/why-names-the-resolver-step/app/billing.py @@ -0,0 +1,10 @@ +class Invoice: + def total(self): + return self.subtotal() + 1 + + def subtotal(self): + return 2 + + +def settle(inv): + return inv.total() diff --git a/tests/cases/python/why-names-the-resolver-step/case.json b/tests/cases/python/why-names-the-resolver-step/case.json new file mode 100644 index 00000000..d61a5ee5 --- /dev/null +++ b/tests/cases/python/why-names-the-resolver-step/case.json @@ -0,0 +1,29 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "--why says an exactly named endpoint matched by its exact declaration, with where it is declared", + "run": ["path", "Invoice.total", "Invoice.subtotal", "--why"], + "want": ["why 'Invoice.total': exact declaration", "why 'Invoice.subtotal': exact declaration", "Invoice.total app/billing.py:2"], + "avoid": ["why 'Invoice.total': qualified suffix", "why 'Invoice.total': simple name"]}, + {"why": "a bare name no declaration carries in full is matched by its simple name, and the step says so with the step it tried first", + "run": ["path", "settle", "subtotal", "--why"], "expect_error": true, + "want": ["why 'settle': exact declaration", "why 'subtotal': simple name", "tried first, no match: exact declaration", "Invoice.subtotal app/billing.py:5"], + "avoid": ["why 'subtotal': exact declaration"]}, + {"why": "a leading segment that matches nothing is dropped, and --why says which part was dropped", + "run": ["path", "zz.Invoice.total", "Invoice.subtotal", "--why"], + "want": ["why 'zz.Invoice.total': qualified suffix", "'zz' in front matched nothing"]}, + {"why": "a qualified typo falls to nothing named, and --why lists the steps that all missed", + "run": ["path", "Invoice.subtotl", "settle", "--why"], "expect_error": true, + "want": ["nothing named 'Invoice.subtotl'", "why 'Invoice.subtotl': nothing named", "type used by name"], + "avoid": ["why 'Invoice.subtotl': exact declaration", "why 'Invoice.subtotl': type used by name"]}, + {"why": "impact --why prints the step under the change line", + "run": ["impact", "subtotal", "--why"], + "want": ["change: Invoice.subtotal", "why 'subtotal': simple name"]}, + {"why": "impact --why --json carries the step in an optional why field", + "run": ["impact", "Invoice.subtotal", "--why", "--json"], "stdout_json": true, + "want": ["\"why\": [", "\"step\": \"exact declaration\""]}, + {"why": "control: without --why neither verb prints the block", + "run": ["path", "settle", "subtotal"], "expect_error": true, + "avoid": ["why 'settle'", "why 'subtotal'", "tried first, no match"]}, + {"why": "control: impact --json without --why has no why field", + "run": ["impact", "Invoice.subtotal", "--json"], "stdout_json": true, + "avoid": ["\"why\": ["]}]} From 996fa55dd0ad21d49968d2c51e6e94ac6902ce84 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:15:02 -0700 Subject: [PATCH 062/258] typescript: a structural pair needs matching arity and a class that can reach the interface A class was counted as implementing an interface whenever it declared a member of each required name. Now a covered method that needs more arguments than the target passes rejects the pair, and outside one tsconfig program the class's module (or a module that uses the class as a value) must import the interface's module. impact never promotes a structural pair to 'must change', and a via-the-interface row reached only structurally is at most one of a set. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/bus.ts | 10 ++ .../src/duck-pub.ts | 12 ++ .../03-structural-satisfaction/src/pub.ts | 25 +++++ .../src/stranger.ts | 7 ++ .../expected/03-structural-satisfaction.edges | 11 ++ .../03-structural-satisfaction.envelope | 2 + .../03-structural-satisfaction.fields | 1 + .../03-structural-satisfaction.fields-oracle | 10 +- .../03-structural-satisfaction.lib.edges | 11 ++ .../03-structural-satisfaction.lib.envelope | 2 + .../03-structural-satisfaction.lib.oracle | 4 +- .../03-structural-satisfaction.oracle | 4 +- .../03-structural-satisfaction.type-use | 10 ++ .../03-structural-satisfaction.types-oracle | 10 +- .../resolution/structural-satisfaction.dl | 105 ++++++++++++++++-- graph/typescript/souffle/decls_all.dl | 12 ++ .../skills/axiomcode/scripts/axiomcode-impact | 5 +- .../skills/axiomcode/scripts/graph_sql.py | 15 ++- .../dispatch-base-is-a-contract/case.json | 6 +- .../dispatch-base-is-a-contract/src/router.ts | 13 +++ 20 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts new file mode 100644 index 00000000..8392d961 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts @@ -0,0 +1,10 @@ +import type { Pub } from "./pub"; + +// Sees Pub, shares the name `publish`, and still does not satisfy it: its publish needs +// two arguments where Pub's callers pass one. +export class Bus { + publish(name: string, payload: unknown): void {} +} + +export const bus = new Bus(); +export type Seen = Pub; diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts new file mode 100644 index 00000000..a87ee5a9 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -0,0 +1,12 @@ +import { Relay, type Pub } from "./pub"; + +// CONTROL: no `implements`, an extra optional parameter, and passed as a Pub — it stays +// a structural candidate of Pub.publish. +export class Duck { + publish(e: { id: string }, trace?: string): void {} +} + +export function wire(): void { + const p: Pub = new Duck(); + new Relay(p).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts new file mode 100644 index 00000000..8dbcd586 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -0,0 +1,25 @@ +// A shared method NAME is not conformance. Pub has a declared implementor (Real) and a +// conformer the program builds and passes as a Pub (Duck, in duck-pub.ts). Two more +// classes share the name `publish` and are NOT Pubs: +// * Bus (bus.ts) sees Pub but its publish needs two arguments — not assignable; +// * Stranger (stranger.ts) has the right arity but no module that holds it ever +// imports this one, so no instance of it can reach a Pub-typed slot. + +export interface Pub { + publish(e: { id: string }): void; +} + +export class Relay { + constructor(private readonly p: Pub) {} + run(): void { + this.p.publish({ id: "1" }); + } +} + +export class Real implements Pub { + publish(e: { id: string }): void {} +} + +export function start(): void { + new Relay(new Real()).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts new file mode 100644 index 00000000..39267f86 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts @@ -0,0 +1,7 @@ +// Same shape as Pub, but nothing that holds a Stranger ever imports pub.ts: no Stranger +// can be handed to code typed by Pub. +export class Stranger { + publish(e: { id: string }): void {} +} + +export const stranger = new Stranger(); diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index f5857b6e..6547b759 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,9 +1,20 @@ ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> - +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() +known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL pub#start() @L24 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope index 224a7991..437f7688 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,2 +1,4 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields b/graph/test/typescript/expected/03-structural-satisfaction.fields index e69de29b..7cdc0dfc 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields @@ -0,0 +1 @@ +known_edge read Relay#run() -> Relay#p diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle index 05a32ce6..54cda29c 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [fields] - precision 0.0000 (0 correct, 0 wrong) - recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index 258f2417..5d6dfab6 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,9 +1,20 @@ boundary_lib FUNCTION_CALL duck#useLibrary() @L51 -> io#drain({ read(): string }) boundary_lib METHOD_CALL duck#useLibrary() @L50 -> Closeable#close() +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) +known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL pub#start() @L24 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope index 595d139a..5581aa10 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -1,3 +1,5 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read structural lib:io#Closeable.close -> duck#FileReader.close +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle index 3716c5f0..26a4cffa 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle @@ -1,4 +1,6 @@ -oracle=7 engine=10 agree=7 missing=0 (known 0, NEW 0) extra=3 +oracle=16 engine=21 agree=16 missing=0 (known 0, NEW 0) extra=5 + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle index 110a730c..126927cb 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,6 @@ -oracle=5 engine=8 agree=5 missing=0 (known 0, NEW 0) extra=3 +oracle=14 engine=19 agree=14 missing=0 (known 0, NEW 0) extra=5 + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index 83c409c6..6b61968a 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,4 +1,14 @@ ambiguous_unknown VARIABLE_TYPE 0 duck [VARIABLE] -> - +known_edge IMPLEMENTS_INTERFACE 0 Real [HERITAGE] -> Pub +known_edge METHOD_PARAM 0 Relay [METHOD_PARAM] -> Pub known_edge METHOD_PARAM 0 duck [METHOD_PARAM] -> Reader +known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Real +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 stranger [EXPRESSION] -> Stranger +known_edge TYPE_ALIAS_RHS 0 Seen [TYPE] -> Pub +known_edge VARIABLE_TYPE 0 duck-pub [VARIABLE] -> Pub diff --git a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle index de06659f..d40ea15f 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [types] - precision 1.0000 (3 correct, 0 wrong) - recall 1.0000 (3 of 3 the compiler resolved) - sites 5 resolved 4 (80.0%) - tiers ambiguous_unknown=1 known_edge=4 - contexts METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 VARIABLE_TYPE=1 + precision 1.0000 (13 correct, 0 wrong) + recall 1.0000 (13 of 13 the compiler resolved) + sites 15 resolved 14 (93.3%) + tiers ambiguous_unknown=1 known_edge=14 + contexts IMPLEMENTS_INTERFACE=1 METHOD_PARAM=2 OBJECT_CREATION_TYPE=9 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 not scored: 1 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index 2753406b..fbbeedc0 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -17,10 +17,14 @@ // // Keeping it off the primary path is deliberate. It is the one relation in this // engine that could FABRICATE rather than over-approximate, because it is name-based -// and does not compare member TYPES; a class with a `read()` that takes different -// arguments would still be counted as satisfying `Readable`. That is acceptable for -// widening a prune-only test and for a labelled reachability fan; it would not be -// acceptable as the answer to "what does this call resolve to". +// and does not compare member TYPES; a class with a `read(s: string)` would still be +// counted as satisfying `Readable { read(n: number) }`. Two cheap tests keep the worst +// of it out: a method that needs MORE arguments than the target passes is rejected +// (sat_arity_conflict), and a class no code can ever hand to the interface's slot is +// not an implementor (sat_can_meet). That is acceptable for widening a prune-only test +// and for a labelled reachability fan; it would not be acceptable as the answer to +// "what does this call resolve to", and a consumer never treats the `structural` basis +// as a declared contract. // // ── THE SOUNDNESS ARGUMENT, STATED ────────────────────────────────────────── // Direction: SOURCE is assignable to TARGET when the source has a member for every @@ -98,13 +102,53 @@ sat_covered(s, t, name) :- sat_pair_seed(s, t), sat_cover_count(s, t, n) :- sat_pair_seed(s, t), n = count : { sat_covered(s, t, _) }. +// ── sat_arity_conflict(Source, Target) — a shared name that cannot be called the same way +// A NAME MATCH IS NOT A MEMBER MATCH. `publish(name, payload)` shares a name with the +// interface's `publish(envelope)` and is not assignable to it: a method that needs more +// arguments than the target's signature passes is rejected by the compiler, whatever +// the types. So a covered METHOD name conflicts when no source method of that name +// needs at most as many arguments as some target method of that name takes. Fewer +// required parameters is fine (`read()` satisfies `read(n: number)`), and so is an +// optional or rest parameter. Only a pair where BOTH sides have arity facts can conflict: +// a member without them (a field holding a function) is left to the name test. +sat_target_method(t, name, m) :- target_required_member(t, name), + scope_sibling(t, sib), + declared_method(sib, name, "false", m). +sat_target_method(t, name, m) :- target_required_member(t, name), + group_of(t, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + scope_sibling(s, sib), + declared_method(sib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + group_of(s, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_arity_ok(s, t, name) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + sat_target_method(t, name, tm), + method_min_arity(sm, lo), + method_max_arity(tm, hi), + lo <= hi. +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + method_min_arity(sm, _), + sat_target_method(t, name, tm), + method_max_arity(tm, _), + !sat_arity_ok(s, t, name). + // ── type_satisfies(SourceTypeHash, TargetTypeHash) ────────────────────────── -// Every required member covered. `k > 0` excludes the empty interface, which +// Every required member covered, and no covered method that could not be called with +// the target's arguments. `k > 0` excludes the empty interface, which // everything satisfies and which therefore carries no information — admitting it // would make every class an implementor of every marker interface in the tree. type_satisfies(s, t) :- sat_cover_count(s, t, k), target_required_count(t, k), - k > 0. + k > 0, + !sat_arity_conflict(s, t). // A nominal implements clause is satisfaction too, and it is the authoritative kind: // the programmer asserted it and the compiler checked it. Included here so consumers @@ -121,7 +165,8 @@ structural_implementor(t, s) :- type_satisfies(s, t), satisfaction_target(t), s != t, !target_has_nominal_implementor(t), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). // ── sat_cross_program(Source, Target) — a shape match no value can cross (#1574) ── // A CLIENT GLOBAL belongs to one program (module-graph.dl). A class in ANOTHER program @@ -204,7 +249,51 @@ structural_implementor(t, s) :- type_satisfies(s, t), target_has_nominal_implementor(t), !implementors(t, s), type_instantiated(s, _), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). + +// ── sat_can_meet(Source, Target) — can a value of the class ever reach the interface's slot +// A SHAPE MATCH BETWEEN TWO CODEBASES THAT NEVER MEET IS NOT A CONFORMANCE. In a monorepo +// one service's event bus and another service's publisher interface share a method name, +// and nothing in either program can hand the one to the other. For an instance of S to +// flow into a slot typed T, some code has to see both: S's own module, or a module that +// uses S as a value (`new S()`, `useClass: S`), must import T's module, directly or +// through other modules and re-exports. Admitted without the walk: +// * S and T in one tsconfig program, or in one module — the program sees both; +// * T a library interface, or either side in a global script — no import is needed +// to see it, so the import graph proves nothing. +// A program-level dependency is deliberately NOT enough: a test that boots two services +// together makes each app depend on the other, and every name the two share would come +// back. The import walk runs only for the pairs left, from the modules that hold S. +sat_meet_pair(s, t) :- type_satisfies(s, t), + satisfaction_target(t), + s != t, + !implementors(t, s). +sat_client_type(t) :- type_module("client", _, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_program(s, p), type_program(t, p). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), type_module("client", m, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), !sat_client_type(t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), module_is_global("client", m). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, t), module_is_global("client", m). +sat_meet_open(s, t) :- sat_meet_pair(s, t), !sat_meets_trivially(s, t). + +sat_holder_module(s, m) :- sat_meet_open(s, _), type_module("client", m, s). +sat_holder_module(s, m) :- sat_meet_open(s, _), + expr_referenced("client", "TYPE", s, e), + expr_module("client", m, e). +sat_module_dep(a, b) :- import_binding("client", _, _, _, a, h), + import_target_module(h, "client", b). +sat_module_dep(a, b) :- export_decl("client", _, _, _, a, h), + export_target_module(h, "client", b). +sat_module_reach(m, m) :- sat_holder_module(_, m). +sat_module_reach(o, b) :- sat_module_reach(o, a), + sat_module_dep(a, b). + +sat_can_meet(s, t) :- sat_meets_trivially(s, t). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + type_module("client", mt, t), + sat_module_reach(o, mt). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb3..b934c465 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -709,3 +709,15 @@ .decl dispatch_assumes_closed_world(c0:symbol,c1:symbol) .decl type_constructible(c0:symbol) .decl type_live(c0:symbol) +.decl sat_arity_conflict(c0:symbol,c1:symbol) +.decl sat_arity_ok(c0:symbol,c1:symbol,c2:symbol) +.decl sat_can_meet(c0:symbol,c1:symbol) +.decl sat_client_type(c0:symbol) +.decl sat_holder_module(c0:symbol,c1:symbol) +.decl sat_meet_open(c0:symbol,c1:symbol) +.decl sat_meet_pair(c0:symbol,c1:symbol) +.decl sat_meets_trivially(c0:symbol,c1:symbol) +.decl sat_module_dep(c0:symbol,c1:symbol) +.decl sat_module_reach(c0:symbol,c1:symbol) +.decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4343911f..bf74f2d8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1156,8 +1156,11 @@ class Impact: # implements `Router.add` whether or not anything constructs a TrieRouter, and a signature change breaks it # either way. Read straight from dispatch_candidates so the contract rule does not inherit the closure's # filter — which is what made the rules and the hook's fast path disagree on exactly those candidates. + # A `structural` pair is a shape match nobody declared: evidence that the class MAY be passed as the + # interface, not that it implements it, so it never makes the declaration a must-change contract. W('implements_pair', sorted({(r[0], r[1]) for r in g.q( - "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id")}) + "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id" + " AND basis <> 'structural'")}) if g.has('dispatch_candidates') else []) # the pairs whose base is a FUNCTION TYPE and whose candidate is a function stored in a field of it (#1206): # the callers of the base call the candidate through that field (impact.dl, `value_pair`) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index b781a96c..ce5ae73f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -950,11 +950,15 @@ def via_base_rows(q, lines=None, stubs=frozenset(), only=None): 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() + pairs, shape_only = set(), 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'")} + declared = set(pairs) + for b, o, basis in q("SELECT base_method_id, candidate_method_id, basis FROM dispatch_candidates WHERE basis <> 'value'"): + pairs.add((b, o)) + (shape_only if basis == 'structural' else declared).add((b, o)) + shape_only -= declared # a shape match nobody declared is never the one thing that runs down = collections.defaultdict(set) for b, o in pairs: if b and o and b != o: down[b].add(o) @@ -1020,7 +1024,8 @@ def recv_types(c, sp): 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)) + sole = n == 1 and (b, m) not in shape_only + rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if sole 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() @@ -1715,10 +1720,12 @@ def contract_for_method(q, ids): if not q("SELECT 1 FROM overrides LIMIT 1"): out += _name_match_contract(q, ids) # the dispatch base the engine records no override row for (#1011): read from dispatch_candidates UNFILTERED, # because whether a declaration implements an interface method is not a question about reachability — the rules - # read `implements_pair`, which is the same table without the closure's RTA filter. + # read `implements_pair`, which is the same table without the closure's RTA filter. A `structural` pair is a + # shape match nobody declared, so it is not a contract (axiomcode-impact excludes it from implements_pair too). if q("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'"): for (b,) in q(f"""SELECT DISTINCT dc.base_method_id FROM dispatch_candidates dc WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis <> 'structural' AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): if b not in ids: out.append((b, 'it implements this — the engine records a dispatch candidate here and no override row')) diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/case.json b/tests/cases/typescript/dispatch-base-is-a-contract/case.json index 6467788c..0afd462a 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/case.json +++ b/tests/cases/typescript/dispatch-base-is-a-contract/case.json @@ -11,5 +11,9 @@ {"why": "the contract holds whether or not anything constructs the implementation: TrieRouter is never instantiated, so the closure's RTA filter drops its dispatch edge, and reading the implements relation through that filter made the rules and the fast path disagree", "run": ["impact", "TrieRouter.add"], "want": ["Router.add", "it implements this"], - "avoid": []} + "avoid": []}, + {"why": "a class that only matches the interface's SHAPE is a dispatch candidate, not a declared contract: its callers are still reached through the base, but the base is never 'must change - it implements this'", + "run": ["impact", "DuckRouter.add"], + "want": ["App.mount"], + "avoid": ["it implements this", "must change with it"]} ]} diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts index f7a0bf47..6a82bf5c 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts +++ b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts @@ -25,3 +25,16 @@ export class App { this.router.add(path); // typed to the interface: the dispatch base } } + +// No `implements`: it fits Router's shape and is passed as one, so it may run at `mount` — but nothing +// declared the contract, so a change to it does not have to change Router.add. +export class DuckRouter { + add(path: string): void { + this.last = path; + } + last = ''; +} + +export function duckApp(): App { + return new App(new DuckRouter()); +} From 167adac6009da1d438352e701324d60100210196 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:09:04 -0700 Subject: [PATCH 063/258] typescript: a private or protected method never satisfies an interface member The compiler rejects assigning a class whose only member of a required name is private, protected or #private, so the structural rule no longer counts such a class as a conformer. The structural-satisfaction case gains a same-arity class with a private method, built in the interface's module, that must not become a dispatch candidate. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/duck-pub.ts | 6 ++++++ .../expected/03-structural-satisfaction.edges | 7 ++++--- .../expected/03-structural-satisfaction.lib.edges | 7 ++++--- .../expected/03-structural-satisfaction.type-use | 1 + .../engine/resolution/structural-satisfaction.dl | 15 +++++++++++++++ graph/typescript/souffle/decls_all.dl | 2 ++ 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts index a87ee5a9..6af8cd5e 100644 --- a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -6,6 +6,12 @@ export class Duck { publish(e: { id: string }, trace?: string): void {} } +// Built beside a Pub and the right arity, but its publish is private: not assignable to Pub. +export class Hidden { + private publish(e: { id: string }): void {} +} +export const hidden = new Hidden(); + export function wire(): void { const p: Pub = new Duck(); new Relay(p).run(); diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index 6547b759..b4946180 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -3,14 +3,15 @@ known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() -known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() known_edge METHOD_CALL pub#start() @L24 -> Relay#run() multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index 5d6dfab6..fac2a511 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -4,13 +4,14 @@ known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) -known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() known_edge METHOD_CALL pub#start() @L24 -> Relay#run() multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index 6b61968a..c27013e8 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -6,6 +6,7 @@ known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Hidden known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Relay known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Real known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Relay diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index fbbeedc0..e28a0ed3 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -139,6 +139,21 @@ sat_arity_conflict(s, t) :- sat_covered(s, t, name), sat_target_method(t, name, tm), method_max_arity(tm, _), !sat_arity_ok(s, t, name). +// A `private` or `protected` method never satisfies an interface member of its name: the +// compiler rejects the assignment ("property is private in type S but not in type T"), +// so a class whose only method of that name is hidden is not a conformer. A covered name +// the class also declares visibly (or as a field) is left alone. +sat_member_hidden("PRIVATE_ACCESS"). +sat_member_hidden("PROTECTED_ACCESS"). +sat_member_hidden("PRIVATE_NAME_ACCESS"). +sat_visible_source_method(s, name) :- sat_source_method(s, name, m), + method_access(_, acc, m), + !sat_member_hidden(acc). +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, m), + method_access(_, acc, m), + sat_member_hidden(acc), + !sat_visible_source_method(s, name). // ── type_satisfies(SourceTypeHash, TargetTypeHash) ────────────────────────── // Every required member covered, and no covered method that could not be called with diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index b934c465..d42b7b48 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -714,6 +714,7 @@ .decl sat_can_meet(c0:symbol,c1:symbol) .decl sat_client_type(c0:symbol) .decl sat_holder_module(c0:symbol,c1:symbol) +.decl sat_member_hidden(c0:symbol) .decl sat_meet_open(c0:symbol,c1:symbol) .decl sat_meet_pair(c0:symbol,c1:symbol) .decl sat_meets_trivially(c0:symbol,c1:symbol) @@ -721,3 +722,4 @@ .decl sat_module_reach(c0:symbol,c1:symbol) .decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_visible_source_method(c0:symbol,c1:symbol) From 6de4d920ad54e4e680732582725719ffe550b30e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:22:03 -0700 Subject: [PATCH 064/258] typescript: an import the walk cannot follow that names the interface lets its class meet it The reachability gate walks imports from the class's modules to the interface's module. A workspace package whose manifest points at absent build output, or a compiled .d.ts, binds no client module, so the walk stopped there and dropped true conformers (a class declaring implements through such a package, or one built by a factory module that imports the interface that way). An import in a reached module that names the interface now counts as the meeting. Control Declared in 03-structural-satisfaction: without the rule it loses Relay.run -> Declared.publish; Stranger and Bus stay absent. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/declared.ts | 12 ++++++++++++ .../cases/03-structural-satisfaction/src/pub.ts | 2 ++ .../expected/03-structural-satisfaction.edges | 14 ++++++++------ .../expected/03-structural-satisfaction.entries | 10 ++++++++++ .../expected/03-structural-satisfaction.envelope | 1 + .../expected/03-structural-satisfaction.lib.edges | 14 ++++++++------ .../03-structural-satisfaction.lib.envelope | 1 + .../03-structural-satisfaction.lib.oracle | 3 ++- .../expected/03-structural-satisfaction.oracle | 3 ++- .../expected/03-structural-satisfaction.type-use | 2 ++ .../03-structural-satisfaction.types-oracle | 12 ++++++------ .../engine/resolution/structural-satisfaction.dl | 15 +++++++++++++++ graph/typescript/souffle/decls_all.dl | 1 + 13 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts create mode 100644 graph/test/typescript/expected/03-structural-satisfaction.entries diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts new file mode 100644 index 00000000..e8b111a5 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts @@ -0,0 +1,12 @@ +// Brings Pub in through a package specifier the engine cannot follow to pub.ts (a +// workspace package whose build output is absent), so the import walk never reaches Pub's +// module. The import still names Pub, and that is the evidence: Declared stays a Pub +// candidate. +// @ts-expect-error the package is not installed in this case +import type { Pub } from "@workspace/pub"; + +export class Declared implements Pub { + publish(e: { id: string }): void {} +} + +export const declared = new Declared(); diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts index 8dbcd586..cc19ffde 100644 --- a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -4,6 +4,8 @@ // * Bus (bus.ts) sees Pub but its publish needs two arguments — not assignable; // * Stranger (stranger.ts) has the right arity but no module that holds it ever // imports this one, so no instance of it can reach a Pub-typed slot. +// The control for Stranger is Declared (declared.ts): it never reaches this module either, +// but it imports a Pub from a package the walk cannot follow, so it stays a candidate. export interface Pub { publish(e: { id: string }): void; diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index b4946180..46193873 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,21 +1,23 @@ ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> - known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() -known_edge METHOD_CALL pub#start() @L24 -> Relay#run() -multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) -multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) -multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.entries b/graph/test/typescript/expected/03-structural-satisfaction.entries new file mode 100644 index 00000000..1dc8130f --- /dev/null +++ b/graph/test/typescript/expected/03-structural-satisfaction.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module duck#consume duck.ts:31 + exported_from_entry_module duck#drive duck.ts:35 + exported_from_entry_module duck#useLibrary duck.ts:45 + exported_from_entry_module duck-pub#wire duck-pub.ts:15 + unimported_module bus# bus.ts:1 + unimported_module declared# declared.ts:1 + unimported_module duck# duck.ts:1 + unimported_module duck-pub# duck-pub.ts:1 + unimported_module stranger# stranger.ts:1 diff --git a/graph/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope index 437f7688..e5fbd8c4 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,4 +1,5 @@ nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read +structural pub#Pub.publish -> declared#Declared.publish structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index fac2a511..ac4f8304 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,21 +1,23 @@ boundary_lib FUNCTION_CALL duck#useLibrary() @L51 -> io#drain({ read(): string }) boundary_lib METHOD_CALL duck#useLibrary() @L50 -> Closeable#close() known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() -known_edge METHOD_CALL pub#start() @L24 -> Relay#run() -multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) -multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) -multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope index 5581aa10..620432e5 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -2,4 +2,5 @@ nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read structural lib:io#Closeable.close -> duck#FileReader.close +structural pub#Pub.publish -> declared#Declared.publish structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle index 26a4cffa..e196b805 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle @@ -1,4 +1,5 @@ -oracle=16 engine=21 agree=16 missing=0 (known 0, NEW 0) extra=5 +oracle=18 engine=24 agree=18 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) extra Relay#run() -> Duck#publish({ id: string },string) extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle index 126927cb..7546b16d 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,5 @@ -oracle=14 engine=19 agree=14 missing=0 (known 0, NEW 0) extra=5 +oracle=16 engine=22 agree=16 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) extra Relay#run() -> Duck#publish({ id: string },string) extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index c27013e8..04be63a8 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,8 +1,10 @@ +ambiguous_unknown IMPLEMENTS_INTERFACE 0 Declared [HERITAGE] -> - ambiguous_unknown VARIABLE_TYPE 0 duck [VARIABLE] -> - known_edge IMPLEMENTS_INTERFACE 0 Real [HERITAGE] -> Pub known_edge METHOD_PARAM 0 Relay [METHOD_PARAM] -> Pub known_edge METHOD_PARAM 0 duck [METHOD_PARAM] -> Reader known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 declared [EXPRESSION] -> Declared known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck diff --git a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle index d40ea15f..d2852d66 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [types] - precision 1.0000 (13 correct, 0 wrong) - recall 1.0000 (13 of 13 the compiler resolved) - sites 15 resolved 14 (93.3%) - tiers ambiguous_unknown=1 known_edge=14 - contexts IMPLEMENTS_INTERFACE=1 METHOD_PARAM=2 OBJECT_CREATION_TYPE=9 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 - not scored: 1 rows whose target is not a client declaration + precision 1.0000 (15 correct, 0 wrong) + recall 1.0000 (15 of 15 the compiler resolved) + sites 18 resolved 16 (88.9%) + tiers ambiguous_unknown=2 known_edge=16 + contexts IMPLEMENTS_INTERFACE=2 METHOD_PARAM=2 OBJECT_CREATION_TYPE=11 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index e28a0ed3..c2ab16df 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -309,6 +309,21 @@ sat_can_meet(s, t) :- sat_meet_open(s, t), sat_holder_module(s, o), type_module("client", mt, t), sat_module_reach(o, mt). +// THE WALK STOPS WHERE AN IMPORT LEADS OUT OF THE CLIENT: a workspace package whose +// manifest points at build output that is not there, or at a compiled .d.ts, binds no +// client module, so the walk cannot tell whether T's module is behind it. An import +// there that NAMES the interface is the evidence instead: the class's module, or one it +// reaches, brings in a type called T from a place the walk cannot follow. +sat_opaque_import_name(m, name) :- import_binding("client", _, name, _, m, h), + !import_target_module(h, "client", _). +sat_opaque_import_name(m, name) :- import_binding("client", _, _, name, m, h), + name != "", + !import_target_module(h, "client", _). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + sat_module_reach(o, m), + sat_opaque_import_name(m, name), + type_decl("client", name, _, _, _, _, t). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index d42b7b48..050acd2a 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -720,6 +720,7 @@ .decl sat_meets_trivially(c0:symbol,c1:symbol) .decl sat_module_dep(c0:symbol,c1:symbol) .decl sat_module_reach(c0:symbol,c1:symbol) +.decl sat_opaque_import_name(c0:symbol,c1:symbol) .decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_visible_source_method(c0:symbol,c1:symbol) From 4e9d3071327dd61ab816fc66117e1e7e4cf71062 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:56:39 -0700 Subject: [PATCH 065/258] javascript: a call through a field holding a platform-made wrapper of a project function reaches that function A field or module variable set to what a platform call returned when handed a project function (promisify(store.find.bind(store))) was an ambient terminal, and path called the caller independent of the very method the wrapper runs. The wrapper call now reaches the bound method when the graph knows it (one of a set), and is an open value callee when it does not. path also counts a call that ends on a function type's signature (TypeScript) as a value callee, and lists a value callee named like the other endpoint first. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 31 ++++++++ graph/javascript/souffle/decls_all.dl | 5 ++ .../skills/axiomcode/scripts/axiomcode-path | 30 ++++++-- .../stored-field-callee-shapes/case.json | 75 +++++++++++++++++++ .../stored-field-callee-shapes/src/shapes.js | 11 ++- .../function-stored-in-a-holder/case.json | 17 +++++ .../function-stored-in-a-holder/src/app.ts | 14 ++++ 7 files changed, 176 insertions(+), 7 deletions(-) diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index 34fbd6b8..a257ba73 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -107,6 +107,37 @@ module_variable_from_call(v) :- var_init("client", _, e, v), expr_kind(_, k, _, !variable_reassigned(v), !call_passes_function(e). call_passes_function(e) :- call_arg(e, _, a), expr_value(a, "func", _). call_passes_function(e) :- call_arg(e, _, a), expr_introduces(_, _, a). +// `promisify(store.find.bind(store))`: a `.bind` always evaluates to a function, typed +// receiver or not. A bound platform function (`Math.max.bind(Math)`) is the platform's. +call_passes_function(e) :- call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_child(_, a, "CALLEE", _, f), !bound_platform_function(f). +bound_platform_function(f) :- expr_kind(_, "PROPERTY_ACCESS", _, f), expr_child(_, f, "ACCESS_TARGET", _, r), + expr_value(r, "ambient", _). +// A holder whose value is what a PLATFORM call returned when handed a project function +// (`this.find = promisify(s.find.bind(s))`, `const f = util.callbackify(g)`): the value +// is a wrapper the platform made around that function, and calling it runs the +// function. The platform value on the holder made the call an ambient terminal, a +// claimed correct end, and path said "independent" of the very method the wrapper +// runs. A platform value made from no project function (`promisify(setTimeout)`) +// keeps its platform reading. +// When the graph knows the function the wrapper was made from (a function value, or +// the method a `.bind` site resolves to), the call runs it: one of a set, beside the +// platform row the call keeps. Only when it knows none is the callee an open value. +unresolved_value_callee(ce, "field") :- wrapper_holder_call(ce, val), !wrapper_call_target(ce, _), made_from_function(val), + field_call(ce, _, _, _). +unresolved_value_callee(ce, "module_variable") :- wrapper_holder_call(ce, e), !wrapper_call_target(ce, _), made_from_function(e), + !field_call(ce, _, _, _). +wrapper_holder_call(ce, val) :- field_call(ce, k, t, n), !call_resolved(ce), field_assignment(k, t, n, val). +wrapper_holder_call(ce, e) :- value_callee_unresolved(ce, c), expr_binding(_, v, c), + var_decl("client", _, _, _, _, _, v), !var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c), + var_init("client", _, e, v), !variable_reassigned(v). +made_from_function(e) :- expr_kind(_, "CALL", _, e), expr_value(e, "ambient", _), call_passes_function(e). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), expr_value(a, "func", m). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_resolves_to_method(a, m). +wrapper_call_target(ce, m) :- wrapper_holder_call(ce, e), wrapper_runs(e, m), call_target_count(ce, 0). +call_chain_edge(ce, caller, "-", m, "client", "multi_inferred", kind) :- + wrapper_call_target(ce, m), !call_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). variable_reassigned(v) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt). // `(c ? a : b)()`, `(0, cb)()`, `make()()`: the callee is computed by an expression. diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17..e0f899c5 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -103,6 +103,11 @@ .decl module_variable_from_call(c0:symbol) .decl variable_reassigned(c0:symbol) .decl call_passes_function(c0:symbol) +.decl bound_platform_function(c0:symbol) +.decl made_from_function(c0:symbol) +.decl wrapper_holder_call(c0:symbol, c1:symbol) +.decl wrapper_runs(c0:symbol, c1:symbol) +.decl wrapper_call_target(c0:symbol, c1:symbol) .decl live_export_variable(c0:symbol, c1:symbol) .decl this_type_open(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f..84efa1c9 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -71,6 +71,8 @@ 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'} +# of those, the ones a call through a VALUE lands on: a function type or a bare call signature, not a member of an interface +FUNCTION_TYPE_KINDS = {'FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_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 = {''} @@ -1767,8 +1769,18 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # `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. + # A typed front end RESOLVES the same call, to the holder's function type (`find: (e: string) => …`), a signature + # with no body: the chain ends there, and the function the holder is given is what runs. Those count too. opaque = [] - if not (remote_found or lib_side or sends) and g.has('ext_unresolved_value_callee'): + value_sql = [] + if g.has('ext_unresolved_value_callee'): + value_sql.append("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)") + if g.has('methods') and g.has('call_edges'): + value_sql.append("SELECT DISTINCT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, 'function_type' why FROM call_edges e" + " JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id" + f" WHERE m.kind IN ({','.join(repr(k) for k in sorted(FUNCTION_TYPE_KINDS))}) AND s.caller_id IN (%s)") + if not (remote_found or lib_side or sends) and value_sql: 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: @@ -1778,8 +1790,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): 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) + for sql in value_sql: rows += g.q(sql % ','.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. @@ -1821,15 +1832,22 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): 'instance_member': 'a function stored on the instance from outside the class', 'parameter_member': 'a member of a parameter', 'expression': 'computed by an expression', 'getattr': 'an attribute looked up by a name computed at run time', - 'imported_variable': "another module's exported variable, which that module reassigns"} + 'imported_variable': "another module's exported variable, which that module reassigns", + 'function_type': 'held by a field, variable or parameter of a function type, whatever function it is given'} + # A value callee that carries the OTHER endpoint's name (`this.find()` where the field holds a wrapper + # around find) is the likeliest connection: listed first, and said so. + names = {la: {g.sym[i]['name'] for i in B_ if i in g.sym}, lb: {g.sym[i]['name'] for i in A_ if i in g.sym}} for lx, rows in opaque: + other = lb if lx == la else la + rows = sorted(rows, key=lambda r: r['n'] not in names[lx]) # the sites that make the answer unknown, in --json too: a consumer reading only `answers` saw "no chain" RESULT['value_calls'] = RESULT.get('value_calls', []) + [ {'side': lx, 'caller': g.disp(r['c']), 'callee': r['n'] or '', 'at': f"{g.site_file(r['f'])}:{r['ln']}", - 'callee_is': WHY.get(r['why'], r['why'])} for r in rows[:50]] + 'callee_is': WHY.get(r['why'], r['why']), 'named_like_target': r['n'] in names[lx]} for r in rows[:50]] 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'])}") + 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'])}" + + (f" — named like the target: if it holds {other}, the chain is real" if r['n'] in names[lx] else '')) if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) return 1 diff --git a/tests/cases/javascript/stored-field-callee-shapes/case.json b/tests/cases/javascript/stored-field-callee-shapes/case.json index b17c9107..12284de9 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/case.json +++ b/tests/cases/javascript/stored-field-callee-shapes/case.json @@ -215,6 +215,81 @@ "avoid": [ "connection is UNKNOWN, not absent" ] + }, + { + "why": "Svc.login calls this.find(), a field holding what a platform call (promisify) returned for a bound project method: the wrapper runs that method, so path says unknown and names the same-named site, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`find()` in Svc.login at src/shapes.js:42 — the callee is a function stored in a field", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the same wrapper held in a module variable, bound to a method the graph resolves: the wrapper runs it, so the chain is real", + "run": [ + "path", + "viaModule", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:44] Store.find" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "a field wrapper whose bound receiver is typed by the argument its constructor is given resolves the same way", + "run": [ + "path", + "Auth.login", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:45] Store.find" + ], + "avoid": [] + }, + { + "why": "control: a field holding what a platform call returned for a platform function (promisify(setTimeout)) is a platform call", + "run": [ + "path", + "Clock.tick", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a platform call handed a bound PLATFORM function (promisify(Math.max.bind(Math))) is a platform call", + "run": [ + "path", + "Reader.go", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] } ] } diff --git a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js index 75349643..9dfe4f1a 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js +++ b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js @@ -37,6 +37,15 @@ const tag = require('util').format; function tags() { return tag('%s', 'x'); } const wrapped = debug(alpha); function callsWrapped() { return wrapped(); } +const { promisify } = require('util'); +class Store { find(e) { return e; } } +class Svc { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +const findAsync = promisify(new Store().find.bind(new Store())); +function viaModule(e) { return findAsync(e); } +class Auth { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +function makeAuth() { return new Auth(new Store()); } +class Clock { constructor() { this.wait = promisify(setTimeout); } tick() { return this.wait(1); } } +class Reader { constructor() { this.read = promisify(Math.max.bind(Math)); } go() { return this.read(1); } } // controls: none of these is a value callee const EventEmitter = require('events'); class Bus extends EventEmitter { go() { return this.emit('x'); } } @@ -44,4 +53,4 @@ class Own { own() { return 1; } run() { return this.own(); } } class Known { constructor() { this.fn = alpha; } run() { return this.fn(); } } class Fixed { constructor(cb) { this.cb = alpha || cb; } } function useOwn() { const o = new Own(); return o.run(); } -module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn }; +module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn, Store, Svc, viaModule, Auth, makeAuth, Clock, Reader }; diff --git a/tests/cases/typescript/function-stored-in-a-holder/case.json b/tests/cases/typescript/function-stored-in-a-holder/case.json index bb0e7873..8bb4b2ee 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/case.json +++ b/tests/cases/typescript/function-stored-in-a-holder/case.json @@ -114,6 +114,23 @@ "src/app.ts: " ], "avoid": [] + }, + { + "why": "a call through a function-typed field the graph cannot fill (a library-made wrapper) ends on the type's signature: path says unknown, names the site and that it carries the target's name, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "— the callee is held by a field, variable or parameter of a function type", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] } ] } diff --git a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts index f7a41a80..29c9a61d 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts +++ b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts @@ -54,3 +54,17 @@ export function price(registry: Registry, n: number): string { export function checkout(registry: Registry): string { return price(registry, 3) } + +import { promisify } from 'util' + +export class Store { + find(e: string): string { return e } +} + +// A field of a function type holding what a library returned for a bound method: the call resolves to the +// field's signature, which has no body; the wrapped method is what runs. +export class Svc { + private find: (e: string) => Promise + constructor(s: Store) { this.find = promisify(s.find.bind(s)) as any } + login(e: string) { return this.find(e) } +} From 87bf15449b9d8487ffc0231ceca145de69464240 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:05:21 -0700 Subject: [PATCH 066/258] path, impact: a file:line inside a type's body but in none of its members is refused, naming the members either side A line number copied before an edit often lands on a blank or doc-comment line between two methods. Where a class body has no module node of its own (TypeScript, JavaScript, Java, C#), that line fell through to the file's module and was answered as its top-level code, with nothing saying the line held no declaration; Java said only "no callable spans". It is now refused with the enclosing type and the nearest declaration above and below it. A Python class body is a module node inside the type and answers as before. Checked: new checks in fileline-line-outside-file (JS) and fileline-dotted-basename (TS) fail before (3 of 32) and pass after (32 of 32); graph unchanged (9988 call edges on a real NestJS repo either way). --- .../skills/axiomcode/scripts/axiomcode-path | 16 +++++++++++++++ .../fileline-line-outside-file/case.json | 13 ++++++++++++ .../src/services/cart.js | 8 ++++++++ .../fileline-dotted-basename/case.json | 20 ++++++++++++++++++- .../fileline-dotted-basename/src/cart.ts | 7 +++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/cases/javascript/fileline-line-outside-file/src/services/cart.js create mode 100644 tests/cases/typescript/fileline-dotted-basename/src/cart.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f..4f92d612 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -406,6 +406,22 @@ class G: # 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) + # A LINE INSIDE A TYPE'S BODY BUT IN NONE OF ITS MEMBERS is not top-level code. A blank or comment line between + # two methods — a line number copied before the file was edited — was answered as the whole file's module + # where a class body has no module node of its own (TypeScript, Java, C#), with no word that the line holds + # nothing. A Python class body IS a module node inside the type, and still answers as before. + ty = self.q("SELECT id, display, line, end_line FROM symbols WHERE file = ? AND type_id IS NOT NULL AND method_id IS NULL AND line < ? AND end_line > ? ORDER BY end_line - line LIMIT 1", f, ln, ln) + mod = self.sym.get(r[0]['id'], {}) if r else {} + if ty and not (r and ty[0]['line'] <= (mod.get('line') or 0) and (mod.get('end_line') or 0) <= ty[0]['end_line']): + t = ty[0] + mem = self.q("SELECT id, line, end_line FROM symbols WHERE file = ? AND line > ? AND end_line < ? AND kind <> 'module' AND (method_id IS NOT NULL OR kind IN ('field','const','enum_member','variable'))", f, t['line'], t['end_line']) + above = max((x for x in mem if (x['end_line'] or x['line']) < ln), key=lambda x: (x['end_line'] or x['line'], -x['line']), default=None) + below = min((x for x in mem if x['line'] > ln), key=lambda x: (x['line'], x['line'] - (x['end_line'] or x['line'])), default=None) + near = [f" {self.name(x['id'])} {f}:{x['line']}" for x in (above, below) if x] + die(f"line {ln} of {f} is inside {t['display']} ({f}:{t['line']}-{t['end_line']}) but in none of its declarations" + " (a blank, comment or separator line — often a line number from before an edit)." + + ("\n the nearest declarations:\n" + '\n'.join(near) if near else '') + + f"\n ask for one of them, by name or by its line; `{t['display']}` asks about the whole type") 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-line-outside-file/case.json b/tests/cases/javascript/fileline-line-outside-file/case.json index 2dae3ef3..7e0cde15 100644 --- a/tests/cases/javascript/fileline-line-outside-file/case.json +++ b/tests/cases/javascript/fileline-line-outside-file/case.json @@ -36,6 +36,19 @@ "run": ["path", "*", "src/services/userService.js:2"], "want": ["getUser"], "avoid": ["is not in it"]}, + {"why": "a blank line between two members of a class is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/services/cart.js:3"], + "expect_error": true, + "want": ["line 3 of src/services/cart.js is inside Cart", "Cart.add src/services/cart.js:5"], + "avoid": [""]}, + {"why": "control: the class header line still answers for the class", + "run": ["impact", "src/services/cart.js:1"], + "want": ["Cart"], + "avoid": ["is inside Cart", "change: cart."]}, + {"why": "control: the line after the class is still the file's top-level code", + "run": ["impact", "src/services/cart.js:7"], + "want": ["cart. (at src/services/cart.js:7)"], + "avoid": ["is inside Cart"]}, {"why": "control: a file the index does not hold is still refused as before", "run": ["impact", "zzz/userService.js:99"], "expect_error": true, diff --git a/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js new file mode 100644 index 00000000..159f3f1b --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js @@ -0,0 +1,8 @@ +class Cart { + constructor() { this.items = []; } + + /** adds one */ + add(x) { this.items.push(x); } +} +new Cart().add('a'); +module.exports = { Cart }; diff --git a/tests/cases/typescript/fileline-dotted-basename/case.json b/tests/cases/typescript/fileline-dotted-basename/case.json index 701c6d74..7efe90b6 100644 --- a/tests/cases/typescript/fileline-dotted-basename/case.json +++ b/tests/cases/typescript/fileline-dotted-basename/case.json @@ -23,4 +23,22 @@ {"why": "path takes a ./ file:line", "run": ["path", "*", "./src/user.service.ts:3"], "want": ["UserController.get"], - "avoid": ["no callable spans"]}]} + "avoid": ["no callable spans"]}, + {"why": "a blank line inside a class body, between two members, is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/cart.ts:3"], + "expect_error": true, + "want": ["line 3 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": [""]}, + {"why": "the same for a member's doc-comment line, through path", + "run": ["path", "*", "src/cart.ts:4"], + "expect_error": true, + "want": ["line 4 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": ["top-level code"]}, + {"why": "control: the member's own line still answers for it", + "run": ["impact", "src/cart.ts:5"], + "want": ["change: Cart.add (at src/cart.ts:5)"], + "avoid": ["is inside Cart"]}, + {"why": "control: a line after the class is still the file's top-level code", + "run": ["impact", "src/cart.ts:7"], + "want": ["cart. (at src/cart.ts:7)"], + "avoid": ["is inside Cart"]}]} diff --git a/tests/cases/typescript/fileline-dotted-basename/src/cart.ts b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts new file mode 100644 index 00000000..8b0c3aa9 --- /dev/null +++ b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts @@ -0,0 +1,7 @@ +export class Cart { + private items: string[] = []; + + /** adds one */ + add(x: string): void { this.items.push(x); } +} +new Cart().add('a'); From 57458ad43f503fbef64803198c94a411e45ab5c4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:01:25 -0700 Subject: [PATCH 067/258] engine: a function wrapped by a library call and kept in a const is registered where the const is handed over TypeScript: a const whose initializer is a boundary call handed a function (fp(async (app) => ...), defineExtension(fn)) hands that function on to a library registration it is passed to, by name or through an import. Not a reassignment, not a call through a project value, not a call whose typed result has no call signature. JavaScript: the same, for a call rooted at a package import, carried as its own value kind that is never a callee. path answered 'independent' for a plugin registered this way although the bare form was reached. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/callbacks.dl | 28 +++++++++++++ .../callee-resolution.dl | 2 +- graph/javascript/souffle/decls_all.dl | 4 ++ .../34-options-object-callbacks/src/main.js | 6 +++ .../expected/34-options-object-callbacks.diag | 4 ++ .../34-options-object-callbacks.edges | 7 ++++ .../34-options-object-callbacks.lib.diag | 2 + .../34-options-object-callbacks.lib.edges | 7 ++++ .../34-options-object-callbacks.oracle | 1 + .../70-single-file-component-scripts.edges | 1 + .../src/app.ts | 19 +++++++++ .../src/plugin.ts | 12 ++++++ .../78-hof-callback-at-library-boundary.edges | 10 +++++ ...8-hof-callback-at-library-boundary.entries | 5 ++- ...78-hof-callback-at-library-boundary.oracle | 2 +- .../engine/resolution/value-flow.dl | 28 +++++++++++++ graph/typescript/souffle/decls_all.dl | 3 ++ .../case.json | 40 +++++++++++++++++++ .../src/app.ts | 19 +++++++++ .../src/plugin.ts | 12 ++++++ 20 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts diff --git a/graph/javascript/engine/call-edge-generation/callbacks.dl b/graph/javascript/engine/call-edge-generation/callbacks.dl index 107842db..6dbc8400 100644 --- a/graph/javascript/engine/call-edge-generation/callbacks.dl +++ b/graph/javascript/engine/call-edge-generation/callbacks.dl @@ -66,6 +66,34 @@ options_value_kind("obj"). call_has_client_target(ce) :- expr_resolves_to_method(ce, m), method_prov(m, "client"). call_has_client_target(ce) :- new_constructs(ce, t), type_decl("client", _, _, _, _, t). +// A function WRAPPED BY A PACKAGE CALL and kept in a binding: `export const plugin = fp(async (app) => …)`, +// `const ext = Prisma.defineExtension((client) => …)`, then `app.register(plugin)`, `client.$extends(ext)`. +// The package returns the function it was handed, or one that runs it, and nothing in the project says +// which, so the binding had no value and the registration reached nothing, although the same literal +// handed to it bare is registered. The wrapping site is a value of its own, ("libwrap", site), carried +// wherever a value goes (a const, an import, an export), and a registration handed it registers what the +// site was handed. The site is recognised syntactically, by a callee rooted at a binding imported from a +// package, so the value stays below the resolver's negations; a project wrapper is followed through its +// body instead (value-flow.dl, "wrap"). +lib_rooted(e) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, _, _, _, _, _, out, _, imp), import_outcome_is_package(out). +lib_rooted(e) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_child(_, e, "ACCESS_TARGET", _, r), lib_rooted(r). +import_outcome_is_package("RESOLVED_EXTERNAL"). +import_outcome_is_package("UNRESOLVED_MISSING"). +access_kind_reads_member("PROPERTY_ACCESS"). +access_kind_reads_member("OPTIONAL_ACCESS"). +lib_wrap_site(s) :- call_site(_, ck, _, _, _, _, s, _, _), call_kind_is_callee_form(ck), + expr_child(_, s, "CALLEE", _, c), lib_rooted(c). +lib_wrap_site(s) :- call_site(_, ck, _, "SYNTACTIC", _, _, s, _, _), call_kind_is_member_form(ck), + expr_child(_, s, "RECEIVER", _, r), lib_rooted(r). +// Handed over by NAME: `register(wrap(f))` needs nothing new, since the inner site already registers f +// from the same caller. The value is not a callee (callee-resolution.dl): what a call of it runs is still +// unknown, and says so. +expr_value(s, "libwrap", s) :- lib_wrap_site(s), call_arg(s, _, a), expr_value(a, "func", _). +callback_registered(ce, m) :- invocation_site(ce, _), !call_has_client_target(ce), !reflective_site(ce), + call_arg(ce, _, arg), !expr_kind(_, "CALL", _, arg), + expr_value(arg, "libwrap", s), call_arg(s, _, a), expr_value(a, "func", m). + // A listener runs with the EMITTER as `this` (`e.on('x', function () { this.other(); })`). this_value(m, k, i) :- event_handler(k, i, _, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), k != "module". diff --git a/graph/javascript/engine/expression-resolution/callee-resolution.dl b/graph/javascript/engine/expression-resolution/callee-resolution.dl index c4160015..946d7cb3 100644 --- a/graph/javascript/engine/expression-resolution/callee-resolution.dl +++ b/graph/javascript/engine/expression-resolution/callee-resolution.dl @@ -27,7 +27,7 @@ // ── callee_value(CallExpr, K, I) — the value being invoked ───────────────── callee_value(ce, k, i) :- call_site(_, ck, _, _, _, _, ce, _, _), call_kind_is_callee_form(ck), - expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i). + expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i), k != "libwrap". # what a package wrapper returns runs nothing known (callbacks.dl) // `f.call(o)`: f runs. But the parser classifies by NAME, so `selector.apply(node)` // on an object with its own `apply` method is here too — and for that reading the // CALLEE child (the object) is the receiver and its member is the target. Both diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17..902a468c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -451,6 +451,10 @@ .decl options_object_member(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl options_value_kind(c0:symbol) .decl call_has_client_target(c0:symbol) +.decl lib_rooted(c0:symbol) +.decl import_outcome_is_package(c0:symbol) +.decl access_kind_reads_member(c0:symbol) +.decl lib_wrap_site(c0:symbol) .decl callback_registered(c0:symbol, c1:symbol) .decl event_handler(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl event_register_method(c0:symbol) diff --git a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js index 0a29fdf7..baabbc0f 100644 --- a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js +++ b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js @@ -14,3 +14,9 @@ function viaCtor() { return new Transform({ transform }); } function viaProject() { return localWalk([1], { filter: keep }); } function main() { direct(); viaVar(); viaPlatform(); viaCtor(); viaProject(); } main(); +// a function wrapped by a package call and kept in a const, then handed to a package registration: registered +function migrate() { return 1; } +const plugin = walk(async () => migrate()); +const settings = walk(42); +function viaWrapped() { walk.register(plugin); walk.register(settings); } +module.exports = { viaWrapped }; diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index f261bb39..2937e0b0 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -8,6 +8,10 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:19:16 FUNCTION_CALL walk callee_untyped +unresolved main.js:20:18 FUNCTION_CALL walk callee_untyped +unresolved main.js:21:25 METHOD_CALL register receiver_untyped +unresolved main.js:21:48 METHOD_CALL register receiver_untyped 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.edges b/graph/test/javascript/expected/34-options-object-callbacks.edges index 04390e7a..2336e81f 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 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 99d2f7ba..72bf6bbb 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -6,6 +6,8 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:21:25 METHOD_CALL register member_absent +unresolved main.js:21:48 METHOD_CALL register member_absent 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.edges b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges index f5582b13..10163a11 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.oracle b/graph/test/javascript/expected/34-options-object-callbacks.oracle index be68d152..f31f7ea5 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.oracle +++ b/graph/test/javascript/expected/34-options-object-callbacks.oracle @@ -5,4 +5,5 @@ main.js:15:39 FUNCTION_CALL viaPlatform EXACT main.js:12:1 main.js:15:54 FUNCTION_CALL viaCtor EXACT main.js:13:1 main.js:15:65 FUNCTION_CALL viaProject EXACT main.js:14:1 main.js:16:1 FUNCTION_CALL main EXACT main.js:15:1 +main.js:19:33 FUNCTION_CALL migrate EXACT main.js:18:1 # defects: 0 diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.edges b/graph/test/javascript/expected/70-single-file-component-scripts.edges index 0bb4c4cb..21bf8d41 100644 --- a/graph/test/javascript/expected/70-single-file-component-scripts.edges +++ b/graph/test/javascript/expected/70-single-file-component-scripts.edges @@ -7,6 +7,7 @@ components/List.svelte:5:39 FUNCTION_CALL fetchProducts -> known_edge lib/for components/List.svelte:6:29 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice components/Price.vue:1:25 FUNCTION_CALL onClick -> known_edge components/Price.vue:8:1 onClick components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> ambiguous_unknown - +components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> callback_registered components/Price.vue:7:24 components/Price.vue:6:15 FUNCTION_CALL defineProps -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> callback_registered components/Price.vue:7:24 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 index 198ebefa..10e32c63 100644 --- 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 @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 00000000..3f31a567 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42) 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 index 4c371265..52529f7b 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -1,14 +1,24 @@ ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +ambient_terminal FUNCTION_CALL plugin#() @L12 -> plugin#wrap(T) +ambient_terminal FUNCTION_CALL plugin#() @L7 -> plugin#wrap(T) callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered FUNCTION_CALL plugin#() @L7 -> plugin#() +callback_registered METHOD_CALL app#boot() @L57 -> plugin#() +callback_registered METHOD_CALL app#bootBare() @L62 -> 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#() @L62 -> plugin#migrate() 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 FUNCTION_CALL plugin#() @L8 -> plugin#migrate() +known_edge METHOD_CALL app#boot() @L57 -> app#register(unknown) +known_edge METHOD_CALL app#bootBare() @L62 -> app#register(unknown) +known_edge METHOD_CALL app#bootSettings() @L67 -> app#register(unknown) 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) =) 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 index 5c3d7421..79860ca6 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -1,5 +1,8 @@ -── entry_point (9) ── +── entry_point (12) ── exported_from_entry_module app#atStartup app.ts:37 + exported_from_entry_module app#boot app.ts:56 + exported_from_entry_module app#bootBare app.ts:61 + exported_from_entry_module app#bootSettings app.ts:66 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 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 index f31a2b29..e6f0ab28 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -1 +1 @@ -oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 +oracle=16 engine=16 agree=16 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index b8c6aba6..439dac71 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -236,6 +236,34 @@ handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUM value_branch(a, x), method_value(x, m). +// …AND WHAT A LIBRARY CALL WRAPPED, KEPT IN A HOLDER. `export const plugin = fp(async (app) => …)`, then +// `app.register(plugin)`: the holder keeps what a call with no client body RETURNED, so no holder rule above +// sees a function in it, and the registration reached nothing although the same literal handed to it bare +// is reached. A library wrapper hands back the function it was handed, or one that runs it (`fp`, +// `defineExtension`, `debounce`), so the function the wrapping site was handed is what the holder hands on. +// Only a wrapping site at the library boundary: a project wrapper has a body, and what it returns is +// followed through that body (fn_value_call, below). The const is named here or imported from the +// module that wraps it (expr_holder), which is the usual shape: a plugin module, a registering app. +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_holder(x, h), + holder_wraps_handed_function(h, m). +// A const's own initializer only, and only a call of a declaration, not of a value the project holds: a +// reassignment (`state = createState(set, get)`) and a call through a parameter hand back what the project +// function returns, which is data as often as it is the function it was handed. +// And not a call whose result the types say is data: `setTimeout(cb)` returns a timer, `xs.filter(cb)` an +// array, and `clearTimeout(timer)` runs nothing. A result typed with no call signature is data; an untyped +// one (a package with no types staged) or a callable one (`fp`'s plugin type, a mock) is kept. +holder_wraps_handed_function(h, m) :- var_initializer("client", _, e, h), e != "", + value_branch(e, w), + expr_kind("client", "CALL_EXPRESSION", _, w), + hof_boundary_site(w), + !called_through(w, _), + !call_returns_data(w), + handed_function(w, m). +call_returns_data(w) :- expr_type(w, _, t), !call_signature_in_scope(t, _), !call_result_callable(w). +call_result_callable(w) :- expr_shape(w, s), call_signature_in_scope(s, _). + // ── method_value(Expr, Method): an INSTANCE method read as a value, not called ── // `xs.forEach(this.handle, this)`, `el.addEventListener('click', this.onClick)`, // `setTimeout(this.onHover.bind(this))`. expr_callable names a free function, an import and diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb3..deb07cea 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -216,6 +216,9 @@ .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl holder_wraps_handed_function(c0:symbol,c1:symbol) // (holder, fn): the holder keeps what a library call returned, and fn was handed to that call +.decl call_returns_data(c0:symbol) // the call's result is typed, and the type has no call signature +.decl call_result_callable(c0:symbol) .decl method_value(c0:symbol,c1:symbol) .decl method_is_accessor(c0:symbol) .decl called_through(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 index 4f1d456b..d59b77d3 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/case.json +++ b/tests/cases/typescript/hof-callback-at-library-boundary/case.json @@ -61,6 +61,46 @@ "no chain of resolved calls" ] }, + { + "why": "a function literal wrapped by a library call and kept in a const (`const plugin = wrap(async () => …)`), then handed to a library registration (`host.register(plugin)`), is reached from the function that registers it, as the literal handed bare is; it was 'the two are independent in this graph'", + "run": [ + "path", + "boot", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ], + "avoid": [ + "the two are independent" + ] + }, + { + "why": "CONTROL: the same literal handed to the registration bare is reached as before", + "run": [ + "path", + "bootBare", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ] + }, + { + "why": "CONTROL: a const holding what a library call built from a plain value registers no function", + "run": [ + "path", + "bootSettings", + "migrate" + ], + "want": [], + "avoid": [ + "1 of 1 target(s) reached" + ], + "expect_error": true + }, { "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": [ 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 index 198ebefa..10e32c63 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 00000000..3f31a567 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42) From 6578d95e22cc4c1f3245f0175a6d73f1191477a1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:10:25 -0700 Subject: [PATCH 068/258] callbacks: a function is handed over only where it is written into the argument A function reached the callee of every call whose argument's abstract value might hold it: a Map key, a string, a promise, a loop variable destructured from a Map of handler sets, an object whose member held a parameter. Each config default arrow and each bus handler collected edges from unrelated get/set/has/emit/assert sites. - javascript: a project callee is registered only for a function written at the argument (literal, declaration, import, const, method read, call result); an options object only for a literal or its variable with the function written in. - javascript, typescript: Map/Set/WeakMap/WeakSet methods other than forEach store and never call; TS recognises them by declared reference or `new` initialiser. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/callbacks.dl | 71 +++++++++++++------ graph/javascript/souffle/decls_all.dl | 9 +-- .../34-options-object-callbacks/src/main.js | 24 ++++++- .../expected/34-options-object-callbacks.diag | 21 ++++++ .../34-options-object-callbacks.edges | 48 +++++++++++-- .../34-options-object-callbacks.lib.diag | 20 ++++++ .../34-options-object-callbacks.lib.edges | 48 +++++++++++-- .../34-options-object-callbacks.oracle | 36 ++++++++-- .../expected/50-enumerated-copy-keys.edges | 2 - .../src/store.ts | 25 +++++++ .../78-hof-callback-at-library-boundary.edges | 7 ++ ...8-hof-callback-at-library-boundary.entries | 6 +- ...78-hof-callback-at-library-boundary.oracle | 2 +- ...-hof-callback-at-library-boundary.type-use | 2 + ...-callback-at-library-boundary.types-oracle | 8 +-- .../engine/resolution/value-flow.dl | 35 +++++++-- graph/typescript/souffle/decls_all.dl | 4 ++ 17 files changed, 310 insertions(+), 58 deletions(-) create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/store.ts diff --git a/graph/javascript/engine/call-edge-generation/callbacks.dl b/graph/javascript/engine/call-edge-generation/callbacks.dl index 107842db..962d5fe9 100644 --- a/graph/javascript/engine/call-edge-generation/callbacks.dl +++ b/graph/javascript/engine/call-edge-generation/callbacks.dl @@ -28,18 +28,59 @@ // ============================================================================ // ── callback_registered(CallExpr, CallbackMethod) ─────────────────────────── -callback_registered(ce, m) :- invocation_site(ce, _), call_arg(ce, _, arg), expr_value(arg, "func", m). +callback_registered(ce, m) :- invocation_site(ce, _), !call_has_client_target(ce), !collection_store_site(ce), + call_arg(ce, _, arg), expr_value(arg, "func", m). +// A PROJECT callee's own body calls what it is handed, so the edge from outside restates +// it — and only for a function WRITTEN at the argument. A parameter or loop variable passed +// on holds whatever the value flow says it may, and `for (const [pattern, handlers] of map)` +// gives the key every value too: `matches(pattern)` would register every handler. +callback_registered(ce, m) :- invocation_site(ce, _), call_has_client_target(ce), call_arg(ce, _, arg), + written_function(arg, m). +// written_function(Expr, Method): the expression names the function where it is written — +// a function or arrow literal, a function declaration or an import of one, a variable a +// literal initialises, a method read off a value (`this.onData`), or a call that returns +// one (`once(memoize(f))`, `fn.bind(this)`). +written_function(e, m) :- expr_introduces(_, m, e). +written_function(e, m) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), function_binding_method(v, m). +written_function(e, m) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_value(imp, "func", m). +written_function(e, m) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_init(_, _, i, v), + written_function(i, m). +written_function(e, m) :- expr_kind(_, "PROPERTY_ACCESS", _, e), expr_value(e, "func", m). +written_function(e, m) :- expr_kind(_, "CALL", _, e), expr_value(e, "func", m). +// A Map or Set stores, finds and drops its argument and never calls it (`subs.add(h)`, +// `cache.get(key)`, `seen.has(entry)`); only `forEach` runs what it is handed. The +// function is reached where it is taken back out and called, not where it is stored. +collection_store_site(ce) :- call_site(_, ck, mn, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), mn != "forEach", + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "coll", _). // A function handed over as a PROPERTY of an options object (`lib({ filter: f })`, // `opts.resolve = f; lib(opts)`, `https.request({ createConnection: f })`, // `new Transform({ transform })`) is a callback too (#643): the callee reads the property -// and invokes it, and nothing else in the project points into the function. Followed -// two levels (`{ hooks: { visit } }`), and only across the boundary: a callee that is -// NOT a project function (a staged library, the platform, an unknown, a dynamic call), -// because a project callee that reads the property has the ordinary edge from inside -// its body. Only object values carry it (below); an array, a collection, a string, a -// module, an instance and the platform are left out. +// and invokes it, and nothing else in the project points into the function. Only across +// the boundary: a callee that is NOT a project function (a staged library, the platform, +// an unknown, a dynamic call), because a project callee that reads the property has the +// ordinary edge from inside its body. +// Read off the SOURCE, not the value flow: the argument is an object literal or a variable +// one initialises, and the function is WRITTEN into it — a property value, a method of the +// literal, an assignment to a property of that variable, or the same one literal deeper +// (`{ hooks: { visit } }`). An argument whose abstract value merely MAY be such an object +// (a parameter, a property read, a call's result, a string key the flow over-approximated) +// hands nothing over, nor does a member holding a parameter or loop variable: +// `assert.equal(cfg.port, 1)`, `cache.get(key)` and `emit('error', { pattern })` would +// otherwise register every function of every object that value might be. callback_registered(ce, m) :- invocation_site(ce, _), !call_has_client_target(ce), !reflective_site(ce), - call_arg(ce, _, arg), expr_value(arg, k, i), options_value_kind(k), options_member_func(k, i, m). + !collection_store_site(ce), call_arg(ce, _, arg), options_argument(arg, l), options_written_member(l, m). +options_argument(l, l) :- expr_kind(_, "OBJECT_LITERAL", _, l). +options_argument(arg, l) :- expr_kind(_, "IDENTIFIER", _, arg), expr_binding(_, v, arg), var_init(_, _, l, v), + expr_kind(_, "OBJECT_LITERAL", _, l). +options_written_member(l, m) :- expr_kind(_, "OBJECT_LITERAL", _, l), literal_owns_method(l, m), + method_decl(_, _, k, _, _, _, _, m), !method_kind_is_accessor(k). +options_written_member(l, m) :- expr_child(_, l, "PROPERTY_VALUE", _, v), options_written_value(v, m). +options_written_member(l, m) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, t), + expr_kind(_, "PROPERTY_ACCESS", _, t), expr_child(_, t, "ACCESS_TARGET", _, r), expr_kind(_, "IDENTIFIER", _, r), + options_argument(r, l), expr_child(_, a, "ASSIGNMENT_VALUE", _, v), options_written_value(v, m). +options_written_value(v, m) :- written_function(v, m). +options_written_value(v, m) :- options_argument(v, l), options_written_member(l, m). // `Object.assign(dst, src)`, `Object.entries(o)`, `Object.setPrototypeOf(a, b)`, // `Reflect.ownKeys(o)`, `JSON.stringify(o)`: reflection over an object reads its // properties and never invokes them; an options-object edge there would be invented. @@ -49,20 +90,6 @@ reflective_ambient("Object"). reflective_ambient("Reflect"). reflective_ambient("JSON"). reflective_ambient("Array"). -// Two levels, each side materialised WITHOUT the name wildcard (#671): joining -// prop_value with itself through `_` in the name column left neither side a -// prefix index for the other's lookup, and on lodash (26M prop_value tuples after -// its mixin copies every function onto every object) that one join took 43 of a -// 51-minute solve to produce 16.8K rows. The projections below are deduplicated -// per (object, member value), and each join is a prefix lookup. -options_member_func(k, i, m) :- options_member_func_own(k, i, m). -options_member_func(k, i, m) :- options_object_member(k, i, k1, i1), options_member_func_own(k1, i1, m). -options_member_func_own(k, i, m) :- prop_value(k, i, _, "func", m). -options_object_member(k, i, k1, i1) :- prop_value(k, i, _, k1, i1), options_value_kind(k1). -// Object values only: an INSTANCE passed to the platform is data (`arr.push(this)`, -// `Promise.resolve(w)`, `items.map(fn, this)`), and registering every method it has -// would invent an edge per method at every such site. -options_value_kind("obj"). call_has_client_target(ce) :- expr_resolves_to_method(ce, m), method_prov(m, "client"). call_has_client_target(ce) :- new_constructs(ce, t), type_decl("client", _, _, _, _, t). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17..c90cda0e 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -446,10 +446,11 @@ // ── call-edge-generation/callbacks.dl ── .decl reflective_site(c0:symbol) .decl reflective_ambient(c0:symbol) -.decl options_member_func(c0:symbol, c1:symbol, c2:symbol) -.decl options_member_func_own(c0:symbol, c1:symbol, c2:symbol) -.decl options_object_member(c0:symbol, c1:symbol, c2:symbol, c3:symbol) -.decl options_value_kind(c0:symbol) +.decl options_argument(c0:symbol, c1:symbol) +.decl options_written_member(c0:symbol, c1:symbol) +.decl options_written_value(c0:symbol, c1:symbol) +.decl written_function(c0:symbol, c1:symbol) +.decl collection_store_site(c0:symbol) .decl call_has_client_target(c0:symbol) .decl callback_registered(c0:symbol, c1:symbol) .decl event_handler(c0:symbol, c1:symbol, c2:symbol, c3:symbol) diff --git a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js index 0a29fdf7..a956bdfe 100644 --- a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js +++ b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js @@ -12,5 +12,27 @@ function viaVar() { const opts = {}; opts.filter = keep; opts.hooks = { visit }; function viaPlatform() { const o = { host: 'localhost', createConnection: connect }; https.request(o).on('error', () => {}).destroy(); } function viaCtor() { return new Transform({ transform }); } function viaProject() { return localWalk([1], { filter: keep }); } -function main() { direct(); viaVar(); viaPlatform(); viaCtor(); viaProject(); } +// Near misses: the object that holds a function reaches these arguments only through a +// parameter, a property read or a keyed collection — none hands the function over. +const assert = require('assert'); +const cache = new Map(); +const subs = new Set(); +function defineConfig(schema) { const out = {}; for (const k of Object.keys(schema)) out[k] = schema[k].default(); return out; } +const spec = { port: { default: () => 3000 } }; +const cfg = defineConfig(spec); +function lookup(key) { return cache.get(key); } +function known(entry) { return subs.has(entry); } +function subscribe(pattern, handler) { const sub = { pattern, handler }; subs.add(sub); cache.set(pattern, sub); return sub; } +function onUpdated() { return 1; } +function check(entry) { assert.equal(spec.port, cfg.port); lookup(spec); known(entry); return walk([entry], {}); } +function viaTimer() { setTimeout(() => keep(1)); [1].forEach(visit); } +// Near miss: iterating a Map of handler sets gives the KEY the handlers too, so the key +// passed to a project matcher or written into an event payload is not a hand-off. The +// handler is reached where it is called. +const byPattern = new Map(); +function on(p, h) { let s = byPattern.get(p); if (!s) { s = new Set(); byPattern.set(p, s); } s.add(h); } +function matchesKey(p, topic) { return p === topic; } +function deliver(bus, topic) { for (const [pattern, handlers] of byPattern) { if (!matchesKey(pattern, topic)) continue; bus.emit('seen', { pattern }); for (const h of handlers) h(topic); } } +function bus(ev) { on('a', onUpdated); deliver(ev, 'a'); } +function main() { direct(); viaVar(); viaPlatform(); viaCtor(); viaProject(); check(subscribe('a.*', onUpdated)); viaTimer(); bus(new (require('events'))()); } main(); diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index f261bb39..b4c9dca3 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -1,4 +1,6 @@ +import_cause main.js:17:16 assert builtin import_cause main.js:2:14 walker not_staged +import_cause main.js:37:136 events builtin import_cause main.js:3:15 https builtin import_cause main.js:4:9 stream builtin package_entry options-callbacks . [] DEFAULT_INDEX index.js MISSING_FILE -> - @@ -8,6 +10,25 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:18:15 CONSTRUCTOR_CALL Map no_target +unresolved main.js:19:14 CONSTRUCTOR_CALL Set no_target +unresolved main.js:20:65 METHOD_CALL keys no_target +unresolved main.js:20:95 METHOD_CALL default receiver_untyped +unresolved main.js:23:31 METHOD_CALL get no_target +unresolved main.js:24:32 METHOD_CALL has no_target +unresolved main.js:25:74 METHOD_CALL add no_target +unresolved main.js:25:89 METHOD_CALL set no_target +unresolved main.js:27:25 METHOD_CALL equal no_target +unresolved main.js:27:95 FUNCTION_CALL walk callee_untyped +unresolved main.js:28:23 FUNCTION_CALL setTimeout no_target +unresolved main.js:28:50 METHOD_CALL forEach no_target +unresolved main.js:32:19 CONSTRUCTOR_CALL Map no_target +unresolved main.js:33:29 METHOD_CALL get no_target +unresolved main.js:33:61 CONSTRUCTOR_CALL Set no_target +unresolved main.js:33:72 METHOD_CALL set no_target +unresolved main.js:33:95 METHOD_CALL add no_target +unresolved main.js:35:122 METHOD_CALL emit no_target +unresolved main.js:37:131 CONSTRUCTOR_CALL 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.edges b/graph/test/javascript/expected/34-options-object-callbacks.edges index 04390e7a..fa33e2b0 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.edges @@ -12,12 +12,48 @@ main.js:12:86 METHOD_CALL https.request(o).on('error', () => {}).destroy -> am main.js:13:29 CONSTRUCTOR_CALL Transform -> ambient_terminal - main.js:13:29 CONSTRUCTOR_CALL Transform -> callback_registered main.js:8:1 transform main.js:14:32 FUNCTION_CALL localWalk -> known_edge main.js:9:1 localWalk -main.js:15:19 FUNCTION_CALL direct -> known_edge main.js:10:1 direct -main.js:15:29 FUNCTION_CALL viaVar -> known_edge main.js:11:1 viaVar -main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatform -main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor -main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject -main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:18:15 CONSTRUCTOR_CALL Map -> ambient_terminal - +main.js:19:14 CONSTRUCTOR_CALL Set -> ambient_terminal - +main.js:20:65 METHOD_CALL Object.keys -> ambient_terminal - +main.js:20:95 METHOD_CALL schema[k].default -> ambiguous_unknown - +main.js:22:13 FUNCTION_CALL defineConfig -> known_edge main.js:20:1 defineConfig +main.js:23:31 METHOD_CALL cache.get -> ambient_terminal - +main.js:24:32 METHOD_CALL subs.has -> ambient_terminal - +main.js:25:74 METHOD_CALL subs.add -> ambient_terminal - +main.js:25:89 METHOD_CALL cache.set -> ambient_terminal - +main.js:27:25 METHOD_CALL assert.equal -> ambient_terminal - +main.js:27:60 FUNCTION_CALL lookup -> known_edge main.js:23:1 lookup +main.js:27:74 FUNCTION_CALL known -> known_edge main.js:24:1 known +main.js:27:95 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:28:23 FUNCTION_CALL setTimeout -> ambient_terminal - +main.js:28:23 FUNCTION_CALL setTimeout -> callback_registered main.js:28:34 +main.js:28:40 FUNCTION_CALL keep -> known_edge main.js:5:1 keep +main.js:28:50 METHOD_CALL [1].forEach -> ambient_terminal - +main.js:28:50 METHOD_CALL [1].forEach -> callback_registered main.js:6:1 visit +main.js:32:19 CONSTRUCTOR_CALL Map -> ambient_terminal - +main.js:33:29 METHOD_CALL byPattern.get -> ambient_terminal - +main.js:33:61 CONSTRUCTOR_CALL Set -> ambient_terminal - +main.js:33:72 METHOD_CALL byPattern.set -> ambient_terminal - +main.js:33:95 METHOD_CALL s.add -> ambient_terminal - +main.js:35:122 METHOD_CALL bus.emit -> ambient_terminal - +main.js:35:179 FUNCTION_CALL h -> ambient_terminal - +main.js:35:179 FUNCTION_CALL h -> multi_inferred main.js:26:1 onUpdated +main.js:35:84 FUNCTION_CALL matchesKey -> known_edge main.js:34:1 matchesKey +main.js:36:20 FUNCTION_CALL on -> callback_registered main.js:26:1 onUpdated +main.js:36:20 FUNCTION_CALL on -> known_edge main.js:33:1 on +main.js:36:40 FUNCTION_CALL deliver -> known_edge main.js:35:1 deliver +main.js:37:115 FUNCTION_CALL viaTimer -> known_edge main.js:28:1 viaTimer +main.js:37:127 FUNCTION_CALL bus -> known_edge main.js:36:1 bus +main.js:37:131 CONSTRUCTOR_CALL (require('events')) -> ambient_terminal - +main.js:37:19 FUNCTION_CALL direct -> known_edge main.js:10:1 direct +main.js:37:29 FUNCTION_CALL viaVar -> known_edge main.js:11:1 viaVar +main.js:37:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatform +main.js:37:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor +main.js:37:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject +main.js:37:79 FUNCTION_CALL check -> known_edge main.js:27:1 check +main.js:37:85 FUNCTION_CALL subscribe -> callback_registered main.js:26:1 onUpdated +main.js:37:85 FUNCTION_CALL subscribe -> known_edge main.js:25:1 subscribe +main.js:38:1 FUNCTION_CALL main -> known_edge main.js:37:1 main main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 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 99d2f7ba..dbe0282b 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -1,3 +1,5 @@ +import_cause main.js:17:16 assert builtin +import_cause main.js:37:136 events builtin import_cause main.js:3:15 https builtin import_cause main.js:4:9 stream builtin package_entry options-callbacks . [] DEFAULT_INDEX index.js MISSING_FILE -> - @@ -6,6 +8,24 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:18:15 CONSTRUCTOR_CALL Map no_target +unresolved main.js:19:14 CONSTRUCTOR_CALL Set no_target +unresolved main.js:20:65 METHOD_CALL keys no_target +unresolved main.js:20:95 METHOD_CALL default receiver_untyped +unresolved main.js:23:31 METHOD_CALL get no_target +unresolved main.js:24:32 METHOD_CALL has no_target +unresolved main.js:25:74 METHOD_CALL add no_target +unresolved main.js:25:89 METHOD_CALL set no_target +unresolved main.js:27:25 METHOD_CALL equal no_target +unresolved main.js:28:23 FUNCTION_CALL setTimeout no_target +unresolved main.js:28:50 METHOD_CALL forEach no_target +unresolved main.js:32:19 CONSTRUCTOR_CALL Map no_target +unresolved main.js:33:29 METHOD_CALL get no_target +unresolved main.js:33:61 CONSTRUCTOR_CALL Set no_target +unresolved main.js:33:72 METHOD_CALL set no_target +unresolved main.js:33:95 METHOD_CALL add no_target +unresolved main.js:35:122 METHOD_CALL emit no_target +unresolved main.js:37:131 CONSTRUCTOR_CALL 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.edges b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges index f5582b13..7c0ae679 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges @@ -12,12 +12,48 @@ main.js:12:86 METHOD_CALL https.request(o).on('error', () => {}).destroy -> am main.js:13:29 CONSTRUCTOR_CALL Transform -> ambient_terminal - main.js:13:29 CONSTRUCTOR_CALL Transform -> callback_registered main.js:8:1 transform main.js:14:32 FUNCTION_CALL localWalk -> known_edge main.js:9:1 localWalk -main.js:15:19 FUNCTION_CALL direct -> known_edge main.js:10:1 direct -main.js:15:29 FUNCTION_CALL viaVar -> known_edge main.js:11:1 viaVar -main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatform -main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor -main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject -main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:18:15 CONSTRUCTOR_CALL Map -> ambient_terminal - +main.js:19:14 CONSTRUCTOR_CALL Set -> ambient_terminal - +main.js:20:65 METHOD_CALL Object.keys -> ambient_terminal - +main.js:20:95 METHOD_CALL schema[k].default -> ambiguous_unknown - +main.js:22:13 FUNCTION_CALL defineConfig -> known_edge main.js:20:1 defineConfig +main.js:23:31 METHOD_CALL cache.get -> ambient_terminal - +main.js:24:32 METHOD_CALL subs.has -> ambient_terminal - +main.js:25:74 METHOD_CALL subs.add -> ambient_terminal - +main.js:25:89 METHOD_CALL cache.set -> ambient_terminal - +main.js:27:25 METHOD_CALL assert.equal -> ambient_terminal - +main.js:27:60 FUNCTION_CALL lookup -> known_edge main.js:23:1 lookup +main.js:27:74 FUNCTION_CALL known -> known_edge main.js:24:1 known +main.js:27:95 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:28:23 FUNCTION_CALL setTimeout -> ambient_terminal - +main.js:28:23 FUNCTION_CALL setTimeout -> callback_registered main.js:28:34 +main.js:28:40 FUNCTION_CALL keep -> known_edge main.js:5:1 keep +main.js:28:50 METHOD_CALL [1].forEach -> ambient_terminal - +main.js:28:50 METHOD_CALL [1].forEach -> callback_registered main.js:6:1 visit +main.js:32:19 CONSTRUCTOR_CALL Map -> ambient_terminal - +main.js:33:29 METHOD_CALL byPattern.get -> ambient_terminal - +main.js:33:61 CONSTRUCTOR_CALL Set -> ambient_terminal - +main.js:33:72 METHOD_CALL byPattern.set -> ambient_terminal - +main.js:33:95 METHOD_CALL s.add -> ambient_terminal - +main.js:35:122 METHOD_CALL bus.emit -> ambient_terminal - +main.js:35:179 FUNCTION_CALL h -> ambient_terminal - +main.js:35:179 FUNCTION_CALL h -> multi_inferred main.js:26:1 onUpdated +main.js:35:84 FUNCTION_CALL matchesKey -> known_edge main.js:34:1 matchesKey +main.js:36:20 FUNCTION_CALL on -> callback_registered main.js:26:1 onUpdated +main.js:36:20 FUNCTION_CALL on -> known_edge main.js:33:1 on +main.js:36:40 FUNCTION_CALL deliver -> known_edge main.js:35:1 deliver +main.js:37:115 FUNCTION_CALL viaTimer -> known_edge main.js:28:1 viaTimer +main.js:37:127 FUNCTION_CALL bus -> known_edge main.js:36:1 bus +main.js:37:131 CONSTRUCTOR_CALL (require('events')) -> ambient_terminal - +main.js:37:19 FUNCTION_CALL direct -> known_edge main.js:10:1 direct +main.js:37:29 FUNCTION_CALL viaVar -> known_edge main.js:11:1 viaVar +main.js:37:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatform +main.js:37:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor +main.js:37:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject +main.js:37:79 FUNCTION_CALL check -> known_edge main.js:27:1 check +main.js:37:85 FUNCTION_CALL subscribe -> callback_registered main.js:26:1 onUpdated +main.js:37:85 FUNCTION_CALL subscribe -> known_edge main.js:25:1 subscribe +main.js:38:1 FUNCTION_CALL main -> known_edge main.js:37:1 main main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.oracle b/graph/test/javascript/expected/34-options-object-callbacks.oracle index be68d152..f4ec8215 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.oracle +++ b/graph/test/javascript/expected/34-options-object-callbacks.oracle @@ -1,8 +1,32 @@ main.js:14:32 FUNCTION_CALL localWalk EXACT main.js:9:1 -main.js:15:19 FUNCTION_CALL direct EXACT main.js:10:1 -main.js:15:29 FUNCTION_CALL viaVar EXACT main.js:11:1 -main.js:15:39 FUNCTION_CALL viaPlatform EXACT main.js:12:1 -main.js:15:54 FUNCTION_CALL viaCtor EXACT main.js:13:1 -main.js:15:65 FUNCTION_CALL viaProject EXACT main.js:14:1 -main.js:16:1 FUNCTION_CALL main EXACT main.js:15:1 +main.js:18:15 CONSTRUCTOR_CALL Map LIB_AMBIENT_OK +main.js:19:14 CONSTRUCTOR_CALL Set LIB_AMBIENT_OK +main.js:20:65 METHOD_CALL keys LIB_AMBIENT_OK +main.js:22:13 FUNCTION_CALL defineConfig EXACT main.js:20:1 +main.js:23:31 METHOD_CALL get LIB_AMBIENT_OK +main.js:24:32 METHOD_CALL has LIB_AMBIENT_OK +main.js:25:74 METHOD_CALL add LIB_AMBIENT_OK +main.js:25:89 METHOD_CALL set LIB_AMBIENT_OK +main.js:27:60 FUNCTION_CALL lookup EXACT main.js:23:1 +main.js:27:74 FUNCTION_CALL known EXACT main.js:24:1 +main.js:28:23 FUNCTION_CALL setTimeout LIB_AMBIENT_OK +main.js:28:40 FUNCTION_CALL keep EXACT main.js:5:1 +main.js:28:50 METHOD_CALL forEach LIB_AMBIENT_OK +main.js:32:19 CONSTRUCTOR_CALL Map LIB_AMBIENT_OK +main.js:33:29 METHOD_CALL get LIB_AMBIENT_OK +main.js:33:61 CONSTRUCTOR_CALL Set LIB_AMBIENT_OK +main.js:33:72 METHOD_CALL set LIB_AMBIENT_OK +main.js:35:84 FUNCTION_CALL matchesKey EXACT main.js:34:1 +main.js:36:20 FUNCTION_CALL on EXACT main.js:33:1 +main.js:36:40 FUNCTION_CALL deliver EXACT main.js:35:1 +main.js:37:115 FUNCTION_CALL viaTimer EXACT main.js:28:1 +main.js:37:127 FUNCTION_CALL bus EXACT main.js:36:1 +main.js:37:19 FUNCTION_CALL direct EXACT main.js:10:1 +main.js:37:29 FUNCTION_CALL viaVar EXACT main.js:11:1 +main.js:37:39 FUNCTION_CALL viaPlatform EXACT main.js:12:1 +main.js:37:54 FUNCTION_CALL viaCtor EXACT main.js:13:1 +main.js:37:65 FUNCTION_CALL viaProject EXACT main.js:14:1 +main.js:37:79 FUNCTION_CALL check EXACT main.js:27:1 +main.js:37:85 FUNCTION_CALL subscribe EXACT main.js:25:1 +main.js:38:1 FUNCTION_CALL main EXACT main.js:37:1 # defects: 0 diff --git a/graph/test/javascript/expected/50-enumerated-copy-keys.edges b/graph/test/javascript/expected/50-enumerated-copy-keys.edges index 3b463f44..6918da3e 100644 --- a/graph/test/javascript/expected/50-enumerated-copy-keys.edges +++ b/graph/test/javascript/expected/50-enumerated-copy-keys.edges @@ -12,8 +12,6 @@ main.js:19:31 METHOD_CALL keys(obj2).forEach -> callback_registered main.js:1 main.js:20:12 FUNCTION_CALL extend -> known_edge main.js:19:1 extend main.js:21:24 METHOD_CALL d4.alpha -> known_edge main.js:7:15 alpha main.js:25:55 FUNCTION_CALL ownKeys -> ambient_terminal - -main.js:25:55 FUNCTION_CALL ownKeys -> callback_registered main.js:7:15 alpha -main.js:25:55 FUNCTION_CALL ownKeys -> callback_registered main.js:7:42 beta main.js:26:12 FUNCTION_CALL assign -> known_edge main.js:25:1 assign main.js:27:24 METHOD_CALL d5.beta -> known_edge main.js:7:42 beta main.js:28:16 METHOD_CALL Object.getOwnPropertyNames -> ambient_terminal - diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/store.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/store.ts new file mode 100644 index 00000000..6ad0b7b8 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/store.ts @@ -0,0 +1,25 @@ +// NEAR MISS: a Map or Set stores, finds and drops a function and never calls it, so storing one is not +// handing it over: `set`, `add` and `has` reach nothing. Neither is declared in this case, so each is +// recognised by the reference it was declared with or by the `new` that initialises it. +function onUpdated(): number { + return 1 +} + +const handlers = new Map() +const subs: Set<() => number> = new Set() + +export function store(): void { + handlers.set('a', onUpdated) + subs.add(onUpdated) +} + +export function known(): boolean { + return subs.has(onUpdated) +} + +// CONTROL: a host API with no body that is handed the same function still reaches it. +declare function later(cb: () => number): void + +export function deferred(): void { + later(onUpdated) +} 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 index 4c371265..f770ff8f 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -1,5 +1,12 @@ ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +ambient_terminal FUNCTION_CALL store#deferred() @L24 -> store#later(() =) +ambiguous_unknown CONSTRUCTOR_CALL store#() @L8 -> - +ambiguous_unknown CONSTRUCTOR_CALL store#() @L9 -> - +ambiguous_unknown METHOD_CALL store#known() @L17 -> - +ambiguous_unknown METHOD_CALL store#store() @L12 -> - +ambiguous_unknown METHOD_CALL store#store() @L13 -> - callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered FUNCTION_CALL store#deferred() @L24 -> store#onUpdated() 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#(?) 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 index 5c3d7421..08e825e3 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -1,4 +1,4 @@ -── entry_point (9) ── +── entry_point (13) ── 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 @@ -6,5 +6,9 @@ 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 + exported_from_entry_module store#deferred store.ts:23 + exported_from_entry_module store#known store.ts:16 + exported_from_entry_module store#store store.ts:11 unimported_module app# app.ts:1 unimported_module globals# globals.d.ts:1 + unimported_module store# store.ts:1 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 index f31a2b29..373cdb59 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -1 +1 @@ -oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 +oracle=11 engine=11 agree=11 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 index 960a82a1..93f4487a 100644 --- 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 @@ -1,2 +1,4 @@ +ambiguous_unknown OBJECT_CREATION_TYPE 0 store [EXPRESSION] -> - +ambiguous_unknown VARIABLE_TYPE 0 store [VARIABLE] -> - 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 index e7a4988d..543823f0 100644 --- 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 @@ -1,7 +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 + sites 5 resolved 2 (40.0%) + tiers ambiguous_unknown=3 known_edge=2 + contexts OBJECT_CREATION_TYPE=2 SUPER_TYPE=2 VARIABLE_TYPE=1 + not scored: 3 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index b8c6aba6..734debe1 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -212,27 +212,52 @@ holder_value(p, a) :- expr_child("client", ce, "ARGUMENT", pos, a), // ============================================================================ 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). +// A Map or Set stores, finds and drops its argument and never calls it (`subs.add(h)`, +// `handlers.set(k, h)`, `seen.has(h)`); only `forEach` runs what it is handed. The function +// is reached where it is taken back out and called, not where it is stored. +collection_store_site(ce) :- expr_resolves_to_method(ce, m), method_decl("lib", mn, _, _, m), mn != "forEach", + method_owner("lib", t, m), type_decl("lib", tn, _, _, _, _, t), keyed_collection_name(tn). +collection_store_site(ce) :- call_site("client", _, mn, _, recv, ce, _), recv != "", mn != "forEach", + expr_type(recv, _, t), type_decl(_, tn, _, _, _, _, t), keyed_collection_name(tn). +collection_store_site(ce) :- call_site("client", _, mn, _, recv, ce, _), recv != "", mn != "forEach", + collection_receiver(recv). +// Without the standard library staged there is no Map declaration to resolve to, so the +// receiver is recognised by the reference it was declared with, or by the `new Map()` an +// un-annotated variable is initialised with. Recognised by NAME only where the program +// does not declare that name itself (builtin_container_ref). +collection_receiver(e) :- expr_decl_ref(e, r), keyed_collection_ref(r). +collection_receiver(e) :- new_expression_ref(e, r), keyed_collection_ref(r). +collection_receiver(e) :- expr_referenced("client", "VARIABLE", v, e), !var_has_declared_type(v), + var_initializer("client", _, init, v), init != "", collection_receiver(init). +keyed_collection_ref(r) :- type_ref(_, "TYPE_REFERENCE", _, tn, _, _, _, r), keyed_collection_name(tn), + builtin_container_ref(r). +keyed_collection_name("Map"). +keyed_collection_name("ReadonlyMap"). +keyed_collection_name("WeakMap"). +keyed_collection_name("Set"). +keyed_collection_name("ReadonlySet"). +keyed_collection_name("WeakSet"). 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), +handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_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), +handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_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), +handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_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), +handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_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). -handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), +handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_site(ce), expr_child("client", ce, "ARGUMENT", _, a), value_branch(a, x), method_value(x, m). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb3..42809633 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -215,6 +215,10 @@ .decl holder_holds_function(c0:symbol,c1:symbol) .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) +.decl collection_store_site(c0:symbol) +.decl keyed_collection_name(c0:symbol) +.decl collection_receiver(c0:symbol) +.decl keyed_collection_ref(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) .decl method_value(c0:symbol,c1:symbol) .decl method_is_accessor(c0:symbol) From c05607c2fa7e1c0e1d70a09f56e97aef23f13281 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:22:12 -0700 Subject: [PATCH 069/258] engine: a bound function carries its bound this, and promisify and partial are transparent - javascript: f.bind(o) gives f's this the value of o (objects and instances, functions no class owns), so this.repo.append() inside a handler bound to { repo } resolves - javascript: util.promisify(f) evaluates to what f holds, recognised on the import of the core util module; a project's own promisify is untouched - typescript: a declared this parameter types this inside the function; the compiler oracle's labels drop it, as the engine's do - python: a functools.partial call denotes its target wherever it goes (attribute, argument), not only in a local name Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/engine/resolution/ambient.dl | 20 ++++++++ .../engine/resolution/value-flow.dl | 17 +++++++ graph/javascript/souffle/decls_all.dl | 4 ++ graph/python/engine/resolution/value-flow.dl | 15 +++--- .../73-bound-function-identity/src/bound.js | 50 +++++++++++++++++++ .../expected/73-bound-function-identity.diag | 11 ++++ .../expected/73-bound-function-identity.edges | 35 +++++++++++++ .../73-bound-function-identity.oracle | 15 ++++++ .../python/cases/08-callables/src/main.py | 10 +++- graph/test/python/expected/08-callables.edges | 4 ++ graph/test/python/expected/08-callables.tiers | 30 +++++------ .../cases/84-declared-this-parameter/src/x.ts | 17 +++++++ .../expected/84-declared-this-parameter.edges | 9 ++++ .../84-declared-this-parameter.entries | 3 ++ .../84-declared-this-parameter.fields | 4 ++ .../84-declared-this-parameter.fields-oracle | 9 ++++ .../84-declared-this-parameter.oracle | 1 + .../84-declared-this-parameter.type-use | 6 +++ .../84-declared-this-parameter.types-oracle | 7 +++ .../typescript/ground-truth/tsc-program.mjs | 5 +- .../engine/expression-resolution/expr-type.dl | 10 ++++ graph/typescript/souffle/decls_all.dl | 2 + 22 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 graph/test/javascript/cases/73-bound-function-identity/src/bound.js create mode 100644 graph/test/javascript/expected/73-bound-function-identity.diag create mode 100644 graph/test/javascript/expected/73-bound-function-identity.edges create mode 100644 graph/test/javascript/expected/73-bound-function-identity.oracle create mode 100644 graph/test/typescript/cases/84-declared-this-parameter/src/x.ts create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.edges create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.entries create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.fields create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.fields-oracle create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.oracle create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.type-use create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.types-oracle diff --git a/graph/javascript/engine/resolution/ambient.dl b/graph/javascript/engine/resolution/ambient.dl index 3f0d8ebe..7d321c89 100644 --- a/graph/javascript/engine/resolution/ambient.dl +++ b/graph/javascript/engine/resolution/ambient.dl @@ -73,6 +73,26 @@ modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_ expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "Object", recv). modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "_extend", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "util", recv). +// `promisify(f)` is f behind a transparent wrapper (value-flow.dl): its value is what f holds. +modelled_platform_call(ce) :- promisify_call(ce). +// promisify_call(Call): the core `util` module's `promisify`, as a name imported from it +// (`const { promisify } = require('util')`, `import { promisify } from 'node:util'`) or as +// a member of it (`util.promisify(f)` with `util` bound to the module, `require('util').promisify(f)`). +// Recognised on the import rows and the binder, never by the name alone: a project's own +// `promisify` is an ordinary function and resolves as one. +promisify_call(ce) :- call_site(_, "FUNCTION_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_binding(_, v, c), var_import(_, imp, v), + import_decl(_, spec, _, _, "promisify", _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_binding(_, v, r), var_import(_, imp, v), + import_decl(_, spec, _, bf, _, _, "RESOLVED_BUILTIN", _, imp), import_binds_whole_module(bf), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "MODULE_EDGE_CALL", _, r), expr_module_edge(_, imp, r), + import_decl(_, spec, _, _, _, _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +import_binds_whole_module("DEFAULT"). +import_binds_whole_module("NAMESPACE"). +util_module("util"). +util_module("node:util"). // The Object statics whose result frameworks.dl gives a value of its own. modelled_object_method("assign"). modelled_object_method("create"). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 15379c69..d6f8c132 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -155,6 +155,11 @@ expr_value(e, k, i) :- expr_kind(_, "NEW", _, e), new_callee_value(e, "func", m) // (callee-resolution.dl); the VALUE of the expression is f as well. expr_value(e, "func", m) :- expr_kind(_, "CALL", _, e), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, e, _, _), expr_child(_, e, "CALLEE", _, c), expr_value(c, "func", m). +// `promisify(f)` evaluates to a function that runs f (with a callback appended), so a call +// through it reaches f: the wrapper is transparent. What the argument holds is what the +// result holds, so `promisify(fs.readFile)` stays the platform's and an unknown argument +// stays unknown. ambient.dl keeps the platform's own value off it (modelled_platform_call). +expr_value(ce, k, i) :- promisify_call(ce), call_arg(ce, 0, a), expr_value(a, k, i). // Transparent wrappers: `await x`, `(c ? a : b)`, `a || b`, `a && b`, `a ?? b`, // `x = v` (an assignment expression evaluates to v), `(a, b)`. expr_value(e, k, i) :- expr_kind(_, "AWAIT", _, e), expr_child(_, e, _, _, c), expr_value(c, k, i). @@ -330,6 +335,18 @@ this_value(m, k, i) :- method_this_binding(_, "LEXICAL", m), method_enclosing(m, this_value(m, "obj", l) :- expr_child(_, l, "PROPERTY_VALUE", _, v), expr_kind(_, "OBJECT_LITERAL", _, l), expr_introduces(_, m, v), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). this_value(m, "obj", l) :- literal_owns_method(l, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). +// `f.bind(o)` fixes f's `this` to o for every call through what it returns, wherever that +// value travels: `onEvent.bind({ repo })` then `this.repo.append()` inside onEvent. The +// thisArg is the site's RECEIVER child. Only objects and instances, and only a function no +// class owns (a member's `this` is its instance already): `bind(null)` and a primitive +// bind nothing, and a class member re-bound to its own instance adds nothing new. +// `f.call(o)` / `f.apply(o)` are not read here: they run f once at that site. +this_value(m, k, i) :- call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_value(c, "func", m), + method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), + expr_child(_, ce, "RECEIVER", _, o), expr_value(o, k, i), bound_this_kind(k). +bound_this_kind("obj"). +bound_this_kind("inst"). // `T.prototype.constructor = T` is a BACK-REFERENCE, not an installation: nothing // calls it with the prototype as `this`, and reading it as one gave a constructor // the `Object.create(Base.prototype)` object as `this` (#708). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17..ba441ed7 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -300,6 +300,10 @@ .decl well_known_symbol_read(c0:symbol) .decl modelled_platform_call(c0:symbol) .decl modelled_object_method(c0:symbol) +.decl promisify_call(c0:symbol) +.decl util_module(c0:symbol) +.decl import_binds_whole_module(c0:symbol) +.decl bound_this_kind(c0:symbol) .decl free_namespace(c0:symbol, c1:symbol) .decl ts_export_star(c0:symbol, c1:symbol) .decl ts_export_star_helper(c0:symbol) diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 5e96f868..230c1f1b 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -329,14 +329,13 @@ partial_target(site, m) :- call_arg(site, "0", a), expr_denotes_method("client", a, m). -// ── a name holding a partial denotes the partial's target ──────────────────── -binding_value_method(p, b, m) :- - binding_rebinding(p, "1", _, _, b), - expr_binding(p, b, "STORE", tgt), - assign_pair(p, tgt, val), - call_of_expr(val, site), - partial_target(site, m), - p = "client". +// ── a partial denotes the partial's target ─────────────────────────────────── +// The call EXPRESSION, so wherever the partial goes the target goes with it: a name +// (`add_ten = partial(add, 10)`, through (d)'s alias rule), an attribute +// (`self.op = partial(repo.append)`, through field_holds_method), an argument. +expr_denotes_method("client", e, m) :- + call_of_expr(e, site), + partial_target(site, m). // ── param_arg_class(ParamHash, TypeHash) — a CLASS OBJECT reaching a parameter ─ // Distinct from param_arg_type on purpose: param_arg_type means "an INSTANCE of this diff --git a/graph/test/javascript/cases/73-bound-function-identity/src/bound.js b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js new file mode 100644 index 00000000..40374a11 --- /dev/null +++ b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js @@ -0,0 +1,50 @@ +'use strict'; +const { promisify } = require('util'); + +class Repo { + append(x) { return x; } + find(k) { return k; } +} +class Other { + append(x) { return x; } +} + +// (a) a field or variable assigned from x.m.bind(x) is x.m +class Service { + constructor(r) { + this.repo = r; + this.add = this.repo.append.bind(this.repo); + this.lookup = promisify(r.find.bind(r)); + } + go() { return this.add(1); } + get(k) { return this.lookup(k); } +} +function makeService() { return new Service(new Repo()); } +const repo = new Repo(); +const bound = repo.append.bind(repo); +function viaVariable() { return bound(2); } +const wrapped = promisify(repo.find.bind(repo)); +function viaPromisified() { return wrapped('k'); } +const util = require('util'); +const viaNamespace = util.promisify(repo.find.bind(repo)); +function viaNamespaceCall() { return viaNamespace('n'); } + +// (b) a function bound to an object literal sees its keys through `this` +function onEvent(e) { return this.repo.append(e); } +const handler = onEvent.bind({ repo: new Repo() }); +function fire() { return handler(1); } +const handlers = { + created: function onCreated(e) { return this.store.append(e); }, +}; +const onCreatedBound = handlers.created.bind({ store: new Other() }); + +// controls: none of these changes +function unbound(e) { return this.repo.append(e); } +function callsUnbound() { return unbound.call({ repo: new Repo() }, 1); } +const snapshot = repo.find.bind(null); +function openBind(fn) { const g = fn.bind(repo); return g(); } +const own = { promisify(f) { return () => f; } }; +const notUtil = own.promisify(repo.find); +function viaOwnPromisify() { return notUtil(); } + +module.exports = { makeService, viaVariable, viaPromisified, fire, onCreatedBound, callsUnbound, snapshot, openBind, viaNamespaceCall, viaOwnPromisify }; diff --git a/graph/test/javascript/expected/73-bound-function-identity.diag b/graph/test/javascript/expected/73-bound-function-identity.diag new file mode 100644 index 00000000..fcdd22f9 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.diag @@ -0,0 +1,11 @@ +import_cause bound.js:28:14 util builtin +import_cause bound.js:2:9 util builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved bound.js:17:19 FUNCTION_CALL promisify no_target +unresolved bound.js:26:17 FUNCTION_CALL promisify no_target +unresolved bound.js:29:22 METHOD_CALL promisify no_target +unresolved bound.js:42:30 METHOD_CALL append receiver_untyped +unresolved bound.js:45:35 FUNCTION_CALL_BIND fn callee_untyped +unresolved bound.js:45:57 FUNCTION_CALL g callee_untyped +value_callee bound.js:45:35 fn.bind parameter +value_callee bound.js:45:57 g local diff --git a/graph/test/javascript/expected/73-bound-function-identity.edges b/graph/test/javascript/expected/73-bound-function-identity.edges new file mode 100644 index 00000000..6cab9951 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.edges @@ -0,0 +1,35 @@ +bound.js:16:16 FUNCTION_CALL_BIND this.repo.append.bind -> known_edge bound.js:5:3 append +bound.js:17:19 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:17:19 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:17:29 FUNCTION_CALL_BIND r.find.bind -> known_edge bound.js:6:3 find +bound.js:19:17 METHOD_CALL this.add -> known_edge bound.js:5:3 append +bound.js:20:19 METHOD_CALL this.lookup -> known_edge bound.js:6:3 find +bound.js:22:33 CONSTRUCTOR_CALL Service -> known_edge bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:23:14 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:24:15 FUNCTION_CALL_BIND repo.append.bind -> known_edge bound.js:5:3 append +bound.js:25:33 FUNCTION_CALL bound -> known_edge bound.js:5:3 append +bound.js:26:17 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:26:17 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:26:27 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:27:36 FUNCTION_CALL wrapped -> known_edge bound.js:6:3 find +bound.js:29:22 METHOD_CALL util.promisify -> ambient_terminal - +bound.js:29:22 METHOD_CALL util.promisify -> callback_registered bound.js:6:3 find +bound.js:29:37 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:30:38 FUNCTION_CALL viaNamespace -> known_edge bound.js:6:3 find +bound.js:33:30 METHOD_CALL this.repo.append -> known_edge bound.js:5:3 append +bound.js:34:17 FUNCTION_CALL_BIND onEvent.bind -> known_edge bound.js:33:1 onEvent +bound.js:34:38 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:35:26 FUNCTION_CALL handler -> known_edge bound.js:33:1 onEvent +bound.js:37:43 METHOD_CALL this.store.append -> known_edge bound.js:9:3 append +bound.js:39:24 FUNCTION_CALL_BIND handlers.created.bind -> known_edge bound.js:37:12 onCreated +bound.js:39:55 CONSTRUCTOR_CALL Other -> implicit_constructor - +bound.js:42:30 METHOD_CALL this.repo.append -> ambiguous_unknown - +bound.js:43:34 FUNCTION_CALL_CALL unbound.call -> known_edge bound.js:42:1 unbound +bound.js:43:55 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:44:18 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:45:35 FUNCTION_CALL_BIND fn.bind -> ambiguous_unknown - +bound.js:45:57 FUNCTION_CALL g -> ambiguous_unknown - +bound.js:47:17 METHOD_CALL own.promisify -> callback_registered bound.js:6:3 find +bound.js:47:17 METHOD_CALL own.promisify -> known_edge bound.js:46:15 promisify +bound.js:48:37 FUNCTION_CALL notUtil -> known_edge bound.js:46:37 diff --git a/graph/test/javascript/expected/73-bound-function-identity.oracle b/graph/test/javascript/expected/73-bound-function-identity.oracle new file mode 100644 index 00000000..01c9e34b --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.oracle @@ -0,0 +1,15 @@ +bound.js:22:33 CONSTRUCTOR_CALL Service EXACT bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:23:14 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:24:15 FUNCTION_CALL_BIND bind EXACT bound.js:5:3 +bound.js:26:27 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:29:37 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:34:17 FUNCTION_CALL_BIND bind EXACT bound.js:33:1 +bound.js:34:38 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:39:24 FUNCTION_CALL_BIND bind EXACT bound.js:37:12 +bound.js:39:55 CONSTRUCTOR_CALL Other SYNTHESIZED_OK +bound.js:43:34 FUNCTION_CALL_CALL call EXACT bound.js:42:1 +bound.js:43:55 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:44:18 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:47:17 METHOD_CALL promisify EXACT bound.js:46:15 +# defects: 0 diff --git a/graph/test/python/cases/08-callables/src/main.py b/graph/test/python/cases/08-callables/src/main.py index 80ac9186..baa116aa 100644 --- a/graph/test/python/cases/08-callables/src/main.py +++ b/graph/test/python/cases/08-callables/src/main.py @@ -9,7 +9,8 @@ partial functools.partial exposes `.func`, so CPython can name the target lambda stored in a dict, reached by subscript: no name at the call site attribute a plain function assigned to an instance attribute, which does NOT - go through the descriptor protocol and so is not a bound method + go through the descriptor protocol and so is not a bound method; + a partial stored there calls its target the same way """ import functools @@ -31,10 +32,16 @@ class Holder: def __init__(self): # A function on an INSTANCE attribute. Not a method: no `self` is bound. self.op = add + self.bump = functools.partial(add, 1) + # control: a partial over a builtin stays the platform's + self.biggest = functools.partial(max, 0) def use(self): return self.op(1, 2) + def use_partial(self): + return self.bump(2) + self.biggest(3) + TABLE = { # A lambda reached by subscript. There is no name to resolve. @@ -52,6 +59,7 @@ def main(): print(TABLE["double"](21)) print(Holder().use()) + print(Holder().use_partial()) if __name__ == "__main__": diff --git a/graph/test/python/expected/08-callables.edges b/graph/test/python/expected/08-callables.edges index a89f21f4..5b0c619d 100644 --- a/graph/test/python/expected/08-callables.edges +++ b/graph/test/python/expected/08-callables.edges @@ -1,7 +1,11 @@ +ambiguous_unknown SELF_CALL main.Holder.use_partial -> - +boundary_lib METHOD_CALL main.Holder.__init__ -> external:functools.partial boundary_lib METHOD_CALL main.main -> external:functools.partial boundary_lib SIMPLE_CALL main.main -> builtin:print known_edge CHAINED_CALL main.main -> main.Holder.use +known_edge CHAINED_CALL main.main -> main.Holder.use_partial known_edge SELF_CALL main.Holder.use -> main.add +known_edge SELF_CALL main.Holder.use_partial -> main.add known_edge SIMPLE_CALL main. -> main.main known_edge SIMPLE_CALL main.main -> main.Holder.__init__ known_edge SIMPLE_CALL main.main -> main.Multiplier.__call__ diff --git a/graph/test/python/expected/08-callables.tiers b/graph/test/python/expected/08-callables.tiers index 8c81e90f..9d83aad2 100644 --- a/graph/test/python/expected/08-callables.tiers +++ b/graph/test/python/expected/08-callables.tiers @@ -1,26 +1,28 @@ -distinct call sites emitted: 13 +distinct call sites emitted: 20 --- by tier: edge ROWS, and the distinct SITES they cover --- - 5 rows 5 sites boundary_lib - 8 rows 8 sites known_edge + 1 rows 1 sites ambiguous_unknown + 8 rows 8 sites boundary_lib + 11 rows 11 sites known_edge --- edge rows by call kind --- - 1 CHAINED_CALL - 1 METHOD_CALL - 1 SELF_CALL - 9 SIMPLE_CALL + 2 CHAINED_CALL + 3 METHOD_CALL + 3 SELF_CALL + 11 SIMPLE_CALL 1 SUBSCRIPT_CALL --- unresolved reasons --- - (none — every site resolved) + 1 no_rule --- the engine's own conservation ledger --- - 13 _total_sites - 5 boundary_lib - 8 known_edge + 20 _total_sites + 1 ambiguous_unknown + 8 boundary_lib + 11 known_edge --- reconciling rows against the conserved site count --- - edge rows 13 + edge rows 20 minus extra rows from multi-target sites 0 - = tier/site pairs 13 - engine's conserved site total 13 + = tier/site pairs 20 + engine's conserved site total 20 diff --git a/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts new file mode 100644 index 00000000..e462aa74 --- /dev/null +++ b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts @@ -0,0 +1,17 @@ +class Repo { append(x: number): number { return x; } } +class Other { append(x: number): number { return x; } } +interface Ctx { repo: Repo } + +// A declared `this` parameter is what `this` is inside the function, whoever binds it. +function onEvent(this: Ctx, e: number) { return this.repo.append(e); } +function onShape(this: { other: Other }, e: number) { return this.other.append(e); } +const h = onEvent.bind({ repo: new Repo() }); +const s = onShape.bind({ other: new Other() }); + +// controls: an untyped `this` stays unknown; a class member's `this` is still its class +function onUntyped(this: any, e: number) { return this.repo.append(e); } +class Owner { + repo = new Repo(); + run(e: number) { return this.repo.append(e); } +} +export { h, s, onUntyped, Owner }; diff --git a/graph/test/typescript/expected/84-declared-this-parameter.edges b/graph/test/typescript/expected/84-declared-this-parameter.edges new file mode 100644 index 00000000..184ea7af --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.edges @@ -0,0 +1,9 @@ +ambiguous_unknown METHOD_CALL x#() @L8 -> - +ambiguous_unknown METHOD_CALL x#() @L9 -> - +ambiguous_unknown METHOD_CALL x#onUntyped(number) @L12 -> - +known_edge CONSTRUCTOR_CALL x#() @L14 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L8 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L9 -> Other#() +known_edge METHOD_CALL Owner#run(number) @L15 -> Repo#append(number) +known_edge METHOD_CALL x#onEvent(number) @L6 -> Repo#append(number) +known_edge METHOD_CALL x#onShape(number) @L7 -> Other#append(number) diff --git a/graph/test/typescript/expected/84-declared-this-parameter.entries b/graph/test/typescript/expected/84-declared-this-parameter.entries new file mode 100644 index 00000000..8e41e4b4 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.entries @@ -0,0 +1,3 @@ +── entry_point (2) ── + exported_from_entry_module x#onUntyped x.ts:12 + unimported_module x# x.ts:1 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields b/graph/test/typescript/expected/84-declared-this-parameter.fields new file mode 100644 index 00000000..de3e89d9 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields @@ -0,0 +1,4 @@ +ambiguous_unknown read x#onUntyped(number) -> - +known_edge read Owner#run(number) -> Owner#repo +known_edge read x#onEvent(number) -> Ctx#repo +known_edge read x#onShape(number) -> { other: Other }#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle new file mode 100644 index 00000000..0a3c5b6f --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle @@ -0,0 +1,9 @@ +84-declared-this-parameter [fields] + precision 0.6667 (2 correct, 1 wrong) + recall 0.6667 (2 of 3 the compiler resolved) + sites 4 resolved 3 (75.0%) + tiers ambiguous_unknown=1 known_edge=3 + access read=4 + not scored: 1 rows whose target is not a client declaration + WRONG x#onShape(number) READ { other: Other }#other + MISSING x#onShape(number) READ x#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.oracle b/graph/test/typescript/expected/84-declared-this-parameter.oracle new file mode 100644 index 00000000..6c4bb5cb --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.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/84-declared-this-parameter.type-use b/graph/test/typescript/expected/84-declared-this-parameter.type-use new file mode 100644 index 00000000..f183c671 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.type-use @@ -0,0 +1,6 @@ +known_edge FIELD_TYPE 0 Ctx [FIELD] -> Repo +known_edge FIELD_TYPE 1 x [METHOD_PARAM] -> Other +known_edge METHOD_PARAM 0 x [METHOD_PARAM] -> Ctx +known_edge OBJECT_CREATION_TYPE 0 Owner [EXPRESSION] -> Repo +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Other +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Repo diff --git a/graph/test/typescript/expected/84-declared-this-parameter.types-oracle b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle new file mode 100644 index 00000000..d0200fc9 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle @@ -0,0 +1,7 @@ +84-declared-this-parameter [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 6 resolved 6 (100.0%) + tiers known_edge=6 + contexts FIELD_TYPE=2 METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index ef034111..907c664c 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -204,7 +204,10 @@ export function loadProgram(srcDir, libDir, toolName, programDir) { const mods = ts.canHaveModifiers(decl) ? (ts.getModifiers(decl) ?? []) : []; if (mods.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) name = 'static ' + name; - const ps = (decl.parameters ?? []).map((param) => { + // A declared `this` parameter types `this`, it is no argument: the engine's label + // leaves it out, so this one does too. + const ps = (decl.parameters ?? []).filter((param) => + !(ts.isIdentifier(param.name) && param.name.text === 'this')).map((param) => { let t = param.type ? param.type.getText(sf) : '?'; if (param.dotDotDotToken && !t.endsWith('[]')) t += '[]'; return simple(t); diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index 7e0a11fc..c049ce6d 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -169,6 +169,16 @@ expr_type(e, "client", t) :- expr_referenced("client", "THIS", _, e), expr_enclosing_type(e, t). expr_type(e, "client", t) :- expr_kind("client", "THIS_REFERENCE", _, e), expr_enclosing_type(e, t). +// A declared `this` parameter (`function onEvent(this: Ctx, e: Evt)`) is what `this` is in +// that function, whoever binds it (`onEvent.bind(ctx)`, `.call(ctx)`, a framework): the +// compiler types `this.repo` from it, and without it the site had no receiver type at all. +expr_type(e, prov, t) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_type(p, prov, t). +expr_shape(e, s) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_shape_target(p, s). +this_expr(e) :- expr_referenced("client", "THIS", _, e). +this_expr(e) :- expr_kind("client", "THIS_REFERENCE", _, e). +method_declared_this(m, p) :- param_shape("client", "THIS", _, _, p), param_decl("client", _, _, _, m, p). // `this` INSIDE A STATIC METHOD IS THE CLASS, so its members are the STATICS. A helper // class that calls its own statics through `this` is ordinary TypeScript — diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb3..1d7675e0 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -370,6 +370,8 @@ .decl method_signature_role(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_signature_role_of(c0:symbol,c1:symbol) .decl method_this_param(c0:symbol) +.decl this_expr(c0:symbol) +.decl method_declared_this(c0:symbol,c1:symbol) .decl module_ambient_specifier(c0:symbol,c1:symbol,c2:symbol) .decl module_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl module_default_export(c0:symbol,c1:symbol,c2:symbol) From 457bc08accb7e48b072cb007699f42e9da2f13a0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:25:40 -0700 Subject: [PATCH 070/258] changed: a grown file read against its base names what was added and what changed When the graph is from a later text of a file than the base it is read against (--range, the edit hook, a refresh), `changed` misread a file that grew: - a parameter inserted into a header written one per line was "body", and a plain parameter read as an added field; it is now the signature change - a constructor recorded as `` (TypeScript, JavaScript, C#) was never found by name: "removed", or left at the graph's line with a note that the graph was indexed from uncommitted edits; its header is looked for as `constructor` or the type's name - new TypeScript methods with a return type, exported functions, and a function after the class in the same insertion were unnamed "N new line(s)", and a local inside them could pass for a field - a method only the later graph declares was called an overload of itself - ``, ``, `` are lambdas: an edit inside one is its method's body - with no recorded tree, the file on disk is tried as the graph's text (the spans are checked against it before it is used) - a Python statement appended after a body's last line is that body Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 92 ++++++++++++++---- .../skills/axiomcode/scripts/axiomcode-path | 7 +- .../case.json | 29 ++++++ .../grown-file-read-against-its-base/new.cs | 33 +++++++ .../grown-file-read-against-its-base/old.cs | 19 ++++ .../src/Service.cs | 33 +++++++ .../case.json | 33 +++++++ .../grown-file-read-against-its-base/new.js | 26 +++++ .../grown-file-read-against-its-base/old.js | 14 +++ .../src/service.js | 26 +++++ .../case.json | 52 ++++++++++ .../grown-file-read-against-its-base/new.py | 21 ++++ .../grown-file-read-against-its-base/old.py | 10 ++ .../src/service.py | 21 ++++ .../body-only.ts | 23 +++++ .../case.json | 97 +++++++++++++++++++ .../grown-file-read-against-its-base/new.ts | 41 ++++++++ .../grown-file-read-against-its-base/old.ts | 23 +++++ .../src/repo.ts | 9 ++ .../src/service.ts | 41 ++++++++ .../indexed-with-uncommitted-edit/case.json | 11 ++- 21 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 tests/cases/csharp/grown-file-read-against-its-base/case.json create mode 100644 tests/cases/csharp/grown-file-read-against-its-base/new.cs create mode 100644 tests/cases/csharp/grown-file-read-against-its-base/old.cs create mode 100644 tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs create mode 100644 tests/cases/javascript/grown-file-read-against-its-base/case.json create mode 100644 tests/cases/javascript/grown-file-read-against-its-base/new.js create mode 100644 tests/cases/javascript/grown-file-read-against-its-base/old.js create mode 100644 tests/cases/javascript/grown-file-read-against-its-base/src/service.js create mode 100644 tests/cases/python/grown-file-read-against-its-base/case.json create mode 100644 tests/cases/python/grown-file-read-against-its-base/new.py create mode 100644 tests/cases/python/grown-file-read-against-its-base/old.py create mode 100644 tests/cases/python/grown-file-read-against-its-base/src/service.py create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/body-only.ts create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/case.json create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/new.ts create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/old.ts create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts create mode 100644 tests/cases/typescript/grown-file-read-against-its-base/src/service.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index ad902e51..72e74028 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -236,8 +236,22 @@ class Changed: def graph_text(self, rel): """the text of `rel` the graph's line numbers are in: the tree it was indexed from, uncommitted edits included; None when that was not recorded (no git, an old graph) or the file is not in it""" - if not self.indexed_tree: return None - return sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) + t = sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) if self.indexed_tree else None + if t is not None: return t + # NO RECORDED TREE (a copy without git, a graph older than the record): the file on disk is the text a graph that + # keeps up with edits was built from. Without it every span was placed by name in the older text, and one whose + # name no header there spells stayed at the graph's line, past the end of that text. anchor() checks the spans + # against it and falls back to placing by name when they are not in it + try: return open(os.path.join(self.repo, rel), errors='replace').read() + except OSError: return None + @staticmethod + def header_name(n, d, rel): + """the name a declaration's header spells. A CONSTRUCTOR IS NOT WRITTEN UNDER ITS GRAPH NAME: TypeScript, JavaScript + and C# record it as ``, which no header spells, so looked for by that name a constructor was never found + again (its header changed: "removed"; the graph from a later text: left at the graph's line). Its header says + `constructor` (TypeScript, JavaScript) or its type's name (C#, Java)""" + if n not in ('', '', ''): return n + return 'constructor' if re.search(r'\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$', rel) else d.rsplit('.', 1)[0].rsplit('.', 1)[-1] @staticmethod def declares(text, n, k, is_py): """does this line (strings and comments blanked) DECLARE `n` as a `k`: a def / a typed method header, a class @@ -266,7 +280,8 @@ class Changed: is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', O or '')) OL = O.split('\n'); OS = strip_code(O, hash_comments=is_py).split('\n') shaped = lambda k, n: k != 'module' and n not in P.LAMBDA_NAMES - def found(n, k, guess, taken): + def found(n, k, guess, taken, d=''): + n = self.header_name(n, d, rel) c = [j for j in range(1, len(OS) + 1) if j not in taken and self.declares(OS[j - 1], n, k, is_py)] return (min(c, key=lambda j: (abs(j - guess), j)), len(c) > 1) if c else (None, False) def starts(L, a, n): @@ -277,9 +292,9 @@ class Changed: out = []; self.unsure.setdefault(rel, set()) if G is None: for a, b, i, k, d, n in spans: - if not shaped(k, n) or starts(OS, a, n) or (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)): + if not shaped(k, n) or starts(OS, a, self.header_name(n, d, rel)) or (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)): out.append((a, b, i, k, d, n)); continue - j, many = found(n, k, a, set()) + j, many = found(n, k, a, set(), d) if j is None: out.append((a, b, i, k, d, n)); self.unsure[rel].add(i); continue # nothing better: kept, unsure out.append((j, j + (b - a), i, k, d, n)) if many or rel in self.mismatch: self.unsure[rel].add(i) @@ -315,7 +330,7 @@ class Changed: a2 = exact.get(a) if a2 is None: if not shaped(k, n): continue # a lambda or a module whose first line is gone - a2, many = found(n, k, near.get(a) or a, taken) + a2, many = found(n, k, near.get(a) or a, taken, d) if a2 is None: continue # this text does not declare it if many: self.unsure[rel].add(i) taken.add(a2) @@ -405,6 +420,7 @@ class Changed: res = [] for p in out: p = re.sub(r'=.*$', '', p).strip(); p = re.sub(r'@\w+(\([^)]*\))?\s*', '', p) + p = re.sub(r'^((public|private|protected|readonly|override)\s+)+(?=[A-Za-z_$][\w$]*\s*[?!]?\s*:)', '', p) # a TypeScript parameter property if not p: continue if ':' in p: name, typ = p.split(':', 1)[0].strip(), p.split(':', 1)[1].strip() # TS / Python: name: Type else: @@ -659,6 +675,15 @@ class Changed: return None decorated = {}; stmt_pair = {} OK = strip_code(old, strings=False, hash_comments=is_py).split('\n'); NK = strip_code(new, strings=False, hash_comments=is_py).split('\n') + # A LINE INSERTED INTO A HEADER CHANGES THE SIGNATURE. A parameter list written one parameter a line gains a + # parameter as an inserted line, which no old line was changed for; charged to the callable as an insertion it + # was "body" (a constructor that gained an injected dependency), and its parameter read as an added field. + # The insertion is charged to the header, which is then compared old against new like an edited header line + header_ins = set() + for after, j1, j2 in added: + m = narrowest(after, {'method', 'function', 'constructor'}) if after else None + if m and m[0] <= after < self.header_end(OL, m[0]): + hits.setdefault(('signature', m), set()).add(after); header_ins.add((after, j1, j2)) def counterpart(ln): """the new line an old changed line became: the most similar line of its replaced block; None when deleted""" if ln in removed_lines: return None @@ -769,6 +794,7 @@ class Changed: oh_raw = ' '.join(x.strip() for x in OL[a - 1:self.header_end(OL, a)]); oh = uncomment(OL[a - 1:self.header_end(OL, a)]) na = new_of.get(a) or (new_of.get(max((x for x in new_of if x < a), default=0), 0) + 1) nh = nh_raw = '' + hn = self.header_name(n, d, rel) # `` is spelled `constructor` 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): @@ -777,8 +803,8 @@ class Changed: code = strip_code(NL[s - 1], strings=False, hash_comments=is_py) # a constructor's name is its type's: the type's own header (`class OrderService {`) two lines # above is not the constructor's new header (#1465) - if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(n)}\b', code): continue - if re.search(rf'\b{re.escape(n)}\b', code) and not calls_only(s): + if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(hn)}\b', code): continue + if re.search(rf'\b{re.escape(hn)}\b', code) and not calls_only(s): 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 @@ -788,7 +814,7 @@ class Changed: # parameter read as removed ("signature f -self, -rel"). Declared nowhere in the new text, it is removed; # declared elsewhere, what changed cannot be read from here if not nh: - again = still_declared(n, k, na or a) + again = still_declared(hn, k, na or a) if not again: if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry.update(kind='removed', target=self.target(kind, d, k, n)); out.append(entry) continue @@ -807,7 +833,7 @@ class Changed: op, np_ = self.params(oh_s), self.params(nh_s) if nh_s else [] on, nn = [x for x, _ in op], [x for x, _ in np_] detail = [] - if nh and not re.search(rf'\b{re.escape(n)}\b', nh): detail.append('renamed') + if nh and not re.search(rf'\b{re.escape(hn)}\b', nh): detail.append('renamed') for x in on: if x not in nn: detail.append(f'-{x}') for x in nn: @@ -816,7 +842,7 @@ class Changed: if x == y and t1 != t2 and t1 and t2: detail.append(f'{x}: {t1} → {t2}') pre_o = re.sub(r'\(.*$', '', oh_s); pre_n = re.sub(r'\(.*$', '', nh_s) if nh_s else '' if nh and pre_o.split() != pre_n.split(): - ro = [w for w in pre_o.split() if w != n]; rn = [w for w in pre_n.split() if w != n] + ro = [w for w in pre_o.split() if w != hn]; rn = [w for w in pre_n.split() if w != hn] if ro != rn: detail.append('return type / modifiers: ' + ' '.join(ro) + ' → ' + ' '.join(rn)) if not detail and nh and re.sub(r'\s', '', oh) != re.sub(r'\s', '', nh): detail.append('header text changed (annotations / throws / generics)') if detail and not decs and re.sub(r'\s', '', oh_s) == re.sub(r'\s', '', nh_s) and nh_s: detail = ['decoration changed above the signature (the signature itself is unchanged)'] @@ -910,6 +936,13 @@ class Changed: # "not anchored" it came back as "N new line(s) at file:518" and changed --impact / test-impact never # followed it. The anchor test still decides at the callable's first line, and a module is never a body if owner and owner[3] != 'module' and owner[0] < after < owner[1]: anchored = True + # A PYTHON BODY HAS NO CLOSING LINE: its last statement is the span's last line, and a statement appended + # after it, indented deeper than the def, is still that body ("N new line(s) inside " otherwise, and + # the callable went untested) + if is_py and is_add and owner and owner[3] != 'module' and after == owner[1] and owner[0] <= len(OL): + first = next((NL[j - 1] for j in range(j1, j2 + 1) if NL[j - 1].strip()), '') + hdr = OL[owner[0] - 1] + if first and len(first) - len(first.lstrip()) > len(hdr) - len(hdr.lstrip()): owner = (owner[0], owner[1] + 1) + tuple(owner[2:]); anchored = True in_body = bool(is_add and owner and owner[0] <= after < owner[1] and anchored) if in_body: # strictly inside a callable's body: that callable changed if not any(e['id'] == owner[2] for e in out): @@ -933,6 +966,7 @@ class Changed: tline = next((j for j in range(1, len(NL) + 1) if re.search(rf'\b(class|interface|enum|record|trait|struct)\s+{re.escape(t[5])}\b', NL[j - 1])), None) if tline: body_depth = ndepth[self.header_end(NL, tline)] if not py else len(NL[tline - 1]) - len(NL[tline - 1].lstrip()) + 4 else: body_depth = 0 + toplevel = set() # names declared at the file's top level for j in range(j1, j2 + 1): text = NL[j - 1]; st = re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', text).strip() # a line wholly inside a docstring, a string or a comment declares nothing: read as code, a docstring @@ -940,14 +974,29 @@ class Changed: if not SL[j - 1].strip(): continue st = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', st) # `@Override public …` on one line here = (ndepth[j - 1] if not py else len(text) - len(text.lstrip())) + # AN INSERTION CAN RUN PAST THE TYPE IT BEGAN IN: new methods at the end of a class and a function after + # it are one block (in Python always, having no closing line). A function at the file's top level is + # its own declaration, owned by no type; read at the type's body level only, it was not named at all + fm = t and here == 0 and body_depth > 0 and re.match(r'^(?:export\s+(?:default\s+)?)?(?:async\s+)?(?:def|function\*?)\s+([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(', st) + if fm and not any(x[0] == fm.group(1) for x in names): + names.append((fm.group(1), 'function', j)); toplevel.add(fm.group(1)); hdrs[fm.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue if here != body_depth or not st or st.startswith(('//', '*', '/*', '@', '#', 'return', 'if', 'for', 'while', 'switch', 'else', 'throw', 'new ', 'case', 'try', 'catch')): continue # a file's own header lines declare nothing: `package a.b.c;` and `import a.b.C;` read as a field `c` / `C` if re.match(r'(package|import|using|namespace|from|module|export\s+\*)\b', st): continue - tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) + # a parameter inserted into a header (header_ins) is the signature's change: it declares a field only as + # a TypeScript parameter property (`private readonly x: T`) + if (after, j1, j2) in header_ins: + ty, nm = self.field_parts(st.rstrip(',')) + if nm and re.match(r'(public|private|protected|readonly)\b', st) and not any(x[0] == nm for x in names): names.append((nm, 'field', j)) + continue + tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed|export|default|declare)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)', j)); hdrs[tm.group(2)] = st; continue - m = re.match(r'^(?:def|async def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or (not py and ( - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;=]*\)\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st))) + # a TypeScript header writes its return type after the parameters (`f(a: A): Promise {`), and a + # module's function is often exported (`export async function f(`): read as neither, a new method or + # function was "N new line(s)" with no name, and a local inside it could pass for a field + m = re.match(r'^(?:export\s+(?:default\s+)?)?(?:def|async def|function\*?|async function\*?)\s+([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(', st) or (not py and ( + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\([^;=]*\)\s*(?::\s*[^;{}=]+?)?\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\([^;={]*$', st))) if m and not re.match(r'^(if|for|while|switch|catch|synchronized|return|new|super|this|else)$', m.group(1)) and '=' not in st.split('(')[0]: names.append((m.group(1), 'method', j)); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue # each declaration on the line: `int a = 1; int b = 2;` and `int a = 1, b = 2;` declare two fields @@ -964,7 +1013,7 @@ class Changed: tt = typ or name # a signature spells types only out.append(re.sub(r'<.*', '', tt.split('.')[-1]).replace('...', '[]').strip()) return out - def old_has(nm, hdr): + def old_has(nm, hdr, t=t): ht = types_of(hdr) if '(' in hdr else None for a, b, i, k, d, n in decls: if n != nm or (t and not d.startswith(t[4] + '.') and d != t[4] + '.' + nm and k not in ('class', 'interface', 'enum')): continue @@ -980,14 +1029,22 @@ class Changed: if ('...' in old_hdr) != ('...' in hdr): continue return True return False + decl_ids = {x[2] for x in decls} + t_in = t for nm, k, jn in names: - if old_has(nm, hdrs.get(nm, '')): continue + t = None if nm in toplevel else t_in + if old_has(nm, hdrs.get(nm, ''), t): continue + if k == 'method' and not t: k = 'function' # declared at a file's top level # an added method whose name already exists on this owner is an OVERLOAD: the compiler may rebind # existing call sites of that name to it, and a call site records no argument types, so which of them # rebind cannot be decided here. Never claim nothing depends on it sib = [] if k in ('method', 'function', 'constructor') and t: for i, sy in self.g.sym.items(): + # an overload is of a declaration the OLD text has: the graph can be from a later text (a range, + # a refresh), where this very method is declared, and its own callers were named as the calls + # an overload might take over. In this file, only what the old text declares (decls) counts + if sy.get('file') == rel and i not in decl_ids: continue if sy.get('method_id') and sy['name'] == nm and (sy['owner'] or '') == t[4]: sib += [r[0] for r in self.g.q("SELECT DISTINCT s.display FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id = ?", sy['method_id'])] note = f'new {k}' @@ -1003,6 +1060,7 @@ class Changed: if mm and mm.group(1) not in ('if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'super', 'this'): out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{mm.group(1)}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new method', target=None)); continue ty2, nm2 = self.field_parts(st2) if nm2 and ty2 and st2.endswith(';') and '(' not in st2.split('=')[0]: out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{nm2}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new field', target=None)) + t = t_in if not names and is_add and not in_body: adds.append((rel, f"{j2 - j1 + 1} new line(s) at {rel}:{j1}" + (f" inside {t[4]}" if t else ''), None)) # A DECLARATION WHOSE SIGNATURE CHANGED IS NOT A NEW ONE. `added` is decided by whether the OLD text has a # header that matches, so editing the header itself — `def f(a)` to `def f(a, *, cap=0)`, or `def` to diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f..64c1bcef 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -71,9 +71,10 @@ 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 = {''} +# the one name a front end gives every lambda it declares (Python and C# ``, C# ``, TypeScript +# and JavaScript `` / ``; 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 = {'', '', '', ''} # what a front end synthesises ON a field's line that is the field's own, never a callable written there (G.decl_at_line): # the node that runs a class's field initializers, and the unnamed function an initializer holds (`cb = wrap(() => …)`), # which JavaScript and TypeScript name `` / `` where Python and C# say `` diff --git a/tests/cases/csharp/grown-file-read-against-its-base/case.json b/tests/cases/csharp/grown-file-read-against-its-base/case.json new file mode 100644 index 00000000..2b5aadfe --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/case.json @@ -0,0 +1,29 @@ +{ + "lang": "csharp", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a constructor header written one per line (a C# constructor is recorded as ``, its header spells the type's name) is its signature change, not its body and not a removal; a new method with a generic return type is named as added, and a local in it is no field", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.cs", + "--new", + "{repo}/new.cs", + "--file", + "src/Service.cs" + ], + "want": [ + "signature Service.", + "+gateway", + "added Service.Pay" + ], + "avoid": [ + "removed", + "Service.total", + "added Service.gateway src/Service.cs:11" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/grown-file-read-against-its-base/new.cs b/tests/cases/csharp/grown-file-read-against-its-base/new.cs new file mode 100644 index 00000000..43150ab5 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/new.cs @@ -0,0 +1,33 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + private readonly Gateway gateway; + + public Service( + Repo repo, + Gateway gateway, + Clock clock) + { + this.repo = repo; + this.gateway = gateway; + } + + public int Get(string id) + { + return repo.Find(id); + } + + public async System.Threading.Tasks.Task Pay(int n) + { + var total = n; + return await gateway.Pay(total); + } + } + + public class Repo { public int Find(string id) { return 0; } } + public class Clock { } + public class Gateway { public System.Threading.Tasks.Task Pay(int n) { return System.Threading.Tasks.Task.FromResult(n); } } +} diff --git a/tests/cases/csharp/grown-file-read-against-its-base/old.cs b/tests/cases/csharp/grown-file-read-against-its-base/old.cs new file mode 100644 index 00000000..0a3b5f0e --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/old.cs @@ -0,0 +1,19 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + public Service( + Repo repo, + Clock clock) + { + this.repo = repo; + } + + public int Get(string id) + { + return repo.Find(id); + } + } +} diff --git a/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs b/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs new file mode 100644 index 00000000..43150ab5 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs @@ -0,0 +1,33 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + private readonly Gateway gateway; + + public Service( + Repo repo, + Gateway gateway, + Clock clock) + { + this.repo = repo; + this.gateway = gateway; + } + + public int Get(string id) + { + return repo.Find(id); + } + + public async System.Threading.Tasks.Task Pay(int n) + { + var total = n; + return await gateway.Pay(total); + } + } + + public class Repo { public int Find(string id) { return 0; } } + public class Clock { } + public class Gateway { public System.Threading.Tasks.Task Pay(int n) { return System.Threading.Tasks.Task.FromResult(n); } } +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/case.json b/tests/cases/javascript/grown-file-read-against-its-base/case.json new file mode 100644 index 00000000..86e5c8ec --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/case.json @@ -0,0 +1,33 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a constructor header is its signature change; an edit inside an arrow passed from a method is that method's body, not an `` of its own; a new method and an exported async function are named, and a local in the function is no field of the class", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new.js", + "--file", + "src/service.js" + ], + "want": [ + "signature Service.", + "+gateway", + "added Service.pay", + "added report", + "body Service.get" + ], + "avoid": [ + "removed", + "Service.base", + "Service.total", + "", + "" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/grown-file-read-against-its-base/new.js b/tests/cases/javascript/grown-file-read-against-its-base/new.js new file mode 100644 index 00000000..2536f70f --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/new.js @@ -0,0 +1,26 @@ +export class Service { + constructor( + repo, + clock, + gateway, + ) { + this.repo = repo; + this.gateway = gateway; + } + + get(id) { + return this.repo.run(async () => { + return this.pay(this.repo.find(id)); + }); + } + + async pay(n) { + const total = n; + return this.gateway.pay(total); + } +} + +export async function report(event) { + const base = { id: event.id }; + return base; +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/old.js b/tests/cases/javascript/grown-file-read-against-its-base/old.js new file mode 100644 index 00000000..ec27fd94 --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/old.js @@ -0,0 +1,14 @@ +export class Service { + constructor( + repo, + clock, + ) { + this.repo = repo; + } + + get(id) { + return this.repo.run(async () => { + return this.repo.find(id); + }); + } +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/src/service.js b/tests/cases/javascript/grown-file-read-against-its-base/src/service.js new file mode 100644 index 00000000..2536f70f --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/src/service.js @@ -0,0 +1,26 @@ +export class Service { + constructor( + repo, + clock, + gateway, + ) { + this.repo = repo; + this.gateway = gateway; + } + + get(id) { + return this.repo.run(async () => { + return this.pay(this.repo.find(id)); + }); + } + + async pay(n) { + const total = n; + return this.gateway.pay(total); + } +} + +export async function report(event) { + const base = { id: event.id }; + return base; +} diff --git a/tests/cases/python/grown-file-read-against-its-base/case.json b/tests/cases/python/grown-file-read-against-its-base/case.json new file mode 100644 index 00000000..53b228d9 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/case.json @@ -0,0 +1,52 @@ +{ + "lang": "python", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a def header written one per line is its signature change; new methods at the end of a class and a function after it are one inserted block, and the function is named at the top level, not dropped nor read as a member", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.py", + "--new", + "{repo}/new.py", + "--file", + "src/service.py" + ], + "want": [ + "signature Service.__init__", + "+gateway", + "added Service.pay", + "added report" + ], + "avoid": [ + "removed", + "Service.total", + "Service.base", + "Service.report", + "new line(s)" + ] + }, + { + "why": "control: a method added after a class's last method, at the same indentation, is not that method's body; a line appended inside `__init__` is", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.py", + "--new", + "{repo}/new.py", + "--file", + "src/service.py" + ], + "want": [ + "signature Service.__init__" + ], + "avoid": [ + "body Service.get", + "inside Service" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/grown-file-read-against-its-base/new.py b/tests/cases/python/grown-file-read-against-its-base/new.py new file mode 100644 index 00000000..956f1381 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/new.py @@ -0,0 +1,21 @@ +class Service: + def __init__( + self, + repo, + clock, + gateway, + ): + self.repo = repo + self.gateway = gateway + + def get(self, id): + return self.repo.find(id) + + async def pay(self, n) -> int: + total = n + return await self.gateway.pay(total) + + +def report(event): + base = {"id": event.id} + return base diff --git a/tests/cases/python/grown-file-read-against-its-base/old.py b/tests/cases/python/grown-file-read-against-its-base/old.py new file mode 100644 index 00000000..22a2a734 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/old.py @@ -0,0 +1,10 @@ +class Service: + def __init__( + self, + repo, + clock, + ): + self.repo = repo + + def get(self, id): + return self.repo.find(id) diff --git a/tests/cases/python/grown-file-read-against-its-base/src/service.py b/tests/cases/python/grown-file-read-against-its-base/src/service.py new file mode 100644 index 00000000..956f1381 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/src/service.py @@ -0,0 +1,21 @@ +class Service: + def __init__( + self, + repo, + clock, + gateway, + ): + self.repo = repo + self.gateway = gateway + + def get(self, id): + return self.repo.find(id) + + async def pay(self, n) -> int: + total = n + return await self.gateway.pay(total) + + +def report(event): + base = {"id": event.id} + return base diff --git a/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts b/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts new file mode 100644 index 00000000..416d6ef8 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts @@ -0,0 +1,23 @@ +import { Clock, Repo } from './repo'; + +export class Service { + private readonly cache: Map; + + constructor( + private readonly repo: Repo, + clock: Clock, + ) { + this.cache = new Map([["", 0]]); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return n; + }); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/case.json b/tests/cases/typescript/grown-file-read-against-its-base/case.json new file mode 100644 index 00000000..17f72283 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/case.json @@ -0,0 +1,97 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "the graph is from a later text of the file than the base it is read against (a range, a refresh): parameters inserted into a constructor header written one per line are its signature change, not its body, and a plain parameter is no field", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "signature Service.", + "+logger", + "+gateway" + ], + "avoid": [ + "body Service.", + "removed", + "added Service.logger", + "Traceback" + ] + }, + { + "why": "methods with a TypeScript return type and an exported module function are named as added; a local inside the new function is not a field of the class, and a declaration only the later graph holds is not an overload of itself", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "added Service.apply", + "added Service.authorize", + "added reportFrom", + "new function", + "added Service.label", + "added Service.gateway" + ], + "avoid": [ + "Service.base", + "overload", + "new line(s)", + "removed or renamed" + ] + }, + { + "why": "an edit inside an arrow passed from a method is that method's body, not an `` of its own", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "body Service.place" + ], + "avoid": [ + "" + ] + }, + { + "why": "control: an edit to the constructor's body alone stays a body change, and adds nothing", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/body-only.ts", + "--file", + "src/service.ts" + ], + "want": [ + "body Service." + ], + "avoid": [ + "signature Service", + "added ", + "removed" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/grown-file-read-against-its-base/new.ts b/tests/cases/typescript/grown-file-read-against-its-base/new.ts new file mode 100644 index 00000000..dbde3758 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/new.ts @@ -0,0 +1,41 @@ +import { Clock, Gateway, Logger, Repo, Event, Report } from './repo'; + +export class Service { + private readonly cache: Map; + + private readonly label = 'service'; + + constructor( + private readonly repo: Repo, + clock: Clock, + logger: Logger, + private readonly gateway: Gateway, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return this.authorize(n); + }); + } + + async apply(event: Event): Promise<'applied' | 'ignored'> { + const n = await this.repo.find(event.id); + return n ? 'applied' : 'ignored'; + } + + private async authorize(n: number): Promise { + return this.gateway.pay(n); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} + +export function reportFrom(event: Event): Report { + const base = { id: event.id }; + return { ...base }; +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/old.ts b/tests/cases/typescript/grown-file-read-against-its-base/old.ts new file mode 100644 index 00000000..c777ce7b --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/old.ts @@ -0,0 +1,23 @@ +import { Clock, Repo } from './repo'; + +export class Service { + private readonly cache: Map; + + constructor( + private readonly repo: Repo, + clock: Clock, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return n; + }); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts b/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts new file mode 100644 index 00000000..352e689e --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts @@ -0,0 +1,9 @@ +export interface Clock { now(): number } +export interface Logger { warn(m: string): void } +export interface Gateway { pay(n: number): Promise } +export interface Event { id: string } +export interface Report { id: string } +export interface Repo { + run(f: () => Promise): Promise; + find(id: string): Promise; +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts b/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts new file mode 100644 index 00000000..dbde3758 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts @@ -0,0 +1,41 @@ +import { Clock, Gateway, Logger, Repo, Event, Report } from './repo'; + +export class Service { + private readonly cache: Map; + + private readonly label = 'service'; + + constructor( + private readonly repo: Repo, + clock: Clock, + logger: Logger, + private readonly gateway: Gateway, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return this.authorize(n); + }); + } + + async apply(event: Event): Promise<'applied' | 'ignored'> { + const n = await this.repo.find(event.id); + return n ? 'applied' : 'ignored'; + } + + private async authorize(n: number): Promise { + return this.gateway.pay(n); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} + +export function reportFrom(event: Event): Report { + const base = { id: event.id }; + return { ...base }; +} diff --git a/tests/cases/typescript/indexed-with-uncommitted-edit/case.json b/tests/cases/typescript/indexed-with-uncommitted-edit/case.json index ed69002a..bbf9d68e 100644 --- a/tests/cases/typescript/indexed-with-uncommitted-edit/case.json +++ b/tests/cases/typescript/indexed-with-uncommitted-edit/case.json @@ -3,7 +3,7 @@ "src": "src", "checks": [ { - "why": "a graph indexed from a working tree with uncommitted edits has spans in that text's line numbers; against the committed file they can run past its end, and `changed` (and test-impact, which calls it) crashed with an IndexError. It now reads the file clamped and says the positions may be off until a re-index", + "why": "a graph indexed from a working tree with uncommitted edits has spans in that text's line numbers; against the committed file they can run past its end, and `changed` (and test-impact, which calls it) crashed with an IndexError. With no recorded tree, the file on disk is the text the spans are checked against and carried from, so the edit is read exactly: `a` gained a parameter", "run": [ "changed", "{repo}", @@ -16,13 +16,16 @@ ], "want": [ "changed declarations", - "indexed from a working tree with uncommitted edits" + "signature a", + "+strict" ], "avoid": [ "Traceback", "IndexError", - "added src/app.ts: the graph" + "added src/app.ts: the graph", + "signature b", + "body b" ] } ] -} +} \ No newline at end of file From d02fde16f98d31f23584132873d9fc03ac531ef7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:17:23 -0700 Subject: [PATCH 071/258] cli, mcp: refuse a repository path that does not exist; impact prints tied text rows in one order A repository argument naming no directory fell back to the working directory. `axiomcode impact ` took the missing for a second target and answered for the working directory; context and path passed it to the verb, which built a graph there or in the working directory. On a working directory with no graph that started a full build of it. The MCP tools' repo parameter went the same way. `index --src ` was joined onto the repository, a tree that is not there. The change: - The dispatcher refuses a missing repository before anything runs, with a short message naming the path and exit status 2. The repository sits at a fixed place for index, graph, context and path, so anything there that is not a directory is refused. impact takes several targets and changed and test-impact take files, so there only an argument written as a directory is (absolute, ./ ../ ~, a trailing slash, or a last part with no extension and no :line). `sub/m.py`, `m.py:1` and `Owner.m` are still targets. - axiomcode-build refuses a missing repository and a missing --src. An absolute --src is used as given: inside the repository it is recorded relative to it, as the file table, stamp and baseline expect; outside it is analysed where it is. - ensure_graph refuses a missing repository, so a verb script run directly never builds one either. - Each MCP tool with a repo parameter refuses a missing one before running. - The hooks still pass the working directory and are unchanged. impact's "bound from outside the source" rows were sorted by (file, line) only. One line can name the method twice (`run` and `Step.run`), and the tie fell back to set order, which depends on the string hash seed. The sort now uses the whole row (file, line, name, how it matched). The [text] lines that write a name are sorted by (file, line, text) for the same reason. Two more orders in impact came from symbol ids, which hash the index directory, so one tree indexed at two paths printed them differently: the example target in "note: names N declarations" and the list after "NOT CHECKED:". Both now follow where the declarations are written. Tests: - tests/repo_arg.py: every verb given a missing repository, and --src missing (absolute and relative), exits non-zero, names the path and creates no .axiomcode. Direct verb scripts and the MCP tools are refused the same way. Near misses still work: an existing repository answers, file-like targets stay targets, and an absolute --src inside the repository indexes only that subtree, recorded as `sub`. Against the old scripts it fails on every refusal check. - tests/row_order.py: three impact questions on two cases, each asked under six PYTHONHASHSEED values, must be byte-identical. A control requires two [text] rows on one file:line in each answer. A fourth question is asked of one case indexed at two paths and must match. Against the old code it fails on all four. Finding the affected checks: every path and impact check of the python, java and csharp case suites (619 answers) was run twice with a re-index, and again under hash seeds 0 to 4 on one index. Seven impact checks changed between runs, all in the rows above: java a-capped-fanout-is-a-sample (two checks), java header-supertype-shadowed-by-member, java mybatis-statement-in-its-namespace (two checks), python unmodelled-entry-not-local and csharp unmodelled-entry-not-local. No path check changed. After the change all 619 answers are byte-identical across the five seeds, and the seven affected checks gave one output each over ten runs (two re-indexed runs and eight seeds). Compared with the base, the only differences are the reordered rows. Not fixed here: the dependents section still breaks ties on one file:line by symbol id (for example `get_X` and `set_X` on one line, or two `[alongside]` siblings), so the same tree indexed at two paths orders those rows differently. 8 of 639 answers differ that way, apart from ids in --json. Suites on the rebased tree: - tests/run.py --lang python: 268 of 269; java: 304 of 304; csharp: 201 of 203. The three FAIL lines are the ones the base already has (python lambda-is-named-by-its-place control, csharp unmodelled-entry-not-local x2). No new FAIL line. - tests/fastpath.py --lang python, java, csharp: 8 of 8 each. - tests/mcp.py, mcp_docs.py, surfaces.py, graph_verb.py, engine_choice.py: ok. hook_languages.py: 7 of 7. freshness.py: 118 of 118. - tests/repo_arg.py and tests/row_order.py: ok. Not yet measured on the full corpus or on held-out projects; no corpus smoke was run, since neither change alters an answer given for an existing repository apart from the order of tied rows. --- plugins/axiomcode/mcp/server.py | 16 +++ .../skills/axiomcode/scripts/ax_contract.py | 5 + .../skills/axiomcode/scripts/ax_text.py | 2 +- .../skills/axiomcode/scripts/axiomcode | 26 ++++ .../skills/axiomcode/scripts/axiomcode-build | 17 ++- .../skills/axiomcode/scripts/axiomcode-impact | 16 ++- tests/README.md | 5 + tests/repo_arg.py | 123 ++++++++++++++++++ tests/row_order.py | 67 ++++++++++ 9 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 tests/repo_arg.py create mode 100644 tests/row_order.py diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 53ab0eb7..4bfb91aa 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -271,15 +271,26 @@ def NOREF(refresh): def grep(full, limit=0): return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) +# A REPOSITORY THAT IS NOT THERE IS REFUSED, NOT REPLACED. The dispatcher took the last argument that was a directory, +# else the working directory, so a repo= naming nothing answered for the server's working directory instead, and on one +# with no graph started a full build of it. The repo parameter is always the repository, so any value that is not a +# directory is an error that names it, and nothing is run. +def need_repo(repo): + if repo and not os.path.isdir(repo): + raise ToolError(f"repo: no such directory: {repo}. Nothing was built or asked; pass a directory that exists " + f"(an absolute path), or leave repo out to use {os.getcwd()}.") + @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.""" + need_repo(repo) a = ['index', repo] + (['--lang', lang] if lang else []) + (['--src', src] if src else []) + (['--library', library] if library else []) return run(a) @srv.tool() def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True) -> 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]`), capped with a count of the rest; limit=N lists more, 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + need_repo(repo) flow = source or explain or from_.strip() or _paged(page) or budget a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] return run(a + NOREF(refresh)) @@ -287,6 +298,7 @@ def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int @srv.tool() def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True) -> 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. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + need_repo(repo) paged = _paged(page); full = full or why a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--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 + NOREF(refresh)) @@ -294,6 +306,7 @@ def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in @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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> 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 (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + need_repo(repo) prose = full or why or delete or _paged(page) 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 []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) return run(a + NOREF(refresh)) @@ -301,12 +314,14 @@ def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, w @srv.tool() def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True) -> str: """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + need_repo(repo) a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) return run(a + NOREF(refresh)) @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: Page = 1, full: bool = False, refresh: bool = True) -> 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]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" + need_repo(repo) prose = full or why or _paged(page) a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) return run(a + NOREF(refresh)) @@ -314,6 +329,7 @@ def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = ' @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path. refresh=False: drawn from the graph as it is, never rebuilt first.""" + need_repo(repo) return run(['graph', repo] + (['--out', out] if out else []) + NOREF(refresh)) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 3f6400fe..987925cf 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -438,6 +438,11 @@ def ensure_graph(repo, db): 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') + # a repository that is not there is an error that names it, never a build: the dispatcher refuses one first, and + # this holds the same for a verb script run directly + if not os.path.isdir(rr): + print(f"axiomcode: no such directory: {repo} (the repository to ask). Nothing was built or asked.", file=sys.stderr) + sys.exit(2) 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, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py index a4b5fe2b..ada6242b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -622,7 +622,7 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): lines.append(f"[text] {head}; the {'other ' if taken else ''}lines that write it{also} — text, not call edges " f"({len(hits)} line(s) in {nfiles} file(s){under}):") shown, per = [], {} - for f, ln, t in sorted(hits, key=lambda h: (h[0], h[1])): + for f, ln, t in sorted(hits, key=lambda h: (h[0], h[1], h[2])): # total: a tie on (file, line) is never left to input order if per.get(f, 0) >= PER_FILE: continue per[f] = per.get(f, 0) + 1; shown.append((f, ln, t)) # one row per file first, so twelve rows name twelve files rather than one file twelve times diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index bd3ce618..92404610 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -145,6 +145,32 @@ case "${ARGS[0]:-}" in -h|--help) if [ -n "$cmd" ] && [ "${#ARGS[@]}" = 1 ]; the # A flag's VALUE is never the repository: `--in plugins/core` names a directory under it, and taking that for the # repository found no .axiomcode/lang there, so a repository in several languages was asked in its main graph alone and # a scope that only another language's graph holds was refused as absent. +# A REPOSITORY THAT IS NOT THERE IS AN ERROR, NOT THE WORKING DIRECTORY. A repository argument naming no directory was +# taken for a target (impact) or passed over, the working directory answered instead, and on one with no graph a full +# build of it started: a parent of many projects, for minutes. The repository sits at a fixed place for most verbs +# (index and graph: the first argument; context: after the task; path: after the two endpoints), so anything there that +# is not a directory is refused. impact takes several targets and changed/test-impact take files, so there only an +# argument WRITTEN as a directory is taken for one: absolute, ./ ../ ~, a trailing slash, or a path whose last part has +# no extension, and no :line. `src/a.py`, `a.py:12` and `Owner.m` are still targets and files. The hooks call the verb +# scripts directly with the working directory, and are not affected. +dirlike(){ [[ "$1" =~ :[0-9]+$ ]] && return 1 + case "$1" in /*|[A-Za-z]:[\\/]*|./*|../*|'~'*|*/|*\\) return 0;; esac + case "$1" in */*|*\\*) case "${1##*[/\\]}" in *.*) return 1;; esac; return 0;; esac; return 1; } +gone(){ echo "axiomcode $cmd: no such directory: $1 (the repository to ask). Nothing was built or asked; pass a directory that exists, or leave it out to use the current one." >&2; exit 2; } +POS=(); skip=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do + if [ -n "$skip" ]; then skip=""; continue; fi + case "$a" in --in|--from|--budget|--seeds|--depth|--limit|--tests-in|--kind|--range|--old|--new|--file|--page|--page-budget|--out|--from-text|--top) skip=1; continue ;; -*) continue ;; esac + POS+=("$a") +done +LASTPOS=""; [ "${#POS[@]}" -gt 0 ] && LASTPOS="${POS[${#POS[@]}-1]}" +case "$cmd" in + index|build) if [ "${#POS[@]}" -gt 0 ] && [ ! -d "${POS[0]}" ]; then gone "${POS[0]}"; fi ;; + graph) case "${POS[0]:-}" in ""|build|export|draw) ;; *) [ -d "${POS[0]}" ] || gone "${POS[0]}" ;; esac ;; + context) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; + path) if [ "${#POS[@]}" -gt 2 ] && [ ! -d "${POS[2]}" ]; then gone "${POS[2]}"; fi ;; + impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;; + changed|test-impact|tests) if [ "${#POS[@]}" -gt 0 ] && [ ! -e "${POS[0]}" ] && dirlike "${POS[0]}"; then gone "${POS[0]}"; fi ;; +esac FR=.; skip=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do if [ -n "$skip" ]; then skip=""; continue; fi case "$a" in --in|--from|--budget|--seeds|--depth|--limit|--tests-in|--kind|--range|--old|--new|--file|--page|--page-budget|--out) skip=1; continue ;; esac diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 6246c20d..e393ae73 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -17,7 +17,21 @@ # language's compile; until the last one is in, .axiomcode/out/building names the languages still to come. set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" +# a repository that is not there is an error, never the working directory, and nothing is built +[ -d "${1:-.}" ] || { echo "axiomcode index: no such directory: $1 (the repository to index). Nothing was built." >&2; exit 2; } REPO="$(cd "${1:-.}" && pwd)" +# --src IS RELATIVE TO THE REPOSITORY, OR ABSOLUTE AND USED AS GIVEN. An absolute --src was joined onto the repository +# (//abs/src), a tree that is not there. One inside the repository is kept as its path relative to it, which is +# what the file table, the stamp and the baseline's `git archive` key on; one outside it is analysed where it is. +if [ -n "${AXIOMCODE_SRC:-}" ]; then + case "$AXIOMCODE_SRC" in + /*|[A-Za-z]:[\\/]*) + [ -d "$AXIOMCODE_SRC" ] || { echo "axiomcode index: no such directory: $AXIOMCODE_SRC (--src). Nothing was built." >&2; exit 2; } + SRC_ABS="$(cd "$AXIOMCODE_SRC" && pwd)" + case "$SRC_ABS" in "$REPO") AXIOMCODE_SRC=. ;; "$REPO"/*) AXIOMCODE_SRC="${SRC_ABS#"$REPO"/}" ;; *) AXIOMCODE_SRC="$SRC_ABS" ;; esac + export AXIOMCODE_SRC ;; + esac +fi # THE ENGINE IS CHOSEN THE SAME WAY EVERY TIME, AND A BUILD THAT FINDS NONE SAYS WHERE IT LOOKED. The background # refresh runs this script from a hook, with the hook's PATH, the hook's Node and, installed from a marketplace, from a # plugin COPIED outside any checkout; an explicit `axiomcode index` runs it from inside the npm package. Each found an @@ -106,7 +120,8 @@ if [ -z "${AXIOMCODE_LANG:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ]; then [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "keeping the languages this graph was indexed with (--lang $AXIOMCODE_LANG${AXIOMCODE_SRC:+ --src $AXIOMCODE_SRC}); pass --lang, or AXIOMCODE_REINDEX=1, to choose again" fi fi -SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" +case "${AXIOMCODE_SRC:-}" in /*|[A-Za-z]:[\\/]*) SRC="$AXIOMCODE_SRC" ;; *) SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" ;; esac +[ -d "$SRC" ] || { echo "axiomcode index: no such directory: $SRC (--src ${AXIOMCODE_SRC:-}, under $REPO). Nothing was built." >&2; exit 2; } # THE FILES OF EACH LANGUAGE, COUNTED AS THE PARSER AND THE REFRESHER SEE THEM: the parser's skip list and git's ignore # rules (ax_fresh.py count). A `find` with its own shorter skip list counted a generated tree the parser never reads — # the "typescript 15108 files" of a 431-file project — and could pick a different main language than the last build. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index b8a00775..ad8cebba 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2280,9 +2280,11 @@ def main(argv): ids = [i for i in (pay if isinstance(pay, list) else []) if isinstance(i, str) and i in g.sym and g.sym[i].get('file')] files = sorted({g.sym[i]['file'] for i in ids}) if k == 'method' and len(files) > 1 and not re.search(r':\d+$', a): - where = ', '.join(dict.fromkeys(g.loc(i) for i in sorted(ids, key=lambda i: (g.sym[i]['file'], g.sym[i].get('line') or 0)))) + # the example is the first declaration as written, not the first id: ids hash the index directory + by_place = sorted(ids, key=lambda i: (g.sym[i]['file'], g.sym[i].get('line') or 0, i)) + where = ', '.join(dict.fromkeys(g.loc(i) for i in by_place)) print(f"note: {a} names {len(ids)} declarations in {len(files)} files ({where}) — this answer is for all of them; " - f"target one by file:line (e.g. `impact {g.loc(ids[0])}`)", file=sys.stderr if as_json else sys.stdout) + f"target one by file:line (e.g. `impact {g.loc(by_place[0])}`)", file=sys.stderr if as_json else sys.stdout) if want_why: target_why += [I.why_of(a, k, lab, pay) for k, lab, pay in rs] targets += rs if IN and os.environ.get('AXIOMCODE_FANOUT') == '1': @@ -2783,7 +2785,10 @@ def main(argv): # AN UNMODELLED ENTRY (P.unmodelled_entry): a decoration the rules do not model, or a library base, on the # declaration or its owner. With no caller bound to it, "local", "tests: 0" and "independent" would be claims about # the model, not the code, and they were false on exactly these declarations: say it was not checked, and how to. - unmod = [(i, sig) for i, sig in P.unmodelled_entry(g, asked)] + # in the order the declarations are WRITTEN (file, line, then the signal): the order of `asked` follows symbol ids, + # which hash the index directory, so one case indexed at two paths listed the same signals in two orders + _at = lambda i: ((getattr(g, 'all_sym', {}).get(i) or g.sym.get(i) or {}).get('file') or '', (getattr(g, 'all_sym', {}).get(i) or g.sym.get(i) or {}).get('line') or 0) + unmod = sorted(((i, sig) for i, sig in P.unmodelled_entry(g, asked)), key=lambda x: (_at(x[0]), x[1], x[0])) unmod_name = next((((getattr(g, 'all_sym', {}).get(i) or g.sym.get(i) or {}).get('name')) for i, _ in unmod), None) unmod_grep = P.grep_for(g, unmod_name) if unmod_name else '' # a decoration on the declaration itself says a framework enters THIS member; one on its owner, or the owner's @@ -2935,7 +2940,10 @@ def main(argv): nfiles = len({g.sym[m]['file'] for m in reached}) if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") - ext = sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])}, key=lambda x: (x[0], x[1])) + # (file, line) is not a total order: one line can name the method twice (`run` and `Step.run`), and the ties then + # fell back to the SET's order, a function of string hashing, so two runs printed the same rows in two orders. + # The name and how it was matched make it total. + ext = sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])}) # A COMMON WORD WRITTEN AS A WORD IS NOT A BINDING (ax_nonsource.py, PROSE): `note` in a template's text, `build` # in a CI file, a Maven `validate`. Those rows leave `ext` here, so nothing below -- the section, # its header count, `next:`, the --delete verdict -- reads them as a place a rename breaks; they are counted with diff --git a/tests/README.md b/tests/README.md index abe38c76..7c41425e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,11 @@ One check needs no graph and is its own script: python3 tests/graph_verb.py `axiomcode graph` draws the existing graph and rebuilds a stale one with the flags it was indexed with; no rebuild path (refresh, repair, bare index) solves a language an explicit --lang left out (builds real graphs, needs the engine) + python3 tests/repo_arg.py a repository argument that is not there (every verb, the MCP tools' repo=, --src) + is an error naming it, with nothing built in the working directory; an absolute + --src is used as given (indexes a small Python project, so it needs the engine) + python3 tests/row_order.py rows tied on one file line print in one order: the same answers, byte for byte, + under several hash seeds (indexes small cases, so it needs the engine) python3 tests/indexed_tree.py changed compares against the tree the graph was indexed from, so an index taken with uncommitted edits reports only later edits (#1222) python3 tests/mcp.py `axiomcode mcp` answers initialize, lists every tool and runs one, diff --git a/tests/repo_arg.py b/tests/repo_arg.py new file mode 100644 index 00000000..d47a84ce --- /dev/null +++ b/tests/repo_arg.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""tests/repo_arg.py — a repository argument that is not there is an error, never the working directory. + +`axiomcode impact ` took a naming no directory for a second target, answered for the working +directory instead, and on one with no graph started a full build of it (a parent of many projects, for minutes). The +other query verbs and the MCP tools' repo parameter did the same. `index --src ` joined the absolute +path onto the repository, a tree that is not there. + +Checks, each run from a working directory with no graph, so a fall-back to it would build one there: + every verb (index, context, path, impact, changed, test-impact, graph) given a missing repository exits non-zero, + names the path, and builds nothing: no .axiomcode appears in the working directory + index --src and --src are refused the same way + a verb script run directly (not through the dispatcher) with a missing repository is refused and builds nothing + the MCP tools refuse a missing repo= before anything runs, and pass an existing one through + near misses, which must keep working: an existing repository answers; a target written like a file (`m.py:1`, + `pkg/m.py`) is still a target, not a repository; index --src builds the + subtree, recorded relative to the repository + + python3 tests/repo_arg.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +AX = os.path.join(SCRIPTS, 'axiomcode') +bad = [] + + +def ax(args, cwd, env=None): + return subprocess.run(['bash', AX] + args, cwd=cwd, capture_output=True, text=True, env=env, timeout=600) + + +def no_graph(where, what): + if os.path.exists(os.path.join(where, '.axiomcode')): bad.append(f"{what}: a .axiomcode appeared in {where}") + + +tmp = os.path.realpath(tempfile.mkdtemp(prefix='ax-repo-arg-')) +try: + cwd = os.path.join(tmp, 'cwd'); os.makedirs(cwd) + with open(os.path.join(cwd, 'c.py'), 'w') as f: f.write("def here():\n return 0\n") + repo = os.path.join(tmp, 'repo'); os.makedirs(os.path.join(repo, 'sub')) + with open(os.path.join(repo, 'sub', 'm.py'), 'w') as f: f.write("def foo():\n return 1\n\ndef bar():\n return foo()\n") + with open(os.path.join(repo, 'top.py'), 'w') as f: f.write("def outside():\n return 2\n") + missing = os.path.join(tmp, 'not-there') + + # 1. a missing repository, every verb: refused, named, nothing built in the working directory or at the path + for args in (['impact', 'foo', missing], ['impact', 'foo', 'bar', missing], ['impact', 'foo', './not-there'], + ['context', 'how does foo work', missing], ['context', 'how does foo work', 'not-there'], + ['path', 'bar', 'foo', missing], ['path', 'bar', 'foo', 'not-there'], + ['changed', missing], ['test-impact', missing], ['graph', missing], + ['index', missing], ['index', 'not-there'], + ['index', '--src', missing], ['index', '--src', 'not-there'], ['index', '--lang', 'python', '--src', missing]): + r = ax(args, cwd) + named = args[-1] if args[-1] != '--src' else args[-2] + if r.returncode == 0: bad.append(f"{' '.join(args)}: exit 0 for a missing repository") + if named not in r.stderr: bad.append(f"{' '.join(args)}: the message does not name {named!r}: {r.stderr.strip()[:200]!r}") + if 'no such directory' not in r.stderr: bad.append(f"{' '.join(args)}: no 'no such directory' in {r.stderr.strip()[:200]!r}") + no_graph(cwd, ' '.join(args)) + if os.path.exists(missing): bad.append(f"{' '.join(args)}: created {missing}") + + # 2. a verb script run directly, with the dispatcher's autobuild on: still refused, nothing built + env = dict(os.environ, AXIOMCODE_AUTOBUILD='1') + for verb, args in (('axiomcode-context', ['how does foo work', missing]), ('axiomcode-path', ['bar', 'foo', missing])): + r = subprocess.run([sys.executable, os.path.join(SCRIPTS, verb)] + args, cwd=cwd, capture_output=True, text=True, env=env, timeout=120) + if r.returncode == 0 or missing not in r.stderr: bad.append(f"{verb} {args}: not refused (exit {r.returncode}): {r.stderr.strip()[:200]!r}") + no_graph(cwd, verb) + + # 3. the MCP tools: a missing repo= is refused before anything runs; an existing one, and the default, pass through + sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp')) + import server + seen = [] + real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') + try: + calls = {'axiomcode_index': lambda r: server.axiomcode_index(repo=r), + 'axiomcode_context': lambda r: server.axiomcode_context('how', repo=r), + 'axiomcode_path': lambda r: server.axiomcode_path('a', 'b', repo=r), + 'axiomcode_impact': lambda r: server.axiomcode_impact(['foo'], repo=r), + 'axiomcode_changed': lambda r: server.axiomcode_changed(repo=r), + 'axiomcode_test_impact': lambda r: server.axiomcode_test_impact(repo=r), + 'axiomcode_graph': lambda r: server.axiomcode_graph(repo=r)} + for name, call in calls.items(): + seen.clear() + try: + call(missing); bad.append(f"{name}(repo=): not refused") + except Exception as e: + if missing not in str(e): bad.append(f"{name}(repo=): the error does not name the path: {e}") + if seen: bad.append(f"{name}(repo=): ran {seen[0]} anyway") + seen.clear(); call(repo) + if not seen or repo not in seen[0]: bad.append(f"{name}(repo=): did not run with it: {seen}") + seen.clear(); call('.') + if not seen: bad.append(f"{name}(repo='.'): did not run") + finally: + server.run = real + + # 4. near misses. An absolute --src inside the repository builds that subtree, recorded relative to the repository + r = ax(['index', repo, '--lang', 'python', '--src', os.path.join(repo, 'sub')], cwd) + if r.returncode: bad.append(f"index --src : exit {r.returncode}: {(r.stderr or r.stdout)[-300:]}") + else: + import json, sqlite3 + db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') + names = {n for (n,) in sqlite3.connect(db).execute("SELECT name FROM symbols")} if os.path.exists(db) else set() + if 'foo' not in names: bad.append(f"index --src : foo (under sub/) is not in the graph: {sorted(names)[:10]}") + if 'outside' in names: bad.append("index --src : top.py, outside the subtree, was indexed") + t = json.load(open(os.path.join(repo, '.axiomcode', 'out', 'files.json'))) + if t.get('src_arg') != 'sub': bad.append(f"index --src : recorded src_arg {t.get('src_arg')!r}, want 'sub'") + no_graph(cwd, 'index --src ') + # an existing repository answers; targets written like files are targets, not a repository + for args, want in ((['impact', 'foo', repo], 'bar'), (['impact', 'sub/m.py:1', repo], 'bar'), + (['impact', 'foo', 'sub/m.py:1', repo], 'bar'), (['path', 'bar', 'foo', repo], 'foo'), + (['context', 'how does bar reach foo', repo], 'm.py')): + r = ax(args, cwd) + if r.returncode or want not in r.stdout: bad.append(f"{' '.join(args)}: exit {r.returncode}, {want!r} not in the answer: {(r.stdout + r.stderr)[-300:]!r}") + if 'no such directory' in r.stderr: bad.append(f"{' '.join(args)}: refused an existing repository") + no_graph(cwd, 'queries on an existing repository') + # and from inside the repository, a relative target path is a target (it does not exist as a directory either) + r = ax(['impact', 'foo', 'sub/m.py'], repo) + if 'no such directory' in r.stderr: bad.append(f"impact foo sub/m.py: a file-like target was taken for a repository: {r.stderr.strip()[:200]!r}") +finally: + shutil.rmtree(tmp, ignore_errors=True) + +for b in bad: print(f"FAIL {b}") +print(f"repo_arg: {'ok' if not bad else f'{len(bad)} failure(s)'}") +sys.exit(1 if bad else 0) diff --git a/tests/row_order.py b/tests/row_order.py new file mode 100644 index 00000000..04a5dfff --- /dev/null +++ b/tests/row_order.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""tests/row_order.py — rows tied on one file line print in one order, whatever the hash seed. + +impact's "bound from outside the source" rows were sorted by (file, line) alone. One line can name a method twice (`run` +and `Step.run` in one case.json line), and the tie then fell back to the order of the set the rows came from, which is +a function of string hashing. The same check, run twice on the same graph, printed the same rows in two orders. + +Each fixture is a case of tests/cases, copied to a scratch directory and indexed once. Every question is asked under +several PYTHONHASHSEED values (the only thing that differs between two runs), and the answers must be byte-identical. +The control keeps it from passing on nothing: each answer must hold two [text] rows on one file:line, the tie itself. + +Symbol ids hash the index directory, so an order taken from ids differs between two copies of one tree. A name declared +in several files named its first ID as the example to target, and the unmodelled-entry signals ("NOT CHECKED: @Mapper on +its type ...") came in id order. One case is indexed at two paths and must give the same answer, paths aside; its +control is that the answer holds both the example and at least two signals. + + python3 tests/row_order.py +""" +import os, re, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +FIXTURES = [('java', 'a-capped-fanout-is-a-sample', 'src', [['impact', 'Step.run'], ['impact', 'Step.run', '--delete']]), + ('python', 'unmodelled-entry-not-local', None, [['impact', 'Nightly.run']])] +SEEDS = ['0', '1', '2', '3', '4', '5'] +bad = []; asked = 0 +tmp = os.path.realpath(tempfile.mkdtemp(prefix='ax-row-order-')) +try: + for lang, case, src, questions in FIXTURES: + d = os.path.join(tmp, lang, case) + shutil.copytree(os.path.join(ROOT, 'tests', 'cases', lang, case), d, ignore=shutil.ignore_patterns('.axiomcode')) + r = subprocess.run(['bash', AX, 'index', d, '--lang', lang] + (['--src', src] if src else []), capture_output=True, text=True) + if r.returncode: bad.append(f"{lang}/{case}: index failed: {(r.stderr or r.stdout)[-300:]}"); continue + for q in questions: + outs = {} + for s in SEEDS: + r = subprocess.run(['bash', AX] + q + [d], capture_output=True, text=True, env=dict(os.environ, PYTHONHASHSEED=s)) + outs[s] = r.stdout + asked += 1 + first = outs[SEEDS[0]] + differ = [s for s in SEEDS if outs[s] != first] + if differ: bad.append(f"{lang}/{case} {' '.join(q)}: the answer under PYTHONHASHSEED={','.join(differ)} differs from seed {SEEDS[0]}") + at = re.findall(r'(?m)^\s*\[text\] (\S+:\d+)\s', first) + if not any(at.count(x) > 1 for x in at): + bad.append(f"{lang}/{case} {' '.join(q)}: no two [text] rows on one file:line, so the order was not tested: {at}") + # the same tree at two paths + lang, case, q = 'java', 'mybatis-statement-in-its-namespace', ['impact', 'findById', '--page', 'all'] + answers = [] + for where in ('one', 'two-at-another-path'): + d = os.path.join(tmp, where, case) + shutil.copytree(os.path.join(ROOT, 'tests', 'cases', lang, case), d, ignore=shutil.ignore_patterns('.axiomcode')) + r = subprocess.run(['bash', AX, 'index', d, '--lang', lang], capture_output=True, text=True) + if r.returncode: bad.append(f"{lang}/{case} at {where}: index failed: {(r.stderr or r.stdout)[-300:]}"); break + r = subprocess.run(['bash', AX] + q + [d], capture_output=True, text=True) + answers.append(r.stdout.replace(d, '')) + if len(answers) == 2: + asked += 1 + if answers[0] != answers[1]: bad.append(f"{lang}/{case} {' '.join(q)}: the answer differs between two paths of one tree") + sig = re.search(r'NOT CHECKED: ([^—]*)', answers[0]) + if 'target one by file:line (e.g.' not in answers[0] or not sig or sig.group(1).count(';') < 1: + bad.append(f"{lang}/{case} {' '.join(q)}: no example target or fewer than two unmodelled-entry signals, so the order was not tested") +finally: + shutil.rmtree(tmp, ignore_errors=True) + +for b in bad: print(f"FAIL {b}") +print(f"row_order: {asked} question(s), under {len(SEEDS)} seeds or at two paths, {'ok' if not bad else f'{len(bad)} failure(s)'}") +sys.exit(1 if bad or not asked else 0) From bba621adbd93326e12d578567b2c1d6c0450b2d7 Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 19:29:47 -0700 Subject: [PATCH 072/258] engines: build the Linux binaries in manylinux_2_28, and fail the build on a glibc above 2.28 Every Linux engine since the first release needed GLIBC_2.38: they were compiled on ubuntu-24.04 (glibc 2.39), and a binary runs only where its build machine's glibc or a newer one is installed. They would not start on Ubuntu 22.04, Debian 12, RHEL/Rocky 9 or Amazon Linux 2023: the official python and node Docker images, most CI and most servers. `axiomcode index` there parsed the whole tree and then failed with "GLIBC_2.38 not found". The compile now runs inside manylinux_2_28 (glibc 2.28, GCC 14) on the same runners, and a new step fails the build if any Linux binary asks for a newer glibc. Built this way the engines need glibc 2.25, link no libstdc++, and run on Ubuntu 20.04, Debian 11, Rocky 8 and Ubuntu 22.04. The cache key already hashes this file, so no binary built on the old runner is restored. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/build-engines.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index 9ebdd1fb..bd8b39bf 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -104,8 +104,8 @@ jobs: fail-fast: false matrix: target: - - { os: ubuntu-24.04, platform: linux-x64 } - - { os: ubuntu-24.04-arm, platform: linux-arm64 } + - { os: ubuntu-24.04, platform: linux-x64, manylinux: quay.io/pypa/manylinux_2_28_x86_64 } + - { os: ubuntu-24.04-arm, platform: linux-arm64, manylinux: quay.io/pypa/manylinux_2_28_aarch64 } - { os: windows-2025, platform: win32-x64 } runs-on: ${{ matrix.target.os }} steps: @@ -119,10 +119,19 @@ jobs: path: engines key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} restore-keys: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}- - - name: Compile every language (Linux) + # THE GLIBC FLOOR. A binary links against the glibc of the machine that built it, and runs only where that + # glibc or a newer one is installed. Built on ubuntu-24.04 (glibc 2.39) every engine needed GLIBC_2.38, so it + # would not start on Ubuntu 22.04, Debian 12, RHEL 9 or Amazon Linux 2023: the official python and node + # Docker images, most CI and most servers. The compile therefore runs inside manylinux_2_28 (glibc 2.28, + # the floor Python wheels use), and the step after it fails the build if any binary asks for more. + - name: Compile every language (Linux, manylinux_2_28) if: startsWith(matrix.target.platform, 'linux') + env: + MANYLINUX: ${{ matrix.target.manylinux }} run: | set -e + docker run --rm -v "$PWD:/w" -w /w -e LANGUAGES="$LANGUAGES" -u "$(id -u):$(id -g)" "$MANYLINUX" bash -ec ' + c++ --version | head -1; ldd --version | head -1 for lang in $LANGUAGES; do if cmp -s "gen/$lang.id" "engines/$lang/ENGINE_ID"; then echo "$lang: cached, rules unchanged"; continue; fi mkdir -p "engines/$lang" @@ -136,7 +145,19 @@ jobs: c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "$cpp" -o "engines/queries/axiomcode-query-$q" cp "gen/queries/$q.id" "engines/queries/$q.id" done + ' ls -la engines/*; ldd engines/java/axiomcode-engine-java || true + - name: Linux binaries need no glibc newer than 2.28 + if: startsWith(matrix.target.platform, 'linux') + run: | + set -e + bad=0 + for f in engines/*/axiomcode-*; do + need="$(objdump -T "$f" | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -V | tail -1)" + echo "$f needs glibc $need" + if [ "$(printf '%s\n2.28\n' "$need" | sort -V | tail -1)" != 2.28 ]; then echo "::error::$f needs glibc $need (> 2.28)"; bad=1; fi + done + exit $bad - uses: ilammy/msvc-dev-cmd@v1 if: startsWith(matrix.target.platform, 'win32') with: { arch: x64 } From a0ac09ee0b27fbd3274f59b1f6dd7b7e0e9712f7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:30:29 -0700 Subject: [PATCH 073/258] context: a type entry point starts the flow at its own methods, and an empty flow lists what was found A question naming classes got entry points that were all types; a type is not a call, so the flow had no root and, with the code shown, the answer printed only its terms and a count of files it did not list. Type entry points now start the flow at their own methods (after the callable ones), a root with a body comes before a signature unless the task names it, and a flow that is still empty says why and prints the entry points and files. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-context | 47 ++++++++++++- tests/cases/typescript/explain-flow/case.json | 35 ++++++++++ .../typescript/explain-flow/src/ledger.ts | 67 +++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/explain-flow/src/ledger.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c..66e92ca2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -842,6 +842,32 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): FLOW_SOURCE_STEPS, FLOW_SOURCE_LINES, FLOW_SOURCE_CHARS = 16, 8, 3800 # code for the earliest steps until the budget, then names only +def type_members(g, types, scored): + """the methods declared inside each of `types` (symbols with no method of their own), in the order the types come: + within one type, the member matching the task best first, then one that calls something (a flow to show), then + the first declared. A member is a method whose span lies inside the type's span in the type's file, which reads the + same in every language.""" + methods, kinds = collections.defaultdict(list), collections.defaultdict(list) + for x, sy in g.sym.items(): + if not sy.get('line') or sy.get('is_test'): continue + if sy.get('method_id'): + if not is_synthetic(sy.get('name')): methods[sy.get('file') or ''].append(x) + elif sy.get('type_id'): + kinds[sy.get('file') or ''].append((sy['line'], sy.get('end_line') or sy['line'])) + out = [] + for t in types: + ty = g.sym.get(t) or {} + if ty.get('method_id') or not ty.get('type_id') or not ty.get('line'): continue + f, a, b = ty.get('file') or '', ty['line'], ty.get('end_line') or ty['line'] + # a member of a type nested inside this one belongs to that type, not to this one + nested = [(c, d) for c, d in kinds.get(f, ()) if a < c and d <= b and (c, d) != (a, b)] + inner = [x for x in methods.get(f, ()) if a <= g.sym[x]['line'] <= b + and not any(c <= g.sym[x]['line'] <= d for c, d in nested)] + inner.sort(key=lambda x: (bodiless(g, x), -(scored.get(x, (0, []))[0]), x not in g.callers_ids, g.sym[x]['line'])) + out += [x for x in inner[:1] if x not in out] + return out + + def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): if not steps: return False print("\nhow it runs — the call flow from " + ("where you started it" if chosen else "this task's entry points") @@ -1102,14 +1128,31 @@ def main(argv): file=sys.stderr) if not roots_: # no --from: the entry points this verb already chose, the callable ones, first FLOW_ROOTS of them - roots_ = [s_ for s_, _t, _sc in seeds if (g.sym.get(s_) or {}).get('method_id')][:FLOW_ROOTS] + # A TYPE is not a call: a question naming classes ("how is FooRepository chosen, how does CachedFoo cache") + # got entry points that were all types, the flow had no root, and the answer came back with nothing in it. + # So a type entry point starts the flow at its own methods, after the callable entry points; and a root + # with a body comes before a signature, which is a flow of one step that goes nowhere — unless the task + # NAMES the signature, which makes it the subject wherever it is declared. + cands = [s_ for s_, _t, _sc in seeds if (g.sym.get(s_) or {}).get('method_id')] + cands += [x for x in type_members(g, [s_ for s_, _t, _sc in seeds], scored) if x not in cands] + asked = {s_ for s_, _why in named} + roots_ = sorted(cands, key=lambda x: x not in asked and bodiless(g, x))[:FLOW_ROOTS] roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS) RESULT['flow'] = [{'step': k + 1, 'depth': d, 'name': g.disp(x), 'at': g.loc(x), 'called_at_line': (via[0] if via else None), 'certainty': (ax_edges.direct_cert(via[1]) if via else 'entry'), 'repeat_of': again, 'unresolved': [f"{nm} L{l}" for l, nm in gaps.get(x, ())[:4]], 'leaves_graph': (marks.get(x, []) if again is None else [])} for k, (d, x, via, again) in enumerate(steps)] - print_flow(g, roots, steps, gaps, bool(starts), show_source, marks) + if not print_flow(g, roots, steps, gaps, bool(starts), show_source, marks): + # the entry points and files were held back because the flow was to BE the answer; with no flow they are + # the answer again, so they are printed, and the reader is told why there is no flow + print("\nno call flow: " + ("none of the entry points is a callable, or a type declaring one" + if not roots_ else "the entry points call nothing the graph resolved") + + " — here are the entry points and the files around them instead; `--from ` starts a flow") + print(f"\nentry points ({len(seeds)}):") + for sid, term, sc in seeds: + print(f" {g.disp(sid):46.46} {g.loc(sid):34.34} {term or 'best overall match'}") + budget = max(budget, 5) depth = neighbourhood(g, [s for s, _t, _sc in seeds]) # A file can match the task by NAME and be reachable by no call at all — an enum of flags, a constants diff --git a/tests/cases/typescript/explain-flow/case.json b/tests/cases/typescript/explain-flow/case.json index acabc5b8..3964c5bd 100644 --- a/tests/cases/typescript/explain-flow/case.json +++ b/tests/cases/typescript/explain-flow/case.json @@ -108,6 +108,41 @@ "page 1 of", "helper20" ] + }, + { + "why": "a question of three clauses naming classes gets entry points that are types and one interface signature; a type starts the flow at its own methods, and a root with a body comes before a signature, so the answer carries code instead of a one-step flow that goes nowhere", + "run": [ + "context", + "how is the ledger Store chosen and how does CachedStore cache entries; where does the audit journal get written", + "--source" + ], + "want": [ + "how it runs —", + " 1 CachedStore.load ", + "| const hit = this.hot.get(key);", + "AuditJournalRelay.relay" + ], + "avoid": [ + " 1 AuditJournal.append " + ] + }, + { + "why": "when every entry point is a type with no method there is no flow; the answer says so and lists the entry points and files it counted, instead of a count of files it does not show", + "run": [ + "context", + "how is the QuotaShape chosen and how does QuotaBudget hold the spent amount; where does the window get read", + "--source" + ], + "want": [ + "no call flow:", + "entry points (2):", + "QuotaShape ", + "where the work is (1 file(s)", + "ledger.ts" + ], + "avoid": [ + "lie within 3 hops of the entry points; `--budget N` lists them" + ] } ] } \ No newline at end of file diff --git a/tests/cases/typescript/explain-flow/src/ledger.ts b/tests/cases/typescript/explain-flow/src/ledger.ts new file mode 100644 index 00000000..41c5b22a --- /dev/null +++ b/tests/cases/typescript/explain-flow/src/ledger.ts @@ -0,0 +1,67 @@ +// A question that names only TYPES: "how is the ledger Store chosen, how does CachedStore keep entries, where does +// the Journal get written". No entry point is a callable, so the flow has to start at the types' own methods. + +export interface Store { + load(key: string): Promise; + keep(key: string, value: string): Promise; +} + +export class MemoryStore implements Store { + private readonly rows = new Map(); + async load(key: string): Promise { + return this.rows.get(key); + } + async keep(key: string, value: string): Promise { + this.rows.set(key, value); + } +} + +export interface AuditJournal { + append(line: string): void; +} + +export class InMemoryAuditJournal implements AuditJournal { + private readonly lines: string[] = []; + append(line: string): void { + this.lines.push(line); + } +} + +export class AuditJournalRelay { + constructor(private readonly journal: AuditJournal) {} + relay(lines: string[]): void { + for (const line of lines) this.journal.append(line); + } +} + +export class CachedStore implements Store { + private readonly hot = new Map(); + constructor(private readonly inner: Store, private readonly journal: AuditJournal) {} + async load(key: string): Promise { + const hit = this.hot.get(key); + if (hit !== undefined) return hit; + const value = await this.inner.load(key); + if (value !== undefined) this.remember(key, value); + return value; + } + async keep(key: string, value: string): Promise { + this.remember(key, value); + this.journal.append(`keep ${key}`); + await this.inner.keep(key, value); + } + private remember(key: string, value: string): void { + this.hot.set(key, value); + } +} + +// Types with no method at all: a question naming only these has no flow anywhere, and the answer must say so and +// still list what it found. +export interface QuotaShape { + readonly limit: number; + readonly window: number; +} + +export interface QuotaBudget { + readonly shape: QuotaShape; + readonly spent: number; +} From dd61cf7ac1fbf1d81a9f43cebb8769cb9ca65e6c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:51:27 -0700 Subject: [PATCH 074/258] impact, hooks: a caller is its declaration, not its display name Every unnamed function carries one display (, , , ) and every module body of a basename another. Keyed by that name, file:line on an arrow answered for every arrow, an edit inside an arrow came back as a removed , and the hook's fast path merged all same-named callers into one row located at whichever came first. - the unnamed-function names of every front end are never a target on their own - the fast path keys callers by id and locates each at its lowest call site, as the rules do - the parity check compares direct rows with their locations - a JavaScript case for arrows and function expressions, with a named-function control Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-path | 8 ++- .../skills/axiomcode/scripts/graph_sql.py | 72 +++++++++++++------ .../lambda-is-named-by-its-place/case.json | 38 ++++++++++ .../new-in-arrow.txt | 18 +++++ .../new-in-function-expression.txt | 18 +++++ .../new-in-one-line-function.txt | 18 +++++ .../new-named.txt | 18 +++++ .../lambda-is-named-by-its-place/new-top.txt | 18 +++++ .../lambda-is-named-by-its-place/old.txt | 18 +++++ .../lambda-is-named-by-its-place/src/lib.js | 18 +++++ .../lambda-is-named-by-its-place/src/other.js | 3 + .../src/test/lib.test.js | 5 ++ .../src/test/other.test.js | 4 ++ tests/fastpath.py | 7 +- 14 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/case.json create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/old.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/other.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133..fa1d805f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -77,9 +77,11 @@ BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIG 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} # of those, the ones a call through a VALUE lands on: a function type or a bare call signature, not a member of an interface FUNCTION_TYPE_KINDS = {'FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_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 = {''} +# the names a front end gives every unnamed function it declares: `` (Python and C#), `` (C# +# `delegate (…) { … }`), `` and `` (JavaScript and TypeScript); Java declares none. A name +# that says nothing about WHICH one, so it is never a target on its own (G.lambda_label, G.lambda_target): taken as +# one, an edit inside one arrow was every arrow of the repository, and a body edit came back as "removed " +LAMBDA_NAMES = {'', '', '', ''} # what a front end synthesises ON a field's line that is the field's own, never a callable written there (G.decl_at_line): # the node that runs a class's field initializers, and the unnamed function an initializer holds (`cb = wrap(() => …)`), # which JavaScript and TypeScript name `` / `` where Python and C# say `` diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index ce5ae73f..549f6ebd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -187,28 +187,40 @@ def _at(f_, l_): 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, 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 + # A CALLER IS ITS ID, NOT ITS DISPLAY. Every unnamed function of a file carries one display (``, + # ``), and so does every module body of one basename (`app.`): keyed by display, the arrow in a + # test and the arrow in the source that really calls this were one row, located at whichever came first in the + # table ("called by event-bus.js:52" for a caller in a test file) + _reads = collections.defaultdict(set); _disp = {}; _sites = collections.defaultdict(list) + for c, d, tier, sid in q(f"""SELECT DISTINCT s.id, 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(ax_edges.STUB_TIER if sid in stubs else tier)) + cert = ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier) + _reads[c].add(cert); _disp[c] = d + if sid: _sites[c].append((cert, sid)) # …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: # it does not enter the walk below. if 'ext_framework_edge' in _tables(con): - for (d,) in q(f"""SELECT DISTINCT s.display FROM ext_framework_edge f JOIN symbols s ON s.id=f.c0 + for c, d in q(f"""SELECT DISTINCT s.id, s.display FROM ext_framework_edge f JOIN symbols s ON s.id=f.c0 WHERE f.c1 IN ({ph}) AND f.c0 <> f.c1""", ids): - _reads[d].add('framework') - read_cert = {d: ax_edges.best_cert(cs) for d, cs in _reads.items()} - reads = sorted(_reads) + _reads[c].add('framework'); _disp[c] = d + read_cert = {c: ax_edges.best_cert(cs) for c, cs in _reads.items()} # by caller id + # where each caller is located: its call sites of its surest certainty, as the rules pick (lowest of those) + sites = {c: [s_ for ct, s_ in v if ct == read_cert[c]] for c, v in _sites.items()} + read_ids = sorted(_reads, key=lambda c: (_disp[c] or '', c)) + reads = [_disp[c] for c in read_ids] # reads / uses it, by name: a site naming this method whose receiver the engine could not type. The parser # records callee_name and the bundle indexes it, so this is a lookup and not an inference. short = target.rsplit('.', 1)[-1] if at_line(target.strip()): # file:line: the name its declaration carries short = (q("SELECT name FROM symbols WHERE id=?", (ids[0],)).fetchone() or [short])[0] - 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=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) + _bn = {} + for c, d, sid in q("""SELECT DISTINCT s.id, s.display, cs.id 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=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,)): + if c in _reads: continue + _bn[c] = d; sites.setdefault(c, []).append(sid) + byname_ids = sorted(_bn, key=lambda c: (_bn[c] or '', c)); byname = [_bn[c] for c in byname_ids] # 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 @@ -276,10 +288,12 @@ def _at(f_, l_): if param: # the rules put the declaring method in `direct` as "declares it" — it is the declaration the edit # is inside, so omitting it under-reports by the one row the caller is certain to care about - own = sorted({r[0] for r in q(f"SELECT display FROM symbols WHERE id IN ({ph})", ids) if r[0]}) - reads = sorted(set(reads) | set(own)) - byname = sorted(set(byname) - set(reads)) - return dict(target=target, overloads=len(ids), contract=contract, reads=reads, read_cert=read_cert, byname=byname, + own = {r[0]: r[1] for r in q(f"SELECT id, display FROM symbols WHERE id IN ({ph})", ids) if r[1]} + _disp.update(own) + read_ids = sorted(set(read_ids) | set(own), key=lambda c: (_disp[c] or '', c)); reads = [_disp[c] for c in read_ids] + byname_ids = [c for c in byname_ids if c not in own]; byname = [_bn[c] for c in byname_ids] + return dict(target=target, overloads=len(ids), contract=contract, reads=reads, read_ids=read_ids, read_cert=read_cert, + byname=byname, byname_ids=byname_ids, sites=sites, reached=max(0, n - len(ids)), tests=t, test_names=test_names, test_ids=sorted(tset), depth=depth) finally: con.close() @@ -321,19 +335,31 @@ def impact_shaped(repo, target, depth=DEPTH, tests_shown=3, file=None): # only the rows that get PRINTED need a location: the formatter shows 4 per line. Resolving file:line for # every display cost 5.7 s against 1.7 s on a target with 739 callers, to fill in text nobody sees. SHOWN = 8 - every = r['contract'][:SHOWN] + r['reads'][:SHOWN] + r['byname'][:SHOWN] at = {} - if every: - for i in range(0, len(every), 400): - chunk = every[i:i + 400] - for d, f, ln in q(f"SELECT display, file, line FROM symbols WHERE display IN ({','.join('?'*len(chunk))})", chunk): - if d not in at and f: at[d] = f"{f}:{ln or 0}" - def mk(d, role, cert): + # a contract row is a display (an override has a name of its own); a caller is located by its id, since an + # unnamed function or a module body shares its display with every other one (impact above) + for d, f, ln in (q(f"SELECT display, file, line FROM symbols WHERE display IN ({','.join('?' * len(r['contract'][:SHOWN]))})", + r['contract'][:SHOWN]) if r['contract'] else ()): + if d not in at and f: at[d] = f"{f}:{ln or 0}" + # …at its LOWEST CALL SITE, the line the rules print (axiomcode-impact: the first site, of the surest certainty), + # in the caller's own file (a site is written in its caller; Java stores the site's path absolute); a row with + # no site (a framework hand-off) at its declaration + ids_ = r['read_ids'][:SHOWN] + r['byname_ids'][:SHOWN] + decl = {i: (f, ln) for i, f, ln in (q(f"SELECT id, file, line FROM symbols WHERE id IN ({','.join('?' * len(ids_))})", ids_) if ids_ else ()) if f} + by_site = {s_: i for i in ids_ for s_ in r['sites'].get(i, ())} + low = {} + for j in range(0, len(by_site), 400): + chunk = list(by_site)[j:j + 400] + for sid, ln in q(f"SELECT id, start_line FROM call_sites WHERE id IN ({','.join('?' * len(chunk))})", chunk): + if ln and int(ln) > 0: low[by_site[sid]] = min(int(ln), low.get(by_site[sid], int(ln))) + at_id = {i: f"{f}:{low.get(i) or ln or 0}" for i, (f, ln) in decl.items()} + def mk(d, i, role, cert): why = ('calls a method of this name (receiver not typed)' if cert == 'by name' else ax_edges.DIRECT_WHY.get(cert, 'calls it')) - return dict(display=d, role=role, certainty=cert, at=at.get(d, ''), why=why) + return dict(display=d, role=role, certainty=cert, at=at_id.get(i, ''), why=why) rc = r.get('read_cert') or {} - direct = [mk(d, 'uses', rc.get(d, 'resolved')) for d in r['reads']] + [mk(d, 'uses', 'by name') for d in r['byname']] + direct = [mk(d, i, 'uses', rc.get(i, 'resolved')) for d, i in zip(r['reads'], r['read_ids'])] + \ + [mk(d, i, 'uses', 'by name') for d, i in zip(r['byname'], r['byname_ids'])] tests = [dict(display=d, owner=d.rsplit('.', 1)[0] if '.' in d else d, name=d.rsplit('.', 1)[-1]) for d in r.get('test_names', [])[:tests_shown]] tests += [dict(display='', owner='', name='')] * max(0, r['tests'] - len(tests)) diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/case.json b/tests/cases/javascript/lambda-is-named-by-its-place/case.json new file mode 100644 index 00000000..32a35344 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/case.json @@ -0,0 +1,38 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "an edit inside an arrow in a method body is a body change of THAT METHOD: every arrow carries one name, , which is never the declaration an edit is charged to (its header was looked for by that name, not found, and the arrow came back removed)", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-arrow.txt", "--file", "src/lib.js"], + "want": ["body Orders.totals", "→ impact src/lib.js:4"], + "avoid": ["", "impact src/lib.js:14"], + "avoid": ["removed", "impact "]}, + {"why": "control: an edit to a named function next to the arrows stays that function's", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-named.txt", "--file", "src/lib.js"], + "want": ["body bump"], + "avoid": ["wire", ""], + "avoid": ["change: ", ""]}, + {"why": "and a function expression the same way", + "run": ["impact", "src/lib.js:6"], + "want": ["change: Orders.totals."], + "avoid": ["change: ", ""]}, + {"why": "the name printed for an arrow is a target that answers for that arrow alone", + "run": ["impact", "Orders.totals."], + "want": ["change: Orders.totals."], + "avoid": ["declarations", "change: "]}]} diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt new file mode 100644 index 00000000..e73c9f1d --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x >= 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt new file mode 100644 index 00000000..1df20e43 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 3; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt new file mode 100644 index 00000000..bee5c5ac --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x) + 1); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 00000000..07de6ebe --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 2; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt new file mode 100644 index 00000000..abddd69c --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length + 0); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/old.txt b/tests/cases/javascript/lambda-is-named-by-its-place/old.txt new file mode 100644 index 00000000..8dee6e60 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js new file mode 100644 index 00000000..8dee6e60 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js new file mode 100644 index 00000000..036378b7 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js @@ -0,0 +1,3 @@ +import { bump } from './lib.js'; + +export function wireOther(list) { return list.map((x) => bump(x)); } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js new file mode 100644 index 00000000..8e4be167 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js @@ -0,0 +1,5 @@ +import test from 'node:test'; +import { Orders, wire } from '../lib.js'; + +test('totals', () => { new Orders().totals([1, -1]); }); +test('wire', () => { wire([1]); }); diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js new file mode 100644 index 00000000..fec434b5 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js @@ -0,0 +1,4 @@ +import test from 'node:test'; +import { wireOther } from '../other.js'; + +test('wireOther', () => { wireOther([1]); }); diff --git a/tests/fastpath.py b/tests/fastpath.py index e0aa3b82..3da1f87b 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -76,10 +76,13 @@ def along_rows(d): return ({x['display'] for x in d.get('direct', []) if x.get('certainty') == ALONG} | {x['display'] for x in d.get(ALONG, [])}) +# A ROW IS A DECLARATION, NOT A NAME: `direct` is compared with where each row is. Every arrow of a file is ``, +# every module body of a basename `app.`; compared as a set of names, one row located at the first arrow in the +# table matched the rules' two rows at their own lines (the hook said "called by " at a test the caller is not in) def rels(d): d = d or {} return dict(contract=sorted({x['display'] for x in d.get('contract', [])}), - direct=sorted({x['display'] for x in graph_sql.hook_direct(d)}), + direct=sorted({(x['display'], x.get('at') or '') for x in graph_sql.hook_direct(d)}), reached=len(d.get('reached', [])), tests=len(d.get('tests', []))) # THE HOOK ON ONE EDIT, BOTH PATHS. The comparison above is of the dicts; this is of what the hook PRINTS, because @@ -176,7 +179,7 @@ def main(argv=None): f"rule for: fast={sorted(fa)} rules={sorted(ra)}"); bad += 1; continue a, b = rels(fast), rels(j) # and neither path may list one of the rules' alongside rows as a dependent in a hook - listed = {p: sorted(ra & set(r['direct'])) for p, r in (('fast', a), ('rules', b))} + listed = {p: sorted(ra & {x for x, _ in r['direct']}) for p, r in (('fast', a), ('rules', b))} if any(listed.values()): print(f"FAIL {target!r}: the rules' `alongside` rows are listed as direct uses: {listed}"); bad += 1; continue if a != b: From f6f9249648253291b57cec3c009ef4e8d1545fec Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:23 -0700 Subject: [PATCH 075/258] evidence for the rows an answer is not sure of Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/changes.py | 8 +- plugins/axiomcode/mcp/server.py | 49 +- .../skills/axiomcode/scripts/ax_evidence.py | 582 ++++++++++++++++++ .../skills/axiomcode/scripts/ax_grep.py | 41 +- .../skills/axiomcode/scripts/axiomcode | 10 + .../axiomcode/scripts/axiomcode-context | 7 + .../skills/axiomcode/scripts/axiomcode-impact | 47 +- .../skills/axiomcode/scripts/axiomcode-path | 10 +- .../axiomcode/scripts/axiomcode-test-impact | 10 +- .../evidence-for-uncertain-rows/case.json | 88 +++ .../evidence-for-uncertain-rows/cs/Stock.cs | 19 + .../java/app/Cache.java | 20 + .../java/app/Pipeline.java | 10 + .../java/app/Store.java | 7 + .../evidence-for-uncertain-rows/js/client.js | 18 + .../evidence-for-uncertain-rows/js/store.js | 5 + .../evidence-for-uncertain-rows/py/billing.py | 33 + .../evidence-for-uncertain-rows/py/gateway.py | 8 + .../evidence-for-uncertain-rows/ts/bus.ts | 21 + .../evidence-for-uncertain-rows/ts/callers.ts | 26 + .../evidence-for-uncertain-rows/ts/ledger.ts | 5 + .../evidence-for-uncertain-rows/ts/orders.ts | 5 + tests/run.py | 12 +- 23 files changed, 1012 insertions(+), 29 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/case.json create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/js/client.js create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/js/store.js create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 44fba37d..a547f630 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -15,7 +15,7 @@ those, the tests) — ≤ 3 declarations per event, in parallel, a few lines each.""" import concurrent.futures, json, os, re, subprocess, sys, tempfile sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) -import graph_sql +import graph_sql, ax_evidence sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _host, _graphline, _where @@ -114,6 +114,12 @@ def impact(d): # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and # 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that # was the fast path answering, the rules answering, or the CLI having given up. + # with evidence on (AXIOMCODE_EVIDENCE, ax_evidence.py), the line that decides the strongest uncertain readers + if ax_evidence.on() and reads: + if not any(x.get('evidence') for x in reads): ax_evidence.impact_doc({'targets': j.get('targets') or [{'label': d['symbol']}], 'direct': reads}, cwd) + for x in [x for x in reads if (x.get('evidence') or {}).get('decider')][:2]: + dd = x['evidence']['decider'] + lines.append(f" decided: [{x['certainty']}] {x['at'].split('/')[-1]} ← {dd['at'].split('/')[-1]}: {dd['text'][:100]} [{dd['kind']}]") lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") if bodies: diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 4bfb91aa..8a91153d 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -122,10 +122,12 @@ def scripts_module(name): PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh'} + '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh', + '--drop': 'drop', '--exact': 'exact', '--alongside': 'alongside'} # a CLI switch that turns a parameter OFF: `--no-refresh` is refresh=False here NEGATED = {'--no-refresh'} -SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh'} +SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh', + '--exact', '--alongside'} PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # 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"(? 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.""" @@ -288,43 +308,48 @@ 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: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True) -> str: +@_doc +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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]`), capped with a count of the rest; limit=N lists more, 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) flow = source or explain or from_.strip() or _paged(page) or budget a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) paged = _paged(page); full = full or why a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--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 + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> str: +@_doc +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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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 (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or delete or _paged(page) 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 []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True) -> str: +@_doc +def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @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: Page = 1, full: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or _paged(page) a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py new file mode 100644 index 00000000..4a625ae2 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Evidence for the rows an answer is not sure of. One module, used by every verb and every surface. + +A row that is not an exact edge -- [by name], [one of a set], [registered], [text], a dispatch or key join -- is a +lead: the graph matched a NAME, or a set, and could not say which declaration the call reaches. Printed alone, the +reader has to open the file to decide it, and measured over the loops' traces about half of the answers that named +files were followed within three tool calls by a Read or a grep of one of them (55% when the answer had [by name] +rows). What decides such a row is almost always ONE line: where the call's receiver (or its dispatch key) gets its +value -- a parameter's annotation, a field declared or assigned in the constructor, a local, an import, the constant +a registration names. So each non-exact row carries two lines: + + call the line the call is written on (file:line and its trimmed text) + decider the line that decides what the receiver is, and its kind (param, field, local, assigned, import, key …) + +and `only_through`: how many callables and tests the answer reaches ONLY through that row, with the way to ask again +without it (`--drop `, or `--exact` for exact edges alone). Evidence is given for the five strongest +non-exact rows; the rest are counted. Exact rows get none, so an answer with no uncertain row does not grow. + +The switch: AXIOMCODE_EVIDENCE=on|off (the dispatcher sets it from --evidence / --no-evidence; the MCP server from its +`evidence` parameter; the hooks inherit it). OFF by default: with it off nothing here runs and every answer is the +answer it was. AXIOMCODE_DROP (comma-separated file:line or row ids), AXIOMCODE_EXACT=1 and AXIOMCODE_ALONGSIDE=1 are +the ask-again and list-alongside switches, set from --drop, --exact and --alongside. + +The decider is read from the source text, not from the graph: the graph has already said it could not type the +receiver, so what it knows is exactly what is missing. It is a heuristic over the file's own lines, per language, and +it says what KIND of line it found; when it finds none the row carries only its call line. +""" +import collections, os, re + +TOP = 5 # rows that get evidence; the rest are counted +TEXT = 150 # a line's text in --json, trimmed (a line is bounded at 160 characters) +PRINTED = 90 # and as printed under a row +UP = 250 # lines searched above a call for its receiver's parameter or local + +# a certainty that is an exact edge (or not a row about a call at all): no evidence +EXACT = {None, '', 'resolved', 'sound', 'entry', 'defines', 'defines (not a call)', 'must change', 'alongside', + 'stubs it', 'at import', 'decorator', 'test'} + + +def _env(k): + return (os.environ.get(k) or '').strip().lower() + + +def on(): + return _env('AXIOMCODE_EVIDENCE') in ('on', '1', 'true', 'yes') + + +def drops(): + return {x.strip() for x in (os.environ.get('AXIOMCODE_DROP') or '').split(',') if x.strip()} + + +def exact_only(): + return _env('AXIOMCODE_EXACT') in ('1', 'on', 'true', 'yes') + + +def list_alongside(): + return _env('AXIOMCODE_ALONGSIDE') in ('1', 'on', 'true', 'yes') + + +def is_exact(cert): + return cert in EXACT + + +def lang_of(f): + e = os.path.splitext(f or '')[1].lower() + if e in ('.py', '.pyi'): return 'python' + if e == '.java': return 'java' + if e == '.cs': return 'csharp' + if e in ('.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'): return 'ts' + return 'python' if not e else 'ts' + + +def trim(s): + s = (s or '').strip() + return s if len(s) <= TEXT else s[:TEXT - 1] + '…' + + +class Source: + """the lines of the repository's files, read once each""" + def __init__(self, repo): + self.repo, self.files = repo or '.', {} + + def lines(self, f): + 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 + return self.files[f] + + +def split_at(at): + f, _, n = (at or '').rpartition(':') + return (f, int(n)) if f and n.isdigit() else (None, None) + + +def simple(name): + """the name a call writes: `Owner.get (at x.ts:3)` → get, `Owner.m(int)` → m""" + n = (name or '').split(' (at ')[0].split('(')[0].strip() + n = re.split(r'[.#:$/]', n)[-1] if n else '' + return '' if n.startswith('<') else n + + +# ── the receiver of a call ───────────────────────────────────────────────────────────────────────────────────────────── +ID = r'[A-Za-z_$][\w$]*' +RECV = re.compile(r'((?:' + ID + r'(?:\(\s*\))?\s*(?:\?\.|!\.|\.|->)\s*)*' + ID + r'(?:\((?:[^()]|\([^()]*\))*\))?)\s*(?:\?\.|!\.|\.|->)\s*$') + + +def receiver(text, name): + """the receiver expression written before `.name(` on a line, or None for a bare call / no call of the name""" + for m in re.finditer(r'(?:\?\.|!\.|\.|->)\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', text): + r = RECV.search(text[:m.start()] + '.') + if r: return re.sub(r'\s+', '', r.group(1)) + return None + + +def parts(recv): + """`this.a.b()` → ('this', ['a', 'b()']); a separator inside parentheses does not split""" + ps, cur, depth, s, i = [], '', 0, recv or '', 0 + while i < len(s): + c = s[i] + if c == '(': depth += 1 + elif c == ')': depth -= 1 + if depth == 0 and (s.startswith('->', i) or c == '.' or (c in '?!' and s.startswith('.', i + 1))): + ps.append(cur); cur = '' + i += 2 if (s.startswith('->', i) or c in '?!') else 1 + continue + cur += c; i += 1 + ps.append(cur) + return ps[0], ps[1:] + + +TS_MOD = r'(?:(?:private|public|protected|readonly|static|declare|override|abstract|export|async)\s+)*' +JV_MOD = r'(?:(?:private|public|protected|internal|static|final|readonly|volatile|transient|const|required|new|override|virtual|sealed|unsafe)\s+)*' +JV_KW = {'return', 'new', 'throw', 'await', 'yield', 'else', 'case', 'in', 'is', 'as', 'out', 'ref', 'using', 'import', 'package', 'var', 'val'} + + +def _type_in(lang, line, x, annotated_only=False): + """the type a declaration line gives x, if it writes one (a parameter's only from its annotation)""" + if lang in ('ts', 'python'): + m = re.search(r'\b' + re.escape(x) + r'\s*[?!]?\s*:\s*([A-Za-z_$][\w$.]*)', line) + if m and m.group(1) not in ('function',): return m.group(1) + else: + m = re.search(r'([A-Za-z_][\w.]*)(?:<[^;=()]*>)?(?:\[\])*\??\s+' + re.escape(x) + r'\b', line) + if m and m.group(1) not in JV_KW: return m.group(1) + if annotated_only: return None + m = re.search(r'=\s*(?:new\s+)?([A-Za-z_$][\w$.]*)\s*(?:<[^()]*>)?\s*\(', line) + if m and m.group(1) not in ('async', 'function', 'await', 'lambda') and (lang != 'python' or m.group(1)[:1].isupper() or '.' in m.group(1)): + return ('new ' if 'new ' + m.group(1) in line else '') + m.group(1) + '(…)' + return None + + +CLASS = re.compile(r'^(\s*)(?:(?:export|default|abstract|public|private|protected|internal|static|final|sealed|partial|data|open|declare)\s+)*' + r'(?:class|interface|struct|record|object)\s+' + ID) + + +def _indent(s): + return len(s) - len(s.lstrip()) + + +def _class_span(L, n): + """[lo, hi) the lines of the class that encloses line n, else the whole file: a field is the enclosing class's own, + and the same name declared by another class in the file decides nothing""" + ind = _indent(L[n - 1]) + for i in range(n - 1, -1, -1): + m = CLASS.match(L[i]) + if m and len(m.group(1)) < ind: + ci = len(m.group(1)) + for j in range(i + 1, len(L)): + t = L[j].strip() + if t and _indent(L[j]) <= ci and not re.match(r'[{})\]]|//|#|/?\*|@|where\b|:', t): return i, j + return i, len(L) + return 0, len(L) + + +def _field(lang, L, x, n): + """where a field x of the class enclosing line n gets its value: its declaration, a constructor parameter property, + or an assignment""" + ex = re.escape(x) + if lang == 'ts': + pats = [(re.compile(r'(?:private|public|protected|readonly)\s+(?:readonly\s+)?' + ex + r'\s*[?!]?\s*:'), 'constructor parameter'), + (re.compile(r'^\s*' + TS_MOD + r'#?' + ex + r'\s*[?!]?\s*[:=](?!=)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + elif lang == 'python': + pats = [(re.compile(r'^\s+' + ex + r'\s*:\s*[A-Za-z_]'), 'field'), + (re.compile(r'\bself\.' + ex + r'\s*(?::[^=]+)?=(?!=)'), 'assigned'), + (re.compile(r'^\s+' + ex + r'\s*=(?!=)'), 'class attribute')] + else: + pats = [(re.compile(r'^\s*(?:\[[^\]]*\]\s*|@\w+(?:\([^)]*\))?\s+)*' + JV_MOD + r'[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:=(?!=)|;|\{|=>)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + lo, hi = _class_span(L, n) + for rx, kind in pats: + for i in range(lo, hi): + line = L[i] + if rx.search(line) and not re.match(r'\s*(//|#|\*|/\*)', line): + if kind == 'assigned': + # `self.gw = gw` / `this.loader = loader` decides nothing on its own: the constructor parameter it + # copies may say the type + rhs = re.search(r'=\s*(' + ID + r')\s*;?\s*$', line.split('#')[0].split('//')[0]) + if rhs: + p = _param(lang, L, rhs.group(1), i + 1) + if p and p[2]: return (p[0], 'constructor parameter', p[2]) + return (i + 1, kind, _type_in(lang, line, x)) + return None + + +JV_NOT = r'(?!(?:return|new|throw|else|case|await|yield|if|for|while|switch|catch|using|lock|do|try|var)\b)' +NAMED = { + 'python': re.compile(r'^\s*(?:async\s+)?def\s+\w+\s*\('), + 'ts': re.compile(r'\bfunction\b\s*\*?\s*[\w$]*\s*(?:<[^>]*>)?\s*\(|\bconstructor\s*\(|^\s*' + TS_MOD + r'(?:get\s+|set\s+)?' + ID + + r'\s*(?:<[^>]*>)?\s*\((?:.*\)\s*(?::\s*[^;{=]+)?\{\s*$|\s*$)'), + 'java': re.compile(r'^\s*' + JV_NOT + r'(?:(?:\[[^\]]*\]|@\w+(?:\([^)]*\))?)\s*)*' + JV_MOD + r'(?:[\w<>\[\],.?]+\s+)?' + ID + r'\s*\([^;]*$'), +} +ANON = re.compile(r'\(([^()]*)\)\s*(?::\s*[^=]+?)?\s*(?:=>|->)|\b(' + ID + r')\s*(?:=>|->)|\blambda\b([^:]*):') + + +def _signatures(lang, L, n): + """the signatures enclosing line n, innermost first, up to and including the nearest named one: (line index, the + signature's text over up to 8 lines, named?)""" + named = NAMED['java' if lang in ('java', 'csharp') else lang] + for i in range(n - 1, max(-1, n - 1 - UP), -1): + line = L[i] + if named.search(line): + text = ' '.join(L[i:i + 8]) + text = text[:text.find('{')] if '{' in text else text + yield i, text, True + return + a = ANON.search(line) + if a: yield i, a.group(0), False + + +def _param(lang, L, x, n, up=UP): + """the parameter x of a callable enclosing line n: of the nearest named signature, or of a closure inside it""" + ex = re.escape(x) + if lang in ('ts', 'python'): + rx = re.compile(r'(?:^|[(,]|\blambda\b)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:(?:private|public|protected|readonly)\s+)*(?:\*{1,2})?' + ex + r'\s*[?]?\s*(?::|=[^=>]|,|\)|=>)') + else: + rx = re.compile(r'(?:[(,]|^)\s*(?:(?:final|this|params|ref|out|in)\s+|@\w+(?:\([^)]*\))?\s+)*[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:[,)=]|$)' + r'|\(\s*' + ex + r'\s*\)\s*(?:->|=>)|\b' + ex + r'\s*(?:->|=>)') + for i, text, named in _signatures(lang, L, n): + if rx.search(text) or (not named and re.search(r'\b' + ex + r'\b', text)): + # the line of the signature that writes it (a parameter list over several lines) + k = next((j for j in range(i, min(len(L), i + 8)) if re.search(r'\b' + ex + r'\b', L[j])), i) + return (k + 1, 'param', _type_in(lang, L[k], x, annotated_only=True)) + return None + + +def _local(lang, L, x, n, up=UP): + ex = re.escape(x) + if lang == 'ts': + rx = [re.compile(r'\b(?:const|let|var)\s+' + ex + r'\b'), re.compile(r'\b(?:const|let|var)\s*[{\[][^=]*\b' + ex + r'\b'), + re.compile(r'\bfor\s*\(\s*(?:const|let|var)\s+' + ex + r'\b')] + elif lang == 'python': + rx = [re.compile(r'^\s*' + ex + r'\s*(?::[^=]+)?=(?!=)'), re.compile(r'\bfor\s+(?:[\w, ]*\b)?' + ex + r'\b[\w, ]*\s+in\b'), + re.compile(r'\bas\s+' + ex + r'\s*[:,)]'), re.compile(r'^\s*(?:[\w.]+\s*,\s*)*' + ex + r'\s*(?:,\s*[\w.]+\s*)*=(?!=)')] + else: + rx = [re.compile(r'(?:^|[;({])\s*(?:final\s+)?[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*=(?!=)'), + re.compile(r'\b(?:var|val)\s+' + ex + r'\b'), re.compile(r'\bforeach\s*\([^)]*\s' + ex + r'\s+in\b'), + re.compile(r'\bfor\s*\([^:;]*\s' + ex + r'\s*:')] + # within the enclosing callable (a module-level `let` above a test's callbacks is the test file's own local) + top = [i for i, _t, named in _signatures(lang, L, n) if named] + stop = top[0] if top and lang != 'ts' else max(-1, n - 1 - up) + for i in range(n - 1, stop - 1 if stop >= 0 else -1, -1): + if any(r.search(L[i]) for r in rx) and not re.match(r'\s*(//|#|\*)', L[i]): + # a member declared with a modifier is the class's field, not a local (a one-line method has no signature + # line above the call to stop the search at) + if lang in ('java', 'csharp') and re.match(r'\s*(?:private|public|protected|internal|static|readonly|const)\b', L[i]): return None + return (i + 1, 'local', _type_in(lang, L[i], x)) + return None + + +def _module(lang, L, x): + """x declared at the top of the file, or imported into it""" + ex = re.escape(x) + for i, line in enumerate(L): + if re.search(r'^\s*(?:import\s+(?:type\s+)?(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r'\b|\*\s+as\s+' + ex + r'\b|[\w$]+\s*,\s*\{[^}]*\b' + ex + r'\b)' + r'|import\s+(?:static\s+)?[\w.]+\.' + ex + r'\s*;|import\s+(?:[\w.]+\s+as\s+)?' + ex + r'\s*$|from\s+\S+\s+import\b[^#]*\b' + ex + r'\b' + r'|using\s+' + ex + r'\s*=|(?:const|let|var)\s+(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r')\s*=\s*require\b)', line): + return (i + 1, 'import', None) + for i, line in enumerate(L): + if re.search(r'^(?:export\s+)?(?:default\s+)?(?:(?:const|let|var)\s+' + ex + r'\b|(?:async\s+)?function\s*\*?\s*' + ex + r'\b|class\s+' + ex + r'\b|' + ex + r'\s*(?::[^=]+)?=(?!=)|def\s+' + ex + r'\b)', line): + return (i + 1, 'module', _type_in(lang, line, x)) + return None + + +def _root_decl(lang, L, x, n): + """where the name x, as written at line n, gets its value: a local or parameter above it, else a field, else the module""" + if x in ('this', 'self', 'cls', 'super', 'base'): return None + return _local(lang, L, x, n) or _param(lang, L, x, n) or _field(lang, L, x, n) or _module(lang, L, x) + + +KEY_ARG = re.compile(r'\b(?:\w+\.)*\w+\s*\(\s*(?:f?["\']([^"\']+)["\']|([A-Za-z_$][\w$.]*))\s*,') + + +def decide(src, at, name, lang=None): + """the deciding line of a non-exact call at `at` to a method called `name`: (line, text, kind) or None""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + lang = lang or lang_of(f) + # a call written over several lines: the name is on the line of the site or just below it + rows = [(k, L[k - 1]) for k in range(n, min(len(L), n + 3) + 1)] + for k, text in rows: + rv = receiver(text, name) if name else None + if not rv: continue + root, rest = parts(rv) + d = None + if root in ('this', 'self', 'base') and rest: + fld = re.sub(r'\(.*$', '', rest[0]) + d = _field(lang, L, fld, k) + if d: kind = d[1] if len(rest) == 1 else d[1] + ' ' + fld + elif root.endswith(')'): + fn = root.split('(')[0] + d = _root_decl(lang, L, fn, k) + if d: kind = 'returned by ' + fn + '()' + else: + d = _root_decl(lang, L, root, k) + if d: kind = d[1] if not rest else d[1] + ' ' + root + if d: + return (d[0], L[d[0] - 1], kind + (f" · type {d[2]}" if d[2] else '')) + return None + # no `.name(` on the line: a dispatch key or a registration decides which declaration runs + text = ' '.join(t for _k, t in rows[:2]) + g = re.search(r'getattr\s*\([^,]+,\s*f?["\']([^"\']*)["\']|getattr\s*\([^,]+,\s*(' + ID + r')', text) + if g: + ids = re.findall(r'\{(' + ID + r')', g.group(1) or '') or ([g.group(2)] if g.group(2) else []) + for x in ids: + d = _root_decl(lang, L, x, n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + x) + return None + s = re.search(r'\[\s*(' + ID + r')\s*\]\s*\(', text) + if s: + d = _root_decl(lang, L, s.group(1), n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + s.group(1)) + return None + if name and re.search(r'\b' + re.escape(name) + r'\b(?!\s*\()', text): + k = KEY_ARG.search(text) + if k and k.group(2): + x = k.group(2).split('.')[-1] if k.group(2).split('.')[0] in ('this', 'self') else k.group(2).split('.')[0] + d = _root_decl(lang, L, x, n) if k.group(2).split('.')[0] not in ('this', 'self') else _field(lang, L, x, n) + if d and d[1] == 'import': + far = _imported(src, f, L[d[0] - 1], x, lang) + if far: return far + ('registration key ' + k.group(2),) + if d: return (d[0], L[d[0] - 1], 'registration key ' + k.group(2)) + return None + + +def _imported(src, f, line, x, lang): + """(file:line, text) where a name imported from a module of this repository is defined, or None""" + m = re.search(r'from\s+[\'"](\.[^\'"]+)[\'"]', line) or re.search(r'require\(\s*[\'"](\.[^\'"]+)[\'"]', line) + cands = [] + if m: + base = os.path.normpath(os.path.join(os.path.dirname(f), m.group(1))) + cands = [base + e for e in ('', '.ts', '.tsx', '.js', '.mjs', '.jsx', '/index.ts', '/index.js')] + else: + m = re.search(r'^\s*from\s+(\.*)([\w.]*)\s+import\b', line) + if m: + up = os.path.dirname(f) + for _ in range(max(0, len(m.group(1)) - 1)): up = os.path.dirname(up) + rel = m.group(2).replace('.', '/') + cands = [os.path.join(up, rel + '.py'), os.path.join(up, rel, '__init__.py'), rel + '.py'] + for c in cands: + c = c.replace(os.sep, '/') + L2 = src.lines(c) if c.rsplit('.', 1)[-1] in ('ts', 'tsx', 'js', 'mjs', 'jsx', 'py') else None + if not L2: continue + d = _module(lang, L2, x) + if d and d[1] == 'module': return (f'{c}:{d[0]}', L2[d[0] - 1]) + return None + + +def evidence(src, at, name): + """{call: {at, text}, decider: {at, text, kind}} for one site; decider absent when no line decides it""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + k = n + if name: + # the site's own line when it writes the name, or is a call that does not (a dispatch through a key); else the + # site is where the callable starts (a decorator, a doc comment above it) and the call is the first line below + # that writes `.name(`, else the first that writes the name at all + own, t = re.search(r'\b' + re.escape(name) + r'\b', L[n - 1]), L[n - 1].strip() + if not own and not ('(' in t and not re.match(r'[@*/#]', t)): + near = range(n, min(len(L), n + 8) + 1) + k = next((j for j in near if re.search(r'\.\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', L[j - 1])), None) \ + or next((j for j in range(n, min(len(L), n + 3) + 1) if re.search(r'\b' + re.escape(name) + r'\b', L[j - 1])), n) + ev = {'call': {'at': f'{f}:{k}', 'text': trim(L[k - 1])}} + try: d = decide(src, f'{f}:{k}', name) + except re.error: d = None + if d: + ev['decider'] = {'at': d[0] if isinstance(d[0], str) else f'{f}:{d[0]}', 'text': trim(d[1]), 'kind': d[2]} + return ev + + +# ── only through it ──────────────────────────────────────────────────────────────────────────────────────────────────── +def _up(callers_of, seeds, within): + seen, stack = set(), list(seeds) + while stack: + x = stack.pop() + for a in callers_of.get(x, ()): + if a in within and a not in seen: + seen.add(a); stack.append(a) + return seen + + +def only_through(callers_of, keep, row, within): + """the members of `within` reached upward from `row` and from none of `keep` (the other dependents): the row's own + callable among them, since asked again without the row it leaves the answer too""" + mine = _up(callers_of, [row], within) | ({row} & within) + if not mine: return set() + others = [k for k in keep if k != row] + return mine - _up(callers_of, others, within) - set(others) + + +def rank(rows, cert_rank): + """the strongest non-exact rows first: the surer rung, then the more the answer stands on it, then source order""" + return sorted(rows, key=lambda r: (cert_rank(r.get('certainty')), -((r.get('only_through') or {}).get('callables', 0) + + (r.get('only_through') or {}).get('tests', 0)), + r.get('at') or '')) + + +# ── one document ─────────────────────────────────────────────────────────────────────────────────────────────────────── +ORDER = ['one of a set', 'dispatch', 'registered', 'remote', 'capped set', 'in scope', 'spawns', 'by key', + 'decorator by name', 'protocol', 'fixture', 'by name', 'text'] + + +def cert_rank(c): + return ORDER.index(c) if c in ORDER else len(ORDER) + + +def attach(rows, src, name_of, top=TOP): + """evidence on the `top` strongest non-exact rows (already carrying only_through when it is known); returns how many + non-exact rows were left without it""" + weak = [r for r in rows if not is_exact(r.get('certainty'))] + chosen = rank(weak, cert_rank)[:top] + for r in chosen: + ev = evidence(src, r.get('call_at') or r.get('at'), name_of(r)) + if ev: r['evidence'] = ev + return max(0, len(weak) - len(chosen)) + + +def impact_doc(doc, repo, callers_of=None, test_via=None): + """annotate an impact --json document in place: only_through on every non-exact direct row (when the edges are + given; a test reached through a fixture counts with its fixture), evidence on the strongest five""" + names = [simple(t.get('label')) for t in doc.get('targets', [])] + names = [n for n in names if n] + src = Source(repo) + direct = doc.get('direct', []) + if callers_of is not None: + seeds = [r['id'] for r in direct if r.get('certainty') not in ('alongside', 'stubs it')] + [r['id'] for r in doc.get('contract', [])] + within = {r['id'] for r in doc.get('reached', [])} | {t['id'] for t in doc.get('tests', [])} + tests = {t['id'] for t in doc.get('tests', [])} + via = test_via or {} + for r in direct: + if is_exact(r.get('certainty')): continue + o = only_through(callers_of, seeds, r['id'], within) + ts = {t for t in tests if t in o or (via.get(t) and via[t] in o)} + r['only_through'] = {'callables': len(o - tests), 'tests': len(ts)} + def name_of(r): + f, n = split_at(r.get('at')) + L = src.lines(f) if f else None + line = (L[n - 1] if L and 0 < n <= len(L) else '') + return next((x for x in names if re.search(r'\b' + re.escape(x) + r'\b', line)), names[0] if names else '') + rest = attach(direct, src, name_of) + _more(doc, rest) + return doc + + +def path_doc(doc, repo): + src = Source(repo) + hops = [] + for a in doc.get('answers', []): + for h in a.get('hops', []): + h.setdefault('certainty', h.get('cert')) + hops.append(h) + import ax_edges + for h in hops: + h['certainty'] = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(h.get('tier')) + _more(doc, attach(hops, src, lambda h: simple(h.get('to')))) + for h in hops: h.pop('certainty', None) + return doc + + +def context_doc(doc, repo): + src = Source(repo) + flow = doc.get('flow', []) + parent = {} + stack = [] + for s in flow: + while stack and stack[-1].get('depth', 0) >= s.get('depth', 0): stack.pop() + if stack and s.get('called_at_line'): + s['call_at'] = f"{split_at(stack[-1].get('at'))[0]}:{s['called_at_line']}" + stack.append(s) + rows = [s for s in flow if s.get('call_at') and not s.get('repeat_of')] + _more(doc, attach(rows, src, lambda s: simple(s.get('name')))) + for s in flow: s.pop('call_at', None) + return doc + + +def tests_doc(doc, repo): + """a test reached through a non-exact hop: the line in the test's body that starts the route, and what decides it""" + src = Source(repo) + changed = [simple(c.get('symbol') or c.get('target') or c.get('display') or '') for c in doc.get('changed', []) if isinstance(c, dict)] + rows = [] + for t in doc.get('tests', []): + if is_exact(t.get('certainty')) or t.get('certainty') == 'fixture': continue + ch = t.get('chain') or [] + want = [simple(ch[1])] if len(ch) > 1 else [x for x in changed if x] + f, n = split_at(t.get('at')) + L = src.lines(f) if f else None + if not L or not n: continue + for k in range(n, min(len(L), n + 80) + 1): + hit = next((w for w in want if w and re.search(r'\b' + re.escape(w) + r'\s*[(`<]', L[k - 1])), None) + if hit: + t['call_at'] = f'{f}:{k}'; t['_name'] = hit; rows.append(t); break + _more(doc, attach(rows, src, lambda t: t.get('_name', ''))) + for t in doc.get('tests', []): t.pop('call_at', None); t.pop('_name', None) + return doc + + +def _more(doc, n): + """the count of non-exact rows left without evidence, said only when there are some: an answer with none is unchanged""" + if n: doc['evidence_more'] = n + + +def annotate(verb, doc, repo, **kw): + """the one entry point for a --json document, and every other language's inside it""" + if not on() or not isinstance(doc, dict): return doc + f = {'impact': impact_doc, 'path': path_doc, 'context': context_doc, 'test-impact': tests_doc, 'tests': tests_doc}.get(verb) + if f: f(doc, repo, **kw) if verb == 'impact' else f(doc, repo) + return doc + + +# ── how it is printed ────────────────────────────────────────────────────────────────────────────────────────────────── +def lines(r, call=True, indent=' '): + """the evidence of one row as at most two lines (and a third, the only-through count and how to ask without it)""" + ev = r.get('evidence') or {} + out = [] + c = ev.get('call') or {} + # a line in the file the row already names is written L: the path is on the row + here = lambda at: 'L' + at.rpartition(':')[2] if at.rpartition(':')[0] == (r.get('at') or '').rpartition(':')[0] else at + # printed lines are shorter than the --json text: five rows of them are the whole cost of the evidence view + short = lambda s: s if len(s) <= PRINTED else s[:PRINTED - 1] + '…' + if call and c: out.append(f"{indent}call {here(c['at'])}: {short(c['text'])}") + d = ev.get('decider') + if d and d['at'] == c.get('at'): out.append(f"{indent}decided on the call's own line [{d['kind']}]") + elif d: out.append(f"{indent}decided {here(d['at'])}: {short(d['text'])} [{d['kind']}]") + # the row's own file:line is what --drop takes: it is on the row already, so it is said once, in DROP_HINT + o = r.get('only_through') + if o and (o.get('callables') or o.get('tests')): + out.append(f"{indent}only through it: {o['callables']} callable(s), {o['tests']} test(s)") + return out + + +DROP_HINT = "--drop asks again without that row and what stands only on it; --exact with exact edges only" + + +def prose_block(rows, more, what='rows'): + """the evidence section of a prose answer: the rows that carry evidence, each with its lines""" + ev = [r for r in rows if r.get('evidence')] + if not ev: return [] + out = [f"evidence for the {len(ev)} strongest non-exact {what} (the line that decides each; the rest are leads):"] + for r in ev: + out.append(f" [{r.get('certainty')}] {r.get('display') or r.get('name') or r.get('to') or ''} {r.get('at')}") + out += lines(r) + if more: out.append(f" +{more} more non-exact {what} without evidence (only the {TOP} strongest carry it)") + if any(r.get('only_through') for r in ev): out.append(' ' + DROP_HINT) + return out + + +def ask_again_note(): + """the line an answer asked again without some rows starts with""" + d, x = drops(), exact_only() + if not d and not x: return '' + return 'asked again ' + ('with exact edges only' if x else '') + (' and ' if x and d else '') + \ + (f"without {', '.join(sorted(d))}" if d else '') + ' — what was reached only through those rows is left out' + + +def dropped(r): + """is this direct row one the caller asked to leave out""" + d = drops() + if exact_only() and not is_exact(r.get('certainty')) and r.get('certainty') != 'alongside': return True + if not d: return False + return r.get('id') in d or r.get('at') in d or any(x.get('at') in d for x in r.get('reasons', []) or []) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d16..b87c214f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -25,7 +25,7 @@ H = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, H) -import ax_edges +import ax_edges, ax_evidence CAP = 30 TEXT_ROWS = 5 # rows of a name written in a non-source file: leads, so a few and a count @@ -87,6 +87,20 @@ def more_in_file(k): return f" · +{k} more in this file" if k else '' +def ev(row, r): + """a row and, when it carries evidence (ax_evidence.py), the line that decides it under it: one entry, so a cap + never separates them""" + return row + ''.join('\n' + l for l in ax_evidence.lines(r, call=False)) if r.get('evidence') else row + + +def ev_foot(d): + n = d.get('evidence_more') + out = [f"evidence: the {ax_evidence.TOP} strongest non-exact rows carry the line that decides them, {n} more do not"] if n else [] + rows = d.get('direct', []) + d.get('tests', []) + [h for a in d.get('answers', []) for h in a.get('hops', [])] + if any(r.get('only_through') for r in rows if isinstance(r, dict)): out.append(ax_evidence.DROP_HINT) + return out + + # ── impact ─────────────────────────────────────────────────────────────────────────────────────────────────────────── def impact(d, code): rows, rest = [], {} @@ -95,11 +109,19 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n rows.append(('contract', site(code, r['at'], f"must change · {r['why']}{stale(r)}", r['display']))) seen = set() if d.get('alongside'): more('alongside (no call, no reference)', len(d['alongside'])) - for r in d.get('direct', []): + if d.get('alongside_count'): more('alongside (no call, no reference; --alongside lists them)', d['alongside_count']) + if d.get('asked_again'): rows.append(('note', d['asked_again']['note'])) + # a row with evidence leads its rung: it is one of the strongest, and the cap must not cut it from its evidence + direct = d.get('direct', []) + if any(r.get('evidence') for r in direct): + rung = {} + for i, r in enumerate(direct): rung.setdefault(r.get('certainty'), i) + direct = sorted(direct, key=lambda r: (rung[r.get('certainty')], not r.get('evidence'))) + for r in 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']))) + rows.append((cert, ev(site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']), r))) 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 @@ -127,6 +149,7 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n 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 += ev_foot(d) 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 @@ -142,7 +165,7 @@ def path(d, code): 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)}"))) + rows.append(('hop', ev(site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"), 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']))) @@ -154,7 +177,7 @@ def path(d, code): 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)] + foot = ev_foot(d) + [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 @@ -169,7 +192,7 @@ def context(d, code): 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']) + rows.append(('flow', ev(site(code, s['at'], tag + stale(s), s['name']), s))); listed.add(s['at']) if not d.get('flow'): for e in d.get('entry_points', []): if e['at'] in listed: continue @@ -181,7 +204,7 @@ def context(d, code): 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 = [] + foot = ev_foot(d) 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 @@ -201,7 +224,7 @@ def test_impact(d, code): 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']))) + rows.append(('test', ev(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']), t))) # 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(): @@ -209,7 +232,7 @@ def test_impact(d, code): 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 = [] + foot = ev_foot(d) if d.get('command'): foot.append(f"run: {d['command']}") if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 92404610..c503f33d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -66,6 +66,10 @@ # --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. +# --evidence / --no-evidence (or AXIOMCODE_EVIDENCE=on|off; off by default): each of the five strongest rows that is not an +# exact edge ([by name], [one of a set], [registered], …) carries the line that decides it — where its receiver or key +# gets its value — and how much of the answer is reached only through it. --drop (repeatable) asks again +# without that row, --exact with exact edges only; --alongside lists the `alongside` rows the evidence view counts. # ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, # C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and # every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. @@ -123,6 +127,12 @@ while [ $# -gt 0 ]; do --no-refresh) export AXIOMCODE_NO_REFRESH=1; shift ;; --grep) GREP=1; shift ;; --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; + # evidence for the uncertain rows, and asking again without them (ax_evidence.py): read by every verb from the env + --evidence) export AXIOMCODE_EVIDENCE=on; shift ;; + --no-evidence) export AXIOMCODE_EVIDENCE=off; shift ;; + --drop) export AXIOMCODE_DROP="${AXIOMCODE_DROP:+$AXIOMCODE_DROP,}$2"; shift 2 ;; + --exact) export AXIOMCODE_EXACT=1; shift ;; + --alongside) export AXIOMCODE_ALONGSIDE=1; shift ;; *) ARGS+=("$1"); shift ;; esac done diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c..14ec00d6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1321,14 +1321,21 @@ def cli(argv): """--json: the same answer as one document. The prose is produced by the same code path and carried in `prose`, so the machine shape is never LESS than the human one — a second traversal to build it would be a second implementation to keep in step.""" + import ax_evidence + repo = next((a for a in reversed([x for x in argv if not x.startswith('--')]) if os.path.isdir(a)), '.') if '--json' not in argv: code = main(argv) + # the line that decides each flow step reached through a call that is not an exact edge (ax_evidence.py) + if ax_evidence.on() and RESULT.get('flow'): + ax_evidence.annotate('context', RESULT, repo) + for l in ax_evidence.prose_block(RESULT['flow'], RESULT.get('evidence_more', 0), 'steps'): print(l) if code in (0, 2): quoted_text(argv) sys.exit(code) import contextlib, io, json buf = io.StringIO() with contextlib.redirect_stdout(buf): code = main(argv) RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('context', RESULT, repo) print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fbd91552..1363e354 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -66,7 +66,7 @@ def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn +sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn, ax_evidence _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) P = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(P) G, run_dl, die, BODILESS_KINDS = P.G, P.run_dl, P.die, P.BODILESS_KINDS @@ -2399,6 +2399,14 @@ def main(argv): parent = collections.defaultdict(list) for a, b, t, q_ in res['parent_up']: parent[a].append((b, t)) prof('outputs read') + # who calls each callable, for what stands ONLY on a row (ax_evidence.only_through): built on first use, so an answer + # with evidence off and no --drop never pays for it + _callers_memo = [] + def callers_of(): + if not _callers_memo: + _callers_memo.append(collections.defaultdict(set)) + for a, b, _ in g.edges(): _callers_memo[0][b].add(a) + return _callers_memo[0] tests = {} for m, d, q_ in res['test_near']: if m in g.sym and int(d) <= depth and int(d) < tests.get(m, (10 ** 9,))[0]: @@ -2427,6 +2435,18 @@ def main(argv): 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} + # ASKED AGAIN WITHOUT SOME ROWS (--drop , --exact; ax_evidence.py): the rows go, and with them what the + # answer reached ONLY through them -- the callables and tests no other dependent reaches. The rest is untouched. + _row = lambda x: {'id': x[0], 'at': x[4], 'certainty': x[3], 'reasons': direct_reasons.get((x[0], _grp(x[1])), [])} + _dropped = [x for x in D if ax_evidence.dropped(_row(x))] + if _dropped: + _keep = [x[0] for x in D if x not in _dropped and x[3] not in ('alongside', 'stubs it')] + [c for c, _ in contract] + _within = set(reached) | set(tests) + _lost = set().union(*(ax_evidence.only_through(callers_of(), _keep, x[0], _within) for x in _dropped)) + D = [x for x in D if x not in _dropped] + reached = {m: d for m, d in reached.items() if m not in _lost} + tests = {m: v for m, v in tests.items() if m not in _lost and not (v[1] and v[1] in _lost)} + byname_seeds -= {x[0] for x in _dropped} - set(_keep) # 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: # they go in source order (file, line, then name), which only an edit changes. @@ -2668,7 +2688,7 @@ def main(argv): 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], + doc = ({'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], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is @@ -2689,11 +2709,19 @@ def main(argv): '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', [])})], 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, - **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}, indent=1)) + **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}) + if ax_evidence.on() or _dropped: + if _dropped: doc['asked_again'] = {'note': ax_evidence.ask_again_note(), 'dropped': [g.loc(x[0]) if not x[4] else x[4] for x in _dropped]} + if ax_evidence.on(): + ax_evidence.impact_doc(doc, g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + if not ax_evidence.list_alongside() and doc.get('alongside') and any(not ax_evidence.is_exact(r['certainty']) for r in doc['direct']): + doc['alongside_count'] = len(doc.pop('alongside')) + print(json.dumps(doc, indent=1)) return 0 for i, (k, lab, _) in enumerate(targets): print(f"change: {lab} [{k}]") if want_why and i < len(target_why): print('\n'.join(g.why_lines(target_why[i]))) + if _dropped: print(ax_evidence.ask_again_note() + f" ({len(_dropped)} row(s))") # --tests-only: every section is still COMPUTED (the tests are derived from them) but only the tests section, # and the verified / bound lines that qualify it, are printed _stdout = sys.stdout @@ -2870,8 +2898,13 @@ def main(argv): if len(own) == 1 and own[0][0] == 'method': return f'grep -rn "\\.{own[0][1]}(" {P.grep_roots(g)}' return 'grep -rnw ' + ' '.join(f'-e "{n}"' for _, n in own) + f' {P.grep_roots(g)}' + _ev_view = ax_evidence.on() and not ax_evidence.list_alongside() and any(not ax_evidence.is_exact(x[3]) for x in D) for title, rows in groups: if not rows: continue + # with evidence on, in an answer that has an uncertain row, the `alongside` section (no call, no reference) is a + # count, not rows: --alongside lists them. An answer of exact rows alone is left exactly as it was. + if _ev_view and all(r[3] == 'alongside' for r in rows): + print(f"{title}: {len({c for c, *_ in rows})} callable(s) — --alongside lists them"); continue # PRODUCTION BEFORE TESTS. A test that reaches the target is a CONSEQUENCE of the change, not a place that # has to be edited, and in a well-tested tree the tests outnumber the production callers several times over. # Ranking by certainty alone therefore interleaved them, and since the window is finite the file that @@ -2940,6 +2973,14 @@ def main(argv): tbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tst if r[4]) where = (f" in {len(tbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in tbyf.most_common(4)) + (' …' if len(tbyf) > 4 else '')) if tbyf else '' print(f" +{len({c for c, *_ in tst})} test callable(s){where} also reach it, not listed here — `axiomcode test-impact` names them and the command that runs them (--tests lists them by file)") + if ax_evidence.on(): + # THE LINE THAT DECIDES EACH UNCERTAIN ROW (ax_evidence.py), for the five strongest, with what stands on it + _ed = ax_evidence.impact_doc({'targets': [{'label': lab} for k, lab, _ in targets], + 'direct': [{'id': c, 'display': g.disp(c), 'certainty': cert, 'at': loc} for c, role, why, cert, loc, n in D], + 'contract': [{'id': c} for c, _ in contract], + 'reached': [{'id': m} for m in reached], 'tests': [{'id': m} for m in tests]}, + g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + for l in ax_evidence.prose_block(_ed['direct'], _ed.get('evidence_more', 0)): print(l) nfiles = len({g.sym[m]['file'] for m in reached}) if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133..3c28d0a1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -2127,11 +2127,19 @@ if __name__ == '__main__': if e.code and UNRESOLVED and not as_json: sys.stdout.flush(); ax_text.emit(repo, UNRESOLVED, IN) raise - if not as_json: sys.exit(answer()) + import ax_evidence + if not as_json: + code = answer() + # the line that decides each hop that is not an exact edge (ax_evidence.py), after the chains it qualifies + if ax_evidence.on() and isinstance(RESULT.get('query'), dict): + ax_evidence.annotate('path', RESULT, RESULT['query'].get('repo') or '.') + for l in ax_evidence.prose_block([h for a in RESULT.get('answers', []) for h in a.get('hops', [])], RESULT.get('evidence_more', 0), 'hops'): print(l) + sys.exit(code) # the prose goes to a buffer, not to the caller: one code path produces both, and `prose` is # carried in the document so a machine answer is never LESS than the human one. buf = io.StringIO() with contextlib.redirect_stdout(buf): code = answer() RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('path', RESULT, (RESULT.get('query') or {}).get('repo') or '.') print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index d984ad6c..d086a90d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -2061,8 +2061,9 @@ def main(argv): 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, repo) + import ax_evidence if as_json: - print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], + print(json.dumps(ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], @@ -2085,7 +2086,7 @@ def main(argv): 'range_note': changed.get('range_note'), 'baseline_note': changed.get('baseline_note'), 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' - 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, + 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, repo), indent=1)) return 0 @@ -2240,6 +2241,11 @@ def main(argv): for t, first in unreadable: print(f" {t} {(first or [''])[0][:100]}") print(" the selection above is incomplete — do not act on it as though it were the whole answer.") + if ax_evidence.on(): + # the line in each test that starts a route which is not an exact edge, and what decides it (ax_evidence.py) + _ed = ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol']} for t in targets], + 'tests': [dict(r) for r in tests.values()]}, repo) + for l in ax_evidence.prose_block(_ed['tests'], _ed.get('evidence_more', 0), 'test routes'): print(l) print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/case.json b/tests/cases/typescript/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..c301fdb6 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/case.json @@ -0,0 +1,88 @@ +{ + "lang": "typescript,javascript,python,java,csharp", + "src": ".", + "checks": [ + { + "why": "a [by name] row carries the line that decides its receiver: a Map field and an `any` parameter show the name-match is another type; the resolved row gets no evidence", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--grep"], + "want": ["ts/callers.ts:16: return this.seen.get(key);", "decided L14: private readonly seen = new Map(); [field · type new Map(…)]", + "decided L20: export function viaAny(box: any, id: string): string { [param · type any]", "only through it:"], + "avoid": ["decided L7"] + }, + { + "why": "a dispatch-key row decides on where the key comes from, and a registration row on the constant it registers under, followed into the module that defines it", + "run": ["impact", "ts/bus.ts:16", "--evidence", "--grep"], + "want": ["decided L10: emit(topic: string, id: string): void { [dispatch key topic]", + "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]"] + }, + { + "why": "--json rows gain evidence {call, decider} and only_through {callables, tests}; alongside rows become a count", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--json"], + "stdout_json": true, + "want": ["\"evidence\"", "\"decider\"", "\"only_through\"", "\"callables\"", "\"alongside_count\""] + }, + { + "why": "the prose answer says the same, in one section after the rows it qualifies", + "run": ["impact", "ts/orders.ts:2", "--evidence"], + "want": ["evidence for the 2 strongest non-exact rows", "call L16: return this.seen.get(key);", "only through it: 1 callable(s), 0 test(s)", "--drop asks again without that row"] + }, + { + "why": "--exact asks again with exact edges only: the name-matches and what stands on them go, the resolved caller stays", + "run": ["impact", "ts/orders.ts:2", "--exact", "--grep"], + "want": ["return this.store.get(id);", "asked again with exact edges only"], + "avoid": ["box.get(id)", "this.seen.get(key)"] + }, + { + "why": "--drop asks again without that one row", + "run": ["impact", "ts/orders.ts:2", "--drop", "ts/callers.ts:21", "--grep"], + "want": ["this.seen.get(key)", "without ts/callers.ts:21"], + "avoid": ["box.get(id)"] + }, + { + "why": "control: an answer with only [resolved] rows is byte-identical with evidence on and off", + "run": ["impact", "ts/ledger.ts:2", "--evidence"], + "same_as": ["impact", "ts/ledger.ts:2", "--no-evidence"], + "want": ["[resolved] sum"] + }, + { + "why": "control: the same resolved-only answer as --json and as grep rows is byte-identical too", + "run": ["impact", "ts/ledger.ts:2", "--evidence", "--json"], + "same_as": ["impact", "ts/ledger.ts:2", "--json"], + "stdout_json": true + }, + { + "why": "control: evidence is off by default, so an answer with uncertain rows is the answer it was", + "run": ["impact", "ts/orders.ts:2", "--grep"], + "same_as": ["impact", "ts/orders.ts:2", "--no-evidence", "--grep"], + "avoid": ["decided ", "only through it"] + }, + { + "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", + "run": ["impact", "js/store.js:2", "--evidence", "--grep"], + "want": ["decided L11: cached(res) { [param]", "decided L16: export function remote(api) { [param]"], + "avoid": ["this.shelf = new Shelf()"] + }, + { + "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", + "run": ["impact", "py/gateway.py:2", "--evidence", "--grep"], + "want": ["py/billing.py:33: return self.gw.charge(-amount)", "decided L30: self.gw = gw [assigned]"], + "avoid": ["def __init__(self, gw: Gateway): [constructor parameter"] + }, + { + "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", + "run": ["impact", "java/app/Pipeline.java:7", "--evidence", "--grep"], + "want": ["decided L6: private final Function loader; [field · type Function]"] + }, + { + "why": "Java: path's hop through the same call carries the same decider", + "run": ["path", "Cache.load", "Pipeline.apply", "--evidence", "--grep"], + "want": ["private final Function loader;"] + }, + { + "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", + "run": ["impact", "cs/Stock.cs:5", "--evidence", "--grep"], + "want": ["decided L11: private readonly dynamic _meter; [field · type dynamic]"], + "avoid": ["decided L10"] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs new file mode 100644 index 00000000..bd7a4df7 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs @@ -0,0 +1,19 @@ +namespace App +{ + public class Stock + { + public int Count(string sku) { return sku.Length; } + } + + public class Counter + { + private readonly Stock _stock = new Stock(); + private readonly dynamic _meter; + + public Counter(dynamic meter) { _meter = meter; } + + public int Total(string sku) { return _stock.Count(sku); } + + public int Metered(string sku) { return _meter.Count(sku); } + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java new file mode 100644 index 00000000..9bd696b4 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java @@ -0,0 +1,20 @@ +package app; + +import java.util.function.Function; + +public class Cache { + private final Function loader; + private final Store store = new Store(); + + public Cache(Function loader) { + this.loader = loader; + } + + public String read(String id) { + return store.lookup(id); + } + + public String load(String id) { + return loader.apply(id); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java new file mode 100644 index 00000000..dd33065e --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java @@ -0,0 +1,10 @@ +package app; + +import java.util.function.Function; + +public class Pipeline implements Function { + @Override + public String apply(String s) { + return s.trim(); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java new file mode 100644 index 00000000..132c2dc8 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java @@ -0,0 +1,7 @@ +package app; + +public class Store { + public String lookup(String id) { + return "store:" + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js new file mode 100644 index 00000000..99274be4 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js @@ -0,0 +1,18 @@ +import { Shelf } from './store.js'; + +export class Client { + constructor() { + this.cache = new Map(); + this.shelf = new Shelf(); + } + load(id) { + return this.shelf.fetch(id); + } + cached(res) { + return res.fetch('x'); + } +} + +export function remote(api) { + return api.fetch('/orders'); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js new file mode 100644 index 00000000..7721cbe8 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js @@ -0,0 +1,5 @@ +export class Shelf { + fetch(id) { + return 'shelf:' + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py new file mode 100644 index 00000000..c423f8cb --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py @@ -0,0 +1,33 @@ +from gateway import Gateway, Mailer + + +class Billing: + def __init__(self, gw: Gateway): + self.gw = gw + + def bill(self, amount): + return self.gw.charge(amount) + + +class Router: + def __init__(self, handlers): + self.handlers = handlers + + def on_refund(self, event): + return event + + def route(self, event): + kind = event["kind"] + return getattr(self, f"on_{kind}")(event) + + +def settle(m: Mailer): + return m.charge(5) + + +class Refunds: + def __init__(self, gw): + self.gw = gw + + def undo(self, amount): + return self.gw.charge(-amount) diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py new file mode 100644 index 00000000..f9f26732 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py @@ -0,0 +1,8 @@ +class Gateway: + def charge(self, amount): + return amount + + +class Mailer: + def charge(self, amount): + return -amount diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts new file mode 100644 index 00000000..13fd6ace --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts @@ -0,0 +1,21 @@ +import { ORDER_PLACED } from './callers'; + +type Handler = (id: string) => void; + +export class Bus { + private readonly handlers: Record = {}; + on(topic: string, handler: Handler): void { + this.handlers[topic] = handler; + } + emit(topic: string, id: string): void { + this.handlers[topic](id); + } +} + +export function onPlaced(id: string): void { + console.log(id); +} + +export function wire(bus: Bus): void { + bus.on(ORDER_PLACED, onPlaced); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts new file mode 100644 index 00000000..e17afae3 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts @@ -0,0 +1,26 @@ +import { OrderStore } from './orders'; +import { Ledger } from './ledger'; + +export const ORDER_PLACED = 'order.placed'; + +export class Report { + constructor(private readonly store: OrderStore) {} + line(id: string): string { + return this.store.get(id); + } +} + +export class Memo { + private readonly seen = new Map(); + recall(key: string): string | undefined { + return this.seen.get(key); + } +} + +export function viaAny(box: any, id: string): string { + return box.get(id); +} + +export function sum(ledger: Ledger): number { + return ledger.total(3); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts new file mode 100644 index 00000000..04a5c1fa --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts @@ -0,0 +1,5 @@ +export class Ledger { + total(n: number): number { + return n * 2; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts new file mode 100644 index 00000000..5b1abdb7 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts @@ -0,0 +1,5 @@ +export class OrderStore { + get(id: string): string { + return 'order:' + id; + } +} diff --git a/tests/run.py b/tests/run.py index 75fcd456..a35f564a 100755 --- a/tests/run.py +++ b/tests/run.py @@ -14,7 +14,9 @@ it. A check fails loudly with the line that was wrong, so a regression names itself. No corpus, no network, nothing outside the case directory. -Three other keys a check may carry: +Four other keys a check may carry: + + "same_as": [run …] STDOUT must be byte-identical to that other run's: a switch that must leave an answer alone. "stdout_json": true STDOUT ALONE must parse as one JSON document. `want` and `avoid` read stdout and stderr CONCATENATED, so no substring can express "this must not be inside the document" — which is @@ -70,6 +72,14 @@ # not do this yet", not "anything may happen here". A crash, a missing fixture or an unreadable answer under # a marker would otherwise be indistinguishable from the gap it names, and the marker becomes the hiding # place this mechanism exists to remove. + # "same_as": [run …] — STDOUT must be BYTE-IDENTICAL to another run's: a switch that must not change an answer + # (evidence on an answer with no uncertain row, a default that is off) is a comparison, not a substring + if ch.get('same_as'): + other = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['same_as']] + [path], capture_output=True, text=True) + if other.stdout != out.stdout: + a_, b_ = out.stdout.split('\n'), other.stdout.split('\n') + i = next((i for i, (x, y) in enumerate(zip(a_, b_)) if x != y), min(len(a_), len(b_))) + bad.append(f"stdout differs from {' '.join(ch['same_as'])} at line {i + 1}: {a_[i] if i < len(a_) else '(end)'!r} vs {b_[i] if i < len(b_) else '(end)'!r}") crashed = bool(out.returncode) and not ch.get('expect_error') if crashed: bad.append(f"(exit {out.returncode})") # "pending": "" — a case that states behaviour the tool does NOT have yet. It still RUNS, and the two From c31ccf2ac5910891da8cc7206f03397cb6e7b275 Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 23:27:32 -0700 Subject: [PATCH 076/258] impact, context: a name the code calls and nothing declares lists its call sites Asked about a function that is called but not yet written, impact counted the sites ("`scale` as written at 1 unresolved call site(s)") and then answered "directly touches it: nothing" and "the change is local"; --grep printed no rows; context dropped the word, since only declared names reach its ranking. Writing such a function starts from exactly those sites, and a text search finds them in one call. - impact: each unresolved call site of a name-matched (written) target is a direct row, [by name], "calls it (no declaration of this name)" at its file:line, so the prose, --json and --grep answers carry them. - context: identifiers the task writes that the code calls and nothing declares are listed first, with their call sites and code lines (called_undeclared in --json), before anything that can stop the answer early. - ax_grep: those sites lead the grep-shaped context answer the MCP tools return by default. tests/cases/python/called-but-undeclared: 4/4 here, 2/4 on the base (the two controls, a declared function, pass on both). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_grep.py | 3 ++ .../axiomcode/scripts/axiomcode-context | 33 +++++++++++++++++++ .../skills/axiomcode/scripts/axiomcode-impact | 11 +++++++ .../python/called-but-undeclared/case.json | 16 +++++++++ .../src/shop/__init__.py | 0 .../called-but-undeclared/src/shop/pricing.py | 10 ++++++ .../called-but-undeclared/src/shop/rates.py | 2 ++ 7 files changed, 75 insertions(+) create mode 100644 tests/cases/python/called-but-undeclared/case.json create mode 100644 tests/cases/python/called-but-undeclared/src/shop/__init__.py create mode 100644 tests/cases/python/called-but-undeclared/src/shop/pricing.py create mode 100644 tests/cases/python/called-but-undeclared/src/shop/rates.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d16..4bd426ee 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -163,6 +163,9 @@ def path(d, code): def context(d, code): rows = [] listed = set() + # first: where a name the task writes is called though nothing declares it — the code to write is used there + for u in d.get('called_undeclared', []): + rows.append(('called', site(code, u['at'], f"calls {u['name']}, declared nowhere" + (f" · in {u['in']}" if u.get('in') else '')))); listed.add(u['at']) for s in d.get('flow', []): if s.get('repeat_of'): continue c = s.get('certainty') or 'entry' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c..e7dc81f2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -64,6 +64,30 @@ TEST_PENALTY = 0.05 # demoted by 20x, never dropped: sometimes the test COMMON_PATH = 0.25 # a word in more than a quarter of the file paths earns no path bonus + +def called_undeclared(g, body, cap=8): + """{name: [(file, line, caller, code)]} for identifiers the task writes that the code CALLS but declares nowhere. + The IDF ranker only knows declared names, so a task about a function the code already calls and nobody has written + yet ("implement numpy_to_votable_dtype") lost that word before ranking. The call sites are where the new function + is used: the one thing a search would find and the graph did not say.""" + if not (g.has('call_sites') and g.has('unresolved_sites')): return {} + ids = sorted({w for w in re.findall(r'[A-Za-z_][A-Za-z0-9_]{3,}', body)}) + if not ids: return {} + ph = ','.join('?' * len(ids)) + declared = {r[0] for r in g.q(f"SELECT DISTINCT name FROM symbols WHERE name IN ({ph}) AND kind NOT IN ('library', 'written')", *ids)} + rows = g.q(f"SELECT cs.callee_name n, cs.file_path f, cs.start_line l, cs.caller_id c FROM call_sites cs " + f"JOIN unresolved_sites u ON u.call_site_id = cs.id WHERE cs.callee_name IN ({ph}) ORDER BY cs.file_path, cs.start_line", *ids) + out = {}; lines = {} + for r in rows: + if r['n'] in declared or len(out.get(r['n'], [])) >= cap: continue + f = g.site_file(r['f']) if hasattr(g, 'site_file') else r['f'] + if f not in lines: + try: lines[f] = open(os.path.join(g.repo, f), errors='replace').read().split('\n') + except OSError: lines[f] = [] + L = lines[f]; code = L[r['l'] - 1].strip() if r['l'] and r['l'] <= len(L) else '' + out.setdefault(r['n'], []).append((f, r['l'], g.disp(r['c']) if r['c'] in g.sym else '', code[:160])) + return out + def score_symbols(g, terms, use_path=True): """{symbol id: (score, matched terms)} — the lexical tier, IDF-weighted. @@ -934,6 +958,15 @@ def main(argv): RESULT['task'], RESULT['repo'] = task, g.repo body = task_text(task) + # before anything that can stop the answer (no terms, no scope): a name the code calls and nobody declares is an + # answer on its own, and "implement " is often all the task says + undecl = called_undeclared(g, body) + RESULT['called_undeclared'] = [{'name': n, 'at': f"{f}:{l}", 'in': c, 'code': code} for n, rs in undecl.items() for f, l, c, code in rs] + if undecl: + print("\ncalled but declared nowhere (the task names them; the code already calls them — this is where they are used):") + for n, rs in undecl.items(): + print(f" {n} — {len(rs)} call site(s)") + for f, l, c, code in rs: print(f" {f}:{l}: {code}" + (f" [in {c}]" if c else '')) terms = task_terms(body) if not terms: die("nothing to search for in that task description") # what the question names that no graph here holds is said FIRST (#1571), and a scope that exists on disk but holds diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fbd91552..11451c98 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2295,6 +2295,17 @@ def main(argv): print(f"axiomcode-scope-declared: {int(scoped_in)}", file=sys.stderr) targets_files =[t for k, _, pay in targets for t in (pay if isinstance(pay, list) else []) if isinstance(t, str) and t in g.sym] prof('targets resolved'); res = I.run(targets); prof('souffle done') + # A NAME THAT IS CALLED BUT DECLARED NOWHERE. The path finder's resolver takes such a name to the call sites written + # with it (a node `w:`, its rows in g.SITES), but the rules cannot seed on a node the graph does not hold, so + # the answer said "directly touches it: nothing" and "the change is local" about a name with callers. That is the + # question asked when writing a function the code already calls: each of those sites is a direct row, by name. + for q_, lab in res['_targets'].items(): + for k, klab, ids in targets: + if k != 'name match' or klab != lab: continue + for i in ids: + for r in getattr(g, 'SITES', {}).get(i, []): + res['direct'].append([r['caller_id'], 'uses', 'calls it (no declaration of this name)', 'by name', + g.site_file(r['file_path']) or '', str(r['start_line'] or 0), q_]) LAB = res['_targets']; multi = len(LAB) > 1 contract_of = collections.defaultdict(set) for c, why, q_ in res['contract']: contract_of[(c, why)].add(LAB[q_]) diff --git a/tests/cases/python/called-but-undeclared/case.json b/tests/cases/python/called-but-undeclared/case.json new file mode 100644 index 00000000..01e09220 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a function the code calls and nobody has written yet: impact lists every call site, where the new function is used", + "run": ["impact", "apply_discount"], + "want": ["[by name] total", "pricing.py:6", "[by name] invoice", "pricing.py:10", "calls it (no declaration of this name)"], + "avoid": ["directly touches it: nothing the graph can see", "the change is local"]}, + {"why": "the same answer from a task that names the missing function, even when it is the task's only word the graph lacks", + "run": ["context", "implement apply_discount"], "expect_error": true, + "want": ["called but declared nowhere", "apply_discount — 2 call site(s)", "pricing.py:6: return apply_discount(net) * (1 + vat_rate()) [in total]"]}, + {"why": "control: a declared function is never listed as called-but-undeclared", + "run": ["context", "how is vat_rate used in total"], + "avoid": ["called but declared nowhere"]}, + {"why": "control: a declared function's impact answers from the graph, not by name", + "run": ["impact", "vat_rate"], + "want": ["[resolved] total"], + "avoid": ["no declaration of this name"]}]} diff --git a/tests/cases/python/called-but-undeclared/src/shop/__init__.py b/tests/cases/python/called-but-undeclared/src/shop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/called-but-undeclared/src/shop/pricing.py b/tests/cases/python/called-but-undeclared/src/shop/pricing.py new file mode 100644 index 00000000..6efb21dc --- /dev/null +++ b/tests/cases/python/called-but-undeclared/src/shop/pricing.py @@ -0,0 +1,10 @@ +from shop.rates import vat_rate + + +def total(items): + net = sum(i.price for i in items) + return apply_discount(net) * (1 + vat_rate()) + + +def invoice(items): + return {"total": total(items), "net": apply_discount(sum(i.price for i in items))} diff --git a/tests/cases/python/called-but-undeclared/src/shop/rates.py b/tests/cases/python/called-but-undeclared/src/shop/rates.py new file mode 100644 index 00000000..12d9c146 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/src/shop/rates.py @@ -0,0 +1,2 @@ +def vat_rate(): + return 0.2 From b6325f1864bed4f1d36b67db39bef245f1b4bdcc Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 23:54:04 -0700 Subject: [PATCH 077/258] impact: a TypeScript signature use is one row again, not a resolved duplicate Symptom: `impact` on a TypeScript type listed each function that names it in a parameter as "names it in its signature (a parameter); also: names it (METHOD_PARAM)". tests/run.py typescript/union-alias-member failed 2 of 3 checks (the alias itself, and the no-common-member control). Cause: the signature rule reads type_use and adds a lineless resolved row for every parameter or return position. It exists for languages whose type_refs carry no line (Java), where the typeref rule matches nothing and the text grep mislabelled the use. TypeScript's type_refs do carry a line, so the typeref rule already named the same signature at its site, and both rows were merged into one entry. Fix: the signature row is added only for a (callable, context) the typeref rule has not already named with a line. Done in both backends (impact.dl sig_at_line, graph_sql.py rule 273b). The text row is still dropped for any callable type_use resolves, so Java answers are unchanged. Tests: union-alias-member 3/3 on both the Datalog and SQL backends (1/3 on each before). java/signature-use-resolved-by-type-use 2/2 on both. tests/run.py --lang typescript 185/185 with the companion case fix (181/185 before); --lang java 304/304. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl | 7 +++++-- plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 600cf0fe..57d46319 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -664,8 +664,11 @@ direct(q, m, "reads", w, "resolved", "", 0) :- target(q, "field", fl, _), persis sig_words("METHOD_PARAM", "a parameter"). sig_words("METHOD_RETURN", "the return type"). .decl sig_resolved(q:symbol, c:symbol) sig_resolved(q, c) :- target(q, "type", t, _), sigtype(c, t, _, _), !inside_target(q, c). -direct(q, c, "uses", cat("names it in its signature (", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, 0), sig_words(ctx, w), !inside_target(q, c). -direct(q, c, "uses", cat("names it in its signature (a type argument of ", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, d), d > 0, sig_words(ctx, w), !inside_target(q, c). +// a signature typeref already names at its line (the rule above, by name with a site) gets no second, lineless row +.decl sig_at_line(q:symbol, c:symbol, ctx:symbol) +sig_at_line(q, c, ctx) :- target(q, "type", t, _), typ(t, n, _), typeref(c, n, ctx, _, _), sig_words(ctx, _). +direct(q, c, "uses", cat("names it in its signature (", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, 0), sig_words(ctx, w), !inside_target(q, c), !sig_at_line(q, c, ctx). +direct(q, c, "uses", cat("names it in its signature (a type argument of ", w, ")"), "resolved", "", 0) :- target(q, "type", t, _), sigtype(c, t, ctx, d), d > 0, sig_words(ctx, w), !inside_target(q, c), !sig_at_line(q, c, ctx). 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), !sig_resolved(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 549f6ebd..1424c15b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -2438,8 +2438,9 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): 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(), [] - sig_resolved = set() # callables whose signature type_use resolves to it (273b) # alias_over(q, a), and the aliases still to expand + over, todo = set(), [] # alias_over(q, a), and the aliases still to expand + sig_resolved = set() # callables whose signature type_use resolves to it (273b) + sig_at_line = set() # (c, ctx) rule 273 names with a line: 273b adds no second row # ── a type the container INJECTS (rule 186) ──────────────────────────────────────────────────────────── # direct(q,c,"uses",cat("receives it by dependency injection (",kind,") — …"),"resolved","",0) @@ -2566,7 +2567,8 @@ def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE li for nm, f, l, ctx in trefs(n): c = holder(f, l) if (c, f, l) in other: continue # typeref_other: the name at that line builds another type of it - if c and c not in inside: rows.append((c, 'uses', f'names it ({ctx})', 'by name', f, l)) + if c and c not in inside: + rows.append((c, 'uses', f'names it ({ctx})', 'by name', f, l)); sig_at_line.add((c, ctx)) if c in anames and c not in inside and c not in over: over.add(c); todo.append(c) # 273b — a signature the engine RESOLVED to this type (#1422): `type_use` holds each parameter, return and # type-argument position with the type it names. Java writes no line into type_refs, so rule 273 matched none of @@ -2579,6 +2581,7 @@ def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE li AND context IN ('METHOD_PARAM', 'METHOD_RETURN')""", t): if not c or c in inside: continue sig_resolved.add(c) + if (c, ctx) in sig_at_line: continue # rule 273 already names this signature at its line what = SIG_CTX_WORDS.get(ctx, ctx.lower()) if depth and int(depth) > 0: what = f"a type argument of {what}" rows.append((c, 'uses', f'names it in its signature ({what})', 'resolved', '', 0)) From 022202148797d9eed5cc76bb283d2964f67f5516 Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 23:54:04 -0700 Subject: [PATCH 078/258] tests: typescript/pipeline-array-dispatch is no longer pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: tests/run.py typescript/pipeline-array-dispatch failed 0 of 2 with "marked pending ... but it PASSES — remove the marker". Cause: both checks were marked pending on the gap where a function held in an array had no edge from the code that iterates it. "typescript: a function held in an array or table is run by the call that iterates or indexes it" closed that gap: `impact measure --tests` now reaches src/pipeline.test.ts and `path run widenRange` finds the one-hop dispatch chain. The case was PEND on that commit's parent and passing-while-pending from that commit on. Fix: drop both pending markers, and expect_error on the path check, which now exits 0 with a chain. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- tests/cases/typescript/pipeline-array-dispatch/case.json | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/cases/typescript/pipeline-array-dispatch/case.json b/tests/cases/typescript/pipeline-array-dispatch/case.json index 7a5d03bc..f963d5ed 100644 --- a/tests/cases/typescript/pipeline-array-dispatch/case.json +++ b/tests/cases/typescript/pipeline-array-dispatch/case.json @@ -14,8 +14,7 @@ ], "avoid": [ "0 of 1 test method(s)" - ], - "pending": "#967 \u2014 a callable registered as DATA (an array element) has no edge from whoever iterates it; PEND is decided from want/avoid only, so a crash here is still a failure" + ] }, { "why": "the same silence, one hop earlier: the pipeline's own caller is the iterating function", @@ -29,9 +28,7 @@ ], "avoid": [ "no chain of resolved calls" - ], - "pending": "#967 \u2014 a callable registered as DATA (an array element) has no edge from whoever iterates it; PEND is decided from want/avoid only, so a crash here is still a failure", - "expect_error": true + ] } ] -} \ No newline at end of file +} From 3e7d9a8800e76b112d5d0d2cca2cd4c9cc5611a3 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 00:00:57 -0700 Subject: [PATCH 079/258] path: a by-name lead on a value-callee site no longer hides the unknown verdict Symptom: path Svc.login Store.find, where Svc.login calls this.find() on a field holding promisify(s.find.bind(s)) with s untyped, printed "WOULD connect ... receiver not typed" and never said the connection is unknown or that the callee is a function stored in a field. path viaModule / Auth.login Store.find reported known_edge where the case wanted multi_inferred. Cause: two changes to the same shape landed together. The promisify rules made util.promisify(f) transparent (its value is what f holds) and dropped its platform value, so the wrapper-holder rules keyed on that platform value no longer fire. With a typed bound method the call now resolves straight to it (known_edge, one declaration); with an untyped one the call is an ordinary unresolved site, which the by-name search bridges and returns on before the value-callee report runs, although the engine still marks the site as a field value callee. Fix: the by-name branch skips a chain whose every bridge site is a value callee, so the value-callee report (UNKNOWN, the site, "named like the target") answers. The case now expects known_edge for the two resolved wrappers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/axiomcode-path | 10 ++++++++++ .../javascript/stored-field-callee-shapes/case.json | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 81a60bf3..98ac6f2a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1854,6 +1854,16 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): m, d = hits[0]; chain = read_back(res['parent_opt'], q, m, srcs) if not chain: continue bridges = [(chain[i][0], chain[i + 1][0]) for i in range(len(chain) - 1) if chain[i + 1][1] == 'by-name'] + # A bridge whose every site calls a VALUE (a field, a variable, a parameter) is not a receiver that lacks a + # type: what it holds decides the callee, and the value-callee report below says so and names the site. + if bridges and g.has('ext_unresolved_value_callee') and all( + g.q("SELECT 1 FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ?" + " AND s.callee_name = ? LIMIT 1", c, g.sym[m2]['name']) + and not g.q("SELECT 1 FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id" + " WHERE s.caller_id = ? AND s.callee_name = ?" + " AND s.id NOT IN (SELECT c0 FROM ext_unresolved_value_callee) LIMIT 1", c, g.sym[m2]['name']) + for c, m2 in bridges): + continue print(f" {word} WOULD connect in {d} hop(s) if {len(bridges)} unresolved call(s) resolve the way their name suggests — look at:") for c, m2 in bridges: sites = g.q("SELECT s.file_path f, s.start_line ln FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ? AND s.callee_name = ?", c, g.sym[m2]['name']) diff --git a/tests/cases/javascript/stored-field-callee-shapes/case.json b/tests/cases/javascript/stored-field-callee-shapes/case.json index 12284de9..fadadc0c 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/case.json +++ b/tests/cases/javascript/stored-field-callee-shapes/case.json @@ -241,7 +241,7 @@ "Store.find" ], "want": [ - "→ [multi_inferred · call @ src/shapes.js:44] Store.find" + "→ [known_edge · call @ src/shapes.js:44] Store.find" ], "avoid": [ "the two are independent in this graph" @@ -255,7 +255,7 @@ "Store.find" ], "want": [ - "→ [multi_inferred · call @ src/shapes.js:45] Store.find" + "→ [known_edge · call @ src/shapes.js:45] Store.find" ], "avoid": [] }, From a37769613d0e90a0849f85506514671935b4d1c6 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 00:07:00 -0700 Subject: [PATCH 080/258] impact: file:line inside a method body names that method, though the line assigns a field Symptom: `impact lib.py:3` on `self.items = []` inside `Orders.__init__` answered "field Orders.items" instead of `Orders.__init__` (python/lambda-is-named-by-its-place, control "file:line inside a method body is still that method"). Cause: field_rows now resolves `file.ext:N` to a field recorded on that line, and the resolver returns that field unless a callable STARTS on the line. A Python attribute assigned in a method body is recorded as a field on the assigning line, so the enclosing method lost to it. G.decl_at_line already refused it; the const-on-the-line branch after it did not look at enclosing callables. Fix: drop the field candidate for a file:line when a callable starting above the line spans it (not a module or class body, not a lambda, not a field-initializer node, and not through a type declared inside that callable). A field line in a class body or at module level, and a const whose initializer holds a function, resolve as before. Tests: python/lambda-is-named-by-its-place 26/26 (25/26 before). tests/run.py --lang python 271/271 (270/271 before); java 304/304, typescript 181/185, javascript 242/245, the same failing checks as before the change. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 19786acc..a2b15125 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -561,6 +561,10 @@ class Impact: # A CLASS field is the other way round: G.decl_at_line has already taken it when only its own initializer node # or lambda is written there, so what is left on its line is a callable of its own that its initializer # declares (`static cfg = make({ run() {…} })`), and that callable is what the line names (#1752) + # A field ASSIGNED inside a callable's body (`self.items = []` in `__init__`) is not declared on that line: the + # line is in the body of the callable whose header is above it, and that callable is what the line names + if kind is None and out and out[0][0] == 'field' and re.fullmatch(r'.+\.\w+:\d+', s) and self.in_body(out[0][2][0]): + out = out[1:] if kind is None and out and out[0][0] == 'field' and re.fullmatch(r'.+\.\w+:\d+', s): at = [i for i in self.methods(s, soft=True) if self.g.sym[i].get('line') == out[0][2][0]['line']] if not {self.g.sym[i]['name'] for i in at} & {f['name'] for f in out[0][2]}: @@ -670,6 +674,17 @@ class Impact: elif all((f['owner'] or '') == owner for f in rows): won = f"a field '{parts[-1]}' declared on {owner}" else: won = f"a field '{parts[-1]}' on a type whose name ends in '{owner}'" return {'step': 'field', 'won': won, 'ids': [], 'rows': [(f['display'], f"{f['file']}:{f['line']}") for f in rows]} + def in_body(self, f): + """is field row `f` written inside the body of a callable that starts above its line? A module, a class body, + a lambda and the node that runs field initializers are not such a body: a field on their lines is declared there, + and so is a field of a type declared inside the callable (a local or anonymous class)""" + for r in self.g.q("SELECT id, name, line FROM symbols WHERE file = ? AND line < ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module'", + f['file'], f['line'], f['line']): + if r['name'] in P.FIELD_OWNED_NAMES or self.g.is_lambda(r['id']): continue + if self.g.q("SELECT 1 FROM symbols WHERE file = ? AND type_id IS NOT NULL AND method_id IS NULL AND line > ? AND line <= ? AND end_line >= ? LIMIT 1", + f['file'], r['line'], f['line'], f['line']): continue + return True + return False 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 From 16539a629b9d42181c53eb87f3439e2d73e88706 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:29:42 -0700 Subject: [PATCH 081/258] parser: a bare import of a package the repository declares binds to its source A monorepo imports a sibling package by its name, and that package's main / types / exports name build output that is not in the source tree. tsc then resolved nothing, or a dist file no program walks, so every call, type and const across the package boundary was matched by name only. The package-entry rows already map a dist target back to the source it is built from; import resolution now uses the same mapping (subpaths and patterns included) for any bare specifier whose package.json lives under the walked tree, in the TypeScript and JavaScript front ends. A package tsc resolves inside node_modules is left alone. impact: asked of an interface method, the implementations the engine dispatches to are listed under "must change with it". Without an override table they had no row at all, and one only surfaced through a wrong by-name edge that the resolved import removed. The hook's SQL summary gets the same leg and now drops shape-only pairs as the rules do. Tests: new TypeScript case 85-workspace-package-import (scoped package with exports, subpath, pattern, unscoped deep import, a field-typed receiver, and a third-party control); the three JavaScript alias cases fold into 70-bare-specifier-to-project-source with a workspace-package table row; the dispatch-base CLI case checks the base side. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/bundler-alias}/legacy/app.js | 0 .../src/bundler-alias}/legacy/lib/extra.js | 0 .../src/bundler-alias}/legacy/lib/index.js | 0 .../bundler-alias}/legacy/lib/util/trim.js | 0 .../bundler-alias}/legacy/webpack.config.cjs | 0 .../src/bundler-alias}/mapped/jsconfig.json | 0 .../src/bundler-alias}/mapped/own/date.js | 0 .../src/bundler-alias}/mapped/probe.js | 0 .../src/bundler-alias}/package.json | 0 .../src/bundler-alias}/shared/fmt.js | 0 .../src/bundler-alias}/src/pages/Home.js | 0 .../src/bundler-alias}/src/utils/date.js | 0 .../src/bundler-alias}/vite.config.js | 0 .../src/framework-alias}/kit/jsconfig.json | 0 .../src/framework-alias}/kit/src/lib/api.js | 0 .../framework-alias}/kit/src/routes/page.js | 0 .../framework-alias}/nuxt/server/api/price.js | 0 .../src/framework-alias}/nuxt/tsconfig.json | 0 .../src/framework-alias}/nuxt/utils/price.js | 0 .../framework-alias}/nuxt4/app/pages/index.js | 0 .../framework-alias}/nuxt4/app/utils/date.js | 0 .../src/framework-alias}/nuxt4/tsconfig.json | 0 .../src/framework-alias}/other/jsconfig.json | 0 .../src/framework-alias}/other/probe.js | 0 .../framework-alias}/other/src/lib/helper.js | 0 .../src/framework-alias}/own/jsconfig.json | 0 .../src/framework-alias}/own/probe.js | 0 .../src/framework-alias}/own/shared/util.js | 0 .../src/framework-alias}/own/src/lib/util.js | 0 .../src/framework-alias}/package.json | 0 .../src/jsconfig-paths}/app.js | 0 .../src/jsconfig-paths}/jsconfig.json | 0 .../src/jsconfig-paths}/lib/check.js | 0 .../src/jsconfig-paths}/noalias/jsconfig.json | 0 .../src/jsconfig-paths}/noalias/probe.js | 0 .../src/jsconfig-paths}/package.json | 0 .../remix/app/models/note.server.js | 0 .../src/jsconfig-paths}/remix/app/route.js | 0 .../src/jsconfig-paths}/remix/tsconfig.json | 0 .../workspace-package/apps/app/package.json | 1 + .../workspace-package/apps/app/src/main.js | 13 ++ .../packages/lib/package.json | 8 ++ .../packages/lib/src/index.js | 9 ++ .../packages/lib/src/sub/index.js | 3 + .../packages/tools/package.json | 1 + .../packages/tools/src/deep.js | 3 + .../70-bare-specifier-to-project-source.diag | 35 ++++++ .../70-bare-specifier-to-project-source.edges | 35 ++++++ ...70-bare-specifier-to-project-source.oracle | 9 ++ .../expected/70-jsconfig-path-alias.diag | 3 - .../expected/70-jsconfig-path-alias.edges | 4 - .../expected/70-jsconfig-path-alias.oracle | 2 - .../expected/71-bundler-config-alias.diag | 21 ---- .../expected/71-bundler-config-alias.edges | 18 --- .../expected/71-bundler-config-alias.oracle | 8 -- .../71-framework-generated-config-alias.diag | 3 - .../71-framework-generated-config-alias.edges | 7 -- ...71-framework-generated-config-alias.oracle | 1 - .../src/apps/app/package.json | 1 + .../src/apps/app/src/local.ts | 1 + .../src/apps/app/src/main.ts | 25 ++++ .../src/apps/app/types/zod.ts | 4 + .../src/package.json | 1 + .../src/packages/lib/package.json | 10 ++ .../src/packages/lib/src/feature/flags.ts | 3 + .../src/packages/lib/src/index.ts | 7 ++ .../src/packages/lib/src/sub/index.ts | 1 + .../src/packages/tools/package.json | 1 + .../src/packages/tools/src/deep.ts | 1 + .../src/packages/tools/src/index.ts | 1 + .../src/pnpm-workspace.yaml | 3 + .../85-workspace-package-import.edges | 8 ++ .../85-workspace-package-import.entries | 3 + .../85-workspace-package-import.fields | 1 + .../85-workspace-package-import.fields-oracle | 7 ++ .../85-workspace-package-import.oracle | 1 + .../85-workspace-package-import.type-use | 1 + .../85-workspace-package-import.types-oracle | 7 ++ .../typescript/ground-truth/tsc-program.mjs | 26 ++++ .../imports/TsImportResolutionKind.ts | 5 + .../extractors/js-fact-extractor.ts | 6 + .../extractors/js-module-edge-extractor.ts | 22 +++- .../extractors/ts-fact-extractor.ts | 22 ++-- .../extractors/ts-import-extractor.ts | 35 +++++- .../typescript/ts-package-entry-extractor.ts | 64 +++++++++- .../parsers/typescript/workspace-packages.ts | 111 ++++++++++++++++++ .../javascript/javascript-project-analyzer.ts | 19 +++ .../typescript/typescript-project-analyzer.ts | 43 +++++++ .../skills/axiomcode/scripts/dl/impact.dl | 6 + .../skills/axiomcode/scripts/graph_sql.py | 18 +++ .../dispatch-base-is-a-contract/case.json | 6 +- 91 files changed, 567 insertions(+), 87 deletions(-) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/app.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/extra.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/index.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/util/trim.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/webpack.config.cjs (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/jsconfig.json (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/own/date.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/probe.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/package.json (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/shared/fmt.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/src/pages/Home.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/src/utils/date.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/vite.config.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/src/lib/api.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/src/routes/page.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/server/api/price.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/tsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/utils/price.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/app/pages/index.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/app/utils/date.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/tsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/probe.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/src/lib/helper.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/probe.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/shared/util.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/src/lib/util.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/package.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/app.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/jsconfig.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/lib/check.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/noalias/jsconfig.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/noalias/probe.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/package.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/app/models/note.server.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/app/route.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/tsconfig.json (100%) create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.diag create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.edges create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.diag delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.edges delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.oracle delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.diag delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.edges delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.oracle delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.diag delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.edges delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.oracle create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml create mode 100644 graph/test/typescript/expected/85-workspace-package-import.edges create mode 100644 graph/test/typescript/expected/85-workspace-package-import.entries create mode 100644 graph/test/typescript/expected/85-workspace-package-import.fields create mode 100644 graph/test/typescript/expected/85-workspace-package-import.fields-oracle create mode 100644 graph/test/typescript/expected/85-workspace-package-import.oracle create mode 100644 graph/test/typescript/expected/85-workspace-package-import.type-use create mode 100644 graph/test/typescript/expected/85-workspace-package-import.types-oracle create mode 100644 parser/src/parsers/typescript/workspace-packages.ts diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/app.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/app.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/extra.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/extra.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/index.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/index.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/util/trim.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/util/trim.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/webpack.config.cjs similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/webpack.config.cjs diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/jsconfig.json diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/own/date.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/own/date.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/probe.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/probe.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/package.json similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/package.json diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/shared/fmt.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/shared/fmt.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/pages/Home.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/pages/Home.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/utils/date.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/utils/date.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/vite.config.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/vite.config.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/lib/api.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/lib/api.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/lib/api.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/lib/api.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/routes/page.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/routes/page.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/routes/page.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/routes/page.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/server/api/price.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/server/api/price.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/server/api/price.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/server/api/price.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/tsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/utils/price.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/utils/price.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/utils/price.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/utils/price.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/pages/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/pages/index.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/pages/index.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/pages/index.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/utils/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/utils/date.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/utils/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/utils/date.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/tsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/probe.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/probe.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/src/lib/helper.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/src/lib/helper.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/src/lib/helper.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/src/lib/helper.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/probe.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/probe.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/shared/util.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/shared/util.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/shared/util.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/shared/util.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/src/lib/util.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/src/lib/util.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/src/lib/util.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/src/lib/util.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/package.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/package.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/app.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/app.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/jsconfig.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/lib/check.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/lib/check.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/jsconfig.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/probe.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/probe.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/package.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/package.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/models/note.server.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/models/note.server.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/route.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/route.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/tsconfig.json diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json new file mode 100644 index 00000000..6eba3270 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json @@ -0,0 +1 @@ +{ "name": "@ws/app", "type": "module" } diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js new file mode 100644 index 00000000..dbef4e0f --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js @@ -0,0 +1,13 @@ +import { f, Bus } from '@ws/lib'; +import { g } from '@ws/lib/sub'; +import { deep } from 'ws-tools/deep'; +// Control: a package this repository does not declare stays unresolved. +import { outside } from 'not-in-this-repo'; + +export function run() { + f(); + g(); + deep(); + new Bus().publish('k'); + return outside(); +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json new file mode 100644 index 00000000..4388cd84 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ws/lib", + "main": "./dist/index.js", + "exports": { + ".": { "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./sub": "./dist/sub/index.js" + } +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js new file mode 100644 index 00000000..b4154a53 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js @@ -0,0 +1,9 @@ +export function f() { + return 1; +} + +export class Bus { + publish(key) { + return key; + } +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js new file mode 100644 index 00000000..ac3e27ac --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js @@ -0,0 +1,3 @@ +export function g() { + return 2; +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json new file mode 100644 index 00000000..c1cd873a --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json @@ -0,0 +1 @@ +{ "name": "ws-tools", "main": "dist/index.js" } diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js new file mode 100644 index 00000000..8539777a --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js @@ -0,0 +1,3 @@ +export function deep() { + return 3; +} diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag b/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag new file mode 100644 index 00000000..fe9810d3 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag @@ -0,0 +1,35 @@ +import_cause bundler-alias/legacy/app.js:4:10 Lib/extra not_staged +import_cause bundler-alias/legacy/app.js:5:10 Loose/extra not_staged +import_cause bundler-alias/legacy/webpack.config.cjs:1:14 path builtin +import_cause bundler-alias/src/pages/Home.js:6:10 @utils/date not_staged +import_cause bundler-alias/vite.config.js:1:10 vite not_staged +import_cause bundler-alias/vite.config.js:2:1 path builtin +import_cause bundler-alias/vite.config.js:3:10 node:url builtin +import_cause bundler-alias/vite.config.js:3:25 node:url builtin +import_cause framework-alias/other/probe.js:2:10 $lib/helper not_staged +import_cause jsconfig-paths/noalias/probe.js:2:10 @/check not_staged +import_cause workspace-package/apps/app/src/main.js:5:10 not-in-this-repo not_staged +package_entry @ws/app . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry @ws/lib . [] MAIN dist/index.js MISSING_FILE -> - +package_entry @ws/lib . [import] EXPORTS dist/index.mjs MISSING_FILE -> - +package_entry @ws/lib . [require] EXPORTS dist/index.js MISSING_FILE -> - +package_entry @ws/lib ./sub [] EXPORTS dist/sub/index.js MISSING_FILE -> - +package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry framework-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry path-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry ws-tools . [] MAIN dist/index.js MISSING_FILE -> - +unresolved bundler-alias/legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped +unresolved bundler-alias/legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped +unresolved bundler-alias/legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped +unresolved bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target +unresolved bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target +unresolved bundler-alias/shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped +unresolved bundler-alias/src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped +unresolved bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String no_target +unresolved bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target +unresolved bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target +unresolved bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped +unresolved bundler-alias/vite.config.js:9:12 METHOD_CALL resolve receiver_untyped +unresolved framework-alias/other/probe.js:5:10 FUNCTION_CALL helper callee_untyped +unresolved jsconfig-paths/noalias/probe.js:5:10 FUNCTION_CALL checkA callee_untyped +unresolved workspace-package/apps/app/src/main.js:12:10 FUNCTION_CALL outside callee_untyped diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges b/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges new file mode 100644 index 00000000..6dc90ef1 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges @@ -0,0 +1,35 @@ +bundler-alias/legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - +bundler-alias/legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge bundler-alias/legacy/lib/util/trim.js:1:1 trim +bundler-alias/legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge bundler-alias/legacy/lib/index.js:1:1 boot +bundler-alias/legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - +bundler-alias/legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - +bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - +bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - +bundler-alias/mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge bundler-alias/mapped/own/date.js:1:1 fmtDate +bundler-alias/shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - +bundler-alias/src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - +bundler-alias/src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge bundler-alias/src/utils/date.js:1:1 fmtDate +bundler-alias/src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge bundler-alias/shared/fmt.js:1:1 fmtMoney +bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - +bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - +bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered bundler-alias/vite.config.js:6:29 +bundler-alias/vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - +framework-alias/kit/src/routes/page.js:10:10 FUNCTION_CALL post -> known_edge framework-alias/kit/src/lib/api.js:5:1 post +framework-alias/kit/src/routes/page.js:6:10 METHOD_CALL api.get -> known_edge framework-alias/kit/src/lib/api.js:1:1 get +framework-alias/nuxt/server/api/price.js:6:10 FUNCTION_CALL formatPrice -> known_edge framework-alias/nuxt/utils/price.js:1:1 formatPrice +framework-alias/nuxt/server/api/price.js:6:27 FUNCTION_CALL viaAt -> known_edge framework-alias/nuxt/utils/price.js:1:1 formatPrice +framework-alias/nuxt4/app/pages/index.js:5:10 FUNCTION_CALL formatDate -> known_edge framework-alias/nuxt4/app/utils/date.js:1:1 formatDate +framework-alias/other/probe.js:5:10 FUNCTION_CALL helper -> ambiguous_unknown - +framework-alias/own/probe.js:5:10 FUNCTION_CALL pick -> known_edge framework-alias/own/shared/util.js:1:1 pick +jsconfig-paths/app.js:11:10 FUNCTION_CALL checkB -> known_edge jsconfig-paths/lib/check.js:5:1 checkB +jsconfig-paths/app.js:7:10 FUNCTION_CALL checkA -> known_edge jsconfig-paths/lib/check.js:1:1 checkA +jsconfig-paths/noalias/probe.js:5:10 FUNCTION_CALL checkA -> ambiguous_unknown - +jsconfig-paths/remix/app/route.js:5:10 FUNCTION_CALL getNote -> known_edge jsconfig-paths/remix/app/models/note.server.js:1:1 getNote +workspace-package/apps/app/src/main.js:10:3 FUNCTION_CALL deep -> known_edge workspace-package/packages/tools/src/deep.js:1:1 deep +workspace-package/apps/app/src/main.js:11:3 CONSTRUCTOR_CALL Bus -> implicit_constructor - +workspace-package/apps/app/src/main.js:11:3 METHOD_CALL new Bus().publish -> known_edge workspace-package/packages/lib/src/index.js:6:3 publish +workspace-package/apps/app/src/main.js:12:10 FUNCTION_CALL outside -> ambiguous_unknown - +workspace-package/apps/app/src/main.js:8:3 FUNCTION_CALL f -> known_edge workspace-package/packages/lib/src/index.js:1:1 f +workspace-package/apps/app/src/main.js:9:3 FUNCTION_CALL g -> known_edge workspace-package/packages/lib/src/sub/index.js:1:1 g diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle b/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle new file mode 100644 index 00000000..c5403e62 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle @@ -0,0 +1,9 @@ +bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK +bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK +bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK +bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK +bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED +bundler-alias/vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK +jsconfig-paths/app.js:11:10 FUNCTION_CALL checkB EXACT jsconfig-paths/lib/check.js:5:1 +# defects: 0 diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.diag b/graph/test/javascript/expected/70-jsconfig-path-alias.diag deleted file mode 100644 index 74d4aa41..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.diag +++ /dev/null @@ -1,3 +0,0 @@ -import_cause noalias/probe.js:2:10 @/check not_staged -package_entry path-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved noalias/probe.js:5:10 FUNCTION_CALL checkA callee_untyped diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.edges b/graph/test/javascript/expected/70-jsconfig-path-alias.edges deleted file mode 100644 index 09a8c77c..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.edges +++ /dev/null @@ -1,4 +0,0 @@ -app.js:11:10 FUNCTION_CALL checkB -> known_edge lib/check.js:5:1 checkB -app.js:7:10 FUNCTION_CALL checkA -> known_edge lib/check.js:1:1 checkA -noalias/probe.js:5:10 FUNCTION_CALL checkA -> ambiguous_unknown - -remix/app/route.js:5:10 FUNCTION_CALL getNote -> known_edge remix/app/models/note.server.js:1:1 getNote diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.oracle b/graph/test/javascript/expected/70-jsconfig-path-alias.oracle deleted file mode 100644 index 16df3e53..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.oracle +++ /dev/null @@ -1,2 +0,0 @@ -app.js:11:10 FUNCTION_CALL checkB EXACT lib/check.js:5:1 -# defects: 0 diff --git a/graph/test/javascript/expected/71-bundler-config-alias.diag b/graph/test/javascript/expected/71-bundler-config-alias.diag deleted file mode 100644 index d46236f8..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.diag +++ /dev/null @@ -1,21 +0,0 @@ -import_cause legacy/app.js:4:10 Lib/extra not_staged -import_cause legacy/app.js:5:10 Loose/extra not_staged -import_cause legacy/webpack.config.cjs:1:14 path builtin -import_cause src/pages/Home.js:6:10 @utils/date not_staged -import_cause vite.config.js:1:10 vite not_staged -import_cause vite.config.js:2:1 path builtin -import_cause vite.config.js:3:10 node:url builtin -import_cause vite.config.js:3:25 node:url builtin -package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped -unresolved legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped -unresolved legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped -unresolved legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target -unresolved legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target -unresolved shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped -unresolved src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped -unresolved src/utils/date.js:2:10 FUNCTION_CALL String no_target -unresolved vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target -unresolved vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target -unresolved vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped -unresolved vite.config.js:9:12 METHOD_CALL resolve receiver_untyped diff --git a/graph/test/javascript/expected/71-bundler-config-alias.edges b/graph/test/javascript/expected/71-bundler-config-alias.edges deleted file mode 100644 index db2535ff..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.edges +++ /dev/null @@ -1,18 +0,0 @@ -legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - -legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge legacy/lib/util/trim.js:1:1 trim -legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge legacy/lib/index.js:1:1 boot -legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - -legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - -legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - -legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - -mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge mapped/own/date.js:1:1 fmtDate -shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - -src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - -src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge src/utils/date.js:1:1 fmtDate -src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge shared/fmt.js:1:1 fmtMoney -src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - -vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - -vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - -vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - -vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered vite.config.js:6:29 -vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - diff --git a/graph/test/javascript/expected/71-bundler-config-alias.oracle b/graph/test/javascript/expected/71-bundler-config-alias.oracle deleted file mode 100644 index 2d722071..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.oracle +++ /dev/null @@ -1,8 +0,0 @@ -legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK -legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK -src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK -vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK -vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK -vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED -vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK -# defects: 0 diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.diag b/graph/test/javascript/expected/71-framework-generated-config-alias.diag deleted file mode 100644 index 008e2a73..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.diag +++ /dev/null @@ -1,3 +0,0 @@ -import_cause other/probe.js:2:10 $lib/helper not_staged -package_entry framework-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved other/probe.js:5:10 FUNCTION_CALL helper callee_untyped diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.edges b/graph/test/javascript/expected/71-framework-generated-config-alias.edges deleted file mode 100644 index 916bc000..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.edges +++ /dev/null @@ -1,7 +0,0 @@ -kit/src/routes/page.js:10:10 FUNCTION_CALL post -> known_edge kit/src/lib/api.js:5:1 post -kit/src/routes/page.js:6:10 METHOD_CALL api.get -> known_edge kit/src/lib/api.js:1:1 get -nuxt/server/api/price.js:6:10 FUNCTION_CALL formatPrice -> known_edge nuxt/utils/price.js:1:1 formatPrice -nuxt/server/api/price.js:6:27 FUNCTION_CALL viaAt -> known_edge nuxt/utils/price.js:1:1 formatPrice -nuxt4/app/pages/index.js:5:10 FUNCTION_CALL formatDate -> known_edge nuxt4/app/utils/date.js:1:1 formatDate -other/probe.js:5:10 FUNCTION_CALL helper -> ambiguous_unknown - -own/probe.js:5:10 FUNCTION_CALL pick -> known_edge own/shared/util.js:1:1 pick diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.oracle b/graph/test/javascript/expected/71-framework-generated-config-alias.oracle deleted file mode 100644 index 8675b52d..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.oracle +++ /dev/null @@ -1 +0,0 @@ -# defects: 0 diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json new file mode 100644 index 00000000..d23a4a6c --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json @@ -0,0 +1 @@ +{ "name": "@x/app", "private": true, "dependencies": { "@x/lib": "workspace:*", "tools": "workspace:*", "zod": "^3.0.0" } } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts new file mode 100644 index 00000000..e21076ca --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts @@ -0,0 +1 @@ +export function local(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts new file mode 100644 index 00000000..74be7308 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts @@ -0,0 +1,25 @@ +import { f, Bus, K } from '@x/lib'; +import { g } from '@x/lib/sub'; +import { isOn } from '@x/lib/feature/flags'; +import { tool } from 'tools'; +import { deep } from 'tools/deep'; +// Control: a third-party package no workspace declares stays a library import. +import { z } from 'zod'; + +import { local } from './local'; + +export class S { + constructor(private bus: Bus) {} + + run(): void { + f(); + g(); + this.bus.publish(K); + if (isOn('x')) { + tool(); + } + deep(); + local(); + z.string(); + } +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts new file mode 100644 index 00000000..0e1757b6 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts @@ -0,0 +1,4 @@ +// A third-party package, declared the way its own types would declare it. +declare module 'zod' { + export const z: { string(): unknown }; +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/package.json new file mode 100644 index 00000000..43c63464 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/package.json @@ -0,0 +1 @@ +{ "name": "ws-root", "private": true } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json new file mode 100644 index 00000000..aa2d2aff --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json @@ -0,0 +1,10 @@ +{ + "name": "@x/lib", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./sub": { "types": "./dist/sub/index.d.ts", "default": "./dist/sub/index.js" }, + "./feature/*": { "types": "./dist/feature/*.d.ts", "default": "./dist/feature/*.js" } + } +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts new file mode 100644 index 00000000..817e02d5 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts @@ -0,0 +1,3 @@ +export function isOn(name: string): boolean { + return name !== ''; +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts new file mode 100644 index 00000000..87c7fa1a --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts @@ -0,0 +1,7 @@ +export function f(): void {} + +export class Bus { + publish(key: string): void {} +} + +export const K = 'k'; diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts new file mode 100644 index 00000000..41f7f92b --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts @@ -0,0 +1 @@ +export function g(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json new file mode 100644 index 00000000..92a5cc83 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json @@ -0,0 +1 @@ +{ "name": "tools", "main": "dist/index.js" } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts new file mode 100644 index 00000000..50bbcc3f --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts @@ -0,0 +1 @@ +export function deep(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts new file mode 100644 index 00000000..d53b1d0a --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts @@ -0,0 +1 @@ +export function tool(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml b/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml new file mode 100644 index 00000000..4e708bd3 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'packages/*' + - 'apps/*' diff --git a/graph/test/typescript/expected/85-workspace-package-import.edges b/graph/test/typescript/expected/85-workspace-package-import.edges new file mode 100644 index 00000000..d8ac1ca7 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.edges @@ -0,0 +1,8 @@ +known_edge FUNCTION_CALL S#run() @L15 -> packages/lib/src/index#f() +known_edge FUNCTION_CALL S#run() @L16 -> packages/lib/src/sub/index#g() +known_edge FUNCTION_CALL S#run() @L18 -> packages/lib/src/feature/flags#isOn(string) +known_edge FUNCTION_CALL S#run() @L19 -> packages/tools/src/index#tool() +known_edge FUNCTION_CALL S#run() @L21 -> packages/tools/src/deep#deep() +known_edge FUNCTION_CALL S#run() @L22 -> apps/app/src/local#local() +known_edge METHOD_CALL S#run() @L17 -> Bus#publish(string) +known_edge METHOD_CALL S#run() @L23 -> apps/app/types/zod#string() diff --git a/graph/test/typescript/expected/85-workspace-package-import.entries b/graph/test/typescript/expected/85-workspace-package-import.entries new file mode 100644 index 00000000..79185187 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.entries @@ -0,0 +1,3 @@ +── entry_point (2) ── + unimported_module apps/app/src/main# main.ts:1 + unimported_module apps/app/types/zod# zod.ts:1 diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields b/graph/test/typescript/expected/85-workspace-package-import.fields new file mode 100644 index 00000000..565a7ee6 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.fields @@ -0,0 +1 @@ +known_edge read S#run() -> S#bus diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle new file mode 100644 index 00000000..44b1f6d7 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle @@ -0,0 +1,7 @@ +85-workspace-package-import [fields] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/85-workspace-package-import.oracle b/graph/test/typescript/expected/85-workspace-package-import.oracle new file mode 100644 index 00000000..00f772ed --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.oracle @@ -0,0 +1 @@ +oracle=8 engine=8 agree=8 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/85-workspace-package-import.type-use b/graph/test/typescript/expected/85-workspace-package-import.type-use new file mode 100644 index 00000000..28bd1b65 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.type-use @@ -0,0 +1 @@ +known_edge METHOD_PARAM 0 S [METHOD_PARAM] -> Bus diff --git a/graph/test/typescript/expected/85-workspace-package-import.types-oracle b/graph/test/typescript/expected/85-workspace-package-import.types-oracle new file mode 100644 index 00000000..ef6d2236 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.types-oracle @@ -0,0 +1,7 @@ +85-workspace-package-import [types] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + contexts METHOD_PARAM=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index 907c664c..020f6614 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -105,6 +105,32 @@ export function loadProgram(srcDir, libDir, toolName, programDir) { } } + // ── a WORKSPACE PACKAGE below the case root, imported by its name ─────────── + // In a real monorepo the compiler reaches a sibling package through a node_modules + // link and the declarations its build wrote. A case holds neither, so the program is + // told where each named package's source is by the plainest convention there is: the + // package is `/src/index.ts`, and `/x` is `/src/x`. Deliberately not + // the parser's rule (which reads main / types / exports): the two must agree on the + // answer without sharing the reasoning. A case with no nested package.json is unchanged. + const workspacePaths = {}; + (function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory() && e.name !== 'node_modules') { + walk(p); + } else if (e.name === 'package.json' && d !== root) { + const name = JSON.parse(fs.readFileSync(p, 'utf-8')).name; + if (typeof name === 'string' && name !== '') { + workspacePaths[name] = [path.join(d, 'src', 'index.ts')]; + workspacePaths[`${name}/*`] = [path.join(d, 'src', '*'), path.join(d, 'src', '*', 'index.ts')]; + } + } + } + })(root); + if (Object.keys(workspacePaths).length > 0) { + options.paths = { ...workspacePaths, ...(options.paths ?? {}) }; + } + const program = ts.createProgram(files, options); const checker = program.getTypeChecker(); // CALLERS come only from the client. TARGETS may be either, which is what makes the diff --git a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts index d2d32d53..bc36e385 100644 --- a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts +++ b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts @@ -41,6 +41,11 @@ export enum TsImportResolutionKind { NODE_MODULES_SOURCE = 'NODE_MODULES_SOURCE', /** Resolved through a package's `exports` map. */ PACKAGE_EXPORTS = 'PACKAGE_EXPORTS', + /** + * A package this repository declares, imported by its name: bound to the walked + * source its `main` / `types` / `exports` entry is built from. + */ + WORKSPACE_PACKAGE = 'WORKSPACE_PACKAGE', /** Matched a `declare module "x"` in this analysis. No file, and none needed. */ AMBIENT_MODULE = 'AMBIENT_MODULE', /** A Node builtin, with or without the `node:` prefix. */ diff --git a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts index 63933436..b2a41e37 100644 --- a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts @@ -111,6 +111,11 @@ export interface JsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** + * A bare specifier naming a package this repository declares -> the absolute path of + * the walked source module its entry is built from (`WorkspacePackages`). + */ + readonly resolveWorkspaceModule?: (specifier: string) => string | undefined; /** * How `sourceText` parses, when the file's extension cannot say: a `.vue` * component's virtual script is JS or JSX by its `lang`, not by its name. @@ -384,6 +389,7 @@ export function extractJavaScriptFile(options: JsFileExtractionOptions): JsFileF typeHashByNode: declarations.typeHashByNode, moduleInitMethodHash: declarations.moduleInitMethodHash, toProjectRelative: options.toProjectRelative, + resolveWorkspaceModule: options.resolveWorkspaceModule, projectModuleHashes: options.projectModuleHashes, declarationTargetByName, }); diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index de97820e..50bd20c4 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -88,6 +88,8 @@ export interface ModuleEdgeExtractionOptions { readonly toProjectRelative: (absolutePath: string) => string; /** Absolute paths the analysis covers, for `RESOLVED_PROJECT`. */ readonly projectModuleHashes: ReadonlyMap; + /** A bare specifier naming a package this repository declares -> its walked source module. */ + readonly resolveWorkspaceModule?: (specifier: string) => string | undefined; /** Declarations by name, so an export can point at what it exports. */ /** * Every declaration under a name, in source order, with its offset. @@ -1214,21 +1216,33 @@ class JsModuleEdgeExtractor { // component in this program is looked up by path. const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath, this.options.compilerOptions); - if (resolved === undefined) { - return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; - } // BOTH SIDES CANONICAL. `projectModuleHashes` is keyed by the files the analyzer // walked from a root it has already resolved through its symlinks; the resolver // answers with the real path for a package found under `node_modules` but does NOT // realpath a relative specifier, so the two sides are compared as real paths and the // spelling of the root cannot decide the outcome any more (#795). - const absolute = realPathOfResolved(path.normalize(resolved)); + const absolute = resolved === undefined ? '' : realPathOfResolved(path.normalize(resolved)); if (this.options.projectModuleHashes.has(absolute)) { return { filePath: this.options.toProjectRelative(absolute), outcome: JsImportResolutionOutcome.RESOLVED_PROJECT, }; } + // A package this repository declares, imported by its name, whose entry names build + // output that was not walked (or not built): the walked source it is built from is + // what the import means. Never for a package installed under `node_modules`. + if (!absolute.includes(`${path.sep}node_modules${path.sep}`)) { + const workspace = this.options.resolveWorkspaceModule?.(specifier); + if (workspace !== undefined) { + return { + filePath: this.options.toProjectRelative(workspace), + outcome: JsImportResolutionOutcome.RESOLVED_PROJECT, + }; + } + } + if (resolved === undefined) { + return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; + } return { filePath: absolute.split(path.sep).join('/'), outcome: JsImportResolutionOutcome.RESOLVED_EXTERNAL, diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts index da67a6b4..9f312e3c 100644 --- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -36,7 +36,7 @@ import { extractExports } from '@/parsers/typescript/extractors/ts-export-extrac import { TsExpressionExtractor } from '@/parsers/typescript/extractors/ts-expression-extractor'; import { TsExpressionWalker } from '@/parsers/typescript/extractors/ts-expression-walker'; -import { TsImportExtractor } from '@/parsers/typescript/extractors/ts-import-extractor'; +import { TsImportExtractor, WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; import { extractModules } from '@/parsers/typescript/extractors/ts-module-extractor'; import { EngineHandoff, @@ -92,6 +92,8 @@ export interface TsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** A bare specifier naming a package this repository declares -> its walked source module. */ + readonly resolveWorkspaceModule?: (specifier: string) => WorkspaceModule | undefined; /** * How `sourceText` parses, when the file's extension cannot say: a `.vue` * component's virtual script is TS or TSX by its `lang`, not by its name. @@ -181,15 +183,18 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF ); const fileName = resolved.resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, options.absoluteFilePath); - if (!fileName) { - return undefined; - } - const absolute = path.normalize(fileName); + const absolute = fileName ? path.normalize(fileName) : ''; const moduleHash = options.projectModuleHashes.get(absolute); - if (!moduleHash) { - return undefined; + if (moduleHash) { + return { moduleHash, relativePath: options.toProjectRelative(absolute) }; } - return { moduleHash, relativePath: options.toProjectRelative(absolute) }; + // `declare module '@scope/lib'` augmenting a package this repository declares. + const workspace = absolute.includes(`${path.sep}node_modules${path.sep}`) + ? undefined + : options.resolveWorkspaceModule?.(specifier); + return workspace === undefined + ? undefined + : { moduleHash: workspace.moduleHash, relativePath: options.toProjectRelative(workspace.absolutePath) }; }, ambientModuleHashes: modules.ambientModuleHashes, }); @@ -203,6 +208,7 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF serviceVersionLinkHash: options.serviceVersionLinkHash, projectModuleHashes: options.projectModuleHashes, toProjectRelative: options.toProjectRelative, + resolveWorkspaceModule: options.resolveWorkspaceModule, }); const importResult = importExtractor.run(); diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts index 8c7e8952..5536a84f 100644 --- a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts @@ -39,6 +39,17 @@ export interface ImportExtractorOptions { /** Absolute resolved path -> `ts_module` hash, for project-internal targets. */ readonly projectModuleHashes: ReadonlyMap; readonly toProjectRelative: (absolutePath: string) => string; + /** + * The walked source module a bare specifier names when its package is one this + * repository declares (`TsWorkspacePackages`), or `undefined`. + */ + readonly resolveWorkspaceModule?: (specifier: string) => WorkspaceModule | undefined; +} + +export interface WorkspaceModule { + readonly absolutePath: string; + readonly moduleHash: string; + readonly packageName: string; } export interface ImportExtractionResult { @@ -455,6 +466,27 @@ export class TsImportExtractor { packageName: '', }; } + const absolute = module ? path.normalize(module.resolvedFileName) : ''; + const moduleHash = this.options.projectModuleHashes.get(absolute) ?? ''; + const isNodeModules = absolute.includes(`${path.sep}node_modules${path.sep}`); + // A package this repository declares, imported by its name. Its entry names + // build output, so tsc found nothing, or a `dist` file no program walks; the + // source that output is built from is walked, and is what the import means. + // Asked only when tsc landed on no walked module and not inside `node_modules`: + // a real installed package keeps its own resolution. + if (moduleHash === '' && !isNodeModules) { + const workspace = this.options.resolveWorkspaceModule?.(specifier); + if (workspace !== undefined) { + return { + absolutePath: workspace.absolutePath, + relativePath: this.options.toProjectRelative(workspace.absolutePath), + moduleHash: workspace.moduleHash, + kind: TsImportResolutionKind.WORKSPACE_PACKAGE, + extension: path.extname(workspace.absolutePath), + packageName: workspace.packageName, + }; + } + } if (!module) { // `undefined` is an honest answer, not a failure to try. It is also the // right answer for a wildcard ambient specifier like `"*.svg"`, which @@ -468,9 +500,6 @@ export class TsImportExtractor { // is filled here whether resolution succeeded or not. return { ...UNRESOLVED, packageName: packageNameOf(specifier) }; } - const absolute = path.normalize(module.resolvedFileName); - const moduleHash = this.options.projectModuleHashes.get(absolute) ?? ''; - const isNodeModules = absolute.includes(`${path.sep}node_modules${path.sep}`); return { absolutePath: absolute, relativePath: this.options.toProjectRelative(absolute), diff --git a/parser/src/parsers/typescript/ts-package-entry-extractor.ts b/parser/src/parsers/typescript/ts-package-entry-extractor.ts index bda21609..dfa851f0 100644 --- a/parser/src/parsers/typescript/ts-package-entry-extractor.ts +++ b/parser/src/parsers/typescript/ts-package-entry-extractor.ts @@ -70,18 +70,19 @@ function sourceStemsFor(targetPath: string): string[] { * * @returns `[moduleHash, exact]` — `exact` when the target itself is the walked file */ -function sourceModuleFor( +function sourceModuleFor( packageDir: string, targetPath: string, - moduleHashOf: (absolutePath: string) => string | undefined -): [string, boolean] | undefined { + moduleHashOf: (absolutePath: string) => T | undefined, + extensions: readonly string[] = SOURCE_EXTENSIONS +): [T, boolean] | undefined { const exact = moduleHashOf(path.normalize(path.resolve(packageDir, targetPath))); if (exact !== undefined) { return [exact, true]; } for (const stem of sourceStemsFor(targetPath)) { for (const candidate of [stem, `${stem}/index`]) { - for (const extension of SOURCE_EXTENSIONS) { + for (const extension of extensions) { const hash = moduleHashOf(path.normalize(path.resolve(packageDir, candidate + extension))); if (hash !== undefined) { return [hash, false]; @@ -138,6 +139,61 @@ function sourceModulesForPattern( return []; } +/** + * The walked source module a consumer's import of `subpath` (`"."`, `"./sub"`) binds + * to, by the same convention as the entry rows: the package's own entry for that + * subpath, mapped from build output back to source. + * + * `source` and `types` count for `"."` as they do for the rows. A subpath pattern + * substitutes its `*` into the target, as Node does. A package with no `exports` + * publishes every file, so a deep subpath (`"./sub"`, `"./dist/sub"`) is its own + * target. With `exports` present, a subpath it does not list is blocked for Node and + * binds to nothing here either. `extensions` are the source extensions looked for, in + * order: TypeScript's by default, JavaScript's for a JavaScript workspace. + */ +export function sourceModuleForSubpath( + facts: PackageJsonFacts, + subpath: string, + moduleHashOf: (absolutePath: string) => T | undefined, + extensions: readonly string[] = SOURCE_EXTENSIONS +): T | undefined { + const packageDir = path.dirname(facts.path); + const strip = (target: string): string => target.replace(/^\.\//, ''); + const entries = packageEntriesOf(facts); + const targets: string[] = []; + if (subpath === '.' && facts.source !== undefined && facts.source !== '') { + targets.push(strip(facts.source)); + } + for (const entry of entries) { + if (entry.subpath === subpath && entry.targetPath !== '' && !entry.targetPath.includes('*')) { + targets.push(entry.targetPath); + } + } + for (const entry of entries) { + const [before, after, ...more] = entry.subpath.split('*'); + if (after === undefined || more.length > 0 || entry.targetPath === '' + || !subpath.startsWith(before!) || !subpath.endsWith(after) + || subpath.length < before!.length + after.length) { + continue; + } + const match = subpath.slice(before!.length, subpath.length - after.length); + targets.push(entry.targetPath.split('*').join(match)); + } + if (subpath === '.' && facts.types !== undefined && facts.types !== '') { + targets.push(strip(facts.types)); + } + if (subpath !== '.' && facts.exports === undefined) { + targets.push(strip(subpath)); + } + for (const target of targets) { + const found = sourceModuleFor(packageDir, target, moduleHashOf, extensions); + if (found !== undefined) { + return found[0]; + } + } + return undefined; +} + const SOURCE_OF: Record = { [JsPackageEntrySource.MAIN]: TsPackageEntrySource.MAIN, [JsPackageEntrySource.MODULE]: TsPackageEntrySource.MODULE, diff --git a/parser/src/parsers/typescript/workspace-packages.ts b/parser/src/parsers/typescript/workspace-packages.ts new file mode 100644 index 00000000..449f2e4f --- /dev/null +++ b/parser/src/parsers/typescript/workspace-packages.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { PackageJsonFacts, PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; +import { sourceModuleForSubpath } from '@/parsers/typescript/ts-package-entry-extractor'; + +/** + * The packages a repository declares itself, by name: every named `package.json` + * under the walked tree. Shared by the TypeScript and JavaScript front ends. + * + * ## Why import resolution needs this + * + * In a monorepo, app code imports a sibling package by its NAME (`'@scope/lib'`, + * `'@scope/lib/sub'`), and that package's `main` / `types` / `exports` name build + * output (`./dist/index.d.ts`) that is not in the source tree. `ts.resolveModuleName` + * then finds nothing (no `node_modules`), or a `dist` file no program walks (a + * `node_modules` symlink into the repository after a build). Either way the import + * bound to no module, and every function, class and const used across the package + * boundary was matched by name only. + * + * The package is in the repository, though, and its entry names the source it is + * built from by the same convention the entry rows already use + * (`sourceModuleForSubpath`). So a bare specifier whose package is one of these binds + * to that walked source module. + * + * ## What it does not claim + * + * A name two `package.json` files share is ambiguous and binds nothing. A specifier + * tsc resolved into `node_modules` is a real installed package and is never asked + * here, so a dependency that happens to share a name with a fixture stays external. + */ +export class WorkspacePackages { + private constructor(private readonly byName: ReadonlyMap) {} + + /** Every named package at or under `rootDir`, skipping `skipDirectories` and dot directories. */ + static discover(rootDir: string, skipDirectories: ReadonlySet): WorkspacePackages { + const reader = new PackageJsonResolver(); + const byName = new Map(); + const visit = (directory: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isFile() && entry.name === 'package.json') { + const facts = reader.packageAt(directory); + if (facts !== undefined && facts.name !== '') { + byName.set(facts.name, byName.has(facts.name) ? null : facts); + } + } else if (entry.isDirectory() && !entry.name.startsWith('.') + && !skipDirectories.has(entry.name)) { + visit(path.join(directory, entry.name)); + } + } + }; + visit(rootDir); + const unique = new Map(); + for (const [name, facts] of byName) { + if (facts !== null) { + unique.set(name, facts); + } + } + return new WorkspacePackages(unique); + } + + get size(): number { + return this.byName.size; + } + + /** + * The walked source module a bare specifier names, or `undefined` when its package + * is not one of these or its entry maps to no walked source. + * + * @param moduleHashOf the module hash of an absolute, normalised source path, or + * `undefined` when no program walks it + */ + resolve( + specifier: string, + moduleHashOf: (absolutePath: string) => string | undefined, + extensions?: readonly string[] + ): { readonly absolutePath: string; readonly moduleHash: string; readonly packageName: string } | undefined { + const name = bareSpecifierPackageName(specifier); + const facts = name === '' ? undefined : this.byName.get(name); + if (facts === undefined) { + return undefined; + } + const rest = specifier.slice(name.length); + const subpath = rest === '' ? '.' : `.${rest}`; + const found = sourceModuleForSubpath(facts, subpath, + (absolutePath) => { + const moduleHash = moduleHashOf(absolutePath); + return moduleHash === undefined ? undefined : { absolutePath, moduleHash }; + }, extensions); + return found === undefined ? undefined : { ...found, packageName: name }; + } +} + +/** `@scope/name` of `@scope/name/deep`, `name` of `name/deep`; `''` for anything not a bare package specifier. */ +function bareSpecifierPackageName(specifier: string): string { + if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/') + || specifier.includes('*') || specifier.includes(':')) { + return ''; + } + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 && segments[1] !== '' ? `${segments[0]}/${segments[1]}` : ''; + } + return segments[0] ?? ''; +} diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 18e3e6ae..47a90e4a 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -23,6 +23,7 @@ import { import { moduleHashFor } from '@/parsers/javascript/extractors/js-module-extractor'; import { BUNDLER_CONFIG_NAMES, readBundlerAliases } from '@/parsers/javascript/bundler-alias-reader'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; import { buildOutputDirectoriesNamedBy, extractPackageEntries, @@ -58,6 +59,9 @@ import { scriptTextOf } from '@/utils/vue-sfc'; * its columns. `getCsvHeader` reads no instance state — the header is a * constant list — which is why the prototype can answer without a row. */ +/** Source extensions a workspace package's entry is mapped back to, in the order they are looked for. */ +const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; + const HEADER_BY_FILE: Readonly> = { [JAVASCRIPT_CSV_FILES.MODULES]: JsModuleRegistry.prototype.getCsvHeader(), [JAVASCRIPT_CSV_FILES.SCOPES]: JsScopeRegistry.prototype.getCsvHeader(), @@ -348,6 +352,20 @@ export class JavaScriptProjectAnalyzer { } const toProjectRelative = (absolutePath: string): string => stripExtension(toRelative(pathAnchor, absolutePath)); + // A sibling package imported by its name binds to the walked source its entry is + // built from, by the same convention as TypeScript's (`WorkspacePackages`). + const workspacePackages = WorkspacePackages.discover(pathAnchor, excludes); + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): string | undefined => { + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, + (absolutePath) => projectModuleHashes.get(absolutePath), JS_SOURCE_EXTENSIONS)?.absolutePath); + } + return workspaceResolutions.get(specifier); + }; // Every package this parse touched: each walk root's own `package.json`, and // the governing config of every file. What each exposes is a fact of the @@ -462,6 +480,7 @@ export class JavaScriptProjectAnalyzer { compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), projectModuleHashes, toProjectRelative, + resolveWorkspaceModule, }); } catch (error) { // An extraction error is a DEFECT, never a decision. Counted apart diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index ea62a178..447c6fcd 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -27,6 +27,8 @@ import { import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extractor'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { extractTsPackageEntries } from '@/parsers/typescript/ts-package-entry-extractor'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; +import { WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; import { TsConfigResolver } from '@/parsers/typescript/tsconfig-resolver'; import { TsRelationWriter } from './ts-relation-writer'; import { EntityUtils } from '@/utils/entity-utils'; @@ -265,6 +267,34 @@ export class TypeScriptProjectAnalyzer { } const toProjectRelative = (absolutePath: string): string => stripExtension(toRelative(pathAnchor, absolutePath)); + // A sibling package imported by its name binds to the source its entry is built + // from. That source may belong to another program under the same anchor, whose + // module hash is the same pure function of its path; a source file outside the + // anchor or under a skipped directory is walked by no program and binds nothing. + const workspacePackages = this.workspacePackagesAt(pathAnchor); + const sourceModuleHashOf = (absolutePath: string): string | undefined => { + const inProgram = projectModuleHashes.get(absolutePath); + if (inProgram !== undefined) { + return inProgram; + } + const relative = path.relative(pathAnchor, absolutePath); + if (relative.startsWith('..') || path.isAbsolute(relative) || /\.d\.(m|c)?ts$/.test(relative) + || relative.split(path.sep).some((segment) => excludes.has(segment)) + || !fs.existsSync(absolutePath)) { + return undefined; + } + return moduleHashFor(toRelative(pathAnchor, absolutePath), options.baseMservPath, serviceVersionLinkHash); + }; + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): WorkspaceModule | undefined => { + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, sourceModuleHashOf)); + } + return workspaceResolutions.get(specifier); + }; // Skips accumulate across the programs one analyzePrograms call drives; a // standalone analyze starts its own list. @@ -332,6 +362,7 @@ export class TypeScriptProjectAnalyzer { packageName: '', projectModuleHashes, toProjectRelative, + resolveWorkspaceModule, }); } catch (error) { // An extraction error is a DEFECT, never a decision. Counted apart from @@ -537,6 +568,18 @@ export class TypeScriptProjectAnalyzer { /** Distinguishes concurrent writes within one process; the pid does the rest. */ private writeSequence = 0; + /** The packages declared under each path anchor, found once for every program under it. */ + private readonly workspacePackages = new Map(); + + private workspacePackagesAt(anchor: string): WorkspacePackages { + let found = this.workspacePackages.get(anchor); + if (found === undefined) { + found = WorkspacePackages.discover(anchor, new Set(TS_SKIP_DIRECTORIES)); + this.workspacePackages.set(anchor, found); + } + return found; + } + private async exportSkippedFilesCsv(outputDir: string): Promise { const header = ['filePath', 'baseMservPath', 'serviceVersionLinkHash', 'reason', 'detail'] .join('\t'); diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 600cf0fe..282818b6 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -251,6 +251,12 @@ contract(q, b, "it overrides this") :- target(q, "method", m, _), override(b, m) .decl implements_pair(b:symbol, m:symbol) .input implements_pair contract(q, b, "it implements this — the engine records a dispatch candidate here and no override row") :- target(q, "method", m, _), implements_pair(b, m), !value_pair(b, m), !override(b, m), !override(m, b), !target(q, _, b, _). +// …and the other direction, asked of the BASE. Java's override row puts an implementation under "overrides it"; +// a structurally typed language has no such row, so an interface method's own implementations were missing from +// its answer entirely: nothing calls them through it (the dispatch edge runs candidate -> base) and no contract +// named them, though a change to the interface method's signature breaks every one of them. +contract(q, m, "implements it — the engine records a dispatch candidate here and no override row") :- + target(q, "method", b, _), implements_pair(b, m), !value_pair(b, m), !override(b, m), !override(m, b), !target(q, _, m, _). // A FUNCTION STORED IN A FIELD (#1206). `value_pair(b, m)`: m flows into a field whose declared type is the // function type b — `this.getPath = options.getPath ?? getPath`, `fetch: T = (req) => …`. A call through the field // is typed to b, so its caller is a DIRECT user of m: without this row the answer said "directly touches it: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 549f6ebd..f8b33678 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -171,6 +171,15 @@ def _at(f_, l_): ({r[0] for r in q( f"""SELECT DISTINCT s.display FROM dispatch_candidates dc JOIN symbols s ON s.method_id = dc.base_method_id WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id AND s.id NOT IN ({ph}) + AND dc.basis <> 'structural' + AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) + OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", + ids + ids)} if 'dispatch_candidates' in _tables(con) else set()) | + # …and, asked of the base, the implementations it dispatches to ("implements it") + ({r[0] for r in q( + f"""SELECT DISTINCT s.display FROM dispatch_candidates dc JOIN symbols s ON s.method_id = dc.candidate_method_id + WHERE dc.base_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id AND s.id NOT IN ({ph}) + AND dc.basis NOT IN ('structural', 'value') AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", ids + ids)} if 'dispatch_candidates' in _tables(con) else set())) @@ -278,6 +287,7 @@ def _at(f_, l_): if dispatch: contract_ids |= {r[0] for r in q(f"""SELECT DISTINCT dc.base_method_id FROM dispatch_candidates dc WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis <> 'structural' AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", ids)} seen_ids -= contract_ids @@ -1755,6 +1765,14 @@ def contract_for_method(q, ids): AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): if b not in ids: out.append((b, 'it implements this — the engine records a dispatch candidate here and no override row')) + # …and asked of the BASE, the implementations it dispatches to: an interface method's own implementers had + # no row at all where the engine keeps no override table. `value` pairs are excluded as the rules exclude them. + for (m,) in q(f"""SELECT DISTINCT dc.candidate_method_id FROM dispatch_candidates dc + WHERE dc.base_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis NOT IN ('structural', 'value') + AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) + OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): + if m not in ids: out.append((m, 'implements it — the engine records a dispatch candidate here and no override row')) return sorted(set(out)) # a set, for the same reason diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/case.json b/tests/cases/typescript/dispatch-base-is-a-contract/case.json index 0afd462a..4133344c 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/case.json +++ b/tests/cases/typescript/dispatch-base-is-a-contract/case.json @@ -15,5 +15,9 @@ {"why": "a class that only matches the interface's SHAPE is a dispatch candidate, not a declared contract: its callers are still reached through the base, but the base is never 'must change - it implements this'", "run": ["impact", "DuckRouter.add"], "want": ["App.mount"], - "avoid": ["it implements this", "must change with it"]} + "avoid": ["it implements this", "must change with it"]}, + {"why": "asked of the BASE, the same contract names its implementations: with no override row an interface method's implementers had no row at all, though a change to its signature breaks each of them. The shape-only DuckRouter stays out, as above", + "run": ["impact", "Router.add"], + "want": ["must change with it (2: bound by a contract", "LinearRouter.add", "TrieRouter.add", "implements it"], + "avoid": ["must change with it (3"]} ]} From 7217619c037244144db28576cc99026115e594f7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:57:51 -0700 Subject: [PATCH 082/258] changed: classify an edit by the declaration's place and its parameters, not by its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-declaration diff behind `changed`, test-impact and the edit hooks found a declaration's new header by searching for its name, and read a parameter list with a regex. So: - a method renamed in place was "removed old" + "added new"; - a JS/TS constructor (`` in the graph, `constructor(` in the text) was never found again: every header edit came back as removed + "added S.constructor"; - a destructured parameter's `{` ended the header, and an arrow inside a default value took the edit ("signature millis"); - a `this.x = ...` field whose line moved was "removed" while still assigned; - a graph holding the new name printed the old header's words as a "return type". Now a header that became another name with the same parameters (and no overload keeps them) is "renamed old → new", targeted at the old declaration; constructors are looked for as written; parameters are parsed by bracket depth, destructured fields are one row each, defaults compared; a function written inside another's parameter list is charged to that one; `this.x =` declares a field; a header found word for word further down is a move, not a signature change. A span whose header line was rewritten into a header of another name with the same parameters is kept there when the graph already holds the new name (a refresh that raced the edit), and reported as the rename. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 192 ++++++++++++++-- .../case.json | 4 +- .../case.json | 4 +- .../cases/javascript/edits-in-place/case.json | 205 ++++++++++++++++++ .../javascript/edits-in-place/new-body.js | 27 +++ .../cases/javascript/edits-in-place/new-c.js | 15 ++ .../javascript/edits-in-place/new-ctor.js | 26 +++ .../cases/javascript/edits-in-place/new-d.js | 5 + .../javascript/edits-in-place/new-default.js | 26 +++ .../edits-in-place/new-destructure.js | 26 +++ .../javascript/edits-in-place/new-errbody.js | 27 +++ .../javascript/edits-in-place/new-rename.js | 26 +++ .../javascript/edits-in-place/new-reorder.js | 26 +++ .../cases/javascript/edits-in-place/old-c.js | 15 ++ .../cases/javascript/edits-in-place/old-d.js | 5 + tests/cases/javascript/edits-in-place/old.js | 26 +++ .../cases/javascript/edits-in-place/src/a.js | 26 +++ .../cases/javascript/edits-in-place/src/b.js | 9 + .../cases/javascript/edits-in-place/src/c.js | 15 ++ .../cases/javascript/edits-in-place/src/d.js | 5 + .../cases/javascript/edits-in-place/src/e.js | 5 + .../case.json | 7 +- tests/edit_stale_spans.py | 24 ++ 23 files changed, 719 insertions(+), 27 deletions(-) create mode 100644 tests/cases/javascript/edits-in-place/case.json create mode 100644 tests/cases/javascript/edits-in-place/new-body.js create mode 100644 tests/cases/javascript/edits-in-place/new-c.js create mode 100644 tests/cases/javascript/edits-in-place/new-ctor.js create mode 100644 tests/cases/javascript/edits-in-place/new-d.js create mode 100644 tests/cases/javascript/edits-in-place/new-default.js create mode 100644 tests/cases/javascript/edits-in-place/new-destructure.js create mode 100644 tests/cases/javascript/edits-in-place/new-errbody.js create mode 100644 tests/cases/javascript/edits-in-place/new-rename.js create mode 100644 tests/cases/javascript/edits-in-place/new-reorder.js create mode 100644 tests/cases/javascript/edits-in-place/old-c.js create mode 100644 tests/cases/javascript/edits-in-place/old-d.js create mode 100644 tests/cases/javascript/edits-in-place/old.js create mode 100644 tests/cases/javascript/edits-in-place/src/a.js create mode 100644 tests/cases/javascript/edits-in-place/src/b.js create mode 100644 tests/cases/javascript/edits-in-place/src/c.js create mode 100644 tests/cases/javascript/edits-in-place/src/d.js create mode 100644 tests/cases/javascript/edits-in-place/src/e.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 72e74028..c97b818d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -262,11 +262,28 @@ class Changed: if k in ('field', 'const', 'enum_member', 'variable'): if is_py: return bool(re.search(rf'(^\s*|\bself\.|\bcls\.){e}\s*(:[^=]*)?=(?!=)|^\s*{e}\s*:', text)) if k == 'enum_member' and re.match(rf'^\s*{e}\s*(\(|,|;|$|=|\{{)', text): return True + # a JavaScript / TypeScript field is declared where it is assigned, `this.x = …` in the constructor: read as + # "no line declares it", a field whose line moved was reported removed while it was still assigned + if k == 'field' and re.search(rf'\bthis\.{e}\s*=(?![=>])', text): return True return bool(re.search(rf'^\s*(?:[\w<>\[\],.?@]+\s+)+{e}\s*(=(?![=>])|;|\{{|=>|$)', text)) and not re.match(r'^\s*(return|throw|new|await|using|yield)\b', text) if is_py: return bool(re.search(rf'^\s*(async\s+)?def\s+{e}\s*[(\[]', text)) m = re.search(rf'^\s*((?:[\w<>\[\],.?@]+\s+)*){e}\s*(<[^()]*>)?\s*\(', text) return bool(m) and not re.match(r'^\s*(return|throw|new|await|if|while|for|foreach|switch|else|using|lock|catch|yield)\b', text) \ and (bool(m.group(1).strip()) or not text.rstrip().endswith(';')) + @staticmethod + def declared_name(h, is_py): + """the name a callable header declares (`async save(a) {`, `def save(self):`, `public int Save(int a)`, + `save = async (a) =>`, `function save(a)`); None when the text declares no callable""" + h = strip_code(h, hash_comments=is_py) + h = re.sub(r'^\s*(@[\w.]+\s*(\([^)]*\))?\s*)+', '', h) + if is_py: + m = re.match(r'\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*[(\[]', h); return m.group(1) if m else None + m = re.match(r'\s*(?:(?:export|default|declare|public|private|protected|static|readonly|override|async|const|let|var)\s+)*' + r'([A-Za-z_$#][\w$]*)\s*[?!]?\s*(?::[^=()]*)?=\s*(?:async\s+)?(?:function\b\s*\*?\s*[\w$]*\s*)?(?:<[^()]*>)?\s*\(', h) + if m: return m.group(1) + m = re.search(r'([A-Za-z_$#][\w$]*)\s*(?:<[^()]*>)?\s*\(', h) + if not m or m.group(1) in ('if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'function', 'super', 'this', 'await', 'typeof', 'yield'): return None + return m.group(1) if Changed.declares(h, m.group(1), 'method', False) else None def anchor(self, rel, spans, G, O): """THE GRAPH'S LINES ARE IN THE TEXT IT WAS INDEXED FROM, and the text an edit is judged against can be a later one: an edit made since the index (the background refresh has not run yet), or a baseline behind a refresh. Read @@ -311,7 +328,7 @@ class Changed: # texts: 3 of 393 files fall under 0.8 this way, and every file does with its text shifted by two lines tstarts = {a for a, b, i, k, d, n in spans if k in ('class', 'interface', 'enum', 'type', 'namespace')} GS = strip_code(G, hash_comments=is_py).split('\n') - chk = [(a, n) for a, b, i, k, d, n in spans if shaped(k, n) and re.fullmatch(r'[A-Za-z_$][\w$]*', n) + chk = [(a, self.header_name(n, d, rel)) for a, b, i, k, d, n in spans if shaped(k, n) and re.fullmatch(r'[A-Za-z_$][\w$]*', self.header_name(n, d, rel)) and not (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)) and (k in ('class', 'interface', 'enum', 'type', 'namespace') or a not in tstarts)] hit = sum(1 for a, n in chk if starts(GS, a, n)) @@ -326,11 +343,23 @@ class Changed: elif tag == 'replace': near[j1 + k_ + 1] = i1 + min(k_ + 1, i2 - i1) # a rewritten line: the line in its position else: near[j1 + k_ + 1] = max(1, i1) # a line this text lacks: the one before it taken = {exact[a] for a, b, i, k, d, n in spans if a in exact} + def renamed_line(a, d, n, k): + """A HEADER THE GRAPH HOLDS UNDER A NEW NAME is the same declaration when this text's line in its place declares + another name with the same parameters (a rename the refresh already indexed). Dropped as "not declared here", + the old header's line fell to the module ("body a.") and the new name was "added".""" + j = near.get(a) + if k not in ('method', 'function', 'constructor') or not j or j in taken or j in exact.values() or a > len(GL) or j > len(OL): return None + gh, oh = ' '.join(GS[a - 1:self.header_end(GS, a)]), ' '.join(OS[j - 1:self.header_end(OS, j)]) + on = self.declared_name(oh, is_py) + if self.declared_name(gh, is_py) != self.header_name(n, d, rel) or not on or on == n: return None + pl = lambda h: [p for p, _, _ in self.param_list(self.before_block_body(self.before_expression_body(h)))] + return j if pl(gh) == pl(oh) else None for a, b, i, k, d, n in spans: a2 = exact.get(a) if a2 is None: if not shaped(k, n): continue # a lambda or a module whose first line is gone a2, many = found(n, k, near.get(a) or a, taken, d) + if a2 is None: a2, many = renamed_line(a, d, n, k), False if a2 is None: continue # this text does not declare it if many: self.unsure[rel].add(i) taken.add(a2) @@ -403,31 +432,74 @@ class Changed: """the last line of a header starting at `start`: up to the first line holding `{`, ending with `;`, or (Python) `:`. Read on stripped text: a route template, a cache key or an authorization expression inside an annotation holds a brace, and taking that for the end of the header classified an annotation edit as body-only, its exact inverse""" + # a `{` inside the parameter list (a destructured parameter, an object default) is not the body's: counted by + # bracket depth, `constructor({` ended its header on its first line and the fields below read as body + depth = 0 for i in range(start - 1, min(len(L), start + 15)): t = strip_code(L[i]) - if '{' in t or t.rstrip().endswith(';') or (t.rstrip().endswith(':') and not t.strip().startswith(('case', 'default'))): return i + 1 + for ch in t: + if ch in '([': depth += 1 + elif ch in ')]': depth = max(0, depth - 1) + elif ch == '{' and depth == 0: return i + 1 + if depth: continue + if t.rstrip().endswith(';') or (t.rstrip().endswith(':') and not t.strip().startswith(('case', 'default'))): return i + 1 return start @staticmethod - def params(header): - m = re.search(r'\(([^()]*(?:\([^()]*\)[^()]*)*)\)', header) - if not m: return [] + def split_top(s, sep=','): + """`s` cut at each `sep` outside brackets; an arrow's `>` (`=>`, `->`) closes nothing""" out = []; depth = 0; cur = '' - for ch in m.group(1): - depth += ch in '<[('; depth -= ch in '>])' - if ch == ',' and depth == 0: out.append(cur); cur = '' + for x, ch in enumerate(s): + if ch in '<[({': depth += 1 + elif ch in '>])}' and not (ch == '>' and x and s[x - 1] in '=-'): depth = max(0, depth - 1) + if ch == sep and depth == 0: out.append(cur); cur = '' else: cur += ch if cur.strip(): out.append(cur) - res = [] - for p in out: - p = re.sub(r'=.*$', '', p).strip(); p = re.sub(r'@\w+(\([^)]*\))?\s*', '', p) - p = re.sub(r'^((public|private|protected|readonly|override)\s+)+(?=[A-Za-z_$][\w$]*\s*[?!]?\s*:)', '', p) # a TypeScript parameter property - if not p: continue - if ':' in p: name, typ = p.split(':', 1)[0].strip(), p.split(':', 1)[1].strip() # TS / Python: name: Type + return out + @staticmethod + def param_list(header): + """[(name, type, default)] of a header's parameter list. The list is the text between the first `(` and the `)` + that closes it, however deep its default values nest (`clock = { now: () => Date.now() }`). A destructured + parameter (`{ users, jwt }`, `[a, b]`) is its fields, one row each: those are the names a caller passes, and + read as one parameter a change to one field was every field removed and added.""" + s = header.find('(') + if s < 0: return _Params() + depth = 0; e = None + for x in range(s, len(header)): + if header[x] in '([{': depth += 1 + elif header[x] in ')]}': + depth -= 1 + if depth == 0: e = x; break + body = header[s + 1:e] if e is not None else header[s + 1:] + res = _Params() + def one(p, into): + p = re.sub(r'@\w+(\([^)]*\))?\s*', '', p).strip() + eq = re.search(r'(?])=(?![=>])', p) + head, dflt = (p[:eq.start()].strip(), re.sub(r'\s+', ' ', p[eq.end():].strip())) if eq and not p.startswith(('{', '[')) else (p, '') + if head.startswith(('{', '[')): + close = {'{': '}', '[': ']'}[head[0]]; depth = 0; end = len(head) + for x, ch in enumerate(head): + depth += ch in '{['; depth -= ch in '}]' + if depth == 0 and ch == close: end = x; break + for f in Changed.split_top(head[1:end]): + f = f.strip() + if not f: continue + eqf = re.search(r'(?])=(?![=>])', f) + fd = re.sub(r'\s+', ' ', f[eqf.end():].strip()) if eqf else '' + key = (f[:eqf.start()] if eqf else f).split(':', 1)[0].strip().lstrip('.') + if key: into.append((key, '', fd)); into.fields.add(key) + return + head = re.sub(r'^((public|private|protected|readonly|override)\s+)+(?=[A-Za-z_$][\w$]*\s*[?!]?\s*:)', '', head) # a TypeScript parameter property + if not head: return + if ':' in head: name, typ = head.split(':', 1)[0].strip(), head.split(':', 1)[1].strip() # TS / Python: name: Type else: - toks = re.findall(r'[A-Za-z_$][\w$]*', p); name = toks[-1] if toks else p; typ = p[:p.rfind(name)].strip() if toks else '' - res.append((name.lstrip('*.'), re.sub(r'\s+', ' ', typ))) + toks = re.findall(r'[A-Za-z_$][\w$]*', head); name = toks[-1] if toks else head; typ = head[:head.rfind(name)].strip() if toks else '' + into.append((name.lstrip('*.').rstrip('?'), re.sub(r'\s+', ' ', typ), dflt)) + for p in Changed.split_top(body): one(p, res) return res @staticmethod + def params(header): + return [(n, t) for n, t, _ in Changed.param_list(header)] + @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)""" @@ -717,6 +789,13 @@ class Changed: elif 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'}) + # A FUNCTION WRITTEN IN ANOTHER'S PARAMETER LIST IS PART OF THAT HEADER. A default value + # (`clock = { millis: () => Date.now() }`) holds a callable the graph records on its own; an edit inside the + # default was charged to it ("signature millis") instead of to the function whose parameter changed + if m and m[3] in ('method', 'function', 'constructor'): + host = [x for x in decls if x[3] in ('method', 'function', 'constructor') and x[2] != m[2] and not is_lam(x) + and x[0] <= m[0] and m[1] <= x[1] and m[1] <= self.header_end(OL, x[0]) and x[0] <= ln <= self.header_end(OL, x[0])] + if host: m = min(host, key=lambda x: x[1] - x[0]) 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 @@ -751,8 +830,30 @@ class Changed: overrun_note = ('', f"{rel}: the graph's spans run past the end of this file's committed version ({len(OL)} lines): it was " "indexed from a working tree with uncommitted edits, so positions here may be off; re-index " "(`axiomcode index`) for exact answers", None) if overrun else None + sig_of = lambda h: re.sub(r'^\s*(@\w[\w.]*\s*(\([^)]*\))?\s*)+', '', h).strip() + def renamed_at(a, n, k): + """A DECLARATION RENAMED IN PLACE is the same declaration: its header line became one that declares another + name, with the same parameters, the old name is declared nowhere in the new text and the new name nowhere in + the old. Read as "removed " plus "added " (or as a signature change of the NEW name, 0 callers), the + edit's dependents were never the old name's callers. (new name, new line), or None""" + if k not in ('method', 'function', 'constructor') or n in P.LAMBDA_NAMES: return None + j = counterpart(a) + if not j or j > len(NL): return None + oh = ' '.join(x.strip() for x in OK[a - 1:self.header_end(OL, a)]); nh = ' '.join(x.strip() for x in NK[j - 1:self.header_end(NL, j)]) + if self.declared_name(oh, is_py) != n: return None + nn = self.declared_name(nh, is_py) + if not nn or nn == n: return None + plist = lambda L, x: [p for p, _, _ in self.param_list(self.before_block_body(self.before_expression_body(sig_of(' '.join(y.strip() for y in L[x - 1:self.header_end(L, x)])))))] + po, pn = plist(OK, a), plist(NK, j) + if po != pn: return None + # the old name may stay declared by an OVERLOAD (another parameter list); declared with this one, it moved + if any(self.declares(NS[x - 1], n, k, is_py) and plist(NK, x) == po for x in range(1, len(NS) + 1)): return None + if any(self.declares(OS[x - 1], nn, k, is_py) and plist(OK, x) == po for x in range(1, len(OS) + 1)): return None + return (nn, j, oh, nh) + renamed_new = set() for (kind, (a, b, i, k, d, n)), lines in hits.items(): decs = decorated.get((a, b, i, k, d, n)) + n = self.header_name(n, d, rel) 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': @@ -814,10 +915,21 @@ class Changed: # parameter read as removed ("signature f -self, -rel"). Declared nowhere in the new text, it is removed; # declared elsewhere, what changed cannot be read from here if not nh: + ren = renamed_at(a, n, k) + if ren: + nn_, j_, oh_r, nh_r = ren + entry.update(detail=f"renamed {n} → {nn_} (same parameters; its callers still name {n})", old_header=oh_r, new_header=nh_r, target=d, target_kind='method') + renamed_new.add((d.rsplit('.', 1)[0] + '.' + nn_) if '.' in d else nn_) + out.append(entry); continue again = still_declared(hn, k, na or a) if not again: if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry.update(kind='removed', target=self.target(kind, d, k, n)); out.append(entry) continue + # the same header, word for word, further along: code inserted above it moved it, and its header did not change + if re.sub(r'\s', '', ' '.join(OS[a - 1:self.header_end(OS, a)])) == re.sub(r'\s', '', ' '.join(NS[again - 1:self.header_end(NS, again)])): + entry.update(kind='body', detail=f"moved: the same header is now at line {again}", target=d, target_kind='method') + if not any(e['id'] == i for e in out): out.append(entry) + continue entry.update(detail=f"may have changed: its header is no longer at its line; a declaration of {n} is at line {again}", target=d, target_kind='method') out.append(entry); continue # A BLOCK BODY ON THE HEADER'S LINE IS NOT HEADER EITHER. `public int total() { return 42; }` is one line, @@ -828,11 +940,24 @@ class Changed: oh, oh_raw, nh, nh_raw = body_brace(oh), body_brace(oh_raw), body_brace(nh), body_brace(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() + sig = sig_of oh_s, nh_s = sig(oh), sig(nh) - op, np_ = self.params(oh_s), self.params(nh_s) if nh_s else [] + opl, npl = self.param_list(oh_s), self.param_list(nh_s) if nh_s else [] + op, np_ = [(x, t) for x, t, _ in opl], [(x, t) for x, t, _ in npl] on, nn = [x for x, _ in op], [x for x, _ in np_] detail = [] + # THE OLD HEADER MUST BE THIS DECLARATION'S. A graph from another text of the file (a refresh that already + # holds the edit) can place a declaration on a line that declares something else, or on a body line; its + # words were then printed as a "return type" (`const iat = Math.floor → export function`). A header that + # declares another name is that name renamed to this one; a line that declares nothing says so + names_n = bool(re.search(rf'(?])=(?![=>])|;', pre_o)): + entry.update(detail=(f"renamed {named_o} → {n} (the graph already holds the new name)" if named_o and nh else + f"may have changed: the graph places it at line {a}, which is not its header (the graph is from another text of this file)"), + old_header=oh_raw, new_header=nh_raw, target=d, target_kind='method') + out.append(entry); continue if nh and not re.search(rf'\b{re.escape(hn)}\b', nh): detail.append('renamed') for x in on: if x not in nn: detail.append(f'-{x}') @@ -840,6 +965,9 @@ class Changed: if x not in on: detail.append(f'+{x}') for (x, t1), (y, t2) in zip(op, np_): if x == y and t1 != t2 and t1 and t2: detail.append(f'{x}: {t1} → {t2}') + od = {x: v for x, _, v in opl} + for x, _, v in npl: + if x in od and re.sub(r'\s', '', od[x]) != re.sub(r'\s', '', v): detail.append(f'{x}: default {od[x] or "(none)"} → {v or "(none)"}') pre_o = re.sub(r'\(.*$', '', oh_s); pre_n = re.sub(r'\(.*$', '', nh_s) if nh_s else '' if nh and pre_o.split() != pre_n.split(): ro = [w for w in pre_o.split() if w != hn]; rn = [w for w in pre_n.split() if w != hn] @@ -850,7 +978,7 @@ class Changed: if decs: detail.append('decoration changed: ' + ', '.join(dict.fromkeys(decs)) + ' — what the framework does with it (a proxy, a transaction, a cache, a route) is NOT in the graph; only the code that names it is') if not detail: entry['kind'] = 'body' # whitespace or a comment in the header entry['detail'] = ', '.join(detail); entry['old_header'] = oh_raw; entry['new_header'] = nh_raw - changed_params = [x for x, _ in op if any(dd.startswith(f'{x}:') or dd == f'-{x}' for dd in detail)] + changed_params = [x for x, _ in op if x not in opl.fields and any(dd.startswith(f'{x}:') or dd == f'-{x}' for dd in detail)] entry['target'] = (f"{d}({changed_params[0]})" if len(changed_params) == 1 and entry['kind'] == 'signature' else d) entry['target_kind'] = 'param' if '(' in entry['target'] else 'method' elif kind == 'field': @@ -912,6 +1040,14 @@ class Changed: # a declaration found removed is not also listed by a body line of it that the diff paired with other text gone_ids = {e['id'] for e in out if e['kind'] == 'removed'} out = [e for e in out if e['kind'] == 'removed' or e['id'] not in gone_ids] + # one body row per declaration (a header line found moved and a body line are two keys of it) + seen_body = set(); keep = [] + for e in out: + if e['kind'] == 'body' and e['id'] is not None: + if e['id'] in seen_body: continue + seen_body.add(e['id']) + keep.append(e) + out = keep adds = [] SL = strip_code(new, hash_comments=is_py).split('\n') # the new text, strings and comments blanked ndepth = [0] * (len(NL) + 2) # brace depth at the START of each new line @@ -1016,7 +1152,7 @@ class Changed: def old_has(nm, hdr, t=t): ht = types_of(hdr) if '(' in hdr else None for a, b, i, k, d, n in decls: - if n != nm or (t and not d.startswith(t[4] + '.') and d != t[4] + '.' + nm and k not in ('class', 'interface', 'enum')): continue + if self.header_name(n, d, rel) != nm or (t and not d.startswith(t[4] + '.') and d != t[4] + '.' + nm and k not in ('class', 'interface', 'enum')): continue if ht is not None and k in ('field', 'const', 'enum_member', 'variable'): continue # a field of that name is not this method if ht is None or k in ('class', 'interface', 'enum', 'field', 'const', 'enum_member'): return True sig = self.g.sym.get(i, {}).get('signature') or '' @@ -1073,6 +1209,8 @@ class Changed: renamed = {(e['file'], e['symbol'].rsplit('.', 1)[0] + '.' + str(e.get('detail'))[len('renamed → '):] if '.' in e['symbol'] else str(e.get('detail'))[len('renamed → '):]) for e in out if e['kind'] == 'field' and str(e.get('detail', '')).startswith('renamed → ')} out = [e for e in out if not (e['kind'] == 'added' and (e['file'], e['symbol']) in renamed)] + # and a callable renamed in place is not also a new callable of the new name (renamed_at) + out = [e for e in out if not (e['kind'] == 'added' and e['symbol'] in renamed_new)] if overrun_note: adds.append(overrun_note) # THE TARGET NAMES THIS DECLARATION, NOT EVERY DECLARATION OF ITS NAME. `symbol` stays the short display; the # target impact is asked is where the declaration is (at_line), and `shown_target` keeps its name for reading. @@ -1095,7 +1233,15 @@ class Changed: if isinstance(i, str) and i.startswith('f:'): row = self.g.q("SELECT file, line FROM symbols WHERE rowid = ?", int(i[2:])) if i[2:].isdigit() else None at = f"{row[0][0]}:{row[0][1]}" if row and row[0][0] and row[0][1] else None - else: at = self.g.lambda_target(i) + else: + at = self.g.lambda_target(i) + # file:line names the NARROWEST callable spanning the line: a function whose line also holds another one (an + # arrow in a default value, `f(clock = { now: () => 0 })`) is not named by it, and asked that way impact + # answered for the arrow. Its qualified name names it + r = self.g.all_sym.get(i) or self.g.sym.get(i) or {} + if at and not self.g.is_lambda(i) and r.get('end_line') and self.g.q( + "SELECT 1 FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' AND id <> ? AND end_line - line < ? LIMIT 1", + r['file'], r['line'], r['line'], i, r['end_line'] - r['line']) and self.qualified(i, 'method'): return None return at + sfx if at else None def new_file(self, rel, new, decls, is_py): """A FILE THE BASE DOES NOT HAVE is one change: `added (N declarations)`. Read line by line as an insertion, @@ -1165,6 +1311,10 @@ class Changed: def target(kind, d, k, n): return d +class _Params(list): + """a parameter list; `fields` are the names that are fields of a destructured parameter, not parameters of their own""" + def __init__(self): super().__init__(); self.fields = set() + NO_GIT = ("no git base: {repo} is not a git checkout (a copy without .git), so there is no earlier version to diff the " "working tree against, and nothing can say what changed. Name the files you edited: `axiomcode changed …` " "or `axiomcode test-impact …` (MCP files=[…]) counts every declaration in each named file as changed.") diff --git a/tests/cases/csharp/edit-targets-the-declaration-edited/case.json b/tests/cases/csharp/edit-targets-the-declaration-edited/case.json index f923fe2a..ab76ede0 100644 --- a/tests/cases/csharp/edit-targets-the-declaration-edited/case.json +++ b/tests/cases/csharp/edit-targets-the-declaration-edited/case.json @@ -22,5 +22,5 @@ "avoid": ["PlainTests", "CountedTests"]}, {"why": "a renamed overload still reports what the old name's callers lose, and only that overload's", "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-rename.txt", "--file", "src/App/Job.cs", "--impact"], - "want": ["Job.Run src/App/Job.cs:10", "→ impact src/App/Job.cs:10", "Callers.Counted"], - "avoid": ["Callers.Plain", "PlainTests"]}]} + "want": ["Job.Run src/App/Job.cs:10", "→ impact src/App/Job.cs:10", "Callers.Counted", "renamed Run → Execute"], + "avoid": ["Callers.Plain", "PlainTests", "added ", "removed "]}]} diff --git a/tests/cases/java/edit-targets-the-declaration-edited/case.json b/tests/cases/java/edit-targets-the-declaration-edited/case.json index a91bbe58..af1aa5f3 100644 --- a/tests/cases/java/edit-targets-the-declaration-edited/case.json +++ b/tests/cases/java/edit-targets-the-declaration-edited/case.json @@ -22,5 +22,5 @@ "avoid": ["PlainTest", "CountedTest"]}, {"why": "a renamed overload still reports what the old name's callers lose, and only that overload's", "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-rename.txt", "--file", "src/app/Job.java", "--impact"], - "want": ["Job.run src/app/Job.java:8", "→ impact src/app/Job.java:8", "Callers.counted"], - "avoid": ["Callers.plain", "PlainTest"]}]} + "want": ["Job.run src/app/Job.java:8", "→ impact src/app/Job.java:8", "Callers.counted", "renamed run → execute"], + "avoid": ["Callers.plain", "PlainTest", "added ", "removed "]}]} diff --git a/tests/cases/javascript/edits-in-place/case.json b/tests/cases/javascript/edits-in-place/case.json new file mode 100644 index 00000000..b595e6e8 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/case.json @@ -0,0 +1,205 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a method renamed in place (same position, same parameters) is one declaration renamed, answered from the OLD name's callers; never 'removed' plus 'added'", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-rename.js", + "--file", + "src/a.js", + "--impact" + ], + "want": [ + "signature S.oldName", + "renamed oldName → newName", + "twice", + "use" + ], + "avoid": [ + "removed ", + "added S.newName", + "signature S.newName" + ] + }, + { + "why": "a constructor header edit is a signature change of the constructor, found under the name it is written with ('constructor'), not removed and re-added", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-ctor.js", + "--file", + "src/a.js" + ], + "want": [ + "signature S.", + "-b", + "+c" + ], + "avoid": [ + "removed S.", + "added S.constructor", + "-a,", + "placed by name" + ] + }, + { + "why": "a field added to a destructured parameter names only that field, and targets the constructor (a destructured field is no parameter of its own)", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-destructure.js", + "--file", + "src/a.js" + ], + "want": [ + "signature S.", + "+c", + "impact src/a.js:2" + ], + "avoid": [ + "-a", + "-b", + "src/a.js:2(c)" + ] + }, + { + "why": "a multi-line destructured parameter with one field replaced is reported as that one field", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old-c.js", + "--new", + "{repo}/new-c.js", + "--file", + "src/c.js" + ], + "want": [ + "signature Auth.", + "-jwt", + "+tokens" + ], + "avoid": [ + "-users", + "-clock", + "removed Auth" + ] + }, + { + "why": "an edit inside a default value is the function's own parameter change, not a change to the arrow written inside the default", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-default.js", + "--file", + "src/a.js", + "--impact" + ], + "want": [ + "signature stamp", + "clock: default", + "twice" + ], + "avoid": [ + "signature millis", + "parameter clock of millis" + ] + }, + { + "why": "a field whose assignment moved and changed (`this.n = 2` now first in the constructor) is still assigned, so it is not removed", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-reorder.js", + "--file", + "src/a.js" + ], + "want": [ + "field S.n", + "still assigned" + ], + "avoid": [ + "removed S.n" + ] + }, + { + "why": "a graph that already holds the new name reads the old header as that name renamed, never as a 'return type' change", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old-d.js", + "--new", + "{repo}/new-d.js", + "--file", + "src/d.js" + ], + "want": [ + "renamed stale → fresh" + ], + "avoid": [ + "return type" + ] + }, + { + "why": "control: a body edit is a body change of that method, and no field of the constructor is touched", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-body.js", + "--file", + "src/a.js" + ], + "want": [ + "body S.oldName" + ], + "avoid": [ + "signature ", + "removed ", + "field " + ] + }, + { + "why": "control: a statement added to a constructor body leaves its header and its untouched field this.name alone", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new-errbody.js", + "--file", + "src/a.js" + ], + "want": [ + "body Err." + ], + "avoid": [ + "Err.name", + "signature ", + "removed " + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/edits-in-place/new-body.js b/tests/cases/javascript/edits-in-place/new-body.js new file mode 100644 index 00000000..40439388 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-body.js @@ -0,0 +1,27 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + const s = x + y; + return s * 2; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-c.js b/tests/cases/javascript/edits-in-place/new-c.js new file mode 100644 index 00000000..4347c45e --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-c.js @@ -0,0 +1,15 @@ +export class Auth { + constructor({ + users, + tokens, + clock, + }) { + this.users = users; + this.tokens = tokens; + this.clock = clock; + } + + check(token) { + return this.tokens.verify(token); + } +} diff --git a/tests/cases/javascript/edits-in-place/new-ctor.js b/tests/cases/javascript/edits-in-place/new-ctor.js new file mode 100644 index 00000000..78e2bd92 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-ctor.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, c }) { + this.a = a; + this.c = c; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-d.js b/tests/cases/javascript/edits-in-place/new-d.js new file mode 100644 index 00000000..512949b4 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-d.js @@ -0,0 +1,5 @@ +export class R { + async fresh(x) { + return x + 1; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-default.js b/tests/cases/javascript/edits-in-place/new-default.js new file mode 100644 index 00000000..4b6f4a52 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-default.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() + 1 }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-destructure.js b/tests/cases/javascript/edits-in-place/new-destructure.js new file mode 100644 index 00000000..271374c5 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-destructure.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b, c }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-errbody.js b/tests/cases/javascript/edits-in-place/new-errbody.js new file mode 100644 index 00000000..37b4b757 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-errbody.js @@ -0,0 +1,27 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.code = 7; + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-rename.js b/tests/cases/javascript/edits-in-place/new-rename.js new file mode 100644 index 00000000..3b8d9805 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-rename.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async newName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/new-reorder.js b/tests/cases/javascript/edits-in-place/new-reorder.js new file mode 100644 index 00000000..75aeddfc --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-reorder.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b }) { + this.n = 2; + this.a = a; + this.b = b; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/old-c.js b/tests/cases/javascript/edits-in-place/old-c.js new file mode 100644 index 00000000..790996dd --- /dev/null +++ b/tests/cases/javascript/edits-in-place/old-c.js @@ -0,0 +1,15 @@ +export class Auth { + constructor({ + users, + jwt, + clock, + }) { + this.users = users; + this.jwt = jwt; + this.clock = clock; + } + + check(token) { + return this.jwt.verify(token); + } +} diff --git a/tests/cases/javascript/edits-in-place/old-d.js b/tests/cases/javascript/edits-in-place/old-d.js new file mode 100644 index 00000000..9fef5b22 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/old-d.js @@ -0,0 +1,5 @@ +export class R { + async stale(x) { + return x; + } +} diff --git a/tests/cases/javascript/edits-in-place/old.js b/tests/cases/javascript/edits-in-place/old.js new file mode 100644 index 00000000..50c66c63 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/old.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/src/a.js b/tests/cases/javascript/edits-in-place/src/a.js new file mode 100644 index 00000000..50c66c63 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/a.js @@ -0,0 +1,26 @@ +export class S { + constructor({ a, b }) { + this.a = a; + this.b = b; + this.n = 1; + } + + async oldName(x, y) { + return x + y; + } + + keep(v) { + return v; + } +} + +export function stamp(v, clock = { millis: () => Date.now() }) { + return { v, at: clock.millis() }; +} + +export class Err extends Error { + constructor(msg) { + super(msg); + this.name = 'Err'; + } +} diff --git a/tests/cases/javascript/edits-in-place/src/b.js b/tests/cases/javascript/edits-in-place/src/b.js new file mode 100644 index 00000000..bfe91357 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/b.js @@ -0,0 +1,9 @@ +import { S, stamp } from './a.js'; + +export function use() { + return new S({ a: 1, b: 2 }).oldName(1, 2); +} + +export function twice() { + return new S({}).oldName(2, 2) + stamp(1).at; +} diff --git a/tests/cases/javascript/edits-in-place/src/c.js b/tests/cases/javascript/edits-in-place/src/c.js new file mode 100644 index 00000000..790996dd --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/c.js @@ -0,0 +1,15 @@ +export class Auth { + constructor({ + users, + jwt, + clock, + }) { + this.users = users; + this.jwt = jwt; + this.clock = clock; + } + + check(token) { + return this.jwt.verify(token); + } +} diff --git a/tests/cases/javascript/edits-in-place/src/d.js b/tests/cases/javascript/edits-in-place/src/d.js new file mode 100644 index 00000000..c2ccd38f --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/d.js @@ -0,0 +1,5 @@ +export class R { + async fresh(x) { + return x; + } +} diff --git a/tests/cases/javascript/edits-in-place/src/e.js b/tests/cases/javascript/edits-in-place/src/e.js new file mode 100644 index 00000000..1e65abf6 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/e.js @@ -0,0 +1,5 @@ +import { Auth } from './c.js'; + +export function login(t) { + return new Auth({ users: [], jwt: null, clock: null }).check(t); +} diff --git a/tests/cases/python/edit-targets-the-declaration-edited/case.json b/tests/cases/python/edit-targets-the-declaration-edited/case.json index 070660ce..3cc8d65b 100644 --- a/tests/cases/python/edit-targets-the-declaration-edited/case.json +++ b/tests/cases/python/edit-targets-the-declaration-edited/case.json @@ -124,11 +124,14 @@ "want": [ "main alpha/cli.py:5", "→ impact alpha/cli.py:5", - "go_a" + "go_a", + "renamed main → entry" ], "avoid": [ "go_b", - "test_beta" + "test_beta", + "added ", + "removed " ] }, { diff --git a/tests/edit_stale_spans.py b/tests/edit_stale_spans.py index aac559c8..7cb4b6c0 100644 --- a/tests/edit_stale_spans.py +++ b/tests/edit_stale_spans.py @@ -16,6 +16,8 @@ control: a real parameter added to that method is still a signature change; · a method moved below its neighbour: not removed; · removing a function whose name another file also declares: only this file's callers; + · a method renamed in place (same parameters): renamed, with the old name's callers; and so when the graph already + holds the new name; control: another parameter list in its place is no rename; · (Python) an edited import line: never a signature of the module; · the graph's rows and the tree it records for them disagree (a refresh raced an edit): declarations are placed by name and the answer says so; control: a faithful recorded tree says nothing of the kind; @@ -178,6 +180,28 @@ def project(lang, files): check(f'{lang}: removing a function lists this file\'s caller', c['mine'] in out and 'removed' in out, out) check(f'{lang}: ... and not the caller of the same-named function in another file', c['other'] not in out, out) + # a rename in place (same line, same parameters) is one declaration renamed, answered from the OLD name's callers + cnt = {'python': 'def count(self)', 'java': 'public int count()', 'csharp': 'public int Count()'}[lang] + ren = cnt.replace('ount(', 'ountAll(') + out = fire(repo, c['stale'], cnt, ren) + check(f'{lang}: a method renamed in place is renamed, with its callers', 'renamed' in out and 'removed' not in out and c['mine'].split('.')[-1] in out, out) + # the graph already holds the new name (a refresh indexed the rename): the old header's line is still that declaration + f = os.path.join(repo, c['stale']); t0 = open(f).read() + tf = tempfile.NamedTemporaryFile('w', suffix=os.path.splitext(f)[1], delete=False); tf.write(t0.replace(cnt, ren, 1)); tf.close() + r = subprocess.run([sys.executable, AX + '-changed', repo, '--old', tf.name, '--new', f, '--file', c['stale'], '--json'], capture_output=True, text=True, timeout=120) + try: kinds = [f"{e['kind']} {e['symbol']} {e.get('detail', '')}" for e in json.loads(r.stdout).get('changed', [])] + except ValueError: kinds = [r.stderr[-300:]] + check(f'{lang}: a rename the graph already holds is that declaration renamed, not a module body edit plus an added method', + any(k.startswith('signature') and 'renamed' in k for k in kinds) and not any(k.startswith('added') or '' in k for k in kinds), kinds) + # control: the same method with another parameter list in its place is not a rename + tf2 = tempfile.NamedTemporaryFile('w', suffix=os.path.splitext(f)[1], delete=False) + tf2.write(t0.replace(cnt, ren.replace('()', '(int k)').replace('(self)', '(self, k)'), 1)); tf2.close() + r = subprocess.run([sys.executable, AX + '-changed', repo, '--old', tf2.name, '--new', f, '--file', c['stale'], '--json'], capture_output=True, text=True, timeout=120) + try: kinds = [f"{e['kind']} {e['symbol']} {e.get('detail', '')}" for e in json.loads(r.stdout).get('changed', [])] + except ValueError: kinds = [r.stderr[-300:]] + check(f'{lang}: control: a header with other parameters in its place is not called a rename', not any('renamed' in k for k in kinds), kinds) + os.unlink(tf.name); os.unlink(tf2.name) + if lang == 'python': out = fire(repo, c['stale'], 'import os\n', 'import os, http\n') check('python: an edited import line is never a signature of the module', 'signature' not in out, out) From 516b36b0419d319f6316bc5f2ecb6ab0f797ab58 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 00:39:55 -0700 Subject: [PATCH 083/258] tests: the hosted-service delete checks split into a registered and an unregistered service Symptom: tests/run.py csharp/unmodelled-entry-not-local failed two checks on 0.1.9: impact --delete on SweepWorker (a BackgroundService that Program adds with AddHostedService()) no longer printed "it extends / implements BackgroundService, which the graph does not contain", and --delete on SweepWorker.ExecuteAsync printed "NOT SAFE" instead of "NOT SAFE TO ASSUME". Cause: the C# framework-behavior pipelines rules now link an AddHostedService() site to T's hook overrides (callback_registered). The registration is a real dependent, so the answer is the stronger one: NOT SAFE, with Program.
    $ listed as calling / registering ExecuteAsync. The library-base note is printed only when nothing in the graph uses the declaration, so it is correctly absent. The checks still described the service as an entry the graph does not model. Fix: the unmodelled-entry checks move to IdleWorker, a BackgroundService no registration in the graph names, which still gets NOT SAFE TO ASSUME and the library-base note. SweepWorker gets two checks for the modelled answer: NOT SAFE with the registration site, never NOT SAFE TO ASSUME or the dead-code verdict. No engine or query code changes. tests/run.py --lang csharp: 202 of 204 before, 206 of 206 after. graph/test/csharp/tools/pipelines-test.sh: ok. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../unmodelled-entry-not-local/case.json | 22 ++++++++++++++----- .../src/App/Workers.cs | 6 +++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index 02c1e1b1..ff4ffef3 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -2,17 +2,29 @@ "lang": "csharp", "checks": [ { - "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", - "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE", "it extends / implements BackgroundService, which the graph does not contain", "Program.
    $ src/App/Program.cs:2 — ", "names it (METHOD_TYPE_ARGUMENT)"], + "why": "--delete on a hosted service the graph sees no registration for says a framework calls it, not the dead-code verdict (#1446)", + "run": ["impact", "IdleWorker", "--kind", "type", "--delete"], + "want": ["NOT SAFE TO ASSUME", "it extends / implements BackgroundService, which the graph does not contain"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { - "why": "--delete on the hosted service's method says the same (#1446)", - "run": ["impact", "SweepWorker.ExecuteAsync", "--delete"], + "why": "--delete on that hosted service's method says the same (#1446)", + "run": ["impact", "IdleWorker.ExecuteAsync", "--delete"], "want": ["NOT SAFE TO ASSUME", "a framework calls it"], "avoid": ["· nothing"] }, + { + "why": "a hosted service AddHostedService() registers: the registration is a dependent, so the verdict is NOT SAFE and names the site", + "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], + "want": ["NOT SAFE — 1 callable(s) call or receive it", "Program.
    $ src/App/Program.cs:2 — ", "names it (METHOD_TYPE_ARGUMENT)"], + "avoid": ["· nothing", "NOT SAFE TO ASSUME", "no dependent at any certainty in this graph. Before deleting"] + }, + { + "why": "the registered hosted service's method: the registration site hands it to the framework, so it is not dead code", + "run": ["impact", "SweepWorker.ExecuteAsync", "--delete"], + "want": ["NOT SAFE — 1 callable(s) call or receive it", "[registered] Program.
    $ src/App/Program.cs:2"], + "avoid": ["· nothing", "NOT SAFE TO ASSUME"] + }, { "why": "control: a type nothing registers, with no library base, keeps the dead-code verdict", "run": ["impact", "Unused", "--kind", "type", "--delete"], diff --git a/tests/cases/csharp/unmodelled-entry-not-local/src/App/Workers.cs b/tests/cases/csharp/unmodelled-entry-not-local/src/App/Workers.cs index ef53495e..c4996573 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/src/App/Workers.cs +++ b/tests/cases/csharp/unmodelled-entry-not-local/src/App/Workers.cs @@ -19,3 +19,9 @@ public void OnOrder(string body) { } [Obsolete] public void Legacy() { } } + +// a hosted service no registration in the graph names +public sealed class IdleWorker : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken t) => Task.CompletedTask; +} From dada661b737d56b372369db7bea5645e45f3269d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:56:31 -0700 Subject: [PATCH 084/258] impact, path: a handler table keyed by an event type joins the publishers of that type [by key] A service publishes an event by its type string, often through a constant (publish(TOPICS.CREATED, doc)); another holds a handler table keyed by the same string ({ [TOPICS.CREATED]: onCreated }, { 'doc.created'(e) {} }, {"doc.created": on_created}) that a consumer dispatches by the message's type. Nothing joined the two ends. - ax_registration: table entries are registrations (kind table); string constants resolve to their value; a key written as a dotted literal or through a constant is a write, never the table's own key position or the constant's declaration. - impact.dl: impact of a publisher lists each handler of its type [by key]; tests that publish the type reach the handler. Only production writers count against the key cap, and a handler writing its own type is no writer. - path and the SQL port's key join read the same writes. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 2 +- .../axiomcode/scripts/ax_registration.py | 174 +++++++++++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 21 ++- .../skills/axiomcode/scripts/axiomcode-path | 3 +- .../skills/axiomcode/scripts/dl/impact.dl | 22 ++- skills/axiomcode/SKILL.md | 2 +- .../event-type-handler-table/case.json | 24 +++ .../src/audit/audit.js | 11 ++ .../src/pkg/topics.js | 4 + .../src/producer/service.js | 14 ++ .../src/search/consumer.js | 4 + .../src/search/handlers.js | 7 + .../src/search/indexer.js | 2 + .../test/drop.test.js | 7 + .../test/service.test.js | 7 + .../event-type-handler-table/app/__init__.py | 0 .../event-type-handler-table/app/handlers.py | 21 +++ .../event-type-handler-table/app/service.py | 6 + .../event-type-handler-table/app/topics.py | 3 + .../python/event-type-handler-table/case.json | 11 ++ .../tests/__init__.py | 0 .../tests/test_other.py | 4 + .../tests/test_service.py | 10 + 23 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 tests/cases/javascript/event-type-handler-table/case.json create mode 100644 tests/cases/javascript/event-type-handler-table/src/audit/audit.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/pkg/topics.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/producer/service.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/consumer.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/handlers.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/indexer.js create mode 100644 tests/cases/javascript/event-type-handler-table/test/drop.test.js create mode 100644 tests/cases/javascript/event-type-handler-table/test/service.test.js create mode 100644 tests/cases/python/event-type-handler-table/app/__init__.py create mode 100644 tests/cases/python/event-type-handler-table/app/handlers.py create mode 100644 tests/cases/python/event-type-handler-table/app/service.py create mode 100644 tests/cases/python/event-type-handler-table/app/topics.py create mode 100644 tests/cases/python/event-type-handler-table/case.json create mode 100644 tests/cases/python/event-type-handler-table/tests/__init__.py create mode 100644 tests/cases/python/event-type-handler-table/tests/test_other.py create mode 100644 tests/cases/python/event-type-handler-table/tests/test_service.py diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 6a7b7a22..29f06c81 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -71,7 +71,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 | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — 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 | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index c0d82607..2f892c57 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,6 +16,7 @@ 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 collections import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} @@ -990,8 +991,150 @@ def route_candidates(written, registered_keys): def all_registrations(q, site_file=None): - """Every (decl, file, line, kind, key, why) this module can derive, from all three sources.""" - return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file))) + """Every (decl, file, line, kind, key, why) this module can derive, from all four sources.""" + return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file) + + table_registrations(q, site_file))) + + +# ── a HANDLER TABLE: a declaration registered under the KEY of the entry that holds it ────────────────────────── +# One service publishes an event by its type (`bus.publish(TOPICS.CREATED, doc)`, `emit("doc.created", …)`); another +# holds a table of handlers keyed by the same string (`{ [TOPICS.CREATED]: onCreated }`, `{ 'doc.created'(env) {…} }`, +# `{"doc.created": on_created}`) and a consumer looks the handler up by the message's type (`handlers[type](env)`). +# The graph has both ends and the lookup is a computed member, so nothing joined the publisher to the handler: impact +# of the producing method missed every consumer, and the tests that publish the type never reached the handler. +# It is a registration like a route: the entry's key is what the dispatcher dispatches on. Two facts are read here: +# the table entry a callable DECLARED on the entry's own line, right after its key (`[K]: function …`, `[K]: (e) =>`, +# `'k'(e) {`, `"k": lambda e: …`), or a callable NAMED as the entry's whole value (`[K]: onCreated,`). +# A key is a string literal or a constant reference resolved to one; an entry whose value is an +# array, a call's result or a schema is data, not a handler, and registers nothing. +# the constant `TOPICS.CREATED` is the string its declaration gives it (`export const TOPICS = { CREATED: +# 'doc.created' }`, `class Topics: CREATED = "doc.created"`, `static final String CREATED = …`), +# kept only when every declaration of that name agrees. A key written through a constant is a +# write of the string; the constant's own declaration and another table's key position are not. +_IDENT = r'[A-Za-z_$][\w$]*' +_CONST_REF = rf'{_IDENT}(?:\.{_IDENT})+' +_KEY_STR = r"""(?P['"])([^'"\\\s]{1,120})(?P=qt)""" # named: its group number differs in each pattern +# the start of a function value: `function`, `async (e) =>`, `e =>`, `(e) =>`, a Python `lambda` +_FN_START = rf'(?:async\s+)?(?:function\b|lambda\b|\(|{_IDENT}\s*=>)' +# the key of an entry that DECLARES its handler on this line: `[K]: `, `[K](…) {`, `'k': `, `'k'(…) {`, and a +# Python dict's `Topics.K: lambda …`. Method shorthand may carry `async` / `static` / `*`. +_ENTRY_DECL = re.compile(rf"""^\s*(?:(?:async|static|get|set)\s+|\*\s*)*(?:(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR})\s*(?::\s*{_FN_START}|\()|({_CONST_REF})\s*:\s*{_FN_START})""") +# an entry whose WHOLE value names a handler: `[K]: onCreated,` / `'k': handlers.onCreated,` / `"k": on_created,` +_ENTRY_REF = re.compile(rf"""^\s*(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR}|({_CONST_REF}))\s*:\s*(?:this\.|self\.)?({_IDENT}(?:\.{_IDENT})*)\s*,?\s*(?:\}}\s*[,;)]*\s*)?$""") +# a string constant: an object literal's `K: 'v'` (one per line or several on one), and a declaration `K = 'v'` +_CONST_ENTRY = re.compile(rf"""(?:^|[{{,])\s*({_IDENT})\s*:\s*{_KEY_STR}\s*(?=,|\}}|$)""") +_CONST_DECL = re.compile(rf"""(?:^|\s)({_IDENT})\s*(?::\s*[\w.<>\[\]]+\s*)?=\s*{_KEY_STR}\s*[;,]?\s*$""") +# a key POSITION, not a write: the quoted key or the constant is followed by `:` (an entry, a `case`), `(` (a method +# shorthand) or `]` and then `:` / `(` / `=` (a computed key, a C# index initializer) +_KEY_POS = re.compile(r'\s*(?::(?!:)|\(|\]\s*[:(=])') + + +def string_constants(q, read=None): + """({'TOPICS.CREATED': 'doc.created', 'CREATED_TYPE': 'doc.created', …}, {(file, line)}): the string each constant + name denotes, where every declaration of the name agrees, and the lines that declare them (a literal there is the + constant's definition, not a write of its value).""" + if not _has(q, 'symbols'): + return {}, set() + read = read or _source_reader(q) + seen = collections.defaultdict(set) + pos = set() + types = {i: n for i, n in q("SELECT id, name FROM symbols WHERE id IS NOT NULL AND method_id IS NULL AND type_id IS NOT NULL")} + for n, f, a, b, owner, kind in q("""SELECT name, file, line, end_line, owner, kind FROM symbols + WHERE method_id IS NULL AND name IS NOT NULL AND file IS NOT NULL AND line > 0 + AND kind NOT IN ('class', 'interface', 'enum', 'record', 'struct', 'module', 'type', 'namespace')"""): + L = read(f) + if not L or not re.fullmatch(_IDENT, n): continue + b = max(a, min(b or a, a + 400, len(L))) + oname = (types.get(owner) or (owner or '').split('.')[-1]) if owner else '' + m = _CONST_DECL.search(L[a - 1]) if a <= len(L) else None + if m and m.group(1) == n and b == a: + seen[f'{oname}.{n}' if oname else n].add(m.group(3)); pos.add((f, a)) + continue + for ln in range(a, b + 1): + for k, _qt, v in _CONST_ENTRY.findall(L[ln - 1]): + seen[f'{n}.{k}'].add(v); pos.add((f, ln)) + return {k: next(iter(vs)) for k, vs in seen.items() if len(vs) == 1}, pos + + +def _entry_key(m, consts): + """the string a matched entry is keyed by: its literal, or the constant it names resolved; None when unknown""" + ref, lit, cref = m.group(1), m.group(3), m.group(4) + if lit: return lit + return consts.get(ref or cref) + + +def table_registrations(q, site_file=None, consts=None): + """[(decl, file, line, 'table', key, why)] — a callable registered in a handler table under the entry's key. + An entry in a test file is a fixture's table, and is not what the application dispatches on.""" + if not _has(q, 'symbols'): + return [] + sf = site_file or (lambda x: x) + read = _source_reader(q) + consts = string_constants(q, read)[0] if consts is None else consts + why = lambda key: f'registered in a handler table under "{key}" here — whoever dispatches the table by that key calls it, no call site does' + out = set() + by_line = collections.defaultdict(list) + for i, f, l in q("""SELECT id, file, line FROM symbols WHERE method_id IS NOT NULL AND file IS NOT NULL AND line > 0 + AND (is_test IS NULL OR is_test = 0) AND kind NOT IN ('module', 'constructor')"""): + by_line[(f, l)].append(i) + for (f, l), ids in by_line.items(): + L = read(f) + if not L or l > len(L) or len(ids) != 1: continue + m = _ENTRY_DECL.match(L[l - 1]) + key = _entry_key(m, consts) if m else None + if key: out.add((ids[0], sf(f), l, 'table', key, why(key))) + # an entry whose value NAMES the handler: the declaration a name identifies uniquely, as `registrations()` requires + if _has(q, 'refs'): + once = {} + for n, i, c in q("""SELECT name, min(id), count(*) FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL + AND name NOT LIKE '<%' GROUP BY name"""): + if c == 1: once[n] = i + tf = {x for (x,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in once or f in tf: continue + L = read(f) + if not L or l > len(L): continue + m = _ENTRY_REF.match(L[l - 1]) + if not m or m.group(5).split('.')[-1] != n: continue + key = _entry_key(m, consts) + if key: out.add((once[n], sf(f), l, 'table', key, why(key))) + return sorted(out) + + +def table_key_writes(q, keys, consts=None, cpos=None): + """[(value, file, line)] — where a handler-table key in `keys` is WRITTEN: a literal of it, or a constant that + resolves to it, outside a key position and outside the constant's own declaration. What a table's key is joined to.""" + if not keys: + return [] + read = _source_reader(q) + if consts is None or cpos is None: + consts, cpos = string_constants(q, read) + out = set() + def written(text, token): + for mm in re.finditer(re.escape(token), text): + # a constant is not the tail of a longer name (`MY_TOPICS.X`) or the head of a longer chain (`TOPICS.X.y`) + if token[0] not in '\'"`' and (re.match(r'[\w$]', text[mm.start() - 1:mm.start()] or ' ') + or re.match(r'[\w$.]', text[mm.end():mm.end() + 1] or ' ')): continue + if not _KEY_POS.match(text, mm.end()): return True + return False + if _has(q, 'literals'): + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + if v not in keys or (f, l) in cpos: continue + L = read(f) + text = L[l - 1] if L and l <= len(L) else None + if text is None or written(text, f"'{v}'") or written(text, f'"{v}"') or written(text, f'`{v}`'): + out.add((v, f, l)) + names = collections.defaultdict(set) # last segment -> the constants it may end + for c, v in consts.items(): + if v in keys: names[c.split('.')[-1]].add(c) + if names and _has(q, 'refs'): + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in names or (f, l) in cpos: continue + L = read(f) + if not L or l > len(L): continue + for c in names[n]: + if written(L[l - 1], c): out.add((consts[c], f, l)) + return sorted(out) # ── the same join the rules make, for a caller that has no Datalog ─────────────────────────────────────────── @@ -1012,16 +1155,22 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): if not _has(q, 'literals'): return [] reg = collections.defaultdict(set) - for decl, _f, _l, _kind, key, _why in all_registrations(q, site_file): - if decl and key: reg[key].add(decl) + table = collections.defaultdict(set) # a handler table's key -> the declarations it registers + for decl, _f, _l, kind, key, _why in all_registrations(q, site_file): + if decl and key: + reg[key].add(decl) + if kind == 'table': table[key].add(decl) if not reg: return [] writes = collections.defaultdict(set) - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in key_writes(q, set(table)): if not isinstance(v, str) or len(v) > 160: continue c = at(f, l) - if c: writes[v].add(c) - capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap} + if c and c not in table.get(v, ()): writes[v].add(c) + # the rules' table_key: a table key's writers in test files drive its handler and are not counted against it + tests = {i for (i,) in q("SELECT id FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")} if table else set() + capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() + if len(cs - tests if k in table else cs) > use_cap} out = set() for v, callers in writes.items(): for key in route_candidates(v, reg): @@ -1030,3 +1179,14 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): for d in reg[key]: if c != d: out.add((c, d, key)) return sorted(out) + + +def key_writes(q, table_keys=None): + """[(value, file, line)] — every string a callable writes that a registration key may be joined to: the literals, + except that a handler table's key is written where table_key_writes says (a dotted literal or a constant, never + the table's own key position or the constant's declaration).""" + if table_keys is None: + table_keys = {r[4] for r in table_registrations(q)} + rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + if v not in table_keys] if _has(q, 'literals') else [] + return rows + table_key_writes(q, table_keys) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index a2b15125..8d2b5ef4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -153,8 +153,10 @@ def ckey(k): # 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. +# `by key` (a handler table's entry under the type a publisher writes, dl/impact.dl) is joined on a string both ends +# spell and nothing the engine resolved: the weakest hop after a name match, as it is in the closure. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, - 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'by key': 8.5, '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 '' @@ -1099,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '55' # 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '56' # 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1385,6 +1387,16 @@ class Impact: if ROUTEISH.fullmatch(r['value']): c = self.at(r['file'], r['line']) if c: lits.append((c, r['value'], r['file'], r['line'])) + # A HANDLER TABLE'S KEY is written as whatever string the dispatcher reads: usually dotted (`doc.created`), and + # usually through a constant (`TOPICS.CREATED`), so neither shape above carries it. Its writes come from + # ax_registration, which also leaves out the table's own key positions and the constant's declaration. + tregs = ax_registration.table_registrations(g.q, g.site_file) + tkeys = {r[4] for r in tregs} + if tkeys: + lits = [x for x in lits if x[1] not in tkeys] + for v, f, l in ax_registration.table_key_writes(g.q, tkeys): + c = self.at(f, l) + if c: lits.append((c, v, f, l)) W('literal', sorted(set(lits))) # a string written inside a decoration (@Listener(topics = "topicOne"), @RequestMapping("/a/{b}")) is in no # other table: literals does not carry it, and it is how a topic, a queue, a route or a bean qualifier binds @@ -1408,9 +1420,12 @@ class Impact: regk = [] # `rd` and not `decl`: `decl` is the dec_literal list above for rd, _f, _l, kind, key, _why in (ax_registration.decoration_keys(g.q, g.site_file) + ax_registration.value_route_registrations(g.q, g.site_file) - + ax_registration.registrations(g.q, g.site_file)): + + ax_registration.registrations(g.q, g.site_file) + tregs): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # the callables in test files: a test that publishes a handler table's key drives the handler, and is not + # counted against the key the way a production writer is (dl/impact.dl, table_key) + W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id'))) # 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))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 98ac6f2a..b44dbe53 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1306,7 +1306,8 @@ def framework_note(g, ids, writers=()): if ff == f and a <= l <= b and (best is None or (b - a) < best[0]): best = (b - a, i) return best[1] if best else None hit = [] - for v, f, l in g.q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + # the literals, and a handler table's key where it is written through a constant (`publish(TOPICS.CREATED)`) + for v, f, l in ax_registration.key_writes(g.q, {r[4] for r in every if r[3] == 'table'}): if not isinstance(v, str): continue for key in ax_registration.route_candidates(v, registered): if key not in keys: continue diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 57d46319..1c15fb72 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -903,8 +903,18 @@ reg_key_fan(k, n) :- reg_key(_, _, k), n = count : { reg_key(_, _, k) }. // decoration argument is not always a registration (`@ValueSource(strings = {"p"})` is test DATA). A key written // everywhere identifies nothing either, whichever side the fan is on, and this needs no catalogue of which // decorations register and which do not. +// A HANDLER TABLE'S KEY (kind "table": `{ [TOPICS.CREATED]: onCreated }`, ax_registration.table_registrations) is a +// message type. Its writers are the services that publish it AND the tests that publish it to drive the consumer, and a +// suite that exercises one event type from ten tests has not made the type identify nothing: only the PRODUCTION +// writers count against the cap. And a handler registered under the key that writes it again (to log it, to pass it +// on) consumes that type; it is not a producer of it. +// test_code(c) c is declared in a test file +.decl test_code(c:symbol) .input test_code +.decl table_key(k:symbol) +table_key(k) :- reg_key(_, "table", k). .decl key_written(c:symbol, k:symbol) -key_written(c, k) :- literal(c, k, _, _). +key_written(c, k) :- literal(c, k, _, _), !table_key(k). +key_written(c, k) :- literal(c, k, _, _), table_key(k), !test_code(c), !reg_key(c, "table", k). .decl key_use_fan(k:symbol, n:number) key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. .decl key_capped(k:symbol) @@ -921,7 +931,7 @@ key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .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. +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b, !reg_key(a, "table", k). // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered 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) @@ -933,11 +943,17 @@ 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). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), !table_key(k). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), table_key(k), !test_code(a). .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. +// and the other direction for a HANDLER TABLE, as for `remote`: what a publisher writes is what the handler registered +// under its type receives, so a change to the publisher's payload is a change to the handler's input. A direct row +// only, never a seed: whoever else reaches the handler does not thereby depend on this publisher. +direct(q, c, "uses", cat("handles what this publishes: registered in a handler table under \"", cat(k, "\" — whatever dispatches the table by that key calls it, no call does")), "by key", "", 0) + :- target(q, "method", m, _), fw_edge(m, c, "by key"), verb_fits(m, c, k, _), table_key(k), c != m. // 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. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9ded5b67..9456d8e1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -71,7 +71,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 | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — 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 | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/tests/cases/javascript/event-type-handler-table/case.json b/tests/cases/javascript/event-type-handler-table/case.json new file mode 100644 index 00000000..16591715 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/case.json @@ -0,0 +1,24 @@ +{"lang": "javascript", "src": ".", + "checks": [ + {"why": "a publisher writes an event type through a constant (TOPICS.CREATED) and another service's handler table is keyed by the same constant, or by the same string as a method shorthand: impact of the publisher lists each handler registered under that type [by key], and not the handler registered under another type or an arrow inside a table of data", + "run": ["impact", "DocService.create", "--grep"], + "want": ["src/search/handlers.js:5", "src/audit/audit.js:5", "[by key]"], + "avoid": ["src/search/handlers.js:6", "src/audit/audit.js:10"]}, + {"why": "the tests that publish the type (through the service) reach the handler registered under it; a test that publishes another type does not", + "run": ["impact", "src/search/handlers.js:5", "--tests-only"], + "want": ["test/service.test.js", "by key"], + "avoid": ["test/drop.test.js"]}, + {"why": "the other handler's tests are the ones that publish ITS type, written through the same constant table", + "run": ["impact", "src/search/handlers.js:6", "--tests-only"], + "want": ["test/drop.test.js"], + "avoid": ["test/service.test.js"]}, + {"why": "a type written as a dotted string literal joins a handler NAMED as a table entry's value; path says a framework connects them instead of calling the two independent", + "run": ["path", "DocService.archive", "onArchived"], + "want": ["registered in a handler table under \"doc.archived\"", "NOT independent"], + "expect_error": true}, + {"why": "control: a publisher of another type stays independent of that handler", + "run": ["path", "DocService.create", "onArchived"], + "want": ["independent in this graph"], + "avoid": ["NOT independent"], + "expect_error": true} + ]} diff --git a/tests/cases/javascript/event-type-handler-table/src/audit/audit.js b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js new file mode 100644 index 00000000..acc9d5a9 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js @@ -0,0 +1,11 @@ +function record(env) { return [env.type, env.payload]; } +function onArchived(env) { return record(env); } + +export const auditHandlers = { + 'doc.created'(env) { return record(env); }, + 'doc.archived': onArchived, +}; + +export const LABELS = { + 'doc.created': ['created', (env) => record(env)], +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js new file mode 100644 index 00000000..41f3cdab --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js @@ -0,0 +1,4 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); diff --git a/tests/cases/javascript/event-type-handler-table/src/producer/service.js b/tests/cases/javascript/event-type-handler-table/src/producer/service.js new file mode 100644 index 00000000..a7f7cd40 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/producer/service.js @@ -0,0 +1,14 @@ +import { TOPICS } from '../pkg/topics.js'; + +export class DocService { + constructor(bus) { this.bus = bus; } + + create(doc) { + this.bus.publish(TOPICS.CREATED, doc); + return doc; + } + + archive(doc) { + this.bus.publish('doc.archived', doc); + } +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/consumer.js b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js new file mode 100644 index 00000000..3d999bd5 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js @@ -0,0 +1,4 @@ +export async function consume(handlers, msg) { + const handler = handlers[msg.headers.type]; + if (handler) await handler(msg); +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/handlers.js b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js new file mode 100644 index 00000000..3bb08d29 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js @@ -0,0 +1,7 @@ +import { TOPICS } from '../pkg/topics.js'; +import { index, drop } from './indexer.js'; + +export const handlers = { + [TOPICS.CREATED]: async (env) => index(env), + [TOPICS.DELETED]: async (env) => drop(env), +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/search/indexer.js b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js new file mode 100644 index 00000000..d33b5d43 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js @@ -0,0 +1,2 @@ +export function index(env) { return env.payload; } +export function drop(env) { return env.payload.id; } diff --git a/tests/cases/javascript/event-type-handler-table/test/drop.test.js b/tests/cases/javascript/event-type-handler-table/test/drop.test.js new file mode 100644 index 00000000..b728e234 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/drop.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { TOPICS } from '../src/pkg/topics.js'; + +test('a delete is published by its type', () => { + const bus = { publish() {} }; + bus.publish(TOPICS.DELETED, { id: 'd1' }); +}); diff --git a/tests/cases/javascript/event-type-handler-table/test/service.test.js b/tests/cases/javascript/event-type-handler-table/test/service.test.js new file mode 100644 index 00000000..67b37d53 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/service.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { DocService } from '../src/producer/service.js'; + +test('create publishes the created event', () => { + const sent = []; + new DocService({ publish: (type, doc) => sent.push([type, doc]) }).create({ id: 'd1' }); +}); diff --git a/tests/cases/python/event-type-handler-table/app/__init__.py b/tests/cases/python/event-type-handler-table/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/app/handlers.py b/tests/cases/python/event-type-handler-table/app/handlers.py new file mode 100644 index 00000000..5d93dba9 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/handlers.py @@ -0,0 +1,21 @@ +from app.topics import Topics + + +def on_created(env): + return env["payload"] + + +def on_deleted(env): + return env["payload"]["id"] + + +HANDLERS = { + Topics.CREATED: on_created, + "doc.deleted": on_deleted, +} + + +def consume(msg): + handler = HANDLERS.get(msg["headers"]["type"]) + if handler: + handler(msg) diff --git a/tests/cases/python/event-type-handler-table/app/service.py b/tests/cases/python/event-type-handler-table/app/service.py new file mode 100644 index 00000000..2a15d92c --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/service.py @@ -0,0 +1,6 @@ +from app.topics import Topics + + +def create(bus, doc): + bus.publish(Topics.CREATED, doc) + return doc diff --git a/tests/cases/python/event-type-handler-table/app/topics.py b/tests/cases/python/event-type-handler-table/app/topics.py new file mode 100644 index 00000000..aa19fb58 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/topics.py @@ -0,0 +1,3 @@ +class Topics: + CREATED = "doc.created" + DELETED = "doc.deleted" diff --git a/tests/cases/python/event-type-handler-table/case.json b/tests/cases/python/event-type-handler-table/case.json new file mode 100644 index 00000000..338e20cd --- /dev/null +++ b/tests/cases/python/event-type-handler-table/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a dict of type -> handler, keyed by a class constant or by the string itself: impact of the publisher lists the handler registered under the type it publishes [by key], and not the handler of another type", + "run": ["impact", "create", "--grep"], + "want": ["app/handlers.py:4", "[by key]"], + "avoid": ["app/handlers.py:8"]}, + {"why": "the test that publishes the type through the service reaches the handler; a test that writes another type does not", + "run": ["impact", "on_created", "--tests-only"], + "want": ["tests/test_service.py", "by key"], + "avoid": ["tests/test_other.py"]} + ]} diff --git a/tests/cases/python/event-type-handler-table/tests/__init__.py b/tests/cases/python/event-type-handler-table/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/tests/test_other.py b/tests/cases/python/event-type-handler-table/tests/test_other.py new file mode 100644 index 00000000..a1eb8187 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_other.py @@ -0,0 +1,4 @@ +def test_delete_is_published_by_its_type(): + sent = [] + sent.append(("doc.deleted", {"id": "d1"})) + assert sent diff --git a/tests/cases/python/event-type-handler-table/tests/test_service.py b/tests/cases/python/event-type-handler-table/tests/test_service.py new file mode 100644 index 00000000..451d7a49 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_service.py @@ -0,0 +1,10 @@ +from app.service import create + + +class Bus: + def publish(self, topic, doc): + pass + + +def test_create_publishes(): + create(Bus(), {"id": "d1"}) From a2c47e2a77c824209fe952b91a4812adf11d59db Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 01:36:39 -0700 Subject: [PATCH 085/258] impact: a TypeScript object literal key survives a bound access of a same-named field on its line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was wrong - `const meta: EventMeta = { eventId: options.eventId ?? … }` stopped listing its function under `impact EventMeta.eventId`. The per-line rule that drops a name match on a line where the engine bound a field access (to this field or to another field of that name) drops every ref of that name on the line. Refs carry no column, and TypeScript stored the literal key `eventId` as a plain UNKNOWN identifier, so the key went with the bound `options.eventId` read of PublishOptions.eventId. field_access has no row for an object literal key, so nothing else reported the write. The change - axiomcode-index: a TypeScript expression in the OBJECT_PROPERTY_KEY role is stored with entity kind OBJECT_PROPERTY_KEY instead of UNKNOWN. - dl/impact.dl: fref keeps a ref of that kind past fa_line. A bound access on another line, and the bound access itself, are still not this field's readers. - IMPACT_VERSION 56. tests/cases/typescript/object-key-beside-a-bound-access: red before, green after, with two controls (a line that only reads the other type's field stays out; the bound read stays the other field's resolved reader). tests/run.py --lang typescript: 204 of 204. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../skills/axiomcode/scripts/axiomcode-index | 5 +++++ .../skills/axiomcode/scripts/dl/impact.dl | 5 ++++- .../object-key-beside-a-bound-access/case.json | 14 ++++++++++++++ .../object-key-beside-a-bound-access/src/bus.ts | 8 ++++++++ .../src/recorder.ts | 7 +++++++ .../src/stamper.ts | 7 +++++++ .../object-key-beside-a-bound-access/src/types.ts | 8 ++++++++ 8 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/case.json create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8d2b5ef4..4e300359 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1101,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '56' # 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '57' # 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 2bbd460a..830ba9a5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -94,6 +94,9 @@ A = { # a property's name (`this.pending`, `row.setX`) is in literalValue with an empty potentialQualifiedName — 36k of 60k references in the parser repo expr=dict(file='all-typescript-expressions.csv', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'tsModuleLinkHash'), refKinds={'IDENTIFIER_REFERENCE', 'PROPERTY_ACCESS'}, entityKind='referencedEntityKind', + # the key of an object literal (`{ eventId: … }`) is stored with the entity kind OBJECT_PROPERTY_KEY, not + # UNKNOWN: it is never a property ACCESS, so an access the engine bound on the same line is not this name + keyRole=dict(role='edgeRole', value='OBJECT_PROPERTY_KEY'), litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), @@ -573,6 +576,7 @@ c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) e = A['expr']; refs = []; lits = [] namecol = e['name'] if isinstance(e['name'], str) else dict(x.split(':') for x in e['name']) mname = e.get('memberName'); child_ek = {} +kr = e.get('keyRole') if mname: # the member-name child's entity kind, for the access that stands for it (the parent's own is UNKNOWN until resolved) for r in rows(e['file']): if r.get(mname['role']) == mname['value'] and r.get(mname['parent']): child_ek[r[mname['parent']]] = r.get(e['entityKind'], '') @@ -591,6 +595,7 @@ for r in rows(e['file']): n = n.rsplit('.', 1)[-1] ek = r.get(e['entityKind'], '') if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek + if kr and ek in ('', 'UNKNOWN') and r.get(kr['role']) == kr['value']: ek = kr['value'] refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: v = (r.get(e['litValue']) or '') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 1c15fb72..c351aa14 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -462,7 +462,7 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), - !fa_line(q, f, l). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"). // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) @@ -533,6 +533,9 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl // more, whichever callable the line is attributed to. fa_known works per caller, and a lambda written on the same line // as the access (`m.GetOrAdd(T.Culture + k, x => ...)`) is a different callable, so its name match on T.Culture came // back as a second, [in scope] reader that reads nothing (#1445). +// An object literal's KEY on that line is not the access the engine bound, and no field_access row ever covers one: +// `const meta: EventMeta = { eventId: options.eventId }` binds `options.eventId` to PublishOptions.eventId, and the key +// `eventId` is EventMeta's. fref keeps a ref of entity kind OBJECT_PROPERTY_KEY (TypeScript) past fa_line. .decl fa_line(q:symbol, f:symbol, l:number) fa_line(q, f, l) :- target(q, "field", fl, _), fa_bound(_, fl, _, f, l). fa_line(q, f, l) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(_, fl2, _, f, l), fl2 != fl, field(fl2, _, n, _, _). diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/case.json b/tests/cases/typescript/object-key-beside-a-bound-access/case.json new file mode 100644 index 00000000..58ec1090 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/case.json @@ -0,0 +1,14 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an object literal key on the same line as a bound access of another type's same-named field is a separate reference: `{ eventId: options.eventId }` typed EventMeta writes EventMeta.eventId, and the engine binding `options.eventId` to PublishOptions.eventId must not hide it", + "run": ["impact", "EventMeta.eventId"], + "want": ["change: field EventMeta.eventId", "Bus.publish", "Recorder.wrap"], + "avoid": ["Stamper.stamp"]}, + {"why": "control: a line that only reads another type's same-named field, bound by the engine, is still not this field's reader", + "run": ["impact", "EventMeta.eventId"], + "avoid": ["src/stamper.ts"]}, + {"why": "control: the bound access is the other field's resolved read", + "run": ["impact", "PublishOptions.eventId"], + "want": ["[resolved] Bus.publish src/bus.ts:5 — reads it", "[resolved] Stamper.stamp src/stamper.ts:5 — reads it"], + "avoid": ["Recorder.wrap"]} + ]} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts new file mode 100644 index 00000000..09e9a5b3 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts @@ -0,0 +1,8 @@ +import { EventMeta, PublishOptions } from './types'; + +export class Bus { + publish(name: string, options: PublishOptions = {}): EventMeta { + const meta: EventMeta = { eventId: options.eventId ?? name, name }; + return meta; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts new file mode 100644 index 00000000..8af5d765 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts @@ -0,0 +1,7 @@ +import { EventMeta } from './types'; + +export class Recorder { + wrap(meta: EventMeta): string { + return meta.eventId; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts new file mode 100644 index 00000000..6a0c0b0a --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts @@ -0,0 +1,7 @@ +import { PublishOptions } from './types'; + +export class Stamper { + stamp(options: PublishOptions): string { + return options.eventId ?? 'none'; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts new file mode 100644 index 00000000..c0cbcec2 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts @@ -0,0 +1,8 @@ +export interface EventMeta { + readonly eventId: string; + readonly name: string; +} + +export interface PublishOptions { + readonly eventId?: string; +} From 5b857c1a62a1fef69b27f0ee9c226e5d14b25505 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 01:19:01 -0700 Subject: [PATCH 086/258] A small surface: find, impact, path and tests, each answered as places with their code An agent was offered 8 MCP tools with 69 parameters and ~10,000 characters of descriptions, and the CLI 10-37 flags per verb; in practice agents asked two questions with no options, and after every located answer read the file. The offering is now four questions and a setup verb, the same on the CLI and over MCP, with no options: - find "" / find(question): where the code for a task lives (context underneath); a name the task writes that the code calls and nothing declares is listed with its call sites - impact / impact(name): callers, what a change reaches, the tests; with no name, the same for the declarations the uncommitted edits changed - path / path(start, end): the call chain - tests / tests(): the tests the uncommitted edits reach, and the command that runs exactly those - index: build the graph (--lang, --src, --library) Each answer is numbered places, each with the enclosing function's code (whole when short, else its header and a window around the lines that matter), one block per function, at most 10, word-match filler and module-scope rows dropped (ax_blocks.py). The shape applies at the front doors (the installed command, the MCP server) when no flag is passed; the dispatcher called directly, AXIOMCODE_RAW, or any flag gives the verb's own answer, so hooks, suites and scripts are unchanged. Old verbs and flags still work and are no longer advertised. Help, SKILL.md, AGENTS.md, the Cursor rule, the Gemini copy, README and the hooks' hints teach only the four. MCP answers drop any clause that names an option the tools refuse; code blocks are never touched. CI: engine () runs tests/run.py --lang after the engine suite (every case must pass; a pending case that passes fails until its mark is removed), and the python leg runs tests/front_door.py, which checks the shape through the installed command and MCP and that direct calls and flags keep the old answers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 21 +- README.md | 133 ++++---- bin/axiomcode | 35 +- packaging/copies.py | 5 +- plugins/axiomcode/AGENTS.md | 33 +- plugins/axiomcode/hooks/_graphline.py | 4 +- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/direct.py | 9 +- plugins/axiomcode/hooks/enrich.py | 4 +- plugins/axiomcode/hooks/orient.py | 17 +- plugins/axiomcode/mcp/server.py | 86 ++--- plugins/axiomcode/rules/axiomcode.mdc | 33 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 185 ++++------ .../axiomcode/reference/changed-and-tests.md | 135 -------- .../skills/axiomcode/reference/context.md | 69 ---- .../skills/axiomcode/reference/diff.md | 68 ---- .../skills/axiomcode/reference/impact.md | 322 ------------------ .../skills/axiomcode/reference/path.md | 130 ------- .../skills/axiomcode/reference/schema.md | 106 ------ .../skills/axiomcode/scripts/ax_blocks.py | 162 +++++++++ .../skills/axiomcode/scripts/axiomcode | 54 ++- .../axiomcode/scripts/axiomcode-install | 24 +- skills/axiomcode/SKILL.md | 185 ++++------ .../axiomcode/reference/changed-and-tests.md | 135 -------- skills/axiomcode/reference/context.md | 69 ---- skills/axiomcode/reference/diff.md | 68 ---- skills/axiomcode/reference/impact.md | 322 ------------------ skills/axiomcode/reference/path.md | 130 ------- skills/axiomcode/reference/schema.md | 106 ------ tests/directive.py | 4 +- tests/freshness.py | 31 +- tests/front_door.py | 150 ++++++++ tests/graph_verb.py | 4 +- tests/hooks_from_path.py | 4 +- tests/latency.py | 8 +- tests/manifests.py | 8 +- tests/mcp.py | 114 +++---- tests/mcp_docs.py | 54 +-- tests/mcp_first.py | 22 +- tests/repo_arg.py | 26 +- tests/surfaces.py | 161 ++++++--- 41 files changed, 914 insertions(+), 2324 deletions(-) delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/context.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/diff.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/impact.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/path.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/schema.md create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py delete mode 100644 skills/axiomcode/reference/changed-and-tests.md delete mode 100644 skills/axiomcode/reference/context.md delete mode 100644 skills/axiomcode/reference/diff.md delete mode 100644 skills/axiomcode/reference/impact.md delete mode 100644 skills/axiomcode/reference/path.md delete mode 100644 skills/axiomcode/reference/schema.md create mode 100644 tests/front_door.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c675ed1..5987254c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,7 +253,7 @@ jobs: needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 90 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) @@ -448,6 +448,25 @@ 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 }} + # THE QUERY LAYER'S CASES (tests/run.py): what impact, path, context, changed and test-impact answer on small + # projects written for one behaviour each. Every case must pass, and a case marked pending that now passes fails + # the run until the mark is removed, so a fix for one shape cannot quietly break another's answer. The suite + # calls the verbs directly, so it checks their own answers, not the front-door rendering (tests/front_door.py). + - name: ${{ matrix.lang }} query cases + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + run: python3 tests/run.py --lang ${{ matrix.lang }} + + # the small surface as users and agents get it: find / impact / path / tests through the installed command and + # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers + - name: the front door answers as places with their code + if: matrix.lang == 'python' + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + run: python3 tests/front_door.py + # The hooks answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (dl/impact.dl) only when # it declines, so the two must list the same rows for the same edit: tests/fastpath.py indexes a small case, asks # both on each target shape, and runs hooks/changes.py on one edit through each path. Same parser and engine diff --git a/README.md b/README.md index 23ca5395..166aaf3d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ That matters because an agent follows edges several hops deep, and one missed li axiomcode graph of an open-source TypeScript web framework, 366 files and 8,657 call edges. Source files form the inner ring, test files the outer ring. A change to basicAuth reaches 7 test files through resolved calls (solid blue); the other 130 test files have no chain to it (dashed red). basicAuth calls a shared compare function (green) that 11 other files also reach (gold).

    -*`axiomcode graph` on an open-source TypeScript web framework, asked which tests a change to `basicAuth` can +*The call graph of an open-source TypeScript web framework, drawn as a page and asked which tests a change to `basicAuth` can affect. Source files form the inner ring and test files the outer one. The solid blue paths are chains of resolved calls from `basicAuth` to the 7 test files that must run; the dashed red ones mark the other 130, which have no chain to it and can be skipped. Green is the shared `compare` that `basicAuth` calls, and gold the 11 other files @@ -123,7 +123,7 @@ established relationships that could lead to false positives. AxiomCode Graph is two parts. The **engine** (`@axiomcode/code-graph` on npm) parses a repository and builds its graph; it also provides the `axiomcode` command and an MCP server. The **plugin** (`plugins/axiomcode/`) is the -agent-facing frontend: a skill, seven MCP tools, and hooks. Install the engine first. +agent-facing frontend: a skill, four MCP tools, and hooks. Install the engine first. Requirements: **Node ≥ 22.5** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also [Git for Windows](https://git-scm.com/download/win): the CLI runs under its bash. The engine ships as a prebuilt @@ -200,55 +200,62 @@ axiomcode path main Ledger.put axiomcode path main SqlStore.put ``` -``` -main → Ledger.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s) - 2 call(s): - main src/main.ts:6 - → [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7 - → [known_edge · call @ src/orders/orderService.ts:8] Ledger.put src/ledger/ledger.ts:4 - verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length - -main → SqlStore.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s) - 2 call(s): - main src/main.ts:6 - → [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7 - → [multi_inferred · call @ src/orders/orderService.ts:9] SqlStore.put src/storage/sqlStore.ts:6 - verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length - what the hops are: - [known_edge] resolved to one declaration - [multi_inferred] several declarations fit; each is a real candidate -``` - -The first chain is `known_edge` all the way: each call has exactly one target. The second ends in -`multi_inferred`, because `store.put` can run `SqlStore.put` or `MemoryStore.put`, depending on which store `main` -built; the graph keeps both as candidates instead of picking one. +Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters +marked `→`: + +```` +1. src/main.ts:9 [resolved · hop 1/2 main → OrderService.place] + ```typescript + 6 export function main(useSql: boolean): void { + 7 const store = useSql ? new SqlStore("orders") : new MemoryStore(); + 8 const service = new OrderService(new Ledger(), store); + → 9 service.place("A-1", 42); + 10 } + ``` +2. src/orders/orderService.ts:8 [resolved · hop 2/2 OrderService.place → Ledger.put] + ```typescript + 7 place(id: string, amount: number): void { + → 8 this.ledger.put(`order ${id}: ${amount}`); + 9 this.store.put(id, amount); + 10 } + ``` +verified: ✓ (2 edge(s) looked up again) +```` + +The second chain ends the same way, at `src/orders/orderService.ts:9`, tagged `one of a set`: `store.put` can run +`SqlStore.put` or `MemoryStore.put`, depending on which store `main` built, and the graph keeps both as candidates +instead of picking one. From an agent, ask in plain words. The skill tells the agent to query the graph instead of grepping: -``` +```` > What breaks if I change SqlStore.put? - axiomcode_impact("SqlStore.put") - must change with it (1: bound by a contract the engine resolved): - Store.put src/storage/store.ts:2 — it implements this - reads or uses it (3 callable(s): 1 one of a set, 2 alongside): - [one of a set] OrderService.place src/orders/orderService.ts:9 — calls it - ... - reaches those through resolved calls: 4 more callable(s) in 3 file(s) - src/main.ts: main → OrderService.place - tests: 1 of 1 test method(s) reach the change - test files: test/orderService.test.ts (1) - verified: 2 printed edge(s) looked up again in the graph, all present -``` - -The change reaches the entry point and the test through a call that never names `SqlStore`. - -Each hop carries the line the call is on, how certain the edge is, and what kind of call it is. Every printed -edge is looked up again in the graph before you see it; the `verified:` line is that check reporting. + impact("SqlStore.put") + 1. src/storage/store.ts:2 [must change · it implements this] + ```typescript + → 2 put(key: string, value: number): void; + ``` + 2. src/orders/orderService.ts:9 [one of a set · OrderService.place] + ```typescript + 7 place(id: string, amount: number): void { + 8 this.ledger.put(`order ${id}: ${amount}`); + → 9 this.store.put(id, amount); + 10 } + ``` + 3. src/main.ts:6 [hop 2] + ... + 4. test/orderService.test.ts:3 [test · one of a set · hop 3] + ... + verified: ✓ (3 edge(s) looked up again) +```` + +The change reaches the entry point and the test through a call that never names `SqlStore`. Every printed edge is +looked up again in the graph before you see it; the `verified:` line is that check reporting. ### Support for agents -Every agent below gets the seven MCP tools and the skill; the hooks, which add the graph's edges to the agent's +Every agent below gets the four MCP tools and the skill; the hooks, which add the graph's edges to the agent's own file reads and searches, run where the last column says so. | Agent | Install | Uninstall | Hooks | @@ -301,27 +308,28 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands -| command | what it does | +Four questions, each answered as numbered places with the code of the function each one sits in. The MCP server +offers the same four as tools: `find(question)`, `impact(name)`, `path(start, end)` and `tests()`. + +| command | what it answers | |---|---| -| `axiomcode path
    ` | the chain of calls from A to B, hop by hop. `'*'` as one end gives the whole closure | -| `axiomcode impact ` | everything that has to be looked at again when a declaration changes, each labelled with how certain it is. `--tests` adds the tests that reach it | -| `axiomcode test-impact` | which tests have to run for the current edit, with the chain that reaches each | -| `axiomcode changed` | which declarations an edit changed, and how (signature, type, body, added, removed). `--impact` adds what that reaches | -| `axiomcode context ""` | where a task's words land in the code, when you have a problem statement and not yet a name | -| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html`, drawn from the existing graph (rebuilt first only when stale, with the flags it was indexed with) | -| `axiomcode index` | build or rebuild the graph explicitly; `--lang`, `--src` and `--library` narrow it | -| `axiomcode mcp` | serve the graph to an agent as MCP tools over stdio | - -A target is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or -`file.py:123`. It is resolved exactly; a miss lists the nearest names. `--range ..` compares two commits. The -query commands take `--json`. `axiomcode help ` prints one command's usage. +| `axiomcode find ""` | where the code for a task lives, when you have it in words and not yet a name | +| `axiomcode impact ` | who calls it, what a change to it reaches, and the tests that exercise it | +| `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | +| `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | +| `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them | +| `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it | + +A name is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or +`file.py:123`. It is resolved exactly; a miss lists the nearest names. `axiomcode help ` prints one +command's usage. > [!NOTE] -> `changed` and `test-impact` compare the working tree with a **baseline**: the last commit (right after an explicit -> `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a commit, a -> merge, a pull, a checkout), so committed edits drop out and nothing accumulates; the two commands wait up to 30 s -> for that. While edits are uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed -> method still shows all its callers. +> `impact` with no name and `tests` compare the working tree with a **baseline**: the last commit (right after an +> explicit `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a +> commit, a merge, a pull, a checkout), so committed edits drop out and nothing accumulates. While edits are +> uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed method still shows all its +> callers. The graph stays current on its own. Every file the parser reads is recorded with its hash at build time; after an edit, a shell command, a finished turn, at session start, and before a query, anything that differs starts one @@ -334,8 +342,7 @@ a session sits idle. The graph records when and why it was built in `index_meta` `AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still ends with a `graph refresh: OFF` line naming the files it predates. When a name asked about finds nothing and an edit since the graph was built writes that name, the line says so, since the declaration may simply be too new -for the graph. `--no-refresh` on any query verb (MCP `refresh=false`) does the same for one query: a read-only answer -from the graph as it is. A query that does start a rebuild says so on its answer's first line, with the reason. The +for the graph. A query that does start a rebuild says so on its answer's first line, with the reason. The hooks and the MCP server's timer never rebuild a graph another axiomcode built (another engine, other rules or another `IMPACT_VERSION` in its build stamp); a hook says so once per session. The log is `.axiomcode/refresh.log`. diff --git a/bin/axiomcode b/bin/axiomcode index 01c25295..f5a73ca2 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,6 +1,10 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── -# axiomcode — build a call graph from a source tree. +# axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# ───────────────────────────────────────────────────────────────────────────── +# INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which +# the dispatcher, the test suites and the agent manifests call. # # Usage: # bin/axiomcode [--library [,…]] [--exclude-tests] [--version V] @@ -88,9 +92,8 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" # (`--verbs`, derived from its own dispatch table) so the two entry points cannot drift: a new # capability is a new row in one case statement, not a new copy here. QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode" -# `tests` is the frontend's alias for test-impact; `test` is THIS command's engine suite. One letter -# apart, entirely different jobs, and putting both on one command is a trap — so the alias is not -# routed here, and the name is refused below with both readings spelled out. +# `tests` is a query verb (the tests an edit reaches); `test` is THIS command's engine suite. The query +# surface advertises `tests`; `test` is internal. # The list is read from the frontend's own dispatch table, the lines ` [|]) exec …` that its `--verbs` # prints, so the two entry points still cannot drift -- but read here, in this shell, instead of by a second bash, a # sed, a tr and two greps on every query. @@ -100,19 +103,18 @@ load_verbs(){ local l v x re='^ ([a-z|-]*)\) *exec ' while IFS= read -r l || [ -n "$l" ]; do [[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}" - for x in ${v//|/ }; do [ "$x" = tests ] || QVERBS+=("$x"); done + for x in ${v//|/ }; do QVERBS+=("$x"); done done < "$QUERY" } query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; } is_query_verb(){ load_verbs; local x; for x in ${QVERBS[@]+"${QVERBS[@]}"}; do [ "$x" = "$1" ] && return 0; done; return 1; } usage(){ - awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//' - if [ -f "$QUERY" ]; then - echo - echo 'ASKING THE GRAPH — `axiomcode help ` for any one of them:' - bash "$QUERY" --help | sed -n 's/^ \(axiomcode [a-z].*\)$/ \1/p' - fi + # the query surface is the dispatcher's own help, so the two cannot drift; the build commands above are internal + if [ -f "$QUERY" ]; then bash "$QUERY" --help + else awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//'; fi } +# the verbs the help advertises (` axiomcode ` lines), for the message a typo gets +public_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --help | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; return 0; } die(){ echo "axiomcode: $*" >&2; exit 2; } need_parser(){ [ -f "$PARSER" ] || { echo "axiomcode: parser not built at $PARSER — run: npm install && npm run build" >&2; exit 1; }; } @@ -129,15 +131,14 @@ case "$cmd" in if [ $# -gt 1 ] && is_query_verb "$2"; then exec bash "$QUERY" help "$2"; fi ;; "") ;; # THE MCP SERVER IS THE PLUGIN'S, NOT A SECOND ONE. The package already ships plugins/axiomcode/, - # so an agent that is not Claude Code gets the same seven tools from one line of MCP config and + # so an agent that is not Claude Code gets the same four tools from one line of MCP config and # no plugin install. launch.js picks the interpreter and falls back to a built-in protocol # implementation, so nothing beyond the python3 the query verbs already need has to be installed. mcp) shift export AXIOMCODE_PLUGIN_ROOT="$ROOT/plugins/axiomcode" exec node "$ROOT/plugins/axiomcode/mcp/launch.js" "$@" ;; - tests) echo "axiomcode: 'tests' is ambiguous here — 'axiomcode test-impact' for the tests an edit needs," >&2 - echo " 'axiomcode test' for this package's own engine suite." >&2; exit 2 ;; - *) if is_query_verb "$cmd"; then shift; exec bash "$QUERY" "$cmd" "$@"; fi ;; + # the installed command is a front door: a query asked here with no flags answers as places with their code + *) if is_query_verb "$cmd"; then shift; export AXIOMCODE_FRONT=1; exec bash "$QUERY" "$cmd" "$@"; fi ;; esac # A NAME THAT IS NOT A VERB AND NOT A DIRECTORY IS A TYPO, NOT A BUILD. `*) cmd=all` is convenient # for `axiomcode ./src out/` and wrong for everything else: a misspelled verb used to be parsed as a @@ -145,9 +146,7 @@ esac case "$cmd" in parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; *) [ -d "$cmd" ] || { echo "axiomcode: '$cmd' is neither a verb nor a directory." >&2 - echo " build: parser engine all test (or: axiomcode )" >&2 - echo " serve: mcp" >&2 - echo " ask: $(query_verbs | tr '\n' ' ')" >&2 + echo " ask: $(public_verbs | tr '\n' ' ')" >&2 echo " \`axiomcode help\` for what each one does." >&2; exit 2; } cmd=all;; esac diff --git a/packaging/copies.py b/packaging/copies.py index b503878a..9440eff4 100755 --- a/packaging/copies.py +++ b/packaging/copies.py @@ -11,7 +11,7 @@ the install: Gemini clones into a temporary directory, copies it with fs.cp, which rewrites a relative link into an absolute one inside that directory, and then deletes the directory. -So skills/axiomcode/ holds a copy of SKILL.md and reference/, and nothing else. The scripts stay in the +So skills/axiomcode/ holds a copy of SKILL.md (and reference/, when the skill has one), and nothing else. The scripts stay in the plugin: the copy's fallback command is rewritten to reach them from the repository root, which Gemini installs whole. @@ -48,7 +48,8 @@ def expected(): if SCRIPTS not in text: sys.exit(f"copies: SKILL.md no longer names {SCRIPTS}…; update the rewrite in {__file__}") files['SKILL.md'] = text.replace(SCRIPTS, FROM_ROOT) - for name in sorted(os.listdir(os.path.join(SOURCE, 'reference'))): + ref = os.path.join(SOURCE, 'reference') + for name in sorted(os.listdir(ref)) if os.path.isdir(ref) else []: with open(os.path.join(SOURCE, 'reference', name)) as f: files[os.path.join('reference', name)] = f.read() return files diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index c99dc574..ac48bfde 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,25 +1,20 @@ # axiomcode -For any why, what or where question about code — how it works, where something lives, who calls it, -what a change breaks, which tests an edit reaches, whether something is safe to delete — ask the -repository's call graph FIRST, through the `axiomcode_*` MCP tools: +For any why, what or where question about code — where something lives, who calls it, what a change +breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - axiomcode_context where the work is, when you have a task in words and no name yet; for "how does X - work", explain=True, source=True (and from_=) returns the call - flow with each step's code - axiomcode_impact what a change reaches: must-change-with-it, users, tests - axiomcode_path how A reaches B, each hop verified - axiomcode_changed which declarations an edit changed, and how - axiomcode_test_impact which tests the edit in front of you has to run - axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent - axiomcode_graph draw the graph as one interactive HTML page, for a person - axiomcode_diff what changed between two graphs of one tree (before/after), by name and line + find(question) where the code for a task lives, when you have a task in words and no name yet + impact(name) who calls it, what a change to it reaches, and its tests; + impact() with no name: the same for your uncommitted edits + path(start, end) how A reaches B, every hop of the call chain + tests() the tests your uncommitted edits reach, and the command that runs them -**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. Every answer ends with `next:`, the -one step to take. For a CHANGE, read only the lines you will cite or change. To EXPLAIN how something -works, the graph gives the reading order: answer from the flow's code, and read further only where a step's -body was cut or a `⚠` marks a call the graph lost. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +`axiomcode path `, `axiomcode tests`. + +Every answer is a numbered list of places, each with the code of the function it sits in and the line that +matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has +already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / +`text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. Text search is still right for a string, a comment, a config value, or a file you already know. diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index 7755c85d..be03b5bd 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -133,7 +133,7 @@ def body_line(db, results, repo='.'): 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], 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" + tail = (f"; run: {cmd}" + (f" (+{more} more: tests(), `axiomcode tests`)" if more > 0 else '')) if cmd else "; tests() (`axiomcode tests`) gives the command" return f"graph: body edit of {what}: {n} test(s) reach it{tail}" @@ -202,4 +202,4 @@ def base_moved_line(repo, st): n = git('rev-list', '--count', '--right-only', '--cherry-pick', f'{prev}...{h}').stdout.strip() return (f"graph: the base moved: HEAD is {h[:10]}, was {prev[:10]}" + (f" ({n} commit(s) it did not have)" if n.isdigit() else '') + " — a rebase, a pull, a checkout, a reset or a commit. What those commits changed is not reported as an edit;" - " edits are read against the file before each one. `axiomcode changed --range ..HEAD` reads committed work.") + " edits are read against the file before each one. impact(name) (`axiomcode impact `) answers for a declaration they touched.") diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index a547f630..42856045 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -121,7 +121,7 @@ def impact(d): dd = x['evidence']['decider'] lines.append(f" decided: [{x['certainty']}] {x['at'].split('/')[-1]} ← {dd['at'].split('/')[-1]}: {dd['text'][:100]} [{dd['kind']}]") lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) - if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") + if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: impact() with no name (`axiomcode impact`) answers for every edit") 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:]], cwd)) diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 37eaf8d3..7a4285b8 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -121,10 +121,11 @@ def directive(hits): named = ', '.join(f"`{h[1]}` ({h[2]}:{h[3]})" for h in hits[:3]) # short on purpose: it is read once and then re-read on every later turn return ( - f"graph: this search is for {named}. Who calls it and what a change breaks,\n" - f" with the callers that never spell the name (an interface, an override, a callback, DI):\n" - f" axiomcode_impact targets=[\"{at}\"] (`axiomcode impact {at}`). Also axiomcode_path (how A reaches B,\n" - f" `axiomcode path A B`), axiomcode_context (a task in words, `axiomcode context \"\"`). Said once this session." + f"graph: this search is for {named}. Who calls it and what a change breaks, each with its code,\n" + f" including the callers that never spell the name (an interface, an override, a callback, DI):\n" + f" impact(name=\"{at}\") (mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact {at}`). Also\n" + f" path(start, end) for how A reaches B (`axiomcode path A B`), find(question) for a task in words\n" + f" (`axiomcode find \"\"`). Said once this session." ) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 6bd2d80a..0b49ea84 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -243,7 +243,7 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' # 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that # was the fast path answering, the rules answering, or the CLI having given up. lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) - if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more changed declaration(s): axiomcode changed --impact") + if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more changed declaration(s): impact() with no name (`axiomcode impact`) answers for every edit") if decls: for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") if bodies: @@ -512,7 +512,7 @@ def lookup(n): if key in seen: lines = [] # the same range, or the same search, is annotated once elif spent >= ENRICH_BUDGET: - lines = [] if st.get('budget_said') else [f"graph: this session's enrichment budget ({ENRICH_BUDGET} characters) is spent, so reads and searches get no more of these blocks; ask `axiomcode impact` / `path` directly for a declaration's edges"] + lines = [] if st.get('budget_said') else [f"graph: this session's enrichment budget ({ENRICH_BUDGET} characters) is spent, so reads and searches get no more of these blocks; ask impact(name) / path(start, end) directly (mcp__plugin_axiomcode_axiomcode__impact / __path; shell `axiomcode impact` / `axiomcode path`) for a declaration's edges"] st['budget_said'] = True elif not novel and spent >= ENRICH_BUDGET // 2: lines = [] # only edges into files already opened: the rest is kept for new ones diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index 2755a551..316cfc7b 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -196,8 +196,9 @@ def names_code(prompt, db): # rather than restating that there was a match, which told the reader nothing about WHICH match _, _, hits = rest.partition('<- ') print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else '')) - print(' the axiomcode_context tool with in_path= ranks the files and declarations inside it; call it ' - 'directly, no skill needs loading first (without that tool: `axiomcode context "" --in `).') + print(' find(question="") (mcp__plugin_axiomcode_axiomcode__find) ranks the functions the task lands in, ' + 'each with its code; call it directly, no skill needs loading first (without that tool: ' + '`axiomcode find ""`).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -209,10 +210,10 @@ def names_code(prompt, db): if 'how it runs —' in out: # a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the # one to make — named here so no turn goes to loading the skill or the tool schemas first - print(" next: the axiomcode_context tool with source=True, from_= returns the call flow with each " - "step's code; call it directly, no skill needs loading first (without that tool: " - '`axiomcode context "" --source --from `).') + print(' next: find(question="") (mcp__plugin_axiomcode_axiomcode__find) returns the functions ' + 'the flow runs through, each with its code; call it directly, no skill needs loading first (without that ' + 'tool: `axiomcode find ""`).') else: - print(' a starting point, not a conclusion: next, the axiomcode_impact tool with targets=[], in_path= ' - 'for what a change reaches; call it directly, no skill needs loading first (without that tool: ' - '`axiomcode impact --in `).') + print(' a starting point, not a conclusion: next, impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) ' + 'for who calls it, what a change reaches and its tests, each with its code; call it directly, no skill ' + 'needs loading first (without that tool: `axiomcode impact `).') diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 8a91153d..2f30e142 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -300,67 +300,45 @@ def _doc(f): f.__doc__ = (f.__doc__ or '') + EV_DOC return f +# THE FOUR TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale +# graph, a refresh in flight) are kept for what they say; a clause that names a flag or a parameter to set is dropped. +_OPTION = re.compile(r"(? 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.""" - need_repo(repo) - a = ['index', repo] + (['--lang', lang] if lang else []) + (['--src', src] if src else []) + (['--library', library] if library else []) - return run(a) +def find(question: str) -> str: + """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); + get the functions involved, each with its code, most relevant first. A name the code calls but nothing declares + is listed with its call sites: that is code you have to write.""" + return plain(run(['find', question, os.getcwd()])) @srv.tool() -@_doc -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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]`), capped with a count of the rest; limit=N lists more, 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. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - flow = source or explain or from_.strip() or _paged(page) or budget - a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) +def impact(name: str = '') -> str: + """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who + calls it, what depends on it further out, and which tests exercise it, each with its code. With no name: the same + for the declarations your uncommitted edits changed.""" + return plain(run(['impact'] + ([name] if name.strip() else []) + [os.getcwd()])) @srv.tool() -@_doc -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - paged = _paged(page); full = full or why - a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--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 + ev(evidence, drop, exact, alongside) + NOREF(refresh)) +def path(start: str, end: str) -> str: + """How one declaration reaches another: every hop of the call chain with the code at the line the call is + written on. start / end as written in the code (Owner.method, function, Type).""" + return plain(run(['path', start, end, os.getcwd()])) @srv.tool() -@_doc -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: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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 (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); 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; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. 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). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - prose = full or why or delete or _paged(page) 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 []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -@_doc -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -@_doc -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: 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]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - prose = full or why or _paged(page) - a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: - """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path. refresh=False: drawn from the graph as it is, never rebuilt first.""" - need_repo(repo) - return run(['graph', repo] + (['--out', out] if out else []) + NOREF(refresh)) - -@srv.tool() -def axiomcode_diff(graph_a: str, graph_b: str, file: str = '', lang: str = '', limit: int = 40, as_json: bool = False) -> str: - """What changed between two graphs of the SAME tree, e.g. one tree copied and indexed before and after an engine or rules change: call edges added, removed, retiered (same callee, another tier) or re-targeted (a site whose callees changed), entry points with their reason, remote and framework edges, config bindings and symbols, and the call edges per tier (A -> B). graph_a / graph_b: a graph.sqlite, or an indexed directory (every language graph in it, paired by language). Rows are matched by file, line, column, qualified name and callee, never by id (ids hash the index directory), so one tree indexed at two paths diffs to nothing. Neither graph is rebuilt. file keeps the rows with a file containing it; limit: rows per section (default 40, 0 for all; the counts are always of the whole diff); as_json=True gives every row.""" - return run(['diff', graph_a, graph_b] + (['--file', file] if file else []) + (['--lang', lang] if lang else []) + ['--limit', str(limit)] + (['--json'] if as_json else [])) +def tests() -> str: + """The tests your uncommitted edits reach, each with its code, and the command that runs exactly those.""" + return plain(run(['tests', os.getcwd()])) if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 7762608c..d77c4ea5 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,26 +5,21 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — how it works, where something lives, who calls it, -what a change breaks, which tests an edit reaches, whether something is safe to delete — ask the -repository's call graph FIRST, through the `axiomcode_*` MCP tools: +For any why, what or where question about code — where something lives, who calls it, what a change +breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - axiomcode_context where the work is, when you have a task in words and no name yet; for "how does X - work", explain=True, source=True (and from_=) returns the call - flow with each step's code - axiomcode_impact what a change reaches: must-change-with-it, users, tests - axiomcode_path how A reaches B, each hop verified - axiomcode_changed which declarations an edit changed, and how - axiomcode_test_impact which tests the edit in front of you has to run - axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent - axiomcode_graph draw the graph as one interactive HTML page, for a person - axiomcode_diff what changed between two graphs of one tree (before/after), by name and line + find(question) where the code for a task lives, when you have a task in words and no name yet + impact(name) who calls it, what a change to it reaches, and its tests; + impact() with no name: the same for your uncommitted edits + path(start, end) how A reaches B, every hop of the call chain + tests() the tests your uncommitted edits reach, and the command that runs them -**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. Every answer ends with `next:`, the -one step to take. For a CHANGE, read only the lines you will cite or change. To EXPLAIN how something -works, the graph gives the reading order: answer from the flow's code, and read further only where a step's -body was cut or a `⚠` marks a call the graph lost. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +`axiomcode path `, `axiomcode tests`. + +Every answer is a numbered list of places, each with the code of the function it sits in and the line that +matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has +already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / +`text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. Text search is still right for a string, a comment, a config value, or a file you already know. diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 29f06c81..3628a493 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,131 +1,74 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Prefer the MCP tools (`axiomcode_`; in Claude Code, `mcp__plugin_axiomcode_axiomcode__axiomcode_`) when they -are in your tool list; otherwise run `/scripts/axiomcode …` from the repository root. Same code, same -verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own -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]` / `[approx]` 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 | -|---|---| -| **`.axiomcode/out/graph.sqlite` already exists** | **query it — do NOT run `index`** | -| no graph at all | `axiomcode index` | -| a task in words, no name to ask about yet | `axiomcode context ""` — then `--in ` it names | -| "who calls X" / "what breaks if X changes" | `axiomcode impact X` | -| "who writes this field" / "is it safe under concurrent access" | `axiomcode impact .` — ask of the FIELD | -| one concept you can name ("the decryption code") | `axiomcode path decrypt '*'` | -| "how does X work" · "explain / walk through X" | `axiomcode context "" --source` — the call flow in order with each step's code; answer from it, and open a file only for a step whose body was cut or a `⚠` call. `--from ` when you know where it begins | -| "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | -| "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | -| "is it safe to delete X" | `axiomcode impact X --delete` | -| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | - -Rules that decide whether an answer means anything: - -- **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last - graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` - (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits - briefly on its own only when the answer touches an edited file and the rebuild is nearly done. -- **Before a delete or a rename, ask with `--fresh`** (MCP `impact`, `path` or `context` with `fresh=True`): it waits for the rebuild, printing its - progress, and answers from a graph that includes every edit. - A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background - refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. -- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, - `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes - from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built - on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a - background rebuild with this axiomcode's engine, and the answer's FIRST line says so - (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph - another axiomcode built; they say so once per session. -- A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls - are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies - resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). -- An unresolved call is *unknown, not absent* — **never report it as "no callers"**. -- Every answer ends with `verified:` and `bound:` (the unresolved calls inside it — a lower bound). A `✗` on - `verified:` means the answer is wrong: report it, do not use it. - -## How certain is each row - -An answer's label is the **worst** rung on its route. Read it before acting on the row. - -| rung | claims | -|---|---| -| `[sound]` / `[resolved]` | an edge the engine resolved: a single-target call, an override, a subtype, a constructor | -| `[one of a set]` · `[dispatch]` | one of a sound target set · an instantiated override reached through its base | -| `[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 | -| `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — 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 | -| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | -| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | - -Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges -connect, not that a test exercises the change. - -## context — a problem statement, no name yet - -`axiomcode context "" [--in [,]] [--budget N] [--source]`: the files and callables the task's -words land in, nearest first, 12 files by default. Scopes you pass restrict and are combined; a scope it offers -does not restrict. Detail: `reference/context.md`. - -## impact — what a change to a declaration reaches - -`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: -`Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, -`src.util.square` and `src/util#square` are one name. **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. -Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config -keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. - -## changed · test-impact — from an edit - -`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the -command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base -that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection -and service loaders are invisible. Detail: `reference/changed-and-tests.md`. - -## path — asking the graph - -`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none -(with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: -`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with -file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. - -## diff: two graphs of the same tree - -`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a -`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call -edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config -bindings and symbols, with the call edges per tier. Rows match by file, line, qualified name and callee, never by id -(ids hash the index directory), so one tree indexed at two paths diffs to nothing. Use it instead of hand SQL for a -before/after. Detail: `reference/diff.md`. - -A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. +Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +`/scripts/axiomcode ` from the repository root. Same answer either way. + +| the question | MCP tool | shell | +|---|---|---| +| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | +| how does A reach B? | `path(start, end)` | `axiomcode path ` | +| which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | + +Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that +line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. + +## What an answer looks like + +A numbered list of places, most relevant first, each with the code of the function it sits in. `→` marks the line +that matters; a short function is shown whole. + + 1. shop/pricing.py:6 [resolved · total] + ```python + 4 def total(prices): + 5 net = sum(prices) + → 6 return net * (1 + vat_rate()) + ``` + verified: ✓ (4 edge(s) looked up again) + +Answer from the code shown; open a file only for a place whose body was cut (`…`). The tag says how sure the place +is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do not re-derive it by grepping; +`one of a set` is one of several real targets; `by name` and `text` are leads, not facts; `test` marks a test; +`hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" +from an empty answer. + +## find + +Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most +relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is +code you have to write. Example: `find(question="how is the invoice total computed")`. + +## impact + +With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: +`impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and +how), then the same answer for all of them. + +## path + +How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. +Example: `path(start="main", end="Ledger.put")`. + +## tests + +The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. +Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. + +## index + +`axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an +existing graph: the graph rebuilds itself after edits, and an answer given before that finishes says so on a +`graph refresh:` line. ## What it cannot see — say so instead of guessing -Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; -what a decoration turns on (proxy, transaction, cache); code outside `--src`. Each is counted in `bound:`. +Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a +decoration turns on (proxy, transaction, cache). Text search is still right for a string, a comment or a config value. diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md deleted file mode 100644 index cb924da2..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ /dev/null @@ -1,135 +0,0 @@ -# changed, test-impact, and the edit hooks - -**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the -baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit -hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another -IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` -rebuilds it, the query saying so on its first line. - - -`axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: -`signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a -script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, -`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, -extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — -nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the -build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two -commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the -edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as -edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for -every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter -changed. `--impact` runs impact on all of them as one change set. - -The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an -edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and -one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere -in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went -while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and -the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. - -What to pass, and what the answer says when the question cannot be answered the way it was asked: - -| situation | ask | what comes back | -|---|---|---| -| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | -| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | -| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | -| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | -| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | -| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | -| 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 file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | -| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | -| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | - -**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 -a lower bound for it. - -Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports -against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact -agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining -disagreement was read in the diff: the scanner charges an `@Override` line above an *added* method to `` where this -names the method; an anonymous class added inside a method body is "that method's body changed" here (the scanner names -the new anonymous methods from the fixed tree); a renamed method is reported under its OLD name (what callers reference); a -new nested type is named as well as its members; one miss stands — a method extracted from an existing body whose header -lands in a replaced region. Nothing in the tool's answers was bent toward the benchmark: where the two differ, the diff -was the judge. - -The plugin's hooks do this without being asked, at every moment an edit can happen (`hooks/enrich.py`, `hooks/changes.py`): -**PreToolUse on Edit / Write / MultiEdit** applies the edit to a copy and, when it changes a signature, a field's type, a type -header or removes a declaration, gives the blast radius *before* the file changes; **PostToolUse on Edit / Write / MultiEdit** -reports every changed declaration after it lands (a body-only edit included); **PostToolUse on Bash** re-reads the working -tree after a command that can modify sources (`sed -i`, `patch`, `git apply / checkout / pull / merge / stash pop`, a redirect -into a source file, a script run); **UserPromptSubmit** is the safety net — whatever changed the tree since the graph's commit -by any means and was not reported yet. Each declaration is reported once per session; each report is `changed` (which -declaration, how) and `impact` (up to three declarations in parallel, a few lines each: what must change with it — for a -signature, a field, a type or a removal —, who produces or writes it, who reads it, how many callables and tests reach it, -the unresolved-call bound). That is where the agent that changed `String zipCode` to `Integer` is told, before the edit -lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and -the four repositories that deserialize a holder. - -**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. - -**Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under -`tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It -covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a -nested type of the same name, an overload written by its parameter type (`Store.get(String)`), a Java text block and a -JavaScript regex literal, `holds` scoped to the declaring type, a subtype contract where the engine emits no override -rows, a Python `@property` as a private field's door, a house decorator that wraps `dataclass`, and a local variable -that must not carry the method's blast radius. Java, Python, TypeScript, JavaScript and C#. - -**Is what the hooks put in context true?** `hooks/validate.py ` generates events (Reads of whole files and ranges, Greps of -declared identifiers, edits that change a body, a signature, a field's type — before and after landing) or replays recorded -ones (every hook block is logged in full with its input in `.axiomcode/hooks.jsonl`), and checks every stated fact against -`graph.sqlite` and the source: each callable named is declared at that line in that file (or the block says the file changed -since the graph was built — the Read block now says so), each caller / callee named has an edge, each count is the table's, -each changed declaration spans a changed line, each name under must-change / produces / reads is in `impact`'s answer with -that role. On a multi-module Java system 1,036 facts, 0 wrong; on a JVM parser 2,693 facts, 0 wrong — after it found two real errors: an -enum's synthesised `values()` / `valueOf()` listed as callables "at L3", and a field named like its fluent accessor handed to -`impact` without its kind. What the hook cannot vouch for is what the graph cannot: an edge the engine did not resolve is -absent, never wrong, and the `? n` count says how many. - -## test-impact — which tests this edit reaches - -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the -declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the -runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline -rather than for a reader. - -**What it costs and what it saves, measured end to end** on a TypeScript library of 311 source files whose suite is -130 files and 5,193 tests: a one-line body edit to one function → the answer in **0.74 s**, naming 8 files / 503 -tests, and running exactly those took **2.2 s against 17.3 s for the whole suite — 7.9× faster**. Against the -behavioural truth for that method (break it, run the suite, record which files newly fail) the selection contained -**every failing file**, with 2 extra. Over 16 such methods: recall 0.778, precision 0.636, mean 4.1 files of 130. - -**It is a lower bound and the wording says so, because the two questions want opposite things.** For "what must be -looked at again", recall is the product and a wide answer is safe. For "what can CI skip", precision is the product -and a wide answer is worthless — and the same answer cannot be tuned for both: on a Python web framework the -registration-key hop takes recall 0.564 → 0.727 and precision 0.527 → 0.310 at the same time. So the rungs are -reported separately and `--json` carries `certainty` per test, and a pipeline can price them: on the TypeScript -library a `[sound]` route (every hop a single resolved target) was right **29 times in 30**, `[one of a set]` 1 in 8, -`[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and -about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with -the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -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/context.md b/plugins/axiomcode/skills/axiomcode/reference/context.md deleted file mode 100644 index 742356c7..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/context.md +++ /dev/null @@ -1,69 +0,0 @@ -# context — from a problem statement, when there is no name yet - -Every other verb needs a name you already have: a method, a type, a `file:line`. That is the wrong first -question on an unfamiliar repository, and it is where a run gives up — asked once, the word resolved to -nothing usable, the graph never touched again. - -``` -axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… [--no-refresh] -``` - -**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - -Deterministic — no model, no embedding index, no network. The task text is split into content terms -(stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, -prefix, substring, then file path — each weighted by inverse document frequency over the graph's own -vocabulary, so a rare term outweighs a common one. A test or benchmark declaration is demoted, not -dropped. The best seed per term is kept, so a multi-concept task gets several entry points; the closure -is walked from those seeds and ranked by nearest hop, then by how many of the task's terms the file -matches — not by how many methods it happens to contain. - -`--budget N` is how many files are listed (12 by default). The ranking does not depend on it: a larger -budget only shows more of the same tail, and the footer always says how many were withheld. - -`--in` is **repeatable and takes a list**: `--in a --in b` or `--in a,b`. A path you supply is knowledge — -a stack frame, the file you just read, the package named in the issue — so it does restrict the answer; -several are **combined, not intersected**, which is what makes a change spanning two roots answerable in -one call. A scope this program offered comes back marked `--in-offered` and does not restrict at all, -because that one is its guess and not your knowledge. - -It ends by saying what it could not see. A partial list that reads as complete is what turns a five-file -change into a one-file patch. - -## How something works: the call flow - -A task that asks how something works ("how does …", "explain …", "walk through …", "what happens when …", or -`--explain`) also gets the call flow. It starts at `--from ` (repeatable) when you know where the mechanism -begins, and otherwise at the entry points above. The steps are chosen breadth-first, so the entry point's own -calls come before any call of a call, and they print as a tree in the order the calls are written. Each step shows -its edge's certainty (`→` resolved, `⇢` one of a set) and the line that makes the call. A `⚠` marks a call in the -step's body that the graph could not resolve, when the project declares that name, so the reader continues -through it instead of stopping. A one-of-a-set site with many candidates is not a step. - -Where the flow leaves the graph it says so on that step, rather than ending silently: `⚠ leaves the graph: Send() L11` -for a library call that hands the work on (send, publish, dispatch, persist, execute, a client stub's `…Async`), and -`⚠ no body in the graph (interface/abstract)` for a step with no code to follow, naming the mapper XML statement bound -to it when there is one. Read from there by hand; a leaf whose library calls hand nothing on is not marked. - -## What the question names - -Entry points start with what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`, `loadByNumber`) -and a route it quotes (`GET /api/widgets`, at the handler registered for it). Then the symbols matching several of -its terms together, then each term left over. Words about code rather than about the subject (`code`, `tests`, -`call`) take no seed, and an inflected word (`validated`) meets the declaration (`Validate`, `…Validator`). - -A file, directory or language the question names that no graph here holds is said FIRST, as -`not indexed: () -- this answer cannot see it; grep it directly`, and `next:` points at it. In a -repository with a graph per language, a question naming one language is answered by that graph alone. - -A question about SQL, configuration or templates lists the text files that name the declarations found (a MyBatis -mapper XML whose namespace is the declaring type ranks first), marked as text bindings, not call paths. `--in` on -a directory that holds no source (`src/main/resources`) is accepted: it says so and lists what under it binds. - -With `--source`, the earliest steps carry their code within a budget and the later ones are named only, so the -answer comes back on one page. Answer from that code, and open a file only for a step whose body was cut or at a -`⚠`. A question that does not ask how something works gets the ranked answer above, unchanged. diff --git a/plugins/axiomcode/skills/axiomcode/reference/diff.md b/plugins/axiomcode/skills/axiomcode/reference/diff.md deleted file mode 100644 index ef2e356b..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/diff.md +++ /dev/null @@ -1,68 +0,0 @@ -# diff: what changed between two graphs of one tree - -```sh -axiomcode diff [--file ] [--lang ] [--limit N] [--json] -``` - -Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its -`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. - -## The before/after recipe - -```sh -rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ -AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python -AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python -cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine -cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite -axiomcode diff /tmp/before.sqlite /tmp/after.sqlite -``` - -Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild -with the installed one, which overwrites the graph under test. The diff itself never does. - -## How rows are matched - -By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so -two indexes of one tree share none, and a join on ids says everything changed. - -| kind | matched on | -|---|---| -| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | -| entry point | the method (qualified name, file:line) and the reason | -| reachable | the method | -| remote edge | transport, destination, sender, handler, confidence | -| framework edge (Python) | mechanism, name, from, to, certainty | -| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | -| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | - -Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same -tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: -compare graphs of one tree, not of two commits. - -## Reading the answer - -``` -python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) - B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) -summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 -call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … - -call edges (…): - + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site - - file:line:col Caller -> Callee [tier, kind] the reverse - ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind - > file:line:col Caller a site whose callees changed: the old set, then the new - - Callee [tier, kind] - + Callee [tier, kind] -``` - -The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). -`--file` keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the -declaration's) and counts only those. `--json` prints every row with the same counts, under -`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. - -## Not compared - -`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both -graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md deleted file mode 100644 index 026784d9..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ /dev/null @@ -1,322 +0,0 @@ -# impact — what a change to a declaration reaches - -The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. - -**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - - -`axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: -`Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / -interface / enum), `Owner.method(param)` (one parameter), `Type` · `Owner.method` (a type parameter — a generic, or a -bound on it), `Owner.method:name` (a local), `Type.` (its construction) / `Type.` (its static initialization: whoever -first uses the type). Several targets in one call are one change set. A name declared as more than one kind stops and asks for -`--kind`. The same answer shape for every kind and language: - -Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, -extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the -line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's -hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode -index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when -there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a -caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. -Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and -the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: -every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). - -- **a configuration key is a target** — `axiomcode impact server.error.path`: the methods the container binds it into - (`@Value`, `@ConfigurationProperties`, a `.yml` / `.properties` key), from the engine's framework facts, then everything - that reaches them. No call site carries these edges, so nothing else finds them. A key the engine never saw **stops with - that sentence** — its impact is unknown, not empty — and a graph with no configuration facts at all says so; a key is - never answered as a by-name match on code, which is what made a wrong answer look like an answer. -- **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the - container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it - over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. - A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` - or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected - with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). -- **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / - `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a - `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed - as `[framework]`, with the mechanism, what joined the ends and the engine's confidence: `framework-mediated, not a call: - task_dispatch via delay [registered]`. It ranks below `[remote]` and above every name match, and like `[remote]` it is a - direct row that does not seed the closure. An unrelated method that shares the name (`Animation.delay`) gains nothing. -- **a handler nothing calls is still used** — a declaration handed over as a *value* (`app.get('/orders/:id', getOrder)`, - `background.add_task(send_receipt, id)`, `setTimeout(flush, 1000)`, `handlers = {"x": handle_x}`) has no call site - anywhere: the call happens inside the framework, or later, or never. Every other rule here is about call sites, so this - used to answer *"the declaration is used only where it is declared"* — and `--delete` said **no dependent at any - certainty** — for a live HTTP handler. The reference the parser recorded is read instead, and the site says what will do - the calling: `registered as a GET route "/orders/:id" here — the router calls it, no call site does` when the call is a - route registration (a router verb *and* a string argument that begins with `/` — `get`/`set`/`delete` alone are Map, Set, - Headers and every cache in this ecosystem, so the verb is never matched by itself), otherwise `handed to add_task(…) as a - callback`. It is `[by name]`: the parser says the identifier binds to a callable, not that it binds to *this* one. - Where the engine already resolved the registration to an edge — a JavaScript `app.get('/pads', listPads)` is a resolved - call in that engine — the row stays `[resolved]` and only the sentence changes, so the reader learns that what they are - changing is `GET /pads` rather than that some module calls it. **JavaScript gets the wording and no name-matched rows:** - its `refs` carry the access mode (`IDENTIFIER|READ`) and no entity kind, so nothing there distinguishes a reference to the - declaration from a parameter of the same name. A site-keyed version was written for it and measured on a 124-file Express - application: eleven rows over 30 sampled targets, and all eleven were wrong (seven a parameter named `callback` inside - `forEach(function (callback) {…})`, four a `settle` being *called* inside the `.then(…)` span it sits in). It is not - shipped. The rule needs the parser to say that an identifier binds to a callable, which TypeScript, Python and Java do - and JavaScript does not. -- **must change with it** — declarations bound to the target by a contract the engine resolved: the overrides of a method (and what - it overrides), the subtypes of a type. A signature change reaches these first. -### What breaks a build, and what does not - -The sections are relations, not severities, and reading them top-down as "most to least urgent" is wrong. -Nothing under `produces or writes it` necessarily fails a build: those rows are dataflow — who makes a value of -this shape, including deserialization that writes it reflectively. A `[text]` row under `bound from outside the -source` can never fail a build; the compiler does not read that file at all, which is exactly why it is printed -last and says so. - -For a field, the rows that stop a build are usually in neither list. Changing a field's TYPE changes the -signature of whatever is generated from it — an all-args constructor, a setter, a copy/`with` — and it is the -callers of THOSE that break, at the argument they pass. They touch the generated member, not the field, so no -rule puts them under the field's own relations. The answer now says this directly under the generated-members -line and names the constructor query to run; take that suggestion before acting on the first list. - -- **produces or writes it** — the blast radius read top-down starts where a value of the new shape has to be *made*: setter and - builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where - that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the - field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. -- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from - the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a - test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission - check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its - name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. -- **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; 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; the Read and Grep hooks count the same callers); `[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 - type references in signatures), comments and strings stripped. A bare name inside a type that declares its own member of that - name is that member, not the target; a qualified `X.name` is confirmed when `X` is the owner and dropped when `X` is another - type. For a field, a **declared accessor** in the owner (`getF` / `setF` / `isF` / `f()`) is its door: the accessor's callers are - listed as reading or writing the field through it. A **generating decoration** — Lombok `@Data` / `@Getter` / `@Setter` / - `@Value` / `@Builder` / `@AllArgsConstructor` / `@With`, a record, a dataclass — declares members the source never spells, so a - call to `getZipCode()` or `new Address(…)` is an unresolved site; the unresolved sites written with the generated name are listed - as calling the generated getter / setter / constructor `[by name]`, with the decoration that generates it. Where the ENGINE - synthesises the member instead of leaving the site unresolved (Java's Lombok and records, C#'s auto-properties: a `methods` - row with provenance `generated`), the call site resolves to it and the caller is named `[resolved]` — *reads it through - getName()* — which is the same answer with a stronger claim behind it. A string literal - equal to the field's name (a map key, a serialized name, a request parameter) is listed `[text]`. -- **the upstream answer is measured against behaviour, not against itself** — `validate/upstream.py ` takes a tree with - `.axiomcode/mutation.json` (a method broken, the test files that then failed), asks `impact --tests` which test files - reach it, and classifies every miss from the graph. a JVM HTML parser, 24 methods, 244 (method, test file) pairs: recall 0.795 → **0.988**, - precision 0.328 → 0.338, after the three rules the misses named — a test class that *extends* a reached one runs its tests - (its HTTP-client test classes declare almost nothing: 20 of the 27 misses), a call site written with the target's name - that the engine could not resolve (`import static Outer.Inner` left `res.prepareResponse(…)` untyped: 8 more), and a test - file's import-time code (a class body, a fixture). a Python validation library, 14 methods, 180 pairs: 0.678 → 0.717 — what remains - is dispatch a static graph cannot see (`__eq__` and the other protocol methods the interpreter calls, a method reached - through `getattr(self, f"_{kind}_schema")`), and the answer now says that instead of printing nothing. A TypeScript web framework - (16 methods, 96 pairs, `validate/mutants.py` builds the truth: break a method, run the suite, record - which test FILES newly fail): 0.000 → 0.790. It was zero because a vitest test is an anonymous callback handed to - `it(…)` — 6,661 of its 7,723 callables in test files are `` and two carried a name the old rule accepted, - so the test universe was empty and every answer named no test file at all. A callable registered by `it` / `test` / - `bench` on its own line is a test, and a helper declared beside them carries them. The PARAMETERISED form needs the - call site rather than the line: `test.each` + a template table writes the arrow after the closing backtick, on a - line naming no registrar at all (18 of them in that library), so a callable inside the span of a `TAGGED_TEMPLATE_CALL` - to `each` — its first line read to confirm the receiver the call site does not carry — is a test too. That shape is - vitest / jest / mocha's alone: a pytest test is found by its name however deep the decorator stack, so nothing there - depends on which line the registrar is written on. - **What a selection costs and buys, on that same TypeScript library, measured again with the rungs separated** (a - fresh clone, 311 source files, 130 test files, 5,193 tests in 17 s; 16 methods broken one at a time, 54 (method, - test file) pairs of behavioural truth): recall **0.778**, precision 0.636, and the answer names **4.1 test files of - 130** for a change — 3 % of the suite. Per rung, against that truth: a `[sound]` route (every hop a single resolved - target) is right **29 times in 30**; `[one of a set]` is right 1 in 8; `[by name]` 0 in 1. By distance: 1 hop 0.667, - 2 hops 0.900, 3 or more 1.000 — the far pairs are few and all real. So a pipeline that runs the sound rung first is - almost never wasting a run, and the waste is concentrated in exactly one rung, which is why the rungs are reported - separately rather than blended. Every remaining miss is `no-edge` — a handler the graph has no resolved caller for - (an adapter, a JSX intrinsic element) — not a rule this tool could tighten. - A library is not a service, and the number differs by population: on a Python SERVICE driven through its frameworks - (two Python web frameworks + CLI routes, a pytest suite with conftest fixtures, a decorator registry, a signal loop, 53 - functions broken one at a time, 95 (method, test file) pairs) recall was **0.216** — 26 of 40 answers named no test - file at all — because the suite reaches the code the way the outside world does: through the framework. The - registration-key hop and the injected-fixture rules take it to **0.695** at precision 0.930, and what is still - missing is named rather than guessed: a function reached only through a table or list of functions dispatched by - index (`TRANSFORMS = [strip, upper]`, `EXPORTERS[kind](x)`), a decorator that wraps a callable in an object whose - method calls it (`@shared_task` … `.delay()`), and a closure defined in one method and returned to another. - Held out, on a subject nothing was tuned against (the Python web framework's own 491-test suite, 40 functions broken, 172 pairs): - 0.564 → **0.727**, precision 0.527 → 0.310. Both halves of that trade are real and neither is free — the recall is - routes and fixtures the answer could not see before; the precision is the fan-in of a framework whose every test - builds an app. A key that identifies MANY declarations identifies none: the Python web framework's own suite registers `"/"` from 236 - places and asks for it from 200 more, so a key registering more than `AXIOMCODE_KEY_CAP` (4) declarations is - REFUSED rather than joined — the engine's `fan_capped` judgement one layer up. Uncapped that subject reads 0.791 - recall at 0.248 precision. The same cap applies to the other side (`AXIOMCODE_KEY_USE_CAP`, 4): on the JVM parser the keys - that survive the registration cap are `p`, `b`, `table`, `em` — HTML tag names, written by 356 callables and - "registered" by two, because a decoration argument is not always a registration (`@ValueSource(strings = {"p"})` - is test DATA). One Java method went from naming 1 test file to naming 61 until that cap was added, and 6 after it. - Neither cap needs a catalogue of which decorations register and which do not, which is the point of them. - **A cap and a kind guard answer different questions, and the second is invisible to the first.** A cap says *this - key is too wide to mean anything*; it cannot say *this was never a dispatch key at all*. A test's own decoration - carries its INPUTS — `@ValueSource(strings = {"/htmltests/large.html"})`, `@CsvSource`, `@pytest.mark.parametrize` - — one declaration, a handful of writers, under every cap, and entirely meaningless as a key; and a route mounted - inside a test file is a fixture, not the application's dispatch table (on one TypeScript router library **every** - route registration line, 6,128 of 6,128, is in a test file). So a decoration on a test declaration is not read as - a registration at all, and a route registered in a test file keeps its dependent row and its sentence but is given - no joinable key. -- **precision is not a bug to fix, it is a property to report** — `validate/precision.py ` places every predicted - (method, test file) pair by the worst hop on its best route and by distance, against the same truth. On the JVM parser: a route of - single-target resolved calls is right 0.765 of the time, one through a call resolved to a SET 0.301, through an override - reached from its base 0.170; within 3 hops 0.70, beyond 5 hops 0.24; a sound route within 3 hops 0.889 — but that keeps - only 48 of 219 true pairs. The split that explains the 0.34 overall is fan-in, not error: 10 of the 24 methods are hubs - every test reaches (it parses HTML in every suite) — those answers are 60 of 98 test files at precision 0.285 with - recall 1.000, while the 14 narrow methods score 0.642 with 7 of them exactly right. A test that *reaches* a change and - does not fail is not a wrong edge: it runs the code and does not observe the change. So `--tests` answers "which tests - CAN observe this" and says how sure each route is (`[sound]`, `[one of a set]`, `[dispatch]`, `[by name]`, nearest and - surest first) and, when most of the suite reaches the method, that at this fan-in reaching says little about failing. - It is a ranking, not a test selection; a narrow answer can be used as one. -- **reaches those through resolved calls** — the transitive impact: everything that can reach a touched callable, by hop and by - file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the - field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with - the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: - the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a - `why` list), and `--tests-in ` narrows - the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k - characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static - initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, - printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). - A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. - `--in ` and `--depth N` bound it; `--json` is the same answer as data. -- **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the - declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` - and `client.post("/orders")`; `@receiver("order_created")` and `emit("order_created", …)`; `@cli.command("price")` and - `invoke(cli, ["price", "4"])`; `@exporter("csv")` and `export(order, "csv")`; a a Python web framework `add_url_rule("/quote/", - view_func=legacy_quote)`, where the declaration is handed over as a value and no call site names it at all. Both ends are - in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most - 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. Only what the decoration registers - under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring - keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member - of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. -- **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 test that runs a script as a child process is a hop** — `execFileSync(node, [path.join(__dirname, '..', 'bin', - 'cli.js')])`, `spawn(process.execPath, [require.resolve('../bin/tool')])`, `subprocess.run([sys.executable, SCRIPT])` - with `SCRIPT = os.path.join(HERE, '..', 'scripts', 'report.py')`: the script's module body runs in another process, and - no call site or import says so. When a call that starts a process names, among its arguments, a file this graph indexed - (a literal, a join of literals, or a constant holding one), the test — or the helper beside the tests that makes the - call — is joined to that file's module entry, so everything the script reaches gains the test. The hop is `[spawns]`: - a key (the path), not a call. Reading the same path (`fs.readFileSync`, `open`) starts no process and is not joined, - and a file of another language is in no graph of this one, so it is never joined across languages. -- **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` - wrapper — retry, cache, login_required, a task — has no caller at all and a change to it reaches nothing. What the - graph still cannot say is the OTHER decorator shape, where the decorator returns an object rather than a function - (`@shared_task` … `.delay()`): there the name denotes an instance, and the engine says so rather than guessing. -- **a fixture the framework injects** — pytest matches a test's PARAMETER NAME against the fixtures visible from its file: - those beside it and those in a `conftest.py` of any ancestor directory, which is not the test's file and is imported by - nothing. A `@pytest.mark.usefixtures` marker names one instead, and an `autouse=True` fixture runs before every test in - its scope without being named anywhere. A fixture may request another fixture, and then both run. None of that is a call. - A route that runs a fixture first is reported as `[fixture]`, and it is the weakest rung above `[by name]`: the - framework does run it and it does reach the change, but the test's own body may never touch it. How often each rung - is right, measured against mutation truth on three Python subjects (`n` is the pairs the rung named, and a rung with - a handful of pairs says nothing — it is printed so you can discount it, not so you can rank on it): - - | rung | small framework service | web framework | CLI library | - |---|---|---|---| - | `[sound]` | 1.000 (n=19) | 0.895 (n=86) | 0.561 (n=132) | - | `[at import]` | 1.000 (n=17) | — | — | - | `[defines]` | — | 1.000 (n=1) | 0.875 (n=8) | - | `[one of a set]` | 1.000 (n=2) | 0.659 (n=44) | 0.657 (n=99) | - | `[by key]` | 0.926 (n=27) | 0.342 (n=73) | 0.000 (n=3) | - | `[decorator by name]` | 1.000 (n=8) | 0.667 (n=3) | — | - | `[protocol]` | 1.000 (n=4) | 0.882 (n=17) | 0.400 (n=5) | - | `[fixture]` | 1.000 (n=27) | 0.382 (n=102) | 0.536 (n=112) | - | `[by name]` | 0.333 (n=3) | 0.531 (n=32) | 0.475 (n=61) | - - `[protocol]` is the newest row and the one to read carefully: its only substantial sample, 17 pairs on the web framework, - puts it at 0.882 — second to `[sound]` on that subject and well above the two rungs printed ABOVE it. That is not - enough to re-rank a ladder on, for the reason the rest of this paragraph gives, but it is enough that a reader - should not discount a `[protocol]` route for its position. - - And read what a rung CLAIMS, not only how often it holds: `[sound]` means a resolved single-target call chain - within three hops — a fact about the edges — and never that the test exercises the change. `[at import]` is the - one rung that is about the test rather than the edge: the module raised while being imported, the file never - loaded, and the test was never collected, so its body is irrelevant. Read that table before trusting the order - the answer prints. The TOP of the ladder holds: `[sound]` and - `[one of a set]` are the best rungs on the subjects with enough pairs to say. BELOW that the order is not stable - across subjects and the printed ranking is a tie-break of what KIND of evidence a hop is, not a measured ordering: - `[by key]` is the best rung on one subject (0.926) and the worst on another (0.342), and `[by name]` is printed - last while measuring above `[by key]` on both of the two large subjects. An answer's label is still the WORST rung - on its route, so it remains a floor — but a `[by name]` route on a library-shaped codebase is not the near-worthless - thing its position suggests. And `[sound]` at 0.561 on the CLI library is the plainest statement of the whole limit: reaching - is not failing, and on a codebase whose tests drive one hub, a resolved call within three hops is right barely more - than half the time. A test - reached BOTH by its own body and through a fixture is reported as the body: the same distance, the stronger claim, - and it moves 37 of the CLI library's pairs off the fixture rung. And what the rules add is a POPULATION effect, not a general - one — on a third held-out subject (a CLI library, 2,058 tests, 40 functions, 293 pairs) they move four - targets and carry 0.802 recall at 0.566 precision, against 0.792 / 0.569 with every framework hop turned off, - because its tests reach its code by CALLING it. The framework hops pay where a framework is in between and very - nearly cancel where it is not: on the CLI library the decorator hop alone adds 3 true pairs and 4 false ones. -- **verified** — every printed edge looked up again in the graph; **bound** counts the unresolved calls inside the impacted - set, so the set is a lower bound on the real one; a **note** counts the entries matched by name or text. - -`--delete` adds a verdict: **is it safe to delete** — the callers and contracts that say no, or, when there are none, exactly -what the graph cannot vouch for (by-name matches, string literals equal to the name — a reflective call, a bean name, a config -key —, the decorations a framework may dispatch on, the unresolved calls inside, the tests that reach it). With **several -targets** (a PR touching many files) each row says which target it came from — `[for Owner.method]` — so a combined radius is -still attributable per change. - -**The unit of change is a declaration in the graph, and half of real Java commits change something else** (592 commits over -five projects: 47 % touch no Java file at all, 30 % touch Java plus a build or resource file). Three of those kinds now have a -target of their own: `@Transactional` (an annotation — every declaration carrying it, and their dependents), `Enum.` (a -constant that does not exist yet — the switches that need a new arm), and a configuration key. A method target also reports -its **throws** contract: adding a checked exception reaches *every* resolved caller, and the answer says how many of them -already catch or declare the ones it has. Still outside the unit, and said rather than guessed: a build file or a dependency -bump, an added overload's rebinding of existing call sites, and what a framework does with an annotation (the proxy, the -transaction, the cache) — `changed` says that in the same line as the decoration change. - -What it cannot see, by construction — say so instead of guessing: a callable that touches a type only through a value it never -names (`t.asStartTag().normalName()` where the engine resolved `normalName` to the inherited `Tag.normalName`) — the graph keeps -no receiver type at a call site, so the compiler sees that dependency and this tool does not; the `[one of a set]` callers are -the engine's over-approximation and most of them will not compile against the change; a bound change on a type parameter -reaches the sites that instantiate `Type<…>`, listed, but nothing checks the argument against the bound; the transitive layer -is the call graph's, so everything `path` cannot find (callbacks handed to a library, reflection, framework dispatch) is a -missing chain here too and is counted in `bound:`, never guessed. What a **decoration turns on** is not in the graph either — -`changed` reports `@Transactional` / `@Cacheable` / a route as a decoration change and says in the same line that the proxying, -the transaction or the cache behind it is invisible; only the code that names it is. Still **not expressible today**, and said -so rather than answered: which `switch` arms an added enum constant breaks, who must catch an added `throws`, which call sites -an added overload rebinds (no argument types per call site), and what a dependency bump reaches (one graph, no library diff). -Test selection from a body change is sound but wide — 41–87 % of a suite on a hub graph — because every path through the hub -is real; narrowing it is ranking, not reachability, and is not attempted here. - -Measured two ways, Java first. (1) A Java defect benchmark: the methods each fix changed as the change set, `--tests` against the tests -it observed failing on the buggy tree — 273 bugs of 17 projects, every triggering test found in 266, trigger recall 0.929, -mean selection 50 % of the suite, and the same verdict as the benchmark's own independent reading of the same graphs in 252 of -256 bugs (better in 3, worse in 1 — a method the fix *added*, absent from the buggy tree); every remaining miss is an engine gap -(an overload set, a callback through `Function.apply`), not a tool loss. (2) The compiler: on five of those projects, 412 sampled -declarations, one edit each — rename a field, a method (all its overloads), a type (plus an empty stub with the old name, so member -uses fail too), a type parameter; remove a parameter — and `javac` over the whole tree names the dependents. Recall: fields 0.997, -methods 1.000, types 0.962, parameters 0.944, type parameters 0.977. Precision by certainty, all kinds: `[resolved]` 528/585, -`[in scope]` 249/256, `[text]` 683/773, `[by name]` 157/291, `[one of a set]` 126/351, contract 69/144 (the compiler confirms -only the override direction that breaks). The harnesses are `impact-arena.py` and `oracle-b.py` next to the arena. (3) By hand, on a -multi-module Spring / SOFA-RPC / Lombok `@Data` system where every model is generated accessors: a `String zipCode` field on a -shared `Address` → the three places an `Integer` breaks (the owner's formatter, the five `getZipCode().length()` / `.trim()` uses -in another service, the generated all-args constructor call in a web controller) and nothing else; a facade method called -through `@SofaReference` fields in two other services → the override, the three callers, the three REST entry points; an enum -member → its one use, with `PaymentStatus.PENDING` and `ShipmentStatus.PENDING` correctly excluded; a shared value type → all six -files, including a chained `product.getPrice().getAmount()` a grep for the type cannot see; a field with declared accessors → -every accessor caller across three services plus the `"stockQuantity"` map key. Other languages share every code path except -the static-import rule (Java syntax) and are not yet measured. - diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md deleted file mode 100644 index e20eeeef..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ /dev/null @@ -1,130 +0,0 @@ -# path — the endpoint grammar and what it cannot find - -**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - - -- **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the - other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so - `path decrypt '*'` answers "where is the decryption code and what does it touch" — 12 declarations, what they - reach, by hop and by file — without knowing a single exact name first. `path '*' ` is the same in reverse. - This is the way into an unfamiliar repository: get the real names out of the answer, then ask the precise - question with one of them. There is no separate search verb, and none is needed — a name you half remember stops - with the exact names that are close, which is the same lookup. -- **Endpoints are names as written in the code**, never guesses: `Owner.method`, `Outer.Inner.method`, `method` (a free - function, or that name under any owner), `Type` (every method it declares), `file.ts:123` (the callable at that - line, top-level code included), `file.py` (every method in the file). `Outer$Inner.m`, `Outer.Inner#m`, `m(int,String)` - and package-qualified `pkg.Outer.Inner.m` are the same name; a Java nested type is found whether or not the outer is - written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one - of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python - and C#; JavaScript works but the engine's JavaScript output is still moving. -- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, - right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, - in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix - (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, - library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and - found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" - (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when - an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. -- **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist - and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain - from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), - by file, then the simple paths through it, shortest first, up to `--paths N` (default 20; the count is exponential, - so the set is the complete answer and the list is a sample of it). The `verified:` line means every hop was looked up - again in the graph and a second, independent traversal found the same length; a `✗` means the answer is wrong — report it, do not use it. -- **Every hop reads `[tier · kind @ file:line]`.** The *tier* is how certain the edge is; the *kind* is what sort of - call it is, in one vocabulary that means the same thing in all five languages (`call` · `new` · `ctor` · `super` · - `decorator` · `property` · `method-ref` · `with` · `import` · `eval` · `dynamic`); and the *line* is where the call - is WRITTEN, which is where you check it — the name after the arrow already tells you the callee, and its own - declaration line follows it. The tiers an answer used are legended beneath it, so none of them has to be looked up: - `known_edge` resolved to one declaration, `multi_inferred` several fit and each is real, `dispatch` a base method to - an override the project instantiates, `callback_registered` handed over as a value and invoked by whoever holds it, - `boundary_lib` / `ambient_terminal` into a dependency or the platform, `defines` **not a call at all** — the callee - is written inside that body, so it runs only after it. The engine emits eleven tiers and thirty kinds across the - five languages and they do not share a vocabulary; `scripts/ax_edges.py` is the single table that normalises them, - and an unrecognised tier ranks LAST there rather than being silently treated as certain. -- **The hop count counts calls.** A chain's header says `7 call(s)` — containment hops (`defines`) are listed - separately (`+2 containment hop(s)`) and excluded, because "A reaches B in 11 calls" is false when five of the - eleven are a closure sitting inside a body. -- **`--json`** gives the same answer as one document — every hop with its tier, kind, call site, callee declaration - and whether it is a call — with the prose carried alongside it, so nothing is lost by asking for the machine shape. -- **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* - connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line - counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. -- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler - that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python - `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# - endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) - are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client - reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls - (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` - counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations - gets them; a hop never joins two languages' graphs. -- **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, - `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser - wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, - in any language. With `--library` staged the same call is a resolved library method and matches by qualified name. A - client declaration always wins over both. The node has in-edges only — nothing is inferred about the library body — and - the answer says how many sites were matched and where. -- **A type the code uses but does not declare is an endpoint**: `path Foo.run File` — every place `File` is touched, - as one target: `new File` at unresolved sites, the library methods of `java.io.File` when staged, - and the methods whose body references the name where the parser gives a line. The answer says which of those it - matched (Java type references carry no line, so there it is the constructor calls and identifier uses). -- **A decoration is an endpoint**: `path '@GetMapping' 'new File'`, `path '@*Mapping' Files.readAllBytes`, `path '@Test' X`, - `path '@Get' '*'`, `path '@Controller' Svc.load` — every method carrying it, so "from any method with this decoration to X" - is one call. A decoration on the **type** is carried by every method that type declares, which is what the class-level form - of every framework needs (`@RestController`, `@Controller`, `@Injectable`, `@Component`, `@Entity`); on one Spring service that is 78 methods for `@*Mapping` where the method-level rows alone are 29. The decorations come from the index's - decorations table **or, where a front end records a decorator as a call and not as a decoration, from those call sites** — - a TypeScript or JavaScript graph has an empty decorations table and its `@Get(':sku')` sitting in `call_sites` as a - `DECORATOR_CALL`, so Nest, Angular and TypeORM used to answer `no method carries @Get` with an empty list of decorations, - which reads as "this repository has no such handler". The owner is the narrowest declaration whose span holds the decorator - line, so `@Get` lands on the method and `@Controller`, which the call site charges to the module, lands on the class. - A graph that records no decoration at all now says so, instead of printing an empty list. -- **End to end, any shape:** `path Type1 method4` asks whether *any* method of Type1 reaches *any* declaration named - method4 — a type on either end is all its methods, a bare name is every declaration under any owner (a free function - in Python/TS/JS has its file as owner). The same rule in every language; nothing is forced to be typed. -- **A name under many owners** (`close`, `run`, `toString`): the closure is computed once from the sources, so a - thousand targets cost nothing; the answer is which owners' declarations are reached and how far, nearest first, - then the nearest chains. Narrow with `Owner.close`, `--in ` (both endpoints restricted to files - containing it), `--limit N`, or `--all` for every chain. -- `Outer$Inner.m` and `Outer$1.m` are looked up through the nesting table, not by string: Inner at any depth inside - Outer; `$N` the N-th anonymous class in source order (javac's numbering — checked against `javap` on a JVM parser's traversal tests, 10/10) or, for an enum, the N-th constant with a body. A miss says which part is wrong: no such - outer / no nested type X (lists them) / only k anonymous classes (with lines) / no method m (lists the methods). -- **One endpoint = a closure, not a chain.** `path '*' X` is everything that can reach X — by hop, by file, and the - *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved - calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is - shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the - call sites written with X's name on a receiver the engine could not type (`[by name] `): the - callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, - and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part - under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) - and bounded by the unresolved calls inside it. -- **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 - method(s)", which is true of calls and false of the program. When the upstream closure is empty the registration is - named instead — *create_order is registered as a route "/orders" by @post (app/api.py:43)* — and when two endpoints - have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one - path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a - chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the - graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the - graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is - not one), a library method it overrides, the call sites that write its name, a library base of its type, a - decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the - one module both tools read (`scripts/ax_registration.py`). -- **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type - (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks - handed to a library (`executor.submit(task)`, `list.forEach(fn)`) are reached from their definer (`[defines]`) but never - from the library that invokes them; calls the framework makes (HTTP dispatch, JUnit, `main`) have no edge — the callee - is an entry point; reflection / string dispatch / event buses / config-wired beans are invisible; overloads sharing a - name are all resolved together (a signature in the query is stripped); a method overriding a library method is called - by the library, so its upstream ends there; code outside `--src` or in another language is not in the graph; a - by-name or written match can be a same-named other thing. A chain says control can reach B from A through these - calls — nothing about the values that travel it. -- Both directions are tried; the reverse is labelled. -- `axiomcode path --selftest ` replays the engine's own expected edges through the tool and separates engine gaps - from tool losses; run it after touching `dl/path.dl` or the exporter. Needs `souffle` on PATH. - -`scripts/` holds `axiomcode` (the entry) and what it dispatches to: `axiomcode-build` (the pipeline), `axiomcode-index`, `axiomcode-graph`, `viewer.html`, `axiomcode-path` with `dl/path.dl`, `axiomcode-impact` with `dl/impact.dl` (the path tool's resolver and edge facts, its own rules and fact export), `axiomcode-changed` (an edit → the declarations it touched, with the kind of change). diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md deleted file mode 100644 index 09039f8f..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/schema.md +++ /dev/null @@ -1,106 +0,0 @@ -# schema — which table holds X, per language - -Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact -lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded -at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. - -## Which graph - -| | | -|---|---| -| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | -| every other language | `.axiomcode/lang//out/graph.sqlite` | -| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | -| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | - -Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` -holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other -tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. - -## Fact by language - -| fact | Python | Java | C# | -|---|---|---|---| -| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | -| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | -| 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 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` | -| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | -| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | -| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | - -`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, -`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty -in Python: its dispatch set is `dispatch_candidates` (basis `mro`). - -## The verb for each fact - -| fact | verb | -|---|---| -| 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 .` (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"'` | -| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | -| entry points by reason | no verb: SQL below | - -## Queries - -```sh -G=.axiomcode/out/graph.sqlite -# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) -sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d - LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" -# [all] entry points by reason, then one reason's methods -sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" -sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e - JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" -# [all] resolved ancestors (Java: library ones too, provenance 'external') -sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id - JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" -# [python] library bases, full text as written -sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" -# [csharp] library bases: the owner is the innermost type whose span holds the base-list line -sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file - AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner - FROM type_refs r WHERE r.context = 'BASE_LIST'" -# [java] field writers -sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a - JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id - WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" -# [csharp] property writers (property_read for readers) -sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e - JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id - JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" -# [all] tiers in this graph; [python] what the unresolved sites are waiting on -sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" -sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" -``` - -## Traps - -- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, - `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with - `LIKE '%/rel/path.java'`. -- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java - base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below - `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built - before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on - its `class Meta:` line. -- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in - `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. - JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as - `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); - an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). -- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` - names them. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py new file mode 100644 index 00000000..731e1d73 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""ax_blocks.py -- · ax_blocks.py edits — an answer as numbered places, each with its code. + + 1. src/shop/pricing.py:6 [by name · in total] + ```python + 4 def total(items): + 5 net = sum(i.price for i in items) + → 6 return apply_discount(net) * (1 + vat_rate()) + ``` + +An agent that is given a location reads the file next, so each place carries the function that encloses it: the whole +function when it is short, else its header and the lines around the one that matters. The verb runs with --json and +its sites are taken in the grep view's order (ax_grep), so the three answers rank exactly as the verbs do; at most CAP +places are shown and the rest counted. A verb that refuses, or finds no place, is printed as the verb said it. +""" +import json, os, re, sqlite3, subprocess, sys +H = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, H) +import ax_grep + +CAP = 10 # places shown; the rest are counted +WHOLE = 14 # a function this short is shown whole +AROUND = 3 # else: its header, then this many lines either side of the line that matters +FENCE = {'.py': 'python', '.java': 'java', '.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx', + '.mjs': 'javascript', '.cjs': 'javascript', '.cs': 'csharp', '.kt': 'kotlin', '.scala': 'scala'} +SITE = re.compile(r'^(?P[^\s:][^:]*):(?P\d+): ?(?P.*?)(?:\s+\[(?P[^\]]*)\])?$') + + +class Graphs: + """the enclosing callable of a line, from every graph the repository holds (one per language)""" + def __init__(self, repo): + out = os.path.join(repo, '.axiomcode', 'out') + self.cons = [] + for d in sorted(os.listdir(out)) if os.path.isdir(out) else []: + p = os.path.join(out, d, 'graph.sqlite') + if os.path.isfile(p): + try: self.cons.append(sqlite3.connect(f"file:{p}?mode=ro", uri=True)) + except sqlite3.Error: pass + + def enclosing(self, f, n): + best = None + for c in self.cons: + try: + r = c.execute("SELECT display, line, end_line FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? " + "AND method_id IS NOT NULL AND display NOT LIKE '%%' ORDER BY end_line - line LIMIT 1", + (f, '%/' + f, n, n)).fetchone() + except sqlite3.Error: + continue + if r and (best is None or r[2] - r[1] < best[2] - best[1]): best = r + return best + + +def block(repo, f, marks, span): + """the lines to show for the marked lines of file f: the enclosing callable (whole, or header + a window around + each mark), numbered, every mark flagged""" + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as h: L = h.read().split('\n') + except OSError: + return [] + marks = sorted(n for n in marks if 0 < n <= len(L)) + if not marks: return [] + lo, hi = (span[1], span[2]) if span else (marks[0], marks[-1]) + lo, hi = max(1, min(lo, marks[0])), min(len(L), max(hi, marks[-1])) + if hi - lo + 1 <= WHOLE: + keep = list(range(lo, hi + 1)) + else: + keep = {lo} + for n in marks: keep |= set(range(max(lo, n - AROUND), min(hi, n + AROUND) + 1)) + keep = sorted(keep) + w = len(str(keep[-1])); out = []; prev = None + for i in keep: + if prev is not None and i != prev + 1: out.append(' ' * (w + 4) + '…') + out.append(f"{'→' if i in marks else ' '} {str(i).rjust(w)} {L[i - 1].rstrip()}") + prev = i + return out + + +# rows that add nothing an agent acts on: a word match offered only because nothing better was found (dropped when a +# better row exists), and a module's own scope (its import lines) +FILLER = 'best overall match' +NOISE = ('module scope',) + + +def render(verb, doc, repo): + code = ax_grep.Code(repo) + rows, _rest, foot = ax_grep.VERBS[{'find': 'context', 'tests': 'test-impact'}.get(verb, verb)](doc, code) + sites = [] + for _k, line in rows: + m = SITE.match(line) + if m: sites.append((m.group('file'), int(m.group('line')), m.group('tag') or '')) + sites = [x for x in sites if not any(w in x[2] for w in NOISE)] + if any(FILLER not in t for _f, _n, t in sites): sites = [x for x in sites if FILLER not in x[2]] + # ONE PLACE PER FUNCTION: two relevant lines of one function are one block with both marked, in the order the + # verb ranked the first of them + graphs = Graphs(repo); places = {} + for f, n, t in sites: + span = graphs.enclosing(f, n) + key = (f, span[1], span[2]) if span else (f, n, n) + p = places.setdefault(key, {'f': f, 'span': span, 'marks': [], 'tags': []}) + if n not in p['marks']: p['marks'].append(n) + t = t.split(' — ')[0].strip() # the tag's short form: what it is, not the explanation after the dash + if t and t not in p['tags']: p['tags'].append(t) + out = [] + for i, p in enumerate(list(places.values())[:CAP], 1): + where = f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" + out.append(f"{i}. {where}" + (f" [{' | '.join(p['tags'][:2])}]" if p['tags'] else '')) + body = block(repo, p['f'], p['marks'], p['span']) + if body: + out.append(f" ```{FENCE.get(os.path.splitext(p['f'])[1], '')}") + out += [' ' + b for b in body] + out.append(' ```') + if not out: return None + if len(places) > CAP: out.append(f"… {len(places) - CAP} more place(s) not shown — ask a narrower question to see them") + out += [x for x in foot if x.startswith(('run:', 'verified'))][:2] + return out + + +def verb_json(cmd): + 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 + return r, doc + + +def edits(repo): + """impact with no name: what the working tree's edits changed, then what depends on those declarations, with code""" + r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-changed'), repo]) + if not isinstance(doc, dict): + sys.stdout.write(r.stdout); return r.returncode + ch = doc.get('changed') or [] + targets = list(dict.fromkeys(c['target'] for c in ch if c.get('target'))) + head = ["your edits: " + (', '.join(f"{c.get('kind')} {c.get('shown_target') or c.get('symbol')}" for c in ch[:8]) or 'none') + + (f" (+{len(ch) - 8} more)" if len(ch) > 8 else '')] + if not targets: + print('\n'.join(head + ["nothing edited is a declaration other code depends on" if ch else + "no edits against the commit the graph was built from"])) + return 0 + r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-impact')] + targets + [repo, '--tests']) + lines = render('impact', doc, repo) if isinstance(doc, dict) else None + print('\n'.join(head + (lines or [x for x in (doc or {}).get('prose', [])] or [r.stdout.strip()]))) + return 0 + + +def main(argv): + verb, repo = argv[0], argv[1] + if verb == 'edits': return edits(repo) + cmd = argv[3:] if len(argv) > 2 and argv[2] == '--' else argv[2:] + r, doc = verb_json(cmd) + if not isinstance(doc, dict): + sys.stdout.write(r.stdout); return r.returncode + lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None + if lines is None: + # a refusal or an answer with no place in it: the verb's own words are the answer + print('\n'.join(doc.get('prose') or []) or r.stdout.strip()); return r.returncode + print('\n'.join(lines)) + return 0 + + +if __name__ == '__main__': + if len(sys.argv) < 3 or (sys.argv[1] != 'edits' and 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 c503f33d..cfcaf9ff 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -1,5 +1,27 @@ #!/bin/bash -# axiomcode — the one entry point. Every capability is a subcommand here; a new skill is a new subcommand, never a new tool. +# axiomcode — ask the repository's call graph. Each answer is a numbered list of places, each with the code of the +# function it sits in. +# +# axiomcode find "" +# where the code for a task lives: the functions involved, most relevant first. +# axiomcode impact [] +# who calls it, what a change to it reaches, and the tests that exercise it. +# With no name: the same for the declarations your uncommitted edits changed. +# axiomcode path +# how A reaches B: every hop of the call chain, with the code at each call. +# axiomcode tests +# the tests your uncommitted edits reach, and the command that runs exactly those. +# axiomcode index [] [--lang [,…]] [--src ] [--library [,…]] +# build the graph (the first query builds it too). defaults to the current directory. +# +# Names are written as in the code: Owner.method, function, Type, or file.py:123. `axiomcode help ` for one verb. + +# THE HELP ABOVE IS THE PUBLIC SURFACE: helptext() prints the comment block up to the blank line above. The verbs below +# are still dispatched and keep every flag -- the hooks, the test suites and scripts call them -- but they are internal +# and are not advertised on any user- or agent-facing surface. find is context and tests is test-impact underneath; +# at the front door (bin/axiomcode sets AXIOMCODE_FRONT, the MCP server AXIOMCODE_SURFACE=mcp) a query with no flag +# answers as places with their code (ax_blocks.py), and any flag, AXIOMCODE_RAW=1 or a direct call gives the verb's +# own answer. # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. @@ -110,7 +132,12 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; # one verb's own usage, from the script that implements it: a python docstring, or a bash file's # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ - local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact" + # a verb of the small surface is explained by its own entry in the help above: what it answers, no options + case "$1" in find|impact|path|tests) + helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' + return 0 ;; + esac + local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact"; [ "$1" = find ] && f="$H/axiomcode-context" [ -f "$f" ] || { echo "axiomcode: no such verb '$1' — try: $(verbs | tr '\n' ' ')" >&2; return 2; } if head -1 "$f" | grep -q python; then python3 -c 'import ast,sys; print(ast.get_docstring(ast.parse(open(sys.argv[1]).read())) or "")' "$f" else awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$f"; fi @@ -176,7 +203,7 @@ LASTPOS=""; [ "${#POS[@]}" -gt 0 ] && LASTPOS="${POS[${#POS[@]}-1]}" case "$cmd" in index|build) if [ "${#POS[@]}" -gt 0 ] && [ ! -d "${POS[0]}" ]; then gone "${POS[0]}"; fi ;; graph) case "${POS[0]:-}" in ""|build|export|draw) ;; *) [ -d "${POS[0]}" ] || gone "${POS[0]}" ;; esac ;; - context) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; + context|find) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; path) if [ "${#POS[@]}" -gt 2 ] && [ ! -d "${POS[2]}" ]; then gone "${POS[2]}"; fi ;; impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;; changed|test-impact|tests) if [ "${#POS[@]}" -gt 0 ] && [ ! -e "${POS[0]}" ] && dirlike "${POS[0]}"; then gone "${POS[0]}"; fi ;; @@ -191,6 +218,25 @@ done # 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 +# THE SMALL SURFACE: find, impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and +# the MCP server set AXIOMCODE_FRONT), answer as numbered places, each with the code of the function it sits in +# (ax_blocks.py), so a place needs no read to be understood. find is context; impact is impact with its tests, +# and impact with no name answers for the declarations the working tree has edited; path is path; tests is +# test-impact. A flag, AXIOMCODE_RAW, or a caller that runs this script directly (the hooks, the suites) gets the +# verb's own answer. +B=""; FRONT="${AXIOMCODE_FRONT:-}"; [ "${AXIOMCODE_SURFACE:-}" = mcp ] && FRONT=1; [ -n "${AXIOMCODE_RAW:-}${GREP:-}" ] && FRONT="" +if [ -n "$FRONT" ]; then for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in -*) FRONT="" ;; esac; done; fi +case "$cmd" in + find) cmd=context; [ -n "$FRONT" ] && B=find ;; + path) [ -n "$FRONT" ] && B=path ;; + tests|test-impact) [ -n "$FRONT" ] && B=tests ;; + impact) if [ -n "$FRONT" ]; then + named=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do [ -d "$a" ] || named=1; done + [ -z "$named" ] && exec python3 "$H/ax_blocks.py" edits "$FR" + ARGS+=(--tests); B=impact + fi ;; +esac +[ -n "$B" ] && G=(python3 "$H/ax_blocks.py" "$B" "$FR" --) # 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. @@ -204,7 +250,7 @@ if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sq fi case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; - context) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + context|find) 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[@]}"} ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index 111cfa64..c562c060 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,23 +35,23 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST, through the -`mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading: +This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools +(`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading: - axiomcode_context task="" # where the work is, when you have no name yet - axiomcode_impact targets=[""] # what a change reaches: contract, users, tests - axiomcode_path from_="" to="" # how A reaches B + find(question="") # where the code for a task lives + impact(name="") # who calls it, what a change reaches, its tests + impact() # the same for your uncommitted edits + path(start="", end="") # how A reaches B + tests() # the tests your edits reach, and how to run them -Only when those tools are not in your list, the same from the shell: `axiomcode context ""`, -`axiomcode impact `, `axiomcode path `. +Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, +`axiomcode impact `, `axiomcode path `, `axiomcode tests`. -**Trust the answer.** A `[resolved]` / `[sound]` row has already been looked up again in the graph -(the `verified:` line) — do not re-derive it by grepping or opening the other files it names. Every -answer ends with `next:`, the one step to take: read only the lines you will cite or change. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +**Trust the answer.** Each place comes with the code of the function it sits in: answer from it. A +`resolved` place has already been looked up again in the graph (the `verified:` line) — do not re-derive it +by grepping. `by name` / `text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. Text search is still right for a string, a comment, a config value, or a file you already know. -`axiomcode index` builds the graph if `.axiomcode/out/graph.sqlite` is absent. """ diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9456d8e1..ff9eb64f 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,131 +1,74 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Prefer the MCP tools (`axiomcode_`; in Claude Code, `mcp__plugin_axiomcode_axiomcode__axiomcode_`) when they -are in your tool list; otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode …` from the repository root. Same code, same -verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own -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]` / `[approx]` 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 | -|---|---| -| **`.axiomcode/out/graph.sqlite` already exists** | **query it — do NOT run `index`** | -| no graph at all | `axiomcode index` | -| a task in words, no name to ask about yet | `axiomcode context ""` — then `--in ` it names | -| "who calls X" / "what breaks if X changes" | `axiomcode impact X` | -| "who writes this field" / "is it safe under concurrent access" | `axiomcode impact .` — ask of the FIELD | -| one concept you can name ("the decryption code") | `axiomcode path decrypt '*'` | -| "how does X work" · "explain / walk through X" | `axiomcode context "" --source` — the call flow in order with each step's code; answer from it, and open a file only for a step whose body was cut or a `⚠` call. `--from ` when you know where it begins | -| "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | -| "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | -| "is it safe to delete X" | `axiomcode impact X --delete` | -| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | - -Rules that decide whether an answer means anything: - -- **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last - graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` - (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits - briefly on its own only when the answer touches an edited file and the rebuild is nearly done. -- **Before a delete or a rename, ask with `--fresh`** (MCP `impact`, `path` or `context` with `fresh=True`): it waits for the rebuild, printing its - progress, and answers from a graph that includes every edit. - A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background - refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. -- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, - `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes - from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built - on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a - background rebuild with this axiomcode's engine, and the answer's FIRST line says so - (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph - another axiomcode built; they say so once per session. -- A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls - are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies - resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). -- An unresolved call is *unknown, not absent* — **never report it as "no callers"**. -- Every answer ends with `verified:` and `bound:` (the unresolved calls inside it — a lower bound). A `✗` on - `verified:` means the answer is wrong: report it, do not use it. - -## How certain is each row - -An answer's label is the **worst** rung on its route. Read it before acting on the row. - -| rung | claims | -|---|---| -| `[sound]` / `[resolved]` | an edge the engine resolved: a single-target call, an override, a subtype, a constructor | -| `[one of a set]` · `[dispatch]` | one of a sound target set · an instantiated override reached through its base | -| `[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 | -| `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — 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 | -| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | -| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | - -Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges -connect, not that a test exercises the change. - -## context — a problem statement, no name yet - -`axiomcode context "" [--in [,]] [--budget N] [--source]`: the files and callables the task's -words land in, nearest first, 12 files by default. Scopes you pass restrict and are combined; a scope it offers -does not restrict. Detail: `reference/context.md`. - -## impact — what a change to a declaration reaches - -`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: -`Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, -`src.util.square` and `src/util#square` are one name. **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. -Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config -keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. - -## changed · test-impact — from an edit - -`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the -command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base -that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection -and service loaders are invisible. Detail: `reference/changed-and-tests.md`. - -## path — asking the graph - -`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none -(with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: -`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with -file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. - -## diff: two graphs of the same tree - -`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a -`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call -edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config -bindings and symbols, with the call edges per tier. Rows match by file, line, qualified name and callee, never by id -(ids hash the index directory), so one tree indexed at two paths diffs to nothing. Use it instead of hand SQL for a -before/after. Detail: `reference/diff.md`. - -A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. +Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +`/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. + +| the question | MCP tool | shell | +|---|---|---| +| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | +| how does A reach B? | `path(start, end)` | `axiomcode path ` | +| which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | + +Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that +line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. + +## What an answer looks like + +A numbered list of places, most relevant first, each with the code of the function it sits in. `→` marks the line +that matters; a short function is shown whole. + + 1. shop/pricing.py:6 [resolved · total] + ```python + 4 def total(prices): + 5 net = sum(prices) + → 6 return net * (1 + vat_rate()) + ``` + verified: ✓ (4 edge(s) looked up again) + +Answer from the code shown; open a file only for a place whose body was cut (`…`). The tag says how sure the place +is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do not re-derive it by grepping; +`one of a set` is one of several real targets; `by name` and `text` are leads, not facts; `test` marks a test; +`hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" +from an empty answer. + +## find + +Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most +relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is +code you have to write. Example: `find(question="how is the invoice total computed")`. + +## impact + +With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: +`impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and +how), then the same answer for all of them. + +## path + +How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. +Example: `path(start="main", end="Ledger.put")`. + +## tests + +The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. +Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. + +## index + +`axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an +existing graph: the graph rebuilds itself after edits, and an answer given before that finishes says so on a +`graph refresh:` line. ## What it cannot see — say so instead of guessing -Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; -what a decoration turns on (proxy, transaction, cache); code outside `--src`. Each is counted in `bound:`. +Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a +decoration turns on (proxy, transaction, cache). Text search is still right for a string, a comment or a config value. diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md deleted file mode 100644 index cb924da2..00000000 --- a/skills/axiomcode/reference/changed-and-tests.md +++ /dev/null @@ -1,135 +0,0 @@ -# changed, test-impact, and the edit hooks - -**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the -baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit -hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another -IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` -rebuilds it, the query saying so on its first line. - - -`axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: -`signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a -script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, -`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, -extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — -nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the -build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two -commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the -edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as -edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for -every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter -changed. `--impact` runs impact on all of them as one change set. - -The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an -edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and -one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere -in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went -while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and -the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. - -What to pass, and what the answer says when the question cannot be answered the way it was asked: - -| situation | ask | what comes back | -|---|---|---| -| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | -| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | -| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | -| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | -| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | -| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | -| 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 file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | -| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | -| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | - -**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 -a lower bound for it. - -Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports -against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact -agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining -disagreement was read in the diff: the scanner charges an `@Override` line above an *added* method to `` where this -names the method; an anonymous class added inside a method body is "that method's body changed" here (the scanner names -the new anonymous methods from the fixed tree); a renamed method is reported under its OLD name (what callers reference); a -new nested type is named as well as its members; one miss stands — a method extracted from an existing body whose header -lands in a replaced region. Nothing in the tool's answers was bent toward the benchmark: where the two differ, the diff -was the judge. - -The plugin's hooks do this without being asked, at every moment an edit can happen (`hooks/enrich.py`, `hooks/changes.py`): -**PreToolUse on Edit / Write / MultiEdit** applies the edit to a copy and, when it changes a signature, a field's type, a type -header or removes a declaration, gives the blast radius *before* the file changes; **PostToolUse on Edit / Write / MultiEdit** -reports every changed declaration after it lands (a body-only edit included); **PostToolUse on Bash** re-reads the working -tree after a command that can modify sources (`sed -i`, `patch`, `git apply / checkout / pull / merge / stash pop`, a redirect -into a source file, a script run); **UserPromptSubmit** is the safety net — whatever changed the tree since the graph's commit -by any means and was not reported yet. Each declaration is reported once per session; each report is `changed` (which -declaration, how) and `impact` (up to three declarations in parallel, a few lines each: what must change with it — for a -signature, a field, a type or a removal —, who produces or writes it, who reads it, how many callables and tests reach it, -the unresolved-call bound). That is where the agent that changed `String zipCode` to `Integer` is told, before the edit -lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and -the four repositories that deserialize a holder. - -**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. - -**Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under -`tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It -covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a -nested type of the same name, an overload written by its parameter type (`Store.get(String)`), a Java text block and a -JavaScript regex literal, `holds` scoped to the declaring type, a subtype contract where the engine emits no override -rows, a Python `@property` as a private field's door, a house decorator that wraps `dataclass`, and a local variable -that must not carry the method's blast radius. Java, Python, TypeScript, JavaScript and C#. - -**Is what the hooks put in context true?** `hooks/validate.py ` generates events (Reads of whole files and ranges, Greps of -declared identifiers, edits that change a body, a signature, a field's type — before and after landing) or replays recorded -ones (every hook block is logged in full with its input in `.axiomcode/hooks.jsonl`), and checks every stated fact against -`graph.sqlite` and the source: each callable named is declared at that line in that file (or the block says the file changed -since the graph was built — the Read block now says so), each caller / callee named has an edge, each count is the table's, -each changed declaration spans a changed line, each name under must-change / produces / reads is in `impact`'s answer with -that role. On a multi-module Java system 1,036 facts, 0 wrong; on a JVM parser 2,693 facts, 0 wrong — after it found two real errors: an -enum's synthesised `values()` / `valueOf()` listed as callables "at L3", and a field named like its fluent accessor handed to -`impact` without its kind. What the hook cannot vouch for is what the graph cannot: an edge the engine did not resolve is -absent, never wrong, and the `? n` count says how many. - -## test-impact — which tests this edit reaches - -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the -declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the -runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline -rather than for a reader. - -**What it costs and what it saves, measured end to end** on a TypeScript library of 311 source files whose suite is -130 files and 5,193 tests: a one-line body edit to one function → the answer in **0.74 s**, naming 8 files / 503 -tests, and running exactly those took **2.2 s against 17.3 s for the whole suite — 7.9× faster**. Against the -behavioural truth for that method (break it, run the suite, record which files newly fail) the selection contained -**every failing file**, with 2 extra. Over 16 such methods: recall 0.778, precision 0.636, mean 4.1 files of 130. - -**It is a lower bound and the wording says so, because the two questions want opposite things.** For "what must be -looked at again", recall is the product and a wide answer is safe. For "what can CI skip", precision is the product -and a wide answer is worthless — and the same answer cannot be tuned for both: on a Python web framework the -registration-key hop takes recall 0.564 → 0.727 and precision 0.527 → 0.310 at the same time. So the rungs are -reported separately and `--json` carries `certainty` per test, and a pipeline can price them: on the TypeScript -library a `[sound]` route (every hop a single resolved target) was right **29 times in 30**, `[one of a set]` 1 in 8, -`[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and -about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with -the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -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/context.md b/skills/axiomcode/reference/context.md deleted file mode 100644 index 742356c7..00000000 --- a/skills/axiomcode/reference/context.md +++ /dev/null @@ -1,69 +0,0 @@ -# context — from a problem statement, when there is no name yet - -Every other verb needs a name you already have: a method, a type, a `file:line`. That is the wrong first -question on an unfamiliar repository, and it is where a run gives up — asked once, the word resolved to -nothing usable, the graph never touched again. - -``` -axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… [--no-refresh] -``` - -**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - -Deterministic — no model, no embedding index, no network. The task text is split into content terms -(stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, -prefix, substring, then file path — each weighted by inverse document frequency over the graph's own -vocabulary, so a rare term outweighs a common one. A test or benchmark declaration is demoted, not -dropped. The best seed per term is kept, so a multi-concept task gets several entry points; the closure -is walked from those seeds and ranked by nearest hop, then by how many of the task's terms the file -matches — not by how many methods it happens to contain. - -`--budget N` is how many files are listed (12 by default). The ranking does not depend on it: a larger -budget only shows more of the same tail, and the footer always says how many were withheld. - -`--in` is **repeatable and takes a list**: `--in a --in b` or `--in a,b`. A path you supply is knowledge — -a stack frame, the file you just read, the package named in the issue — so it does restrict the answer; -several are **combined, not intersected**, which is what makes a change spanning two roots answerable in -one call. A scope this program offered comes back marked `--in-offered` and does not restrict at all, -because that one is its guess and not your knowledge. - -It ends by saying what it could not see. A partial list that reads as complete is what turns a five-file -change into a one-file patch. - -## How something works: the call flow - -A task that asks how something works ("how does …", "explain …", "walk through …", "what happens when …", or -`--explain`) also gets the call flow. It starts at `--from ` (repeatable) when you know where the mechanism -begins, and otherwise at the entry points above. The steps are chosen breadth-first, so the entry point's own -calls come before any call of a call, and they print as a tree in the order the calls are written. Each step shows -its edge's certainty (`→` resolved, `⇢` one of a set) and the line that makes the call. A `⚠` marks a call in the -step's body that the graph could not resolve, when the project declares that name, so the reader continues -through it instead of stopping. A one-of-a-set site with many candidates is not a step. - -Where the flow leaves the graph it says so on that step, rather than ending silently: `⚠ leaves the graph: Send() L11` -for a library call that hands the work on (send, publish, dispatch, persist, execute, a client stub's `…Async`), and -`⚠ no body in the graph (interface/abstract)` for a step with no code to follow, naming the mapper XML statement bound -to it when there is one. Read from there by hand; a leaf whose library calls hand nothing on is not marked. - -## What the question names - -Entry points start with what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`, `loadByNumber`) -and a route it quotes (`GET /api/widgets`, at the handler registered for it). Then the symbols matching several of -its terms together, then each term left over. Words about code rather than about the subject (`code`, `tests`, -`call`) take no seed, and an inflected word (`validated`) meets the declaration (`Validate`, `…Validator`). - -A file, directory or language the question names that no graph here holds is said FIRST, as -`not indexed: () -- this answer cannot see it; grep it directly`, and `next:` points at it. In a -repository with a graph per language, a question naming one language is answered by that graph alone. - -A question about SQL, configuration or templates lists the text files that name the declarations found (a MyBatis -mapper XML whose namespace is the declaring type ranks first), marked as text bindings, not call paths. `--in` on -a directory that holds no source (`src/main/resources`) is accepted: it says so and lists what under it binds. - -With `--source`, the earliest steps carry their code within a budget and the later ones are named only, so the -answer comes back on one page. Answer from that code, and open a file only for a step whose body was cut or at a -`⚠`. A question that does not ask how something works gets the ranked answer above, unchanged. diff --git a/skills/axiomcode/reference/diff.md b/skills/axiomcode/reference/diff.md deleted file mode 100644 index ef2e356b..00000000 --- a/skills/axiomcode/reference/diff.md +++ /dev/null @@ -1,68 +0,0 @@ -# diff: what changed between two graphs of one tree - -```sh -axiomcode diff [--file ] [--lang ] [--limit N] [--json] -``` - -Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its -`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. - -## The before/after recipe - -```sh -rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ -AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python -AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python -cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine -cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite -axiomcode diff /tmp/before.sqlite /tmp/after.sqlite -``` - -Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild -with the installed one, which overwrites the graph under test. The diff itself never does. - -## How rows are matched - -By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so -two indexes of one tree share none, and a join on ids says everything changed. - -| kind | matched on | -|---|---| -| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | -| entry point | the method (qualified name, file:line) and the reason | -| reachable | the method | -| remote edge | transport, destination, sender, handler, confidence | -| framework edge (Python) | mechanism, name, from, to, certainty | -| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | -| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | - -Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same -tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: -compare graphs of one tree, not of two commits. - -## Reading the answer - -``` -python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) - B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) -summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 -call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … - -call edges (…): - + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site - - file:line:col Caller -> Callee [tier, kind] the reverse - ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind - > file:line:col Caller a site whose callees changed: the old set, then the new - - Callee [tier, kind] - + Callee [tier, kind] -``` - -The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). -`--file` keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the -declaration's) and counts only those. `--json` prints every row with the same counts, under -`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. - -## Not compared - -`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both -graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md deleted file mode 100644 index 026784d9..00000000 --- a/skills/axiomcode/reference/impact.md +++ /dev/null @@ -1,322 +0,0 @@ -# impact — what a change to a declaration reaches - -The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. - -**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - - -`axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: -`Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / -interface / enum), `Owner.method(param)` (one parameter), `Type` · `Owner.method` (a type parameter — a generic, or a -bound on it), `Owner.method:name` (a local), `Type.` (its construction) / `Type.` (its static initialization: whoever -first uses the type). Several targets in one call are one change set. A name declared as more than one kind stops and asks for -`--kind`. The same answer shape for every kind and language: - -Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, -extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the -line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's -hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode -index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when -there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a -caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. -Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and -the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: -every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). - -- **a configuration key is a target** — `axiomcode impact server.error.path`: the methods the container binds it into - (`@Value`, `@ConfigurationProperties`, a `.yml` / `.properties` key), from the engine's framework facts, then everything - that reaches them. No call site carries these edges, so nothing else finds them. A key the engine never saw **stops with - that sentence** — its impact is unknown, not empty — and a graph with no configuration facts at all says so; a key is - never answered as a by-name match on code, which is what made a wrong answer look like an answer. -- **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the - container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it - over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. - A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` - or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected - with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). -- **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / - `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a - `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed - as `[framework]`, with the mechanism, what joined the ends and the engine's confidence: `framework-mediated, not a call: - task_dispatch via delay [registered]`. It ranks below `[remote]` and above every name match, and like `[remote]` it is a - direct row that does not seed the closure. An unrelated method that shares the name (`Animation.delay`) gains nothing. -- **a handler nothing calls is still used** — a declaration handed over as a *value* (`app.get('/orders/:id', getOrder)`, - `background.add_task(send_receipt, id)`, `setTimeout(flush, 1000)`, `handlers = {"x": handle_x}`) has no call site - anywhere: the call happens inside the framework, or later, or never. Every other rule here is about call sites, so this - used to answer *"the declaration is used only where it is declared"* — and `--delete` said **no dependent at any - certainty** — for a live HTTP handler. The reference the parser recorded is read instead, and the site says what will do - the calling: `registered as a GET route "/orders/:id" here — the router calls it, no call site does` when the call is a - route registration (a router verb *and* a string argument that begins with `/` — `get`/`set`/`delete` alone are Map, Set, - Headers and every cache in this ecosystem, so the verb is never matched by itself), otherwise `handed to add_task(…) as a - callback`. It is `[by name]`: the parser says the identifier binds to a callable, not that it binds to *this* one. - Where the engine already resolved the registration to an edge — a JavaScript `app.get('/pads', listPads)` is a resolved - call in that engine — the row stays `[resolved]` and only the sentence changes, so the reader learns that what they are - changing is `GET /pads` rather than that some module calls it. **JavaScript gets the wording and no name-matched rows:** - its `refs` carry the access mode (`IDENTIFIER|READ`) and no entity kind, so nothing there distinguishes a reference to the - declaration from a parameter of the same name. A site-keyed version was written for it and measured on a 124-file Express - application: eleven rows over 30 sampled targets, and all eleven were wrong (seven a parameter named `callback` inside - `forEach(function (callback) {…})`, four a `settle` being *called* inside the `.then(…)` span it sits in). It is not - shipped. The rule needs the parser to say that an identifier binds to a callable, which TypeScript, Python and Java do - and JavaScript does not. -- **must change with it** — declarations bound to the target by a contract the engine resolved: the overrides of a method (and what - it overrides), the subtypes of a type. A signature change reaches these first. -### What breaks a build, and what does not - -The sections are relations, not severities, and reading them top-down as "most to least urgent" is wrong. -Nothing under `produces or writes it` necessarily fails a build: those rows are dataflow — who makes a value of -this shape, including deserialization that writes it reflectively. A `[text]` row under `bound from outside the -source` can never fail a build; the compiler does not read that file at all, which is exactly why it is printed -last and says so. - -For a field, the rows that stop a build are usually in neither list. Changing a field's TYPE changes the -signature of whatever is generated from it — an all-args constructor, a setter, a copy/`with` — and it is the -callers of THOSE that break, at the argument they pass. They touch the generated member, not the field, so no -rule puts them under the field's own relations. The answer now says this directly under the generated-members -line and names the constructor query to run; take that suggestion before acting on the first list. - -- **produces or writes it** — the blast radius read top-down starts where a value of the new shape has to be *made*: setter and - builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where - that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the - field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. -- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from - the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a - test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission - check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its - name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. -- **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; 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; the Read and Grep hooks count the same callers); `[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 - type references in signatures), comments and strings stripped. A bare name inside a type that declares its own member of that - name is that member, not the target; a qualified `X.name` is confirmed when `X` is the owner and dropped when `X` is another - type. For a field, a **declared accessor** in the owner (`getF` / `setF` / `isF` / `f()`) is its door: the accessor's callers are - listed as reading or writing the field through it. A **generating decoration** — Lombok `@Data` / `@Getter` / `@Setter` / - `@Value` / `@Builder` / `@AllArgsConstructor` / `@With`, a record, a dataclass — declares members the source never spells, so a - call to `getZipCode()` or `new Address(…)` is an unresolved site; the unresolved sites written with the generated name are listed - as calling the generated getter / setter / constructor `[by name]`, with the decoration that generates it. Where the ENGINE - synthesises the member instead of leaving the site unresolved (Java's Lombok and records, C#'s auto-properties: a `methods` - row with provenance `generated`), the call site resolves to it and the caller is named `[resolved]` — *reads it through - getName()* — which is the same answer with a stronger claim behind it. A string literal - equal to the field's name (a map key, a serialized name, a request parameter) is listed `[text]`. -- **the upstream answer is measured against behaviour, not against itself** — `validate/upstream.py ` takes a tree with - `.axiomcode/mutation.json` (a method broken, the test files that then failed), asks `impact --tests` which test files - reach it, and classifies every miss from the graph. a JVM HTML parser, 24 methods, 244 (method, test file) pairs: recall 0.795 → **0.988**, - precision 0.328 → 0.338, after the three rules the misses named — a test class that *extends* a reached one runs its tests - (its HTTP-client test classes declare almost nothing: 20 of the 27 misses), a call site written with the target's name - that the engine could not resolve (`import static Outer.Inner` left `res.prepareResponse(…)` untyped: 8 more), and a test - file's import-time code (a class body, a fixture). a Python validation library, 14 methods, 180 pairs: 0.678 → 0.717 — what remains - is dispatch a static graph cannot see (`__eq__` and the other protocol methods the interpreter calls, a method reached - through `getattr(self, f"_{kind}_schema")`), and the answer now says that instead of printing nothing. A TypeScript web framework - (16 methods, 96 pairs, `validate/mutants.py` builds the truth: break a method, run the suite, record - which test FILES newly fail): 0.000 → 0.790. It was zero because a vitest test is an anonymous callback handed to - `it(…)` — 6,661 of its 7,723 callables in test files are `` and two carried a name the old rule accepted, - so the test universe was empty and every answer named no test file at all. A callable registered by `it` / `test` / - `bench` on its own line is a test, and a helper declared beside them carries them. The PARAMETERISED form needs the - call site rather than the line: `test.each` + a template table writes the arrow after the closing backtick, on a - line naming no registrar at all (18 of them in that library), so a callable inside the span of a `TAGGED_TEMPLATE_CALL` - to `each` — its first line read to confirm the receiver the call site does not carry — is a test too. That shape is - vitest / jest / mocha's alone: a pytest test is found by its name however deep the decorator stack, so nothing there - depends on which line the registrar is written on. - **What a selection costs and buys, on that same TypeScript library, measured again with the rungs separated** (a - fresh clone, 311 source files, 130 test files, 5,193 tests in 17 s; 16 methods broken one at a time, 54 (method, - test file) pairs of behavioural truth): recall **0.778**, precision 0.636, and the answer names **4.1 test files of - 130** for a change — 3 % of the suite. Per rung, against that truth: a `[sound]` route (every hop a single resolved - target) is right **29 times in 30**; `[one of a set]` is right 1 in 8; `[by name]` 0 in 1. By distance: 1 hop 0.667, - 2 hops 0.900, 3 or more 1.000 — the far pairs are few and all real. So a pipeline that runs the sound rung first is - almost never wasting a run, and the waste is concentrated in exactly one rung, which is why the rungs are reported - separately rather than blended. Every remaining miss is `no-edge` — a handler the graph has no resolved caller for - (an adapter, a JSX intrinsic element) — not a rule this tool could tighten. - A library is not a service, and the number differs by population: on a Python SERVICE driven through its frameworks - (two Python web frameworks + CLI routes, a pytest suite with conftest fixtures, a decorator registry, a signal loop, 53 - functions broken one at a time, 95 (method, test file) pairs) recall was **0.216** — 26 of 40 answers named no test - file at all — because the suite reaches the code the way the outside world does: through the framework. The - registration-key hop and the injected-fixture rules take it to **0.695** at precision 0.930, and what is still - missing is named rather than guessed: a function reached only through a table or list of functions dispatched by - index (`TRANSFORMS = [strip, upper]`, `EXPORTERS[kind](x)`), a decorator that wraps a callable in an object whose - method calls it (`@shared_task` … `.delay()`), and a closure defined in one method and returned to another. - Held out, on a subject nothing was tuned against (the Python web framework's own 491-test suite, 40 functions broken, 172 pairs): - 0.564 → **0.727**, precision 0.527 → 0.310. Both halves of that trade are real and neither is free — the recall is - routes and fixtures the answer could not see before; the precision is the fan-in of a framework whose every test - builds an app. A key that identifies MANY declarations identifies none: the Python web framework's own suite registers `"/"` from 236 - places and asks for it from 200 more, so a key registering more than `AXIOMCODE_KEY_CAP` (4) declarations is - REFUSED rather than joined — the engine's `fan_capped` judgement one layer up. Uncapped that subject reads 0.791 - recall at 0.248 precision. The same cap applies to the other side (`AXIOMCODE_KEY_USE_CAP`, 4): on the JVM parser the keys - that survive the registration cap are `p`, `b`, `table`, `em` — HTML tag names, written by 356 callables and - "registered" by two, because a decoration argument is not always a registration (`@ValueSource(strings = {"p"})` - is test DATA). One Java method went from naming 1 test file to naming 61 until that cap was added, and 6 after it. - Neither cap needs a catalogue of which decorations register and which do not, which is the point of them. - **A cap and a kind guard answer different questions, and the second is invisible to the first.** A cap says *this - key is too wide to mean anything*; it cannot say *this was never a dispatch key at all*. A test's own decoration - carries its INPUTS — `@ValueSource(strings = {"/htmltests/large.html"})`, `@CsvSource`, `@pytest.mark.parametrize` - — one declaration, a handful of writers, under every cap, and entirely meaningless as a key; and a route mounted - inside a test file is a fixture, not the application's dispatch table (on one TypeScript router library **every** - route registration line, 6,128 of 6,128, is in a test file). So a decoration on a test declaration is not read as - a registration at all, and a route registered in a test file keeps its dependent row and its sentence but is given - no joinable key. -- **precision is not a bug to fix, it is a property to report** — `validate/precision.py ` places every predicted - (method, test file) pair by the worst hop on its best route and by distance, against the same truth. On the JVM parser: a route of - single-target resolved calls is right 0.765 of the time, one through a call resolved to a SET 0.301, through an override - reached from its base 0.170; within 3 hops 0.70, beyond 5 hops 0.24; a sound route within 3 hops 0.889 — but that keeps - only 48 of 219 true pairs. The split that explains the 0.34 overall is fan-in, not error: 10 of the 24 methods are hubs - every test reaches (it parses HTML in every suite) — those answers are 60 of 98 test files at precision 0.285 with - recall 1.000, while the 14 narrow methods score 0.642 with 7 of them exactly right. A test that *reaches* a change and - does not fail is not a wrong edge: it runs the code and does not observe the change. So `--tests` answers "which tests - CAN observe this" and says how sure each route is (`[sound]`, `[one of a set]`, `[dispatch]`, `[by name]`, nearest and - surest first) and, when most of the suite reaches the method, that at this fan-in reaching says little about failing. - It is a ranking, not a test selection; a narrow answer can be used as one. -- **reaches those through resolved calls** — the transitive impact: everything that can reach a touched callable, by hop and by - file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the - field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with - the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: - the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a - `why` list), and `--tests-in ` narrows - the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k - characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static - initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, - printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). - A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. - `--in ` and `--depth N` bound it; `--json` is the same answer as data. -- **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the - declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` - and `client.post("/orders")`; `@receiver("order_created")` and `emit("order_created", …)`; `@cli.command("price")` and - `invoke(cli, ["price", "4"])`; `@exporter("csv")` and `export(order, "csv")`; a a Python web framework `add_url_rule("/quote/", - view_func=legacy_quote)`, where the declaration is handed over as a value and no call site names it at all. Both ends are - in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most - 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. Only what the decoration registers - under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring - keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member - of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. -- **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 test that runs a script as a child process is a hop** — `execFileSync(node, [path.join(__dirname, '..', 'bin', - 'cli.js')])`, `spawn(process.execPath, [require.resolve('../bin/tool')])`, `subprocess.run([sys.executable, SCRIPT])` - with `SCRIPT = os.path.join(HERE, '..', 'scripts', 'report.py')`: the script's module body runs in another process, and - no call site or import says so. When a call that starts a process names, among its arguments, a file this graph indexed - (a literal, a join of literals, or a constant holding one), the test — or the helper beside the tests that makes the - call — is joined to that file's module entry, so everything the script reaches gains the test. The hop is `[spawns]`: - a key (the path), not a call. Reading the same path (`fs.readFileSync`, `open`) starts no process and is not joined, - and a file of another language is in no graph of this one, so it is never joined across languages. -- **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` - wrapper — retry, cache, login_required, a task — has no caller at all and a change to it reaches nothing. What the - graph still cannot say is the OTHER decorator shape, where the decorator returns an object rather than a function - (`@shared_task` … `.delay()`): there the name denotes an instance, and the engine says so rather than guessing. -- **a fixture the framework injects** — pytest matches a test's PARAMETER NAME against the fixtures visible from its file: - those beside it and those in a `conftest.py` of any ancestor directory, which is not the test's file and is imported by - nothing. A `@pytest.mark.usefixtures` marker names one instead, and an `autouse=True` fixture runs before every test in - its scope without being named anywhere. A fixture may request another fixture, and then both run. None of that is a call. - A route that runs a fixture first is reported as `[fixture]`, and it is the weakest rung above `[by name]`: the - framework does run it and it does reach the change, but the test's own body may never touch it. How often each rung - is right, measured against mutation truth on three Python subjects (`n` is the pairs the rung named, and a rung with - a handful of pairs says nothing — it is printed so you can discount it, not so you can rank on it): - - | rung | small framework service | web framework | CLI library | - |---|---|---|---| - | `[sound]` | 1.000 (n=19) | 0.895 (n=86) | 0.561 (n=132) | - | `[at import]` | 1.000 (n=17) | — | — | - | `[defines]` | — | 1.000 (n=1) | 0.875 (n=8) | - | `[one of a set]` | 1.000 (n=2) | 0.659 (n=44) | 0.657 (n=99) | - | `[by key]` | 0.926 (n=27) | 0.342 (n=73) | 0.000 (n=3) | - | `[decorator by name]` | 1.000 (n=8) | 0.667 (n=3) | — | - | `[protocol]` | 1.000 (n=4) | 0.882 (n=17) | 0.400 (n=5) | - | `[fixture]` | 1.000 (n=27) | 0.382 (n=102) | 0.536 (n=112) | - | `[by name]` | 0.333 (n=3) | 0.531 (n=32) | 0.475 (n=61) | - - `[protocol]` is the newest row and the one to read carefully: its only substantial sample, 17 pairs on the web framework, - puts it at 0.882 — second to `[sound]` on that subject and well above the two rungs printed ABOVE it. That is not - enough to re-rank a ladder on, for the reason the rest of this paragraph gives, but it is enough that a reader - should not discount a `[protocol]` route for its position. - - And read what a rung CLAIMS, not only how often it holds: `[sound]` means a resolved single-target call chain - within three hops — a fact about the edges — and never that the test exercises the change. `[at import]` is the - one rung that is about the test rather than the edge: the module raised while being imported, the file never - loaded, and the test was never collected, so its body is irrelevant. Read that table before trusting the order - the answer prints. The TOP of the ladder holds: `[sound]` and - `[one of a set]` are the best rungs on the subjects with enough pairs to say. BELOW that the order is not stable - across subjects and the printed ranking is a tie-break of what KIND of evidence a hop is, not a measured ordering: - `[by key]` is the best rung on one subject (0.926) and the worst on another (0.342), and `[by name]` is printed - last while measuring above `[by key]` on both of the two large subjects. An answer's label is still the WORST rung - on its route, so it remains a floor — but a `[by name]` route on a library-shaped codebase is not the near-worthless - thing its position suggests. And `[sound]` at 0.561 on the CLI library is the plainest statement of the whole limit: reaching - is not failing, and on a codebase whose tests drive one hub, a resolved call within three hops is right barely more - than half the time. A test - reached BOTH by its own body and through a fixture is reported as the body: the same distance, the stronger claim, - and it moves 37 of the CLI library's pairs off the fixture rung. And what the rules add is a POPULATION effect, not a general - one — on a third held-out subject (a CLI library, 2,058 tests, 40 functions, 293 pairs) they move four - targets and carry 0.802 recall at 0.566 precision, against 0.792 / 0.569 with every framework hop turned off, - because its tests reach its code by CALLING it. The framework hops pay where a framework is in between and very - nearly cancel where it is not: on the CLI library the decorator hop alone adds 3 true pairs and 4 false ones. -- **verified** — every printed edge looked up again in the graph; **bound** counts the unresolved calls inside the impacted - set, so the set is a lower bound on the real one; a **note** counts the entries matched by name or text. - -`--delete` adds a verdict: **is it safe to delete** — the callers and contracts that say no, or, when there are none, exactly -what the graph cannot vouch for (by-name matches, string literals equal to the name — a reflective call, a bean name, a config -key —, the decorations a framework may dispatch on, the unresolved calls inside, the tests that reach it). With **several -targets** (a PR touching many files) each row says which target it came from — `[for Owner.method]` — so a combined radius is -still attributable per change. - -**The unit of change is a declaration in the graph, and half of real Java commits change something else** (592 commits over -five projects: 47 % touch no Java file at all, 30 % touch Java plus a build or resource file). Three of those kinds now have a -target of their own: `@Transactional` (an annotation — every declaration carrying it, and their dependents), `Enum.` (a -constant that does not exist yet — the switches that need a new arm), and a configuration key. A method target also reports -its **throws** contract: adding a checked exception reaches *every* resolved caller, and the answer says how many of them -already catch or declare the ones it has. Still outside the unit, and said rather than guessed: a build file or a dependency -bump, an added overload's rebinding of existing call sites, and what a framework does with an annotation (the proxy, the -transaction, the cache) — `changed` says that in the same line as the decoration change. - -What it cannot see, by construction — say so instead of guessing: a callable that touches a type only through a value it never -names (`t.asStartTag().normalName()` where the engine resolved `normalName` to the inherited `Tag.normalName`) — the graph keeps -no receiver type at a call site, so the compiler sees that dependency and this tool does not; the `[one of a set]` callers are -the engine's over-approximation and most of them will not compile against the change; a bound change on a type parameter -reaches the sites that instantiate `Type<…>`, listed, but nothing checks the argument against the bound; the transitive layer -is the call graph's, so everything `path` cannot find (callbacks handed to a library, reflection, framework dispatch) is a -missing chain here too and is counted in `bound:`, never guessed. What a **decoration turns on** is not in the graph either — -`changed` reports `@Transactional` / `@Cacheable` / a route as a decoration change and says in the same line that the proxying, -the transaction or the cache behind it is invisible; only the code that names it is. Still **not expressible today**, and said -so rather than answered: which `switch` arms an added enum constant breaks, who must catch an added `throws`, which call sites -an added overload rebinds (no argument types per call site), and what a dependency bump reaches (one graph, no library diff). -Test selection from a body change is sound but wide — 41–87 % of a suite on a hub graph — because every path through the hub -is real; narrowing it is ranking, not reachability, and is not attempted here. - -Measured two ways, Java first. (1) A Java defect benchmark: the methods each fix changed as the change set, `--tests` against the tests -it observed failing on the buggy tree — 273 bugs of 17 projects, every triggering test found in 266, trigger recall 0.929, -mean selection 50 % of the suite, and the same verdict as the benchmark's own independent reading of the same graphs in 252 of -256 bugs (better in 3, worse in 1 — a method the fix *added*, absent from the buggy tree); every remaining miss is an engine gap -(an overload set, a callback through `Function.apply`), not a tool loss. (2) The compiler: on five of those projects, 412 sampled -declarations, one edit each — rename a field, a method (all its overloads), a type (plus an empty stub with the old name, so member -uses fail too), a type parameter; remove a parameter — and `javac` over the whole tree names the dependents. Recall: fields 0.997, -methods 1.000, types 0.962, parameters 0.944, type parameters 0.977. Precision by certainty, all kinds: `[resolved]` 528/585, -`[in scope]` 249/256, `[text]` 683/773, `[by name]` 157/291, `[one of a set]` 126/351, contract 69/144 (the compiler confirms -only the override direction that breaks). The harnesses are `impact-arena.py` and `oracle-b.py` next to the arena. (3) By hand, on a -multi-module Spring / SOFA-RPC / Lombok `@Data` system where every model is generated accessors: a `String zipCode` field on a -shared `Address` → the three places an `Integer` breaks (the owner's formatter, the five `getZipCode().length()` / `.trim()` uses -in another service, the generated all-args constructor call in a web controller) and nothing else; a facade method called -through `@SofaReference` fields in two other services → the override, the three callers, the three REST entry points; an enum -member → its one use, with `PaymentStatus.PENDING` and `ShipmentStatus.PENDING` correctly excluded; a shared value type → all six -files, including a chained `product.getPrice().getAmount()` a grep for the type cannot see; a field with declared accessors → -every accessor caller across three services plus the `"stockQuantity"` map key. Other languages share every code path except -the static-import rule (Java syntax) and are not yet measured. - diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md deleted file mode 100644 index e20eeeef..00000000 --- a/skills/axiomcode/reference/path.md +++ /dev/null @@ -1,130 +0,0 @@ -# path — the endpoint grammar and what it cannot find - -**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. Without it, a query on a graph that is out of -date (files edited since, or built by another axiomcode) starts a background rebuild with this axiomcode's engine and -says so on the answer's first line, with the reason. - - -- **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the - other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so - `path decrypt '*'` answers "where is the decryption code and what does it touch" — 12 declarations, what they - reach, by hop and by file — without knowing a single exact name first. `path '*' ` is the same in reverse. - This is the way into an unfamiliar repository: get the real names out of the answer, then ask the precise - question with one of them. There is no separate search verb, and none is needed — a name you half remember stops - with the exact names that are close, which is the same lookup. -- **Endpoints are names as written in the code**, never guesses: `Owner.method`, `Outer.Inner.method`, `method` (a free - function, or that name under any owner), `Type` (every method it declares), `file.ts:123` (the callable at that - line, top-level code included), `file.py` (every method in the file). `Outer$Inner.m`, `Outer.Inner#m`, `m(int,String)` - and package-qualified `pkg.Outer.Inner.m` are the same name; a Java nested type is found whether or not the outer is - written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one - of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python - and C#; JavaScript works but the engine's JavaScript output is still moving. -- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, - right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, - in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix - (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, - library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and - found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" - (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when - an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. -- **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist - and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain - from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), - by file, then the simple paths through it, shortest first, up to `--paths N` (default 20; the count is exponential, - so the set is the complete answer and the list is a sample of it). The `verified:` line means every hop was looked up - again in the graph and a second, independent traversal found the same length; a `✗` means the answer is wrong — report it, do not use it. -- **Every hop reads `[tier · kind @ file:line]`.** The *tier* is how certain the edge is; the *kind* is what sort of - call it is, in one vocabulary that means the same thing in all five languages (`call` · `new` · `ctor` · `super` · - `decorator` · `property` · `method-ref` · `with` · `import` · `eval` · `dynamic`); and the *line* is where the call - is WRITTEN, which is where you check it — the name after the arrow already tells you the callee, and its own - declaration line follows it. The tiers an answer used are legended beneath it, so none of them has to be looked up: - `known_edge` resolved to one declaration, `multi_inferred` several fit and each is real, `dispatch` a base method to - an override the project instantiates, `callback_registered` handed over as a value and invoked by whoever holds it, - `boundary_lib` / `ambient_terminal` into a dependency or the platform, `defines` **not a call at all** — the callee - is written inside that body, so it runs only after it. The engine emits eleven tiers and thirty kinds across the - five languages and they do not share a vocabulary; `scripts/ax_edges.py` is the single table that normalises them, - and an unrecognised tier ranks LAST there rather than being silently treated as certain. -- **The hop count counts calls.** A chain's header says `7 call(s)` — containment hops (`defines`) are listed - separately (`+2 containment hop(s)`) and excluded, because "A reaches B in 11 calls" is false when five of the - eleven are a closure sitting inside a body. -- **`--json`** gives the same answer as one document — every hop with its tier, kind, call site, callee declaration - and whether it is a call — with the prose carried alongside it, so nothing is lost by asking for the machine shape. -- **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* - connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line - counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. -- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler - that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python - `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# - endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) - are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client - reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls - (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` - counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations - gets them; a hop never joins two languages' graphs. -- **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, - `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser - wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, - in any language. With `--library` staged the same call is a resolved library method and matches by qualified name. A - client declaration always wins over both. The node has in-edges only — nothing is inferred about the library body — and - the answer says how many sites were matched and where. -- **A type the code uses but does not declare is an endpoint**: `path Foo.run File` — every place `File` is touched, - as one target: `new File` at unresolved sites, the library methods of `java.io.File` when staged, - and the methods whose body references the name where the parser gives a line. The answer says which of those it - matched (Java type references carry no line, so there it is the constructor calls and identifier uses). -- **A decoration is an endpoint**: `path '@GetMapping' 'new File'`, `path '@*Mapping' Files.readAllBytes`, `path '@Test' X`, - `path '@Get' '*'`, `path '@Controller' Svc.load` — every method carrying it, so "from any method with this decoration to X" - is one call. A decoration on the **type** is carried by every method that type declares, which is what the class-level form - of every framework needs (`@RestController`, `@Controller`, `@Injectable`, `@Component`, `@Entity`); on one Spring service that is 78 methods for `@*Mapping` where the method-level rows alone are 29. The decorations come from the index's - decorations table **or, where a front end records a decorator as a call and not as a decoration, from those call sites** — - a TypeScript or JavaScript graph has an empty decorations table and its `@Get(':sku')` sitting in `call_sites` as a - `DECORATOR_CALL`, so Nest, Angular and TypeORM used to answer `no method carries @Get` with an empty list of decorations, - which reads as "this repository has no such handler". The owner is the narrowest declaration whose span holds the decorator - line, so `@Get` lands on the method and `@Controller`, which the call site charges to the module, lands on the class. - A graph that records no decoration at all now says so, instead of printing an empty list. -- **End to end, any shape:** `path Type1 method4` asks whether *any* method of Type1 reaches *any* declaration named - method4 — a type on either end is all its methods, a bare name is every declaration under any owner (a free function - in Python/TS/JS has its file as owner). The same rule in every language; nothing is forced to be typed. -- **A name under many owners** (`close`, `run`, `toString`): the closure is computed once from the sources, so a - thousand targets cost nothing; the answer is which owners' declarations are reached and how far, nearest first, - then the nearest chains. Narrow with `Owner.close`, `--in ` (both endpoints restricted to files - containing it), `--limit N`, or `--all` for every chain. -- `Outer$Inner.m` and `Outer$1.m` are looked up through the nesting table, not by string: Inner at any depth inside - Outer; `$N` the N-th anonymous class in source order (javac's numbering — checked against `javap` on a JVM parser's traversal tests, 10/10) or, for an enum, the N-th constant with a body. A miss says which part is wrong: no such - outer / no nested type X (lists them) / only k anonymous classes (with lines) / no method m (lists the methods). -- **One endpoint = a closure, not a chain.** `path '*' X` is everything that can reach X — by hop, by file, and the - *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved - calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is - shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the - call sites written with X's name on a receiver the engine could not type (`[by name] `): the - callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, - and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part - under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) - and bounded by the unresolved calls inside it. -- **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 - method(s)", which is true of calls and false of the program. When the upstream closure is empty the registration is - named instead — *create_order is registered as a route "/orders" by @post (app/api.py:43)* — and when two endpoints - have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one - path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a - chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the - graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the - graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is - not one), a library method it overrides, the call sites that write its name, a library base of its type, a - decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the - one module both tools read (`scripts/ax_registration.py`). -- **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type - (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks - handed to a library (`executor.submit(task)`, `list.forEach(fn)`) are reached from their definer (`[defines]`) but never - from the library that invokes them; calls the framework makes (HTTP dispatch, JUnit, `main`) have no edge — the callee - is an entry point; reflection / string dispatch / event buses / config-wired beans are invisible; overloads sharing a - name are all resolved together (a signature in the query is stripped); a method overriding a library method is called - by the library, so its upstream ends there; code outside `--src` or in another language is not in the graph; a - by-name or written match can be a same-named other thing. A chain says control can reach B from A through these - calls — nothing about the values that travel it. -- Both directions are tried; the reverse is labelled. -- `axiomcode path --selftest ` replays the engine's own expected edges through the tool and separates engine gaps - from tool losses; run it after touching `dl/path.dl` or the exporter. Needs `souffle` on PATH. - -`scripts/` holds `axiomcode` (the entry) and what it dispatches to: `axiomcode-build` (the pipeline), `axiomcode-index`, `axiomcode-graph`, `viewer.html`, `axiomcode-path` with `dl/path.dl`, `axiomcode-impact` with `dl/impact.dl` (the path tool's resolver and edge facts, its own rules and fact export), `axiomcode-changed` (an edit → the declarations it touched, with the kind of change). diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md deleted file mode 100644 index 09039f8f..00000000 --- a/skills/axiomcode/reference/schema.md +++ /dev/null @@ -1,106 +0,0 @@ -# schema — which table holds X, per language - -Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact -lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded -at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. - -## Which graph - -| | | -|---|---| -| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | -| every other language | `.axiomcode/lang//out/graph.sqlite` | -| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | -| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | - -Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` -holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other -tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. - -## Fact by language - -| fact | Python | Java | C# | -|---|---|---|---| -| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | -| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | -| 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 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` | -| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | -| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | -| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | - -`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, -`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty -in Python: its dispatch set is `dispatch_candidates` (basis `mro`). - -## The verb for each fact - -| fact | verb | -|---|---| -| 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 .` (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"'` | -| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | -| entry points by reason | no verb: SQL below | - -## Queries - -```sh -G=.axiomcode/out/graph.sqlite -# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) -sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d - LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" -# [all] entry points by reason, then one reason's methods -sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" -sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e - JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" -# [all] resolved ancestors (Java: library ones too, provenance 'external') -sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id - JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" -# [python] library bases, full text as written -sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" -# [csharp] library bases: the owner is the innermost type whose span holds the base-list line -sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file - AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner - FROM type_refs r WHERE r.context = 'BASE_LIST'" -# [java] field writers -sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a - JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id - WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" -# [csharp] property writers (property_read for readers) -sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e - JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id - JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" -# [all] tiers in this graph; [python] what the unresolved sites are waiting on -sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" -sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" -``` - -## Traps - -- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, - `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with - `LIKE '%/rel/path.java'`. -- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java - base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below - `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built - before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on - its `class Meta:` line. -- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in - `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. - JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as - `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); - an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). -- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` - names them. diff --git a/tests/directive.py b/tests/directive.py index 7c572f16..76576227 100644 --- a/tests/directive.py +++ b/tests/directive.py @@ -74,7 +74,7 @@ def check(why, cond, detail=''): rc, out, _ = fire(repo, 'Grep', {'pattern': r'doWork\('}) first = ctx(out) check('a search for a declared method is told that declaration, where it is, and the impact call for it', - rc == 0 and first and 'Worker.doWork' in first and 'src/Worker.java:7' in first and 'axiomcode_impact' in first + rc == 0 and first and 'Worker.doWork' in first and 'src/Worker.java:7' in first and 'impact(name="src/Worker.java:7")' in first and 'never spell the name' in first, f'out={out[:300]}') rc, out, _ = fire(repo, 'Grep', {'pattern': 'Worker'}) @@ -133,7 +133,7 @@ def check(why, cond, detail=''): check('the declaration inside the searched path is the one named, not the first in the repository', rc == 0 and ctx(out) and 'lib/view.py:5' in ctx(out) and 'src/' not in ctx(out), f'out={out[:200]}') - rc, out, _ = fire(repo, 'mcp__plugin_axiomcode_axiomcode__axiomcode_impact', {'targets': ['Worker.doWork']}, session='s4') + rc, out, _ = fire(repo, 'mcp__plugin_axiomcode_axiomcode__impact', {'name': 'Worker.doWork'}, session='s4') rc2, out2, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s4') check('an agent that already called the graph through MCP is not told about it afterwards', rc == 0 and out == '' and rc2 == 0 and out2 == '', f'out={out2[:120]}') diff --git a/tests/freshness.py b/tests/freshness.py index d2c7d82d..c106676f 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -813,33 +813,22 @@ def mcp_checks(): m = importlib.util.module_from_spec(spec) import io, contextlib with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) - check("mcp: context, path and impact take fresh", all('fresh' in m.PARAMS.get(t, []) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')), - {t: m.PARAMS.get(t) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')}) + # THE SMALL SURFACE takes no options: freshness is the dispatcher's own (a query waits briefly, or answers from the + # last graph and says so), so no tool takes fresh or refresh, and none passes --fresh or --no-refresh + tools = ('find', 'impact', 'path', 'tests') + check("mcp: no tool takes fresh or refresh", not any(p in m.PARAMS.get(t, []) for t in tools for p in ('fresh', 'refresh')) + and all(t in m.PARAMS for t in tools), {t: m.PARAMS.get(t) for t in tools}) seen = [] m.run = lambda args, *a, **k: seen.append(args) or '' - fn = lambda name: getattr(m, name) - try: - fn('axiomcode_impact')(['X'], repo='.', fresh=True); fn('axiomcode_path')('A', 'B', fresh=True); fn('axiomcode_context')('t', fresh=True) - fn('axiomcode_impact')(['X'], repo='.') - except TypeError as e: - seen.append(str(e)) - check("mcp: fresh=true passes --fresh to the CLI, and only when asked", - len(seen) == 4 and all('--fresh' in s for s in seen[:3]) and '--fresh' not in seen[3], seen) + m.find('t'); m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() + check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 5 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) + check("mcp: fresh=true is refused as an unknown argument, not dropped", + 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), + m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) check("mcp: an answer's --fresh is written as the parameter", 'fresh=True' in m.mcp_words('ask again with --fresh to wait'), m.mcp_words('ask again with --fresh to wait')) - tools = ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact', 'axiomcode_changed', 'axiomcode_test_impact', 'axiomcode_graph') - check("mcp: every query tool takes refresh", all('refresh' in m.PARAMS.get(t, []) for t in tools), {t: m.PARAMS.get(t) for t in tools}) - seen.clear() - fn('axiomcode_impact')(['X'], refresh=False); fn('axiomcode_path')('A', 'B', refresh=False); fn('axiomcode_context')('t', refresh=False) - fn('axiomcode_changed')(refresh=False); fn('axiomcode_test_impact')(refresh=False); fn('axiomcode_graph')(refresh=False) - fn('axiomcode_impact')(['X']); fn('axiomcode_changed')(); fn('axiomcode_graph')() - check("mcp: refresh=false passes --no-refresh to the CLI, and only when asked", - len(seen) == 9 and all('--no-refresh' in s for s in seen[:6]) and not any('--no-refresh' in s for s in seen[6:]), seen) w = m.mcp_words('pass --no-refresh (MCP refresh=false) to query without rebuilding') check("mcp: an answer's --no-refresh is written as refresh=False", 'refresh=False' in w and '--no-refresh' not in w, w) - check("mcp: the CLI's no_refresh is refused, naming refresh", 'refresh' in (m.unknown_arguments('axiomcode_impact', {'no_refresh': True}) or ''), - m.unknown_arguments('axiomcode_impact', {'no_refresh': True})) - if __name__ == '__main__': prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); read_only_checks(); cap_checks(); lock_checks(); named_checks(); mcp_checks() diff --git a/tests/front_door.py b/tests/front_door.py new file mode 100644 index 00000000..73f2d034 --- /dev/null +++ b/tests/front_door.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""tests/front_door.py — the four questions answer as numbered places with their code, at the front door only. + +The product's surface is `find`, `impact`, `path` and `tests` (plus `index`), each answered as a numbered list of places, +every place with the code of the function it sits in, in a fenced block. That shape is given at the front doors — the +installed command (bin/axiomcode sets AXIOMCODE_FRONT) and the MCP server (AXIOMCODE_SURFACE=mcp) — when no flag is +passed. Everything that calls the dispatcher directly (the hooks, the case suite, loops) or passes a flag gets the verb's +own answer, unchanged. + + a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): find, impact and + path answer with numbered places and a fenced code block; after an edit, impact with no name starts with + `your edits:`, and tests lists the test with its code and ends with a `run:` line. + b. the MCP server lists exactly find, impact, path and tests, each with at most two parameters, and a call to one + answers in the same shape. + c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no + fenced block — for the same question. + + python3 tests/front_door.py indexes one small Python repository, so it needs the engine +""" +import json, os, re, shutil, subprocess, sys, tempfile, threading + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CLI = os.path.join(ROOT, 'bin', 'axiomcode') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') +FILES = { + 'shop/__init__.py': '', + 'shop/rates.py': 'def vat_rate():\n return 0.2\n', + 'shop/pricing.py': ('from shop.rates import vat_rate\n\n\n' + 'def total(prices):\n net = sum(prices)\n return net * (1 + vat_rate())\n\n\n' + 'def invoice(prices):\n return {"total": total(prices), "net": sum(prices)}\n'), + 'tests/__init__.py': '', + 'tests/test_pricing.py': ('from shop.pricing import invoice\n\n\n' + 'def test_invoice_total():\n assert abs(invoice([10])["total"] - 12) < 1e-9\n'), +} +# the caller's own settings must not choose the engine or the shape +ENV = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_ENGINE', 'AXIOMCODE_RAW', 'AXIOMCODE_FRONT', 'AXIOMCODE_SURFACE')} +ENV['AXIOMCODE_REFRESH_INTERVAL'] = '0' +PLACE = re.compile(r'^\d+\. \S+:\d+', re.M) +FENCE = re.compile(r'^\s*```python\s*$', re.M) + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: fails.append(why) + + +def cli(repo, *args, env=None): + r = subprocess.run(['bash', CLI, *args], cwd=repo, capture_output=True, text=True, timeout=600, env=env or ENV) + return r.returncode, r.stdout, r.stderr + + +def places(out): + return bool(PLACE.search(out)) and bool(FENCE.search(out)) and '→' in out + + +def mcp(repo, calls): + """tools/list, then each (name, arguments) as tools/call, on the SDK-free server started in repo""" + frames = [{'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', + 'params': {'protocolVersion': '2025-06-18', 'capabilities': {}, 'clientInfo': {'name': 'tests', 'version': '0'}}}, + {'jsonrpc': '2.0', 'method': 'notifications/initialized'}, + {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}] + frames += [{'jsonrpc': '2.0', 'id': 3 + i, 'method': 'tools/call', 'params': {'name': n, 'arguments': a}} + for i, (n, a) in enumerate(calls)] + p = subprocess.Popen([sys.executable, '-S', SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, cwd=repo, text=True, env=ENV) + timer = threading.Timer(600, p.kill); timer.start() + got = {} + try: + for f in frames: + p.stdin.write(json.dumps(f) + '\n'); p.stdin.flush() + if 'id' not in f: continue + while f['id'] not in got: + line = p.stdout.readline() + if not line: return got + try: m = json.loads(line) + except ValueError: continue + if 'id' in m: got[m['id']] = m.get('result') or {} + return got + finally: + timer.cancel(); p.stdin.close(); p.wait() + + +def main(): + work = tempfile.mkdtemp(prefix='ax-front-door-') + try: + repo = os.path.join(work, 'shop') + for rel, text in FILES.items(): + os.makedirs(os.path.dirname(os.path.join(repo, rel)), exist_ok=True) + with open(os.path.join(repo, rel), 'w') as f: f.write(text) + git = lambda *a: subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', *a], cwd=repo, capture_output=True, text=True) + git('init', '-q'); git('add', '-A'); git('commit', '-qm', 'init') + rc, out, err = cli(repo, 'index', '--lang', 'python') + check('the repository indexes', rc == 0 and os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')), (out + err)[-400:]) + if fails: return 1 + + # ── a. the installed command, no flags ─────────────────────────────────────────────────────────────────── + rc, out, err = cli(repo, 'find', 'how is the invoice total computed') + check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) + rc, out, err = cli(repo, 'impact', 'vat_rate') + check('impact : its caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out + and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) + check('impact : the test that reaches it is one of the places', 'tests/test_pricing.py' in out, out[:800]) + rc, out, err = cli(repo, 'path', 'invoice', 'vat_rate') + check('path: every hop a numbered place with the code at the call', rc == 0 and places(out) + and 'shop/pricing.py:10' in out and 'shop/pricing.py:6' in out, out[:600] + err[-300:]) + + with open(os.path.join(repo, 'shop', 'rates.py'), 'w') as f: f.write('def vat_rate():\n return 0.25\n') + rc, out, err = cli(repo, 'impact') + first = out.lstrip().split('\n', 1)[0] + check('impact with no name: the answer starts with "your edits:" and names the edited declaration', + rc == 0 and first.startswith('your edits:') and 'vat_rate' in first, out[:600] + err[-300:]) + check('impact with no name: then what the edit reaches, as places with their code', places(out) and 'shop/pricing.py:6' in out, out[:600]) + rc, out, err = cli(repo, 'tests') + last = [l for l in out.splitlines() if l.strip()][-1:] or [''] + check('tests: the reached test as a numbered place with its code', rc == 0 and places(out) and 'tests/test_pricing.py' in out, out[:600] + err[-300:]) + check('tests: the answer ends with the "run:" line', last[0].startswith('run:') and 'test_pricing' in last[0], last) + + # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── + got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) + tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} + check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) + text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) + check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) + check('MCP impact answers as numbered places with their code', places(text(4)) and 'shop/pricing.py:6' in text(4), text(4)[:600]) + + # ── c. controls: the same question anywhere else gets the verb's own answer ────────────────────────────────── + r = subprocess.run(['bash', AX, 'impact', 'vat_rate', repo], cwd=repo, capture_output=True, text=True, timeout=600, env=ENV) + check('CONTROL: the dispatcher run directly gives the old answer, no fenced block', + r.returncode == 0 and '```' not in r.stdout and 'reads or uses it' in r.stdout, r.stdout[:600]) + rc, out, err = cli(repo, 'impact', 'vat_rate', '--json') + try: doc = json.loads(out) + except ValueError: doc = None + check('CONTROL: bin/axiomcode with --json gives the old answer, the JSON document, no fenced block', + isinstance(doc, dict) and '```' not in out, out[:400]) + rc, out, err = cli(repo, 'impact', 'vat_rate', env=dict(ENV, AXIOMCODE_RAW='1')) + check('CONTROL: AXIOMCODE_RAW=1 at the installed command gives the old answer, no fenced block', + rc == 0 and '```' not in out and 'reads or uses it' in out, out[:600]) + rc, out, err = cli(repo, 'tests', '--why') + check('CONTROL: tests with a flag gives the old answer, no fenced block', '```' not in out and bool(out.strip()), out[:600]) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/graph_verb.py b/tests/graph_verb.py index 2f680ffa..594cf387 100644 --- a/tests/graph_verb.py +++ b/tests/graph_verb.py @@ -173,8 +173,8 @@ def check(ok, why, detail=''): h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) - doc = open(MCP).read(); i = doc.index('def axiomcode_graph('); doc = doc[i:i + 1200] - check('it was indexed with' in doc and 'absolute path' in doc, 'the MCP axiomcode_graph description says the same', doc) + # graph is internal: not an MCP tool (tests/surfaces.py holds the list of public verbs) + check('def graph(' not in open(MCP).read(), 'graph is not offered as an MCP tool', '') finally: shutil.rmtree(work, ignore_errors=True) print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index a771dd73..a52833a3 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -121,13 +121,13 @@ def write(base, files): # it speaks once per session (stamped in the temp directory), so each check gets a session no earlier run used sid = lambda s: f'{s}-{os.getpid()}-{int(time.time() * 1000)}' d = fire(ws, sid('d1'), 'Grep', {'pattern': 'findById', 'path': app}, hook='direct.py', event='PreToolUse') - check('the directive speaks before a Grep by absolute path from a directory with no graph', 'axiomcode_impact' in d and 'OrderStore.java' in d, d) + check('the directive speaks before a Grep by absolute path from a directory with no graph', 'impact(name=' in d and 'OrderStore.java' in d, d) check('control: the directive is silent before a Grep of a tree with no graph', fire(ws, sid('d2'), 'Grep', {'pattern': 'findById', 'path': plain}, hook='direct.py', event='PreToolUse') == '') check('the directive is silent on a shell grep of a file that is not source', fire(app, sid('d3'), 'Bash', {'command': 'grep -n findById notes.md'}, hook='direct.py', event='PreToolUse') == '') d = fire(app, sid('d4'), 'Bash', {'command': f'grep -n findById {os.path.relpath(store, app)}'}, hook='direct.py', event='PreToolUse') - check('control: the directive speaks on a shell grep of a source file for a declared method', 'axiomcode_impact' in d, d) + check('control: the directive speaks on a shell grep of a source file for a declared method', 'impact(name=' in d, d) # ── orientation ────────────────────────────────────────────────────────────────────────────────────── for i in range(30): diff --git a/tests/latency.py b/tests/latency.py index 99f2abdb..ad406498 100644 --- a/tests/latency.py +++ b/tests/latency.py @@ -32,7 +32,7 @@ python3 tests/latency.py [--no-engine] """ -import builtins, importlib.util, json, os, shutil, subprocess, sys, tempfile, time +import builtins, importlib.util, json, os, re, shutil, subprocess, sys, tempfile, time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') @@ -154,8 +154,10 @@ def verbs_checks(): front = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), '--verbs'], capture_output=True, text=True).stdout.split() r = subprocess.run(['bash', os.path.join(ROOT, 'bin', 'axiomcode'), 'no-such-verb'], capture_output=True, text=True) listed = next((l.split(':', 1)[1].split() for l in r.stderr.splitlines() if l.strip().startswith('ask:')), []) - check("verbs: bin/axiomcode knows exactly the verbs the frontend dispatches (less its `tests` alias)", - listed == [v for v in front if v != 'tests'], (listed, front)) + public = re.findall(r'^\s*axiomcode ([a-z][a-z-]*)\b', subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), '--help'], + capture_output=True, text=True).stdout, re.M) + check("verbs: bin/axiomcode offers exactly the verbs the frontend's help advertises, and the frontend dispatches each", + sorted(listed) == sorted(public) and bool(public) and set(public) <= set(front), (listed, public, front)) work = tempfile.mkdtemp(prefix='axiomcode-verbs-') try: log = os.path.join(work, 'spawned') diff --git a/tests/manifests.py b/tests/manifests.py index 679f9105..24310091 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -172,11 +172,13 @@ def gemini_path(value): if re.search(r'(? 2): + bad.append(f"{label}: {name} takes {params}, want {TOOLS[name]} (at most two)") content = replies.get(3, {}).get('result', {}).get('content', []) if not any(c.get('type') == 'text' and c.get('text') for c in content): bad.append(f"{label}: tools/call returned no text: {replies.get(3)}") @@ -379,7 +343,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_front_door() if os.name != 'nt': bad += check_install_move(work) bad += check('symlinked axiomcode mcp', [link, 'mcp'], repo) diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index 45c67f3a..c6da2d9b 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """tests/mcp_docs.py: every MCP argument the skill documents is one the tool it names accepts. -SKILL.md and reference/*.md tell an agent which MCP arguments to pass (`full=True`, `limit=N`, `page="all"`, -`range='a..b'`, `files=[…]`). An argument the tool's schema does not take is refused, and the agent that followed the -docs is left with an error and no answer. The docs name the arguments in prose, so this reads them the way an agent -does: in each paragraph, list item or table row that speaks of MCP, every `name=value` belongs to the nearest tool named -before it (`axiomcode_impact`, MCP `impact`, `changed --range …`; a run like "`impact`, `path` and `context`" is one -group, and every tool in it must take the argument). The schemas are the server's own tools/list, from the SDK-free +SKILL.md (and reference/*.md, when there is one) tells an agent which MCP arguments to pass (`find(question="…")`, +`impact(name="…")`, `path(start="…", end="…")`). An argument the tool's schema does not take is refused, and the agent +that followed the docs is left with an error and no answer. The docs name the arguments in prose, so this reads them the +way an agent does: in each paragraph, list item or table row that speaks of a tool, every `name=value` belongs to the +nearest tool named before it (`impact(`, MCP `impact`, `mcp__plugin_axiomcode_axiomcode__impact`; a run like "`impact` +and `path`" is one group, and every tool in it must take the argument). The schemas are the server's own tools/list, from the SDK-free fallback, so what is checked is what a client is offered. Both skill copies are read (plugins/axiomcode/skills/axiomcode and the root skills/axiomcode), and -plugins/axiomcode/AGENTS.md and rules/axiomcode.mdc, which name the same tools. An argument a schema +plugins/axiomcode/AGENTS.md and rules/axiomcode.mdc, which name the same tools, and the block `axiomcode install` writes. An argument a schema takes and the docs never mention is fine. A documented argument with no tool named before it is a failure too: the agent cannot tell which tool takes it. @@ -25,11 +25,13 @@ for p in [os.path.join(d, 'SKILL.md')] + glob.glob(os.path.join(d, 'reference', '*.md'))) + \ [os.path.join(ROOT, 'plugins', 'axiomcode', 'AGENTS.md')] + \ sorted(glob.glob(os.path.join(ROOT, 'plugins', 'axiomcode', 'rules', '*.mdc'))) +INSTALL = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-install') -VERB = r'(index|context|impact|path|changed|test[-_]impact|graph)' -# a tool named: `axiomcode_impact`, bare axiomcode_impact, MCP `impact`, or a command in backticks (`changed --range x`); -# `path:line: …` is an answer's shape, not the tool -MENTION = re.compile(r'`(?:axiomcode[ _])?' + VERB + r'(?:[ \t][^`\n]*)?`|\baxiomcode_' + VERB + r'\b') +VERB = r'(find|impact|path|tests)' +# a tool named: a call `impact(`, the Claude Code name mcp__plugin_axiomcode_axiomcode__impact, MCP `impact`, or a command +# in backticks (`axiomcode impact x`); `path:line: …` is an answer's shape, not the tool +MENTION = re.compile(r'`(?:axiomcode )?' + VERB + r'(?:[ \t][^`\n]*)?`|\bmcp__plugin_axiomcode_axiomcode__' + VERB + r'\b' + r'|(?= 0, text[:200]) check(f'{surface}: {v} keeps its shell form, for a host without the MCP server', s >= 0, text[:200]) if t >= 0 and s >= 0: @@ -58,13 +60,13 @@ def fire(hook, ev): m = re.search(r'^description: >-\n(.*?)\n---', skill, re.S | re.M) check('SKILL.md has a description block', bool(m)) if m: - tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('context', 'impact', 'path')) + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('find', 'impact', 'path', 'tests')) # 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True, timeout=30) check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('context', 'impact', 'path')) +tool_first('install block', r.stdout, ('find', 'impact', 'path', 'tests')) # 3. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: @@ -77,8 +79,8 @@ def fire(hook, ev): rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep', 'tool_input': {'pattern': 'findOrder'}, 'cwd': repo, 'session_id': 'm1'}) check('direct: the first search for a declared name hears the directive', rc == 0 and bool(said), f'rc={rc}') - # only the verbs that answer a search: changed / test-impact are about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path', 'context')) + # only the verbs that answer a search: tests is about an edit, not about what a grep looks for + tool_first('direct', said, ('impact', 'path', 'find')) # 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: @@ -95,15 +97,15 @@ def fire(hook, ev): rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2', 'prompt': 'How does Consumer.go work, step by step?'}) check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) - tool_first('orient (how)', said, ('context',)) + tool_first('orient (how)', said, ('find',)) # 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() -i = src.find("the axiomcode_context tool with in_path=") +i = src.find("find(question=\"\") (mcp__plugin_axiomcode_axiomcode__find) ranks") check('orient (scope refused): its hint is still in the source', i >= 0) if i >= 0: - tool_first('orient (scope refused)', src[i:src.find('\n', src.find("')", i))], ('context',)) + tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode find', i))], ('find',)) print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') sys.exit(1 if fails else 0) diff --git a/tests/repo_arg.py b/tests/repo_arg.py index d47a84ce..3a18427b 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -65,30 +65,20 @@ def no_graph(where, what): if r.returncode == 0 or missing not in r.stderr: bad.append(f"{verb} {args}: not refused (exit {r.returncode}): {r.stderr.strip()[:200]!r}") no_graph(cwd, verb) - # 3. the MCP tools: a missing repo= is refused before anything runs; an existing one, and the default, pass through + # 3. the MCP tools take no repository: each asks about the session's own directory, and a repo= is refused as an + # unknown argument before anything runs sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp')) import server seen = [] real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') try: - calls = {'axiomcode_index': lambda r: server.axiomcode_index(repo=r), - 'axiomcode_context': lambda r: server.axiomcode_context('how', repo=r), - 'axiomcode_path': lambda r: server.axiomcode_path('a', 'b', repo=r), - 'axiomcode_impact': lambda r: server.axiomcode_impact(['foo'], repo=r), - 'axiomcode_changed': lambda r: server.axiomcode_changed(repo=r), - 'axiomcode_test_impact': lambda r: server.axiomcode_test_impact(repo=r), - 'axiomcode_graph': lambda r: server.axiomcode_graph(repo=r)} + calls = {'find': lambda: server.find('how'), 'path': lambda: server.path('a', 'b'), + 'impact': lambda: server.impact('foo'), 'tests': lambda: server.tests()} for name, call in calls.items(): - seen.clear() - try: - call(missing); bad.append(f"{name}(repo=): not refused") - except Exception as e: - if missing not in str(e): bad.append(f"{name}(repo=): the error does not name the path: {e}") - if seen: bad.append(f"{name}(repo=): ran {seen[0]} anyway") - seen.clear(); call(repo) - if not seen or repo not in seen[0]: bad.append(f"{name}(repo=): did not run with it: {seen}") - seen.clear(); call('.') - if not seen: bad.append(f"{name}(repo='.'): did not run") + seen.clear(); call() + if not seen or seen[0][-1] != os.getcwd(): bad.append(f"{name}(): did not ask about the working directory: {seen}") + if not server.unknown_arguments(name, {'repo': repo}): + bad.append(f"{name}(repo=…): not refused") finally: server.run = real diff --git a/tests/surfaces.py b/tests/surfaces.py index 30234cc9..ecc8e356 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -1,100 +1,159 @@ #!/usr/bin/env python3 -"""tests/surfaces.py — every verb the dispatcher dispatches is documented on every caller-facing surface. +"""tests/surfaces.py — the public verbs are on every caller-facing surface, and nothing else is. -The defect this exists for (#1034): `context` and `test-impact` both worked, both documented themselves -properly under their own `--help`, and appeared on NONE of the surfaces a caller actually looks at. The -cause was three separate hand-maintained lists, none derived from the `case` statement that dispatches. -`axiomcode --help` is now derived from the dispatcher's own comment block, so it cannot drift; SKILL.md -and the MCP server still cannot be, and this is what says so out loud when one of them falls behind. +The product offers a small surface: `index` to set up, then four questions — `find`, `impact`, `path`, `tests` — +answered as numbered places with the code of the function each sits in. Every public verb must be: -The fourth surface is `bin/axiomcode` — the command an install actually puts on $PATH (#1107). Every -query verb was implemented, shipped and unreachable from it, and `axiomcode path A B` was silently -taken for a build of a directory called `path`. That surface is checked BY RUNNING IT, not by reading -it: the verb must dispatch, not merely be mentioned. + · in `axiomcode --help` (the dispatcher's own comment block) and in `bin/axiomcode --help`, the command an install + puts on $PATH (#1107). That surface is checked BY RUNNING IT: the installed command and the frontend are given the + same argv and must produce the same bytes, so a verb listed and not dispatched is caught; + · a section of SKILL.md; + · an MCP tool of the same name (index excepted: the first query builds the graph). - python3 tests/surfaces.py +Every other verb the dispatcher still dispatches (the hooks, the suites and scripts call them with their flags) is +INTERNAL, with the reason written down, and must appear on none of those surfaces. A verb added to the dispatch table +that is in neither list fails, so exposing one is a decision rather than an accident (#1034). + +The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, the block `axiomcode install` writes, and +the README's CLI section) name no old MCP tool (`axiomcode_context` …) and no flag other than index's. -A verb that is deliberately not exposed on a surface goes in EXEMPT with the reason, so the exemption is -a written decision rather than a silent gap. + python3 tests/surfaces.py """ import os, re, subprocess, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') -AX = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts', 'axiomcode') +SCRIPTS = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts') +AX = os.path.join(SCRIPTS, 'axiomcode') SKILL = os.path.join(PLUG, 'skills', 'axiomcode', 'SKILL.md') MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -# verb -> surfaces it is deliberately absent from, and why -EXEMPT = { - 'install': {'mcp', 'skill_section'}, # writes CLAUDE.md once at setup; not a query an agent issues per turn - 'index': {'skill_section'}, # covered by the Start here table and the four rules, not its own section - 'graph': {'skill_section'}, # produces a page for a human, documented in Reference - 'changed': set(), +PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] +NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} +# dispatched, not advertised: verb -> why +INTERNAL = { + 'build': 'the old name of index', + 'context': 'what find runs; its flags (--in, --source, --from, …) serve the hooks and the suites', + 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', + 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', + 'graph': 'draws the graph as a page for a person; not one of the four questions', + 'diff': 'compares two graphs of one tree; a tool for checking an engine change', + 'install': 'writes the CLAUDE.md block once, at setup', } +OLD_TOOLS = re.compile(r'\baxiomcode_(context|impact|path|changed|test_impact|graph|index|diff)\b') +INDEX_FLAGS = {'--lang', '--src', '--library'} + + +def dispatched(): + """the verbs of the dispatch table, as the dispatcher itself lists them (`--verbs`)""" + return subprocess.run(['bash', AX, '--verbs'], capture_output=True, text=True).stdout.split() + + +def advertised(help_text): + return re.findall(r'^\s*axiomcode ([a-z][a-z-]*)\b', help_text, re.M) + + +def readme_cli(): + text = open(os.path.join(ROOT, 'README.md'), encoding='utf-8').read() + i = text.index('## CLI commands') + return text[i:text.index('\n## ', i + 1)] -def verbs(): - """the dispatch table is the source of truth: the verbs of `case "$cmd" in`, aliases split out""" - src = open(AX).read() - body = src[src.index('case "$cmd" in'):src.index('\nesac')] + +def docs(): + """(label, text) of every agent-facing text that teaches the surface""" out = [] - for m in re.finditer(r'^\s{2}([a-z][a-z|-]*)\)', body, re.M): - out += [v for v in m.group(1).split('|')] - return [v for v in out if v not in ('build', 'tests')] # aliases of index / test-impact + for p in (SKILL, os.path.join(ROOT, 'skills', 'axiomcode', 'SKILL.md'), os.path.join(PLUG, 'AGENTS.md'), + os.path.join(PLUG, 'rules', 'axiomcode.mdc')): + out.append((os.path.relpath(p, ROOT), open(p, encoding='utf-8').read())) + r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True) + out.append(('the install block', r.stdout)) + out.append(('README.md CLI section', readme_cli())) + return out + + +def doc_findings(label, text): + bad = [] + for m in OLD_TOOLS.finditer(text): + bad.append(f"{label}: names the old MCP tool {m.group(0)}") + for flag in sorted(set(re.findall(r'(?` is NOT the check: it has - # its own branch, and it kept answering while the verb dispatch beneath it was broken — which - # is exactly the shape of #1107. So the installed command and the frontend are given the same - # argv and must produce the same bytes; if bin/axiomcode handles the verb itself (or falls - # through to a build) they differ. + if v not in NO_MCP and v not in tools: + bad.append(f"{v}: no MCP tool {v}") + # RUN IT, THROUGH THE VERB'S OWN CASE: `axiomcode help ` has its own branch and kept answering while the + # dispatch beneath it was broken (#1107), so the installed command and the frontend get the same argv direct = subprocess.run(['bash', AX, v, '--help'], capture_output=True, text=True) viacli = subprocess.run(['bash', CLI, v, '--help'], capture_output=True, text=True) if (viacli.stdout, viacli.stderr) != (direct.stdout, direct.stderr): bad.append(f"{v}: `axiomcode {v}` does not reach the frontend — the installed command answers it itself") if len(direct.stdout.strip()) < 20: bad.append(f"{v}: the frontend prints no usage for it, so the comparison above proves nothing") + for v in INTERNAL: + if v in tools: bad.append(f"{v}: internal, and still an MCP tool") + if re.search(r'^## .*\b%s\b' % re.escape(v), skill, re.M): bad.append(f"{v}: internal, and still a SKILL.md section") + if tools != {v for v in PUBLIC if v not in NO_MCP}: + bad.append(f"the MCP tools are {sorted(tools)}, want {sorted(v for v in PUBLIC if v not in NO_MCP)}") + for label, text in docs(): + bad += doc_findings(label, text) - # a typo must not be taken for a source tree (#1107): `*) cmd=all` used to make it one + # a typo must not be taken for a source tree (#1107), and the verbs it offers are the public ones r = subprocess.run(['bash', CLI, 'impackt'], capture_output=True, text=True) if r.returncode == 0 or 'neither a verb nor a directory' not in r.stderr: bad.append("an unknown verb is not refused — it is still being taken for a build") + offered = next((l.split(':', 1)[1].split() for l in r.stderr.splitlines() if l.strip().startswith('ask:')), []) + if sorted(offered) != sorted(PUBLIC): + bad.append(f"a typo is offered {offered}, want the public verbs {PUBLIC}") # THE FRONTMATTER IS YAML, AND NOT EVERY READER IS LENIENT. A plain scalar may not contain `: ` or ` #`: # strict parsers read the first as a nested mapping and the second as a comment, so the description - # that decides when the skill fires fails to load ("mapping values are not allowed here") wherever the - # file is parsed properly, though the harness that loads it accepted it. Checked without PyYAML, which - # a plain checkout does not have: a value that is quoted or a block scalar (`>`, `|`) is left alone. + # that decides when the skill fires fails to load wherever the file is parsed properly. Checked without PyYAML: + # a value that is quoted or a block scalar (`>`, `|`) is left alone. fm = skill.split('---', 2)[1] if skill.startswith('---') else '' for line in fm.splitlines(): m = re.match(r'^([A-Za-z_-]+):[ \t]+(.*)$', line) if m and not m.group(2).startswith(('"', "'", '>', '|')) and re.search(r': | #', m.group(2)): bad.append(f"SKILL.md frontmatter: `{m.group(1)}` is a plain YAML scalar containing ': ' or ' #' — " f"quote it or make it a block scalar (`{m.group(1)}: >-`)") - print(f"dispatched verbs: {', '.join(vs)} (surfaces: bin/axiomcode --help, its dispatch, skill --help, SKILL.md, MCP)") + print(f"dispatched verbs: {', '.join(vs)}; public: {', '.join(PUBLIC)}; MCP tools: {', '.join(sorted(tools))}") for b in bad: print("FAIL " + b) if bad: - print(f"\n{len(bad)} surface(s) behind the dispatcher — document the verb, or add it to EXEMPT with the reason.") + print(f"\n{len(bad)} failure(s)") return 1 - print(f"ok — {len(vs)} verbs, every surface present (exemptions: " + - ", ".join(f"{k}:{'/'.join(sorted(s))}" for k, s in EXEMPT.items() if s) + ")") + print(f"ok — {len(PUBLIC)} public verbs on every surface, {len(INTERNAL)} internal verbs on none") return 0 if __name__ == '__main__': From 5d01fdd9689c31bae642f4f7cff65327bea3d6a6 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:19:27 -0700 Subject: [PATCH 087/258] context: the flow continues through a middleware's next(), names a dispatch point's handlers, and starts at the producer - a registered callback that calls its own parameter (next) continues into what the same function registers after it on the same receiver, in order, with the registration line - a call through a value whose candidates exceed the fan cap names the registered handlers; the fan counts project, non-test candidates only - a question naming a producing verb (publish, send, forward...) that lands on the producing method starts at its caller; a caller joined only by name is one labelled step - a nested function's step names the factory that made it, read from spans Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-context | 212 +++++++++++++++++- .../flow-through-dispatch-points/case.json | 29 +++ .../flow-through-dispatch-points/src/bus.js | 54 +++++ .../src/gateway.js | 60 +++++ tests/cases/typescript/explain-flow/case.json | 16 +- .../typescript/explain-flow/src/middleware.ts | 19 ++ 6 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 tests/cases/javascript/flow-through-dispatch-points/case.json create mode 100644 tests/cases/javascript/flow-through-dispatch-points/src/bus.js create mode 100644 tests/cases/javascript/flow-through-dispatch-points/src/gateway.js create mode 100644 tests/cases/typescript/explain-flow/src/middleware.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 16ad22e8..d7077e55 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -761,7 +761,167 @@ CONTAINER = frozenset('items keys values get pop popitem append extend add updat 'length size isEmpty contains equals hashCode'.split()) -def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): +DISPATCH_NAMED = 6 # the registered handlers a dispatch point names; the rest are counted +HANDOFF = {} # (step, registration line, file) -> the declaration that registered it +ENTRY_NOTE = {} # entry step -> why the flow starts there, when it is not a match of the task's words + + +def param_names(g, x): + """the parameter names of `x`, read from the text of its signature: the first balanced (...) after its name.""" + sy = g.sym.get(x) or {} + if not sy.get('file') or not sy.get('line'): return set() + text = ' '.join(source_lines(g.repo, sy['file'])[sy['line'] - 1:sy['line'] + 3]) + nm = sy.get('name') or '' + k = text.find(nm + '(') if nm and nm + '(' in text else 0 + a = text.find('(', k) + if a < 0: return set() + depth, b = 0, a + for b in range(a, len(text)): + depth += {'(': 1, ')': -1}.get(text[b], 0) + if depth == 0: break + return set(re.findall(r'[A-Za-z_$][\w$]*', re.sub(r'=[^,)]*', '', text[a + 1:b]))) - {'self', 'this'} + + +def handoffs(g, rows, project): + """where a registered callback HANDS ON by calling its own parameter: `function auth(req, res, next) { next(); }` + registered with `router.use(auth)`. The framework runs the chain, so no call edge leaves `next()`; what it runs is + what the same function registers after it on the same receiver with the same method (`router.use('/api', proxy)`), + in the order written. Returns step -> [(registration line, continuation, 'handoff', file)], FLOW_FAN at most. + A callback that calls no parameter (a route handler that sends the response) ends the chain and continues nowhere; + a registration on another receiver in the same function is another chain.""" + regs = [r for r in rows if r['t'] == 'callback_registered' and r['m'] and project(r['m'])] + if not regs: return {} + sites = {r['id']: r for r in g.q("""SELECT DISTINCT s.id, s.caller_id, s.callee_name, s.file_path, s.start_line, s.start_column + FROM call_sites s JOIN call_edges e ON e.call_site_id = s.id + WHERE e.tier = 'callback_registered'""")} + def site_text(s): + ln = source_lines(g.repo, g.site_file(s['file_path'])) + a = s['start_line'] or 0 + if not 0 < a <= len(ln): return '' + return ' '.join([ln[a - 1][max((s['start_column'] or 1) - 1, 0):]] + ln[a:a + 5]) + def receiver(text, name): + m_ = re.match(r'\s*([\w$.\[\]]*?)\s*\??\.\s*' + re.escape(name) + r'\s*\(', text) + return m_.group(1) if m_ else '' + def arg_at(text, y): + """where callback `y` is written among the site's arguments: its own name, or the factory that makes it + (`requireAuth(auth)` makes the middleware). None when it is passed through a variable (`limiter`).""" + p = made_by(g, y) + hits = [m_.start() for nm in {(g.sym.get(y) or {}).get('name'), (g.sym.get(p) or {}).get('name') if p else None} + if nm and not nm.startswith('<') for m_ in [re.search(r'(? [(line, col, arg, callback, file)] + where = {} # callback -> [(chain key, (line, col, arg))] + for r in regs: + s = sites.get(r['sid']) + if not s or not s['callee_name'] or s['callee_name'] in CONTAINER: continue + text = site_text(s) + key = (s['caller_id'], s['callee_name'], receiver(text, s['callee_name'])) + pos = (s['start_line'] or 0, s['start_column'] or 0, arg_at(text, r['m'])) + chain[key].append(pos + (r['m'], g.site_file(s['file_path']))) + where.setdefault(r['m'], []).append((key, pos)) + calls = collections.defaultdict(set) # callback -> the names it calls + ids = list(where) + for k in range(0, len(ids), 500): + part = ids[k:k + 500] + for c, nm in g.q(f"SELECT caller_id, callee_name FROM call_sites WHERE caller_id IN ({','.join('?' * len(part))})", *part): + if nm: calls[c].add(nm) + out = {} + for x, regd in where.items(): + hands = calls.get(x, set()) & param_names(g, x) + if not hands: continue + nxt = [] + for key, (l0, c0, a0) in regd: + # a later registration follows; an argument of the SAME call follows only when both are written there and + # it comes after (`use(auth, limiter)`): one passed through a variable has no place to order it by + nxt += [(l, y, f, key[0]) for l, c, a, y, f in sorted(chain[key], key=lambda t: (t[0], t[1], t[2] or 0)) + if y != x and ((l, c) > (l0, c0) or ((l, c) == (l0, c0) and a0 is not None and (a is None or a > a0)))] + seen, keep_ = set(), [] + for l, y, f, by in nxt: + if y in seen: continue + seen.add(y); keep_.append((l, y, 'handoff', f)) + HANDOFF[(y, l, f)] = by + if keep_: out[x] = keep_[:FLOW_FAN] + return out + + +PRODUCE = frozenset('publish emit send forward produce enqueue broadcast'.split()) + + +def asked_verbs(task): + """the producing verbs the task is written with, any inflection: 'published', 'forwards', 'sending'.""" + out = set() + for w in re.findall(r'[a-z]+', task_text(task).lower()): + for v in PRODUCE: + if w == v or (w.startswith(v[:-1] if v.endswith('e') else v) and len(w) - len(v) <= 3): out.add(v) + return out + + +def producer_first(g, roots, seeds, task, terms): + """a question naming the PRODUCING side ('how are events published and dispatched') matched the bus's own + `publish`, and the flow started inside the bus: the code that publishes was never shown. When an entry point is + itself named for a verb the task asks with, the flow starts at the project code that calls it from outside its own + type or module — the caller matching most of the task's words — and runs through it. A root the task does not ask + that way stays. + An injected bus is often an untyped receiver (`this.bus.publish(...)`), so no edge reaches the method from the code + that publishes. When no resolved caller qualifies and the name is declared in few places (GAP_DECLS), a caller + that writes the name on an unresolved site is taken, labelled `by name`, and the method stays the next root.""" + verbs = asked_verbs(task) + if not verbs: return roots + tset = set(terms) + usable = lambda c, sy: (c.get('method_id') and not c.get('is_test') and not (c.get('file') or '').startswith('<') + and not (c.get('name') or '<').startswith('<') + and (c.get('qualified_name') or '').rsplit('.', 1)[0] != (sy.get('qualified_name') or '').rsplit('.', 1)[0]) + score = lambda c: len(tset & set(subtokens((c.get('display') or '') + ' ' + (c.get('file') or '')))) + for s in seeds: + sy = g.sym.get(s) or {} + nm = sy.get('name') or '' + toks = subtokens(nm) + if not sy.get('method_id') or not toks or toks[0] not in verbs: continue + cands = [] + for r in g.q("SELECT DISTINCT caller_id c, tier t FROM call_edges WHERE callee_method_id = ?", s): + c = g.sym.get(r['c']) or {} + if usable(c, sy) and ax_edges.direct_cert(r['t']) == 'resolved': + cands.append((-score(c), c.get('file') or '', c.get('line') or 0, r['c'], None)) + if not any(c_[0] for c_ in cands): + decls = sum(1 for x in g.sym.values() if x.get('name') == nm and x.get('method_id') and not x.get('is_test') + and not (x.get('file') or '').startswith('<')) + if decls <= GAP_DECLS: + for r in g.q("""SELECT s.caller_id c, min(s.start_line) l FROM call_sites s + WHERE s.callee_name = ? AND NOT EXISTS (SELECT 1 FROM call_edges e WHERE e.call_site_id = s.id + AND e.callee_method_id IS NOT NULL) + GROUP BY s.caller_id""", nm): + c = g.sym.get(r['c']) or {} + if usable(c, sy): cands.append((-score(c), c.get('file') or '', c.get('line') or 0, r['c'], r['l'])) + cands = [c_ for c_ in cands if c_[0]] # no caller the task's words point at: which producer is a guess + if not cands: continue + _h, _f, _l, p, byname = min(cands, key=lambda c_: (c_[4] is not None,) + c_[:3]) + if byname is None: + return [p] + [x for x in roots if x != s and x != p][:FLOW_ROOTS - 1] + ENTRY_NOTE[p] = f"calls {nm}() by name at L{byname} (receiver not typed): the producer of {g.disp(s)}, which follows" + return [p, s] + return roots + + +def made_by(g, x): + """the function a nested function is written inside — the factory that makes a closure (`requireAuth` returning + the middleware) — or None for a top-level function or a class member. + Read from the spans, which every language records the same way (a TypeScript qualified name is flat, + `src/gateway#inner`): the narrowest named function whose span holds this one.""" + sy = g.sym.get(x) or {} + f, a, b = sy.get('file'), sy.get('line'), sy.get('end_line') or sy.get('line') + if not sy.get('method_id') or not f or not a or f.startswith('<'): return None + if not hasattr(g, '_fn_spans'): + g._fn_spans = collections.defaultdict(list) + for i, s in g.sym.items(): + if s.get('method_id') and s.get('line') and not (s.get('name') or '<').startswith('<'): + g._fn_spans[s.get('file')].append((s['line'], s.get('end_line') or s['line'], i)) + best = None + for c, d, i in g._fn_spans.get(f, ()): + if i != x and c <= a and b <= d and (c, d) != (a, b) and (best is None or d - c < best[0]): best = (d - c, i) + return best[1] if best else None + + +def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS, shallow=frozenset()): """The call flow from `roots`, in the order the calls are written. Nothing here is ranked or scored. Where a flow STARTS is the reader's decision (--from) or the entry points this verb already chose; which of a @@ -779,11 +939,15 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): gaps = collections.defaultdict(list) # caller -> [(line, name)] fan = collections.Counter() rows = g.q("""SELECT e.call_site_id sid, e.caller_id c, e.callee_method_id m, e.tier t, coalesce(e.callee_label, s.callee_name) n, - s.start_line l, s.file_path f + s.start_line l, s.file_path f, s.callee_name cn FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id""") + project = lambda m: m in g.sym and not (g.sym[m].get('file') or '').startswith('<') and not g.sym[m].get('is_test') + # the fan of a site counts what a flow could show: a test's handler registered on the same bus is one more candidate + # of `handler(envelope)`, and six of them hid the three the project registers for r in rows: - if r['m']: fan[r['sid']] += 1 + if r['m'] and project(r['m']): fan[r['sid']] += 1 exits = collections.defaultdict(list) # caller -> [(line, library call name)] that hand the flow to a library + wide = collections.defaultdict(dict) # caller -> {(line, name written): [candidates]} too many to be steps for r in rows: lib = (r['t'] or '').startswith('boundary_lib') or (r['m'] in g.sym and (g.sym[r['m']].get('file') or '') == '') if lib: @@ -791,9 +955,14 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): if nm and DISPATCH(nm): exits[r['c']].append((r['l'] or 0, nm, 'library call, target not followed')) continue if r['m'] and r['m'] in g.sym and r['m'] != r['c']: - sy = g.sym[r['m']] - if (sy.get('file') or '').startswith('<') or sy.get('is_test'): continue - if ax_edges.direct_cert(r['t']) != 'resolved' and fan[r['sid']] > FLOW_FAN: continue + if not project(r['m']): continue + if ax_edges.direct_cert(r['t']) != 'resolved' and fan[r['sid']] > FLOW_FAN: + # a call through a VALUE (`handler(envelope)`, `table[key].apply(...)`) whose candidates are named + # otherwise is a dispatch point: what runs there is what was registered, so it is named on the step. + # `pair[0]` matched to every `__getitem__` calls candidates of its own name, and stays unlisted. + if r['cn'] and r['cn'] != g.sym[r['m']].get('name') and r['t'] != 'callback_registered': + wide[r['c']].setdefault((r['l'] or 0, r['cn']), []).append(r['m']) + continue out[r['c']].append((r['l'] or 0, r['m'], r['t'], r['f'])) elif not r['m'] and r['t'] == 'ambiguous_unknown' and r['n']: gaps[r['c']].append((r['l'] or 0, r['n'])) @@ -809,7 +978,9 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): for b, ms in held.items(): if len(ms) <= FLOW_FAN: out[b] += [(0, m, 'value', None) for m in ms] for x in out: out[x].sort(key=lambda r: r[0]) - declared = collections.Counter((sy.get('name') or '') for sy in g.sym.values() + for x, hs in handoffs(g, rows, project).items(): + out[x] += [h for h in hs if h[1] not in {y for _l, y, _t, _f in out[x]}] + declared =collections.Counter((sy.get('name') or '') for sy in g.sym.values() if sy.get('method_id') and not (sy.get('file') or '').startswith('<') and not sy.get('is_test')) for x in list(gaps): # a call the graph could not resolve to a name declared NOWHERE here is a library the index does not hold (a @@ -824,6 +995,7 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): for _d in range(depth_cap): nxt = [] for x in frontier: + if x in shallow: continue for _l, y, _t, _f in out.get(x, ()): if y not in keep and len(keep) < max_steps: keep.add(y); nxt.append(y) @@ -837,7 +1009,7 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): steps.append((depth, x, via, seen[x])); return seen[x] = sum(1 for st in steps if st[3] is None) + 1 # the printed step number steps.append((depth, x, via, None)) - if depth >= depth_cap: return + if depth >= depth_cap or x in shallow: return done = set() for l, y, t, f in out.get(x, ()): if y in done or y not in keep: continue @@ -852,6 +1024,12 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): marks = {} for x in {x for _d, x, _v, again in steps if again is None}: m = [f"leaves the graph: {nm}() L{l} ({why})" for l, nm, why in sorted(set(exits.get(x, ())))[:2]] + for (l, nm), ms in sorted(wide.get(x, {}).items()): + ms = sorted(set(ms), key=lambda y: (g.sym[y].get('file') or '', g.sym[y].get('line') or 0)) + names = [g.disp(y) + (f" ({g.loc(y)})" if (g.sym[y].get('name') or '<').startswith('<') else '') for y in ms] + m.append(f"dispatch point: {nm}() L{l} runs one of {len(ms)} registered: " + ', '.join(names[:DISPATCH_NAMED]) + + (f" … +{len(ms) - DISPATCH_NAMED} more" if len(ms) > DISPATCH_NAMED else '') + + " — `--from ` follows it") if not any(y in keep for _l, y, _t, _f in out.get(x, ())) and not gaps.get(x) and bodiless(g, x): bound = text_bindings(g, [x], want_ext=('.xml', '.sql'))[:1] # the best-proven one: a mapper of this type m.append("no body in the graph (interface/abstract): the flow cannot continue from here" @@ -916,8 +1094,14 @@ def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): head, where = f"{pad}{arrow} {g.disp(x)}", (f"called at L{l}: {src}" if src else f"called at L{l}") if t == 'value': where = "held by the step above: assigned or passed where it is expected, so a call to it runs this" + if t == 'handoff': + by = HANDOFF.get((x, l, f)) + where = (f"runs next: registered after it by {g.disp(by) if by else '?'} at {g.site_file(f)}:{l}" + + (f": {src}" if src else '') + " — the step above hands on by calling its parameter") else: - head, where = g.disp(x), "entry point" + head, where = g.disp(x), "entry point" + (f" · {ENTRY_NOTE[x]}" if x in ENTRY_NOTE else '') + mk_by = made_by(g, x) + if mk_by: where += f" · made by {g.disp(mk_by)}" n += 1 print(f" {n:2} {head:60.60} {g.loc(x)}") print(f" {pad} {where}") @@ -1170,10 +1354,18 @@ def main(argv): cands += [x for x in type_members(g, [s_ for s_, _t, _sc in seeds], scored) if x not in cands] asked = {s_ for s_, _why in named} roots_ = sorted(cands, key=lambda x: x not in asked and bodiless(g, x))[:FLOW_ROOTS] - roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS) + roots_ = producer_first(g, roots_, [s_ for s_, _t, _sc in seeds], task, terms) + # a producer joined only by name is shown as one step: its code holds the call, and the budget goes to the + # mechanism the task asked about rather than to everything else the producer does + roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS, + shallow=frozenset(ENTRY_NOTE)) RESULT['flow'] = [{'step': k + 1, 'depth': d, 'name': g.disp(x), 'at': g.loc(x), 'called_at_line': (via[0] if via else None), 'certainty': (ax_edges.direct_cert(via[1]) if via else 'entry'), 'repeat_of': again, 'unresolved': [f"{nm} L{l}" for l, nm in gaps.get(x, ())[:4]], + 'made_by': (g.disp(made_by(g, x)) if made_by(g, x) else None), + 'why_here': (ENTRY_NOTE.get(x) if not via else None), + 'registered_by': (g.disp(HANDOFF[(x, via[0], via[2])]) if via and via[1] == 'handoff' + and (x, via[0], via[2]) in HANDOFF else None), 'leaves_graph': (marks.get(x, []) if again is None else [])} for k, (d, x, via, again) in enumerate(steps)] if not print_flow(g, roots, steps, gaps, bool(starts), show_source, marks): diff --git a/tests/cases/javascript/flow-through-dispatch-points/case.json b/tests/cases/javascript/flow-through-dispatch-points/case.json new file mode 100644 index 00000000..94f49ba9 --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/case.json @@ -0,0 +1,29 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a middleware registered on a router calls its own `next` parameter; the flow used to stop there with `next()` unresolved. It continues into what the same router registers after it, in order, and names where that was registered and which factory made the continuation", + "run": ["context", "how does the gateway authenticate a request and forward it upstream", "--from", "checkBearer", "--source"], + "want": [" 1 checkBearer ", "⇢ throttleRequest", "⇢ forwardUpstream", "runs next: registered after it by gatewayRouter at src/gateway.js:50: router.use('/docs', createForwarder", "made by createForwarder", "→ relayBody"]}, + {"why": "control: a registration on another receiver in the same function (`audit.use`) is another chain, not a step of this one", + "run": ["context", "how does the gateway authenticate a request and forward it upstream", "--from", "checkBearer", "--source"], + "avoid": ["⇢ recordHit"]}, + {"why": "control: a middleware passed through a variable after another argument of the same call does not loop back to the one written before it; it continues into the later registration", + "run": ["context", "how is a request throttled", "--from", "throttleRequest"], + "want": ["⇢ forwardUpstream"], + "avoid": ["checkBearer"]}, + {"why": "control: a handler that does not call a parameter ends the chain there, so the one registered after it is not a step of it", + "run": ["context", "how does the gateway forward a request upstream", "--from", "forwardUpstream"], + "want": ["→ relayBody"], + "avoid": ["lastResort"]}, + {"why": "control: route handlers registered one after another do not call a parameter, so neither continues into the next", + "run": ["context", "how does the status route render", "--from", "renderStatus"], + "avoid": ["renderVersion"]}, + {"why": "a call through a parameter that the graph resolves to more candidates than a step can hold is a dispatch point: the flow names the registered handlers there instead of dropping the call without a word", + "run": ["context", "how does the bus deliver an envelope", "--from", "TopicBus.deliver"], + "want": ["dispatch point: handler() L22 runs one of 4", "onInvoiceIssued", "onInvoiceOverdue"]}, + {"why": "a question that names the producing side (publish) starts at the code that publishes, not at the bus's own publish method, so the flow reads producer -> publish -> deliver -> dispatch", + "run": ["context", "how are invoice events published on the bus and dispatched to handlers", "--source"], + "want": [" 1 InvoiceService.issue ", "→ TopicBus.publish", "→ TopicBus.deliver"]}, + {"why": "control: a question that does not name the producing side starts where its words land", + "run": ["context", "how does the bus deliver an envelope to a handler"], + "want": ["how it runs —"], + "avoid": [" 1 InvoiceService.issue "]}]} diff --git a/tests/cases/javascript/flow-through-dispatch-points/src/bus.js b/tests/cases/javascript/flow-through-dispatch-points/src/bus.js new file mode 100644 index 00000000..908bc6ff --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/src/bus.js @@ -0,0 +1,54 @@ +export class TopicBus { + constructor() { + this.subscribers = new Map(); + } + + subscribe(topic, handler) { + if (!this.subscribers.has(topic)) this.subscribers.set(topic, []); + this.subscribers.get(topic).push(handler); + } + + publish(topic, payload) { + const envelope = { topic, payload }; + this.deliver(envelope); + return envelope; + } + + deliver(envelope) { + for (const handler of this.subscribers.get(envelope.topic) ?? []) this.runHandler(handler, envelope); + } + + runHandler(handler, envelope) { + handler(envelope); + } +} + +export class InvoiceService { + constructor(bus, store) { + this.bus = bus; + this.store = store; + } + + issue(invoice) { + this.store.save(invoice); + this.bus.publish('invoice.issued', invoice); + } +} + +function onInvoiceIssued(envelope) { return envelope.payload; } +function onInvoicePaid(envelope) { return envelope.payload; } +function onInvoiceVoided(envelope) { return envelope.payload; } +function onInvoiceOverdue(envelope) { return envelope.payload; } + +export function wireInvoiceHandlers(bus) { + bus.subscribe('invoice.issued', onInvoiceIssued); + bus.subscribe('invoice.paid', onInvoicePaid); + bus.subscribe('invoice.voided', onInvoiceVoided); + bus.subscribe('invoice.overdue', onInvoiceOverdue); + return bus; +} + +export function createBilling(store) { + const bus = wireInvoiceHandlers(new TopicBus()); + return new InvoiceService(bus, store); +} diff --git a/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js b/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js new file mode 100644 index 00000000..f8c0395f --- /dev/null +++ b/tests/cases/javascript/flow-through-dispatch-points/src/gateway.js @@ -0,0 +1,60 @@ +import express from 'express'; + +export function requireAuth(tokens) { + return function checkBearer(req, res, next) { + const token = req.get('authorization'); + if (!token) return next(new Error('bearer token required')); + req.user = tokens.verify(token); + next(); + }; +} + +export function createForwarder(target) { + return function forwardUpstream(req, res) { + relayBody(target, req, res); + }; +} + +function relayBody(target, req, res) { + res.send({ target, path: req.path }); +} + +function recordHit(req, res, next) { + req.seen = true; + next(); +} + +function makeThrottle() { + return function throttleRequest(req, res, next) { + next(); + }; +} + +function lastResort(req, res) { + res.status(404).end(); +} + +function renderStatus(req, res) { + res.send('ok'); +} + +function renderVersion(req, res) { + res.send('1'); +} + +export function gatewayRouter(tokens, audit) { + const router = express.Router(); + const throttle = makeThrottle(); + audit.use(recordHit); + router.use(requireAuth(tokens), throttle); + router.use('/docs', createForwarder('http://docs.internal')); + router.use(lastResort); + return router; +} + +export function statusRouter() { + const router = express.Router(); + router.get('/status', renderStatus); + router.get('/version', renderVersion); + return router; +} diff --git a/tests/cases/typescript/explain-flow/case.json b/tests/cases/typescript/explain-flow/case.json index 3964c5bd..f1f9b383 100644 --- a/tests/cases/typescript/explain-flow/case.json +++ b/tests/cases/typescript/explain-flow/case.json @@ -143,6 +143,20 @@ "avoid": [ "lie within 3 hops of the entry points; `--budget N` lists them" ] + }, + { + "why": "a middleware that calls its `next` parameter continues into what the same app registers after it (the TypeScript graph records the registration as the JavaScript one does); the continuation names the factory that made it", + "run": [ + "context", + "how does a site request get its session checked and relayed", + "--from", + "checkSession" + ], + "want": [ + "⇢ relayUpstream", + "runs next: registered after it by mountSite", + "made by createRelay" + ] } ] -} \ No newline at end of file +} diff --git a/tests/cases/typescript/explain-flow/src/middleware.ts b/tests/cases/typescript/explain-flow/src/middleware.ts new file mode 100644 index 00000000..254ca634 --- /dev/null +++ b/tests/cases/typescript/explain-flow/src/middleware.ts @@ -0,0 +1,19 @@ +type Next = (err?: unknown) => void; + +export function requireSession(sessions: { check(token: string): string }) { + return function checkSession(req: any, res: any, next: Next) { + req.user = sessions.check(req.get('cookie')); + next(); + }; +} + +export function createRelay(target: string) { + return function relayUpstream(req: any, res: any) { + res.send(target); + }; +} + +export function mountSite(app: any, sessions: { check(token: string): string }) { + app.use(requireSession(sessions)); + app.use('/site', createRelay('http://site.internal')); +} From b79147e4aed46db11dae6b01fa2c03480be4688d Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 02:39:29 -0700 Subject: [PATCH 088/258] tests/run.py --jobs N runs cases side by side; CI runs the query cases four at a time The query-case step made engine (typescript) a 28-minute job (19 of them the cases, one after another). Each case indexes its own directory and shares nothing but the compiled rules, so the first case runs alone (it compiles and caches them) and the rest run N at a time; each case's output is printed as one block, in case order. TypeScript locally: 175 s -> 41 s with --jobs 6, the same 204/204 checks and the same case order and outcomes. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- tests/run.py | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5987254c..cb6710f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: env: AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache - run: python3 tests/run.py --lang ${{ matrix.lang }} + run: python3 tests/run.py --lang ${{ matrix.lang }} --jobs 4 # the small surface as users and agents get it: find / impact / path / tests through the installed command and # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers diff --git a/tests/run.py b/tests/run.py index a35f564a..863a7c06 100755 --- a/tests/run.py +++ b/tests/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""tests/run.py [ …] [--lang java|python|typescript|javascript] [--keep] [-v] +"""tests/run.py [ …] [--lang java|python|typescript|javascript] [--keep] [-v] [--jobs N] What the plugin CLAIMS to find, checked on code that is small enough to read. Each case is a directory under tests/cases/// holding a tiny synthetic project and a case.json: @@ -32,26 +32,29 @@ and neither announces itself as a want/avoid mismatch. If anyone ever "simplifies" `pending` into a skip, that is the property they will have removed. """ -import json, os, re, shutil, subprocess, sys +import builtins, concurrent.futures, io, json, os, re, shutil, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE) AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') args = sys.argv[1:]; keep = '--keep' in args; verbose = '-v' in args lang = args[args.index('--lang') + 1] if '--lang' in args else None -only = [a for a in args if not a.startswith('-') and a not in (lang,)] +jobs = int(args[args.index('--jobs') + 1]) if '--jobs' in args else 1 +only = [a for a in args if not a.startswith('-') and a not in (lang, str(jobs))] cases = [] for l in sorted(os.listdir(os.path.join(HERE, 'cases'))): if lang and l != lang: continue d = os.path.join(HERE, 'cases', l) for c in sorted(os.listdir(d)): if os.path.isfile(os.path.join(d, c, 'case.json')) and (not only or c in only or l in only): cases.append((l, c, os.path.join(d, c))) -fail = tot = pend = 0 -for l, name, path in cases: +def run_case(case): + """one case: index it, run its checks; its output as one block and its counts, so cases can run side by side""" + l, name, path = case; buf = io.StringIO(); fail = tot = pend = 0 + def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) print(f"… {l}/{name}", flush=True) spec = json.load(open(os.path.join(path, 'case.json'))) build = ['bash', AX, 'index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) \ + (['--library', os.path.join(path, spec['library'])] if spec.get('library') else []) # a staged dependency root, relative to the case r = subprocess.run(build, capture_output=True, text=True) - if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); fail += 1; continue + if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); return buf.getvalue(), 0, 1, 0 for stmt in spec.get('sql', []): # facts a framework extension would have written subprocess.run(['sqlite3', os.path.join(path, '.axiomcode', 'out', 'graph.sqlite'), stmt], capture_output=True, text=True) for ch in spec['checks']: @@ -97,5 +100,17 @@ print(' ' + '\n '.join(text.strip().split('\n')[:14])) elif verbose: print(f"ok {l}/{name}: {ch['why']}") if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) + return buf.getvalue(), tot, fail, pend + + +# CASES RUN SIDE BY SIDE with --jobs N: each indexes its own directory and shares nothing but the compiled rules, so the +# first case runs alone (it compiles and caches them) and the rest run N at a time. Output is printed in case order. +fail = tot = pend = 0 +def report(res): + global fail, tot, pend + text, t, f, p = res; sys.stdout.write(text); sys.stdout.flush(); tot += t; fail += f; pend += p +if cases: report(run_case(cases[0])) +with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs)) as ex: + for res in ex.map(run_case, cases[1:]): report(res) print(f"\n{tot - fail - pend} of {tot} check(s) passed in {len(cases)} case(s)" + (f" - {pend} PENDING" if pend else '') + ('' if not fail else f" - {fail} FAILED")) sys.exit(1 if fail else 0) From 1d50368e31470ddd27144b5667748ce340b5eb35 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 02:55:39 -0700 Subject: [PATCH 089/258] tests: the uncertain-row evidence case runs in each language's own leg typescript/evidence-for-uncertain-rows indexed five languages, so the typescript leg compiled the java, csharp, python and javascript engines it has no cache for: 964 s of the leg's 19-minute query-case step, where the median case takes 4 s. The case is now one per language (ts 9 checks, js 1, python 1, java 2, csharp 1), each indexed in the leg whose engine is already built; the 14 checks are unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../evidence-for-uncertain-rows/case.json | 21 +++ .../evidence-for-uncertain-rows/cs/Stock.cs | 0 .../evidence-for-uncertain-rows/case.json | 31 ++++ .../java/app/Cache.java | 0 .../java/app/Pipeline.java | 0 .../java/app/Store.java | 0 .../evidence-for-uncertain-rows/case.json | 22 +++ .../evidence-for-uncertain-rows/js/client.js | 0 .../evidence-for-uncertain-rows/js/store.js | 0 .../evidence-for-uncertain-rows/case.json | 22 +++ .../evidence-for-uncertain-rows/py/billing.py | 0 .../evidence-for-uncertain-rows/py/gateway.py | 0 .../evidence-for-uncertain-rows/case.json | 171 ++++++++++++------ 13 files changed, 213 insertions(+), 54 deletions(-) create mode 100644 tests/cases/csharp/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => csharp}/evidence-for-uncertain-rows/cs/Stock.cs (100%) create mode 100644 tests/cases/java/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Cache.java (100%) rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Pipeline.java (100%) rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Store.java (100%) create mode 100644 tests/cases/javascript/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => javascript}/evidence-for-uncertain-rows/js/client.js (100%) rename tests/cases/{typescript => javascript}/evidence-for-uncertain-rows/js/store.js (100%) create mode 100644 tests/cases/python/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => python}/evidence-for-uncertain-rows/py/billing.py (100%) rename tests/cases/{typescript => python}/evidence-for-uncertain-rows/py/gateway.py (100%) diff --git a/tests/cases/csharp/evidence-for-uncertain-rows/case.json b/tests/cases/csharp/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..f745109f --- /dev/null +++ b/tests/cases/csharp/evidence-for-uncertain-rows/case.json @@ -0,0 +1,21 @@ +{ + "lang": "csharp", + "src": ".", + "checks": [ + { + "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", + "run": [ + "impact", + "cs/Stock.cs:5", + "--evidence", + "--grep" + ], + "want": [ + "decided L11: private readonly dynamic _meter; [field \u00b7 type dynamic]" + ], + "avoid": [ + "decided L10" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs b/tests/cases/csharp/evidence-for-uncertain-rows/cs/Stock.cs similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs rename to tests/cases/csharp/evidence-for-uncertain-rows/cs/Stock.cs diff --git a/tests/cases/java/evidence-for-uncertain-rows/case.json b/tests/cases/java/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..4ba282c0 --- /dev/null +++ b/tests/cases/java/evidence-for-uncertain-rows/case.json @@ -0,0 +1,31 @@ +{ + "lang": "java", + "src": ".", + "checks": [ + { + "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", + "run": [ + "impact", + "java/app/Pipeline.java:7", + "--evidence", + "--grep" + ], + "want": [ + "decided L6: private final Function loader; [field \u00b7 type Function]" + ] + }, + { + "why": "Java: path's hop through the same call carries the same decider", + "run": [ + "path", + "Cache.load", + "Pipeline.apply", + "--evidence", + "--grep" + ], + "want": [ + "private final Function loader;" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Cache.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Cache.java diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Pipeline.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Pipeline.java diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Store.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Store.java diff --git a/tests/cases/javascript/evidence-for-uncertain-rows/case.json b/tests/cases/javascript/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..e5351252 --- /dev/null +++ b/tests/cases/javascript/evidence-for-uncertain-rows/case.json @@ -0,0 +1,22 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", + "run": [ + "impact", + "js/store.js:2", + "--evidence", + "--grep" + ], + "want": [ + "decided L11: cached(res) { [param]", + "decided L16: export function remote(api) { [param]" + ], + "avoid": [ + "this.shelf = new Shelf()" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js b/tests/cases/javascript/evidence-for-uncertain-rows/js/client.js similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/js/client.js rename to tests/cases/javascript/evidence-for-uncertain-rows/js/client.js diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js b/tests/cases/javascript/evidence-for-uncertain-rows/js/store.js similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/js/store.js rename to tests/cases/javascript/evidence-for-uncertain-rows/js/store.js diff --git a/tests/cases/python/evidence-for-uncertain-rows/case.json b/tests/cases/python/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..d449c094 --- /dev/null +++ b/tests/cases/python/evidence-for-uncertain-rows/case.json @@ -0,0 +1,22 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", + "run": [ + "impact", + "py/gateway.py:2", + "--evidence", + "--grep" + ], + "want": [ + "py/billing.py:33: return self.gw.charge(-amount)", + "decided L30: self.gw = gw [assigned]" + ], + "avoid": [ + "def __init__(self, gw: Gateway): [constructor parameter" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py b/tests/cases/python/evidence-for-uncertain-rows/py/billing.py similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py rename to tests/cases/python/evidence-for-uncertain-rows/py/billing.py diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py b/tests/cases/python/evidence-for-uncertain-rows/py/gateway.py similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py rename to tests/cases/python/evidence-for-uncertain-rows/py/gateway.py diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/case.json b/tests/cases/typescript/evidence-for-uncertain-rows/case.json index c301fdb6..bbad3d4d 100644 --- a/tests/cases/typescript/evidence-for-uncertain-rows/case.json +++ b/tests/cases/typescript/evidence-for-uncertain-rows/case.json @@ -1,88 +1,151 @@ { - "lang": "typescript,javascript,python,java,csharp", + "lang": "typescript", "src": ".", "checks": [ { "why": "a [by name] row carries the line that decides its receiver: a Map field and an `any` parameter show the name-match is another type; the resolved row gets no evidence", - "run": ["impact", "ts/orders.ts:2", "--evidence", "--grep"], - "want": ["ts/callers.ts:16: return this.seen.get(key);", "decided L14: private readonly seen = new Map(); [field · type new Map(…)]", - "decided L20: export function viaAny(box: any, id: string): string { [param · type any]", "only through it:"], - "avoid": ["decided L7"] + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence", + "--grep" + ], + "want": [ + "ts/callers.ts:16: return this.seen.get(key);", + "decided L14: private readonly seen = new Map(); [field \u00b7 type new Map(\u2026)]", + "decided L20: export function viaAny(box: any, id: string): string { [param \u00b7 type any]", + "only through it:" + ], + "avoid": [ + "decided L7" + ] }, { "why": "a dispatch-key row decides on where the key comes from, and a registration row on the constant it registers under, followed into the module that defines it", - "run": ["impact", "ts/bus.ts:16", "--evidence", "--grep"], - "want": ["decided L10: emit(topic: string, id: string): void { [dispatch key topic]", - "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]"] + "run": [ + "impact", + "ts/bus.ts:16", + "--evidence", + "--grep" + ], + "want": [ + "decided L10: emit(topic: string, id: string): void { [dispatch key topic]", + "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]" + ] }, { "why": "--json rows gain evidence {call, decider} and only_through {callables, tests}; alongside rows become a count", - "run": ["impact", "ts/orders.ts:2", "--evidence", "--json"], + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence", + "--json" + ], "stdout_json": true, - "want": ["\"evidence\"", "\"decider\"", "\"only_through\"", "\"callables\"", "\"alongside_count\""] + "want": [ + "\"evidence\"", + "\"decider\"", + "\"only_through\"", + "\"callables\"", + "\"alongside_count\"" + ] }, { "why": "the prose answer says the same, in one section after the rows it qualifies", - "run": ["impact", "ts/orders.ts:2", "--evidence"], - "want": ["evidence for the 2 strongest non-exact rows", "call L16: return this.seen.get(key);", "only through it: 1 callable(s), 0 test(s)", "--drop asks again without that row"] + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence" + ], + "want": [ + "evidence for the 2 strongest non-exact rows", + "call L16: return this.seen.get(key);", + "only through it: 1 callable(s), 0 test(s)", + "--drop asks again without that row" + ] }, { "why": "--exact asks again with exact edges only: the name-matches and what stands on them go, the resolved caller stays", - "run": ["impact", "ts/orders.ts:2", "--exact", "--grep"], - "want": ["return this.store.get(id);", "asked again with exact edges only"], - "avoid": ["box.get(id)", "this.seen.get(key)"] + "run": [ + "impact", + "ts/orders.ts:2", + "--exact", + "--grep" + ], + "want": [ + "return this.store.get(id);", + "asked again with exact edges only" + ], + "avoid": [ + "box.get(id)", + "this.seen.get(key)" + ] }, { "why": "--drop asks again without that one row", - "run": ["impact", "ts/orders.ts:2", "--drop", "ts/callers.ts:21", "--grep"], - "want": ["this.seen.get(key)", "without ts/callers.ts:21"], - "avoid": ["box.get(id)"] + "run": [ + "impact", + "ts/orders.ts:2", + "--drop", + "ts/callers.ts:21", + "--grep" + ], + "want": [ + "this.seen.get(key)", + "without ts/callers.ts:21" + ], + "avoid": [ + "box.get(id)" + ] }, { "why": "control: an answer with only [resolved] rows is byte-identical with evidence on and off", - "run": ["impact", "ts/ledger.ts:2", "--evidence"], - "same_as": ["impact", "ts/ledger.ts:2", "--no-evidence"], - "want": ["[resolved] sum"] + "run": [ + "impact", + "ts/ledger.ts:2", + "--evidence" + ], + "same_as": [ + "impact", + "ts/ledger.ts:2", + "--no-evidence" + ], + "want": [ + "[resolved] sum" + ] }, { "why": "control: the same resolved-only answer as --json and as grep rows is byte-identical too", - "run": ["impact", "ts/ledger.ts:2", "--evidence", "--json"], - "same_as": ["impact", "ts/ledger.ts:2", "--json"], + "run": [ + "impact", + "ts/ledger.ts:2", + "--evidence", + "--json" + ], + "same_as": [ + "impact", + "ts/ledger.ts:2", + "--json" + ], "stdout_json": true }, { "why": "control: evidence is off by default, so an answer with uncertain rows is the answer it was", - "run": ["impact", "ts/orders.ts:2", "--grep"], - "same_as": ["impact", "ts/orders.ts:2", "--no-evidence", "--grep"], - "avoid": ["decided ", "only through it"] - }, - { - "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", - "run": ["impact", "js/store.js:2", "--evidence", "--grep"], - "want": ["decided L11: cached(res) { [param]", "decided L16: export function remote(api) { [param]"], - "avoid": ["this.shelf = new Shelf()"] - }, - { - "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", - "run": ["impact", "py/gateway.py:2", "--evidence", "--grep"], - "want": ["py/billing.py:33: return self.gw.charge(-amount)", "decided L30: self.gw = gw [assigned]"], - "avoid": ["def __init__(self, gw: Gateway): [constructor parameter"] - }, - { - "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", - "run": ["impact", "java/app/Pipeline.java:7", "--evidence", "--grep"], - "want": ["decided L6: private final Function loader; [field · type Function]"] - }, - { - "why": "Java: path's hop through the same call carries the same decider", - "run": ["path", "Cache.load", "Pipeline.apply", "--evidence", "--grep"], - "want": ["private final Function loader;"] - }, - { - "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", - "run": ["impact", "cs/Stock.cs:5", "--evidence", "--grep"], - "want": ["decided L11: private readonly dynamic _meter; [field · type dynamic]"], - "avoid": ["decided L10"] + "run": [ + "impact", + "ts/orders.ts:2", + "--grep" + ], + "same_as": [ + "impact", + "ts/orders.ts:2", + "--no-evidence", + "--grep" + ], + "avoid": [ + "decided ", + "only through it" + ] } ] } From 29449d6403ec257c867e13e1f6936a85781a7405 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:38:00 -0700 Subject: [PATCH 090/258] typescript: join a message key's sender to its handler (remote_edge) A producer that emits on a key (a Nest microservices client's emit/send, a kafkajs producer's send({ topic })) was not joined to the handler registered on the same key (@EventPattern / @MessagePattern, a kafkajs consumer's subscribe), so path stopped at the send and impact of a handler named no sender. destinations.dl gains a messaging section. Both ends are recognised by the receiver's DECLARED type and the package it is imported from (knobs.dl, ts_msg_client_type), never by the method name, so emit on a Node event emitter is not a send. The key is read the way a route is: a literal, a const, a const-object member (now also through `as const`), local or imported. One wrapper hop is bound: a private send(pattern, x) called with the key joins from its caller. A decorator's Transport argument names the broker; an end that names none joins any. One-sided keys are reported as remote_unserved / remote_unsent, an unreadable key as remote_undetermined. remote_unsent is judged by destination, so a handler reached by a send that names no broker is not also reported unsent. Checked: new CLI case cross-process-message (emit on an imported const-object member, send on a const, a key through a wrapper, kafkajs send/subscribe, controls: an EventEmitter emit and a handler nobody sends to) fails 5/7 before and passes 7/7; typescript case suite 212/212, engine suite 99/0. On a monorepo with three services: probes 44 -> 47 of 74, no regression, call edges unchanged, 5 amqp + 2 kafka remote edges added. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/config-resolution/knobs.dl | 50 ++++++ .../engine/framework-behavior/destinations.dl | 143 +++++++++++++++++- .../cross-process-message/case.json | 27 ++++ .../src/consumer/items.controller.ts | 23 +++ .../src/producer/microservices.d.ts | 13 ++ .../src/producer/producer.test.ts | 7 + .../src/producer/producer.ts | 39 +++++ .../src/shared/package.json | 1 + .../src/shared/topics.ts | 4 + 9 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 tests/cases/typescript/cross-process-message/case.json create mode 100644 tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/producer.test.ts create mode 100644 tests/cases/typescript/cross-process-message/src/producer/producer.ts create mode 100644 tests/cases/typescript/cross-process-message/src/shared/package.json create mode 100644 tests/cases/typescript/cross-process-message/src/shared/topics.ts diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 3e093b7a..c9064f72 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -202,3 +202,53 @@ ts_http_client_module("axios"). ts_http_client_factory("create"). .decl ts_http_base_url_key(c0:symbol) ts_http_base_url_key("baseURL"). + +// ── MESSAGING: a key both ends spell (a topic, a queue, a message pattern) ── +// ts_msg_client_type(Specifier, TypeName, Role): a receiver DECLARED as TypeName, +// imported from Specifier, sends or subscribes. Matched on the declared type and the +// package it comes from, never on the method name alone: `emit` on a Node event +// emitter, `send` on an HTTP reply or a socket, `subscribe` on an observable are none +// of these. +// nest_client `.emit(key, data)` / `.send(key, data)` +// kafka_producer `.send({ topic, messages })` +// kafka_consumer `.subscribe({ topic })` / `({ topics: [...] })` +.decl ts_msg_client_type(c0:symbol, c1:symbol, c2:symbol) +ts_msg_client_type("@nestjs/microservices", "ClientProxy", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientKafka", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientRMQ", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientNats", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientRedis", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientMqtt", "nest_client"). +ts_msg_client_type("@nestjs/microservices", "ClientTCP", "nest_client"). +ts_msg_client_type("kafkajs", "Producer", "kafka_producer"). +ts_msg_client_type("kafkajs", "Consumer", "kafka_consumer"). +// ts_msg_send(Role, Method, Transport): the call that sends; the key is argument 0, +// or its `topic` property when the argument is a record (ts_msg_record_key) +.decl ts_msg_send(c0:symbol, c1:symbol, c2:symbol) +ts_msg_send("nest_client", "emit", "message"). +ts_msg_send("nest_client", "send", "message"). +ts_msg_send("kafka_producer", "send", "kafka"). +.decl ts_msg_subscribe(c0:symbol, c1:symbol, c2:symbol) +ts_msg_subscribe("kafka_consumer", "subscribe", "kafka"). +// the property of a record argument that names the key, and the one naming several +.decl ts_msg_record_key(c0:symbol) +ts_msg_record_key("topic"). +.decl ts_msg_record_keys(c0:symbol) +ts_msg_record_keys("topics"). +// ts_msg_handler_decorator(Name): a method decorator whose argument 0 is the key the +// method handles (`@EventPattern(TOPICS.x)`, `@MessagePattern('orders.place')`) +.decl ts_msg_handler_decorator(c0:symbol) +ts_msg_handler_decorator("EventPattern"). +ts_msg_handler_decorator("MessagePattern"). +// ts_msg_transport_member(Written, Transport): the decorator's optional argument 1 +.decl ts_msg_transport_member(c0:symbol, c1:symbol) +ts_msg_transport_member("Transport.KAFKA", "kafka"). +ts_msg_transport_member("Transport.RMQ", "amqp"). +ts_msg_transport_member("Transport.NATS", "nats"). +ts_msg_transport_member("Transport.REDIS", "redis"). +ts_msg_transport_member("Transport.MQTT", "mqtt"). +ts_msg_transport_member("Transport.TCP", "tcp"). +// the transport of an end that does not say which broker it uses (a Nest client is +// bound to one in module configuration): it joins an end of any transport +.decl ts_remote_transport_message(c0:symbol) +ts_remote_transport_message("message"). diff --git a/graph/typescript/engine/framework-behavior/destinations.dl b/graph/typescript/engine/framework-behavior/destinations.dl index 7370082c..35df1c87 100644 --- a/graph/typescript/engine/framework-behavior/destinations.dl +++ b/graph/typescript/engine/framework-behavior/destinations.dl @@ -29,13 +29,18 @@ // literal, a template, a `+` concatenation, a constant (in this module or imported), // and a property of a const object literal (a config module). // +// COVERED: MESSAGING (section 6). A key (a topic, a queue, a message pattern) sent by +// a Nest microservices client (`emit` / `send`) or a kafkajs producer, joined to a +// handler registered on the same key (`@EventPattern` / `@MessagePattern`, a kafkajs +// consumer's `subscribe`), the key read as a route is; one wrapper hop is bound. +// // NOT COVERED YET, stated rather than left to be discovered: // - a router mounted under a prefix (`app.use('/api', router)`): the routes of the // router are compared without it; // - an object-form registration (`fastify.route({ method, url, handler })`); // - a URL built by a wrapper the client calls with the path (the C# rules bind one // hop of that; here the wrapper's own send is reported undetermined); -// - messaging (a topic, a queue) and gRPC. +// - gRPC. // ============================================================================ // remote_edge, remote_unserved, remote_unsent and remote_undetermined are declared in @@ -81,11 +86,17 @@ rd_demand(val) :- rd_prop_value(_, val). rd_demand(c) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, _, _, c). +// the object literal a const is initialised with, through `as const` / `satisfies T` +.decl rd_init_obj(c0:symbol, c1:symbol) +rd_init_obj(v, o) :- var_initializer("client", _, o, v), expr_kind("client", "OBJECT_LITERAL", _, o). +rd_init_obj(v, o) :- var_initializer("client", _, i, v), expr_kind("client", k, _, i), expr_kind_is_transparent(k), + expr_child("client", i, _, _, o), expr_kind("client", "OBJECT_LITERAL", _, o). + // `config.reportsUrl`, where `config` is a const initialised with an object literal rd_prop_value(e, val) :- rd_demand(e), expr_kind("client", "PROPERTY_ACCESS", _, e), expr_child("client", e, "RECEIVER", _, r), expr_child("client", e, "PROPERTY_NAME", _, pn), expr_literal_value("client", key, pn), - rd_denotes_var(r, v), var_initializer("client", _, o, v), rd_obj_prop(o, key, val). + rd_denotes_var(r, v), rd_init_obj(v, o), rd_obj_prop(o, key, val). .decl rd_is_str(c0:symbol) .decl rd_is_tmpl(c0:symbol) @@ -411,5 +422,129 @@ remote_undetermined(from, tr, "unresolved_destination") :- ts_remote_transport_h remote_edge(from, to, tr, d, conf) :- remote_edge_via(_, from, to, tr, d, conf). // judged per SEND SITE: a site that linked is not also reported under another spelling remote_unserved(m, tr, d) :- remote_send_at(e, m, tr, d), !remote_edge_via(e, _, _, _, _, _). -// a handler nothing here sends to: its client is in another repository -remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_edge(_, m, tr, d, _). +// a handler nothing here sends to: its client is in another repository. Judged by +// destination alone: a message sent without naming its broker (transport "message") +// is reported under the handler's broker, and still serves it. +.decl remote_reached_at(c0:symbol, c1:symbol) +remote_reached_at(m, d) :- remote_edge(_, m, _, d, _). +remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_reached_at(m, d). + +// ───────────────────────────────────────────────────────────────────────────── +// 6. MESSAGING: a key both ends spell +// ───────────────────────────────────────────────────────────────────────────── +// A producer sends on a KEY (a topic, a queue, a message pattern) and a handler is +// registered on one; neither calls the other. The key is read exactly as a route is +// (section 1): a literal, a const, a const-object member, imported or local. Both +// ends are recognised by what the RECEIVER is declared as, a type imported from the +// messaging package (config-resolution/knobs.dl, ts_msg_client_type), never by the +// method name: `emit` on a Node event emitter is not a broker send. +// +// NOT COVERED YET: a pattern object (`@MessagePattern({ cmd: 'x' })`), a key held in a +// field set at run time (`this.topic`), a receiver whose type is only inferred from a +// factory call in a package outside the graph (`kafka.producer()` with no annotation). + +// the receivers of a call that could be a send or a subscribe +.decl rm_candidate_recv(c0:symbol) +rm_candidate_recv(q) :- call_site("client", "METHOD_CALL", cn, _, q, _, _), ts_msg_send(_, cn, _). +rm_candidate_recv(q) :- call_site("client", "METHOD_CALL", cn, _, q, _, _), ts_msg_subscribe(_, cn, _). +// `this.client`: the field it names, a parameter property included +.decl rm_this_field(c0:symbol, c1:symbol) +rm_this_field(q, f) :- rm_candidate_recv(q), property_access_name(q, n), + expr_child("client", q, "RECEIVER", _, t), expr_type(t, _, tt), field_in_scope(tt, n, "false", f). +// the type reference the receiver is DECLARED with +.decl rm_recv_ref(c0:symbol, c1:symbol) +rm_recv_ref(q, r) :- rm_candidate_recv(q), expr_referenced("client", "PARAMETER", p, q), param_type_ref("client", r, p). +rm_recv_ref(q, r) :- rm_candidate_recv(q), expr_referenced("client", "VARIABLE", v, q), var_type_ref("client", r, v). +rm_recv_ref(q, r) :- rm_this_field(q, f), field_type_ref("client", r, f). +rm_recv_ref(q, r) :- rm_this_field(q, f), param_declares_field("client", f, p), param_type_ref("client", r, p). +// the package a type name is imported from, under the name it exports +// (`import type { ClientProxy as Client } from '@nestjs/microservices'`) +.decl rm_ref_import(c0:symbol, c1:symbol, c2:symbol) +rm_ref_import(r, spec, orig) :- rm_recv_ref(_, r), type_ref("client", _, _, tn, _, _, _, r), type_ref_module("client", m, r), + import_binding("client", _, tn, orig, m, ih), orig != "", rd_import_path(ih, spec). +rm_ref_import(r, spec, tn) :- rm_recv_ref(_, r), type_ref("client", _, _, tn, _, _, _, r), type_ref_module("client", m, r), + import_binding("client", _, tn, "", m, ih), rd_import_path(ih, spec). +.decl rm_recv_role(c0:symbol, c1:symbol) +rm_recv_role(q, role) :- rm_recv_ref(q, r), rm_ref_import(r, spec, name), ts_msg_client_type(spec, name, role). + +// (call, role, transport) +.decl rm_send_call(c0:symbol, c1:symbol, c2:symbol) +rm_send_call(ce, role, tr) :- call_site("client", "METHOD_CALL", cn, _, q, ce, _), ts_msg_send(role, cn, tr), + rm_recv_role(q, role). +.decl rm_sub_call(c0:symbol, c1:symbol, c2:symbol) +rm_sub_call(ce, role, tr) :- call_site("client", "METHOD_CALL", cn, _, q, ce, _), ts_msg_subscribe(role, cn, tr), + rm_recv_role(q, role). + +// the expression holding the key: argument 0, or the `topic` of a record argument, or +// each element of its `topics` +.decl rm_key_arg(c0:symbol, c1:symbol) +rm_key_arg(ce, a) :- rm_send_call(ce, "nest_client", _), expr_child("client", ce, "ARGUMENT", "0", a). +rm_key_arg(ce, v) :- rm_send_call(ce, role, _), role != "nest_client", expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, v), ts_msg_record_key(k). +rm_key_arg(ce, v) :- rm_sub_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, v), ts_msg_record_key(k). +rm_key_arg(ce, el) :- rm_sub_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", o), + rd_obj_prop(o, k, arr), ts_msg_record_keys(k), expr_kind("client", "ARRAY_LITERAL", _, arr), + expr_child("client", arr, _, _, el). +rd_demand(a) :- rm_key_arg(_, a). +// a key is a value with no computed part (a key expression of either end) +.decl rm_key_expr(c0:symbol) +rm_key_expr(a) :- rm_key_arg(_, a). +rm_key_expr(a) :- rm_handler_key_arg(_, _, a). +.decl rm_key(c0:symbol, c1:symbol) +rm_key(e, k) :- rm_key_expr(e), rd_val(e, k), k != "", !contains("{}", k). + +// ONE WRAPPER HOP: the key is a parameter of the method that sends, and each call of +// that method names it: `place() { return this.send(CMD.place, x); }` over +// `private send(pattern, x) { return this.proxy.send(pattern, x); }`. The send is then +// judged at the CALL of the wrapper, from the method that makes it. +.decl rm_key_param(c0:symbol, c1:symbol, c2:symbol) +rm_key_param(ce, w, pos) :- rm_key_arg(ce, a), expr_referenced("client", "PARAMETER", p, a), + param_decl("client", _, pos, _, w, p), expr_enclosing_method(ce, w). +.decl rm_wrap_arg(c0:symbol, c1:symbol, c2:symbol) +rm_wrap_arg(ce, oc, a) :- rm_key_param(ce, w, pos), call_chain_edge(oc, _, "-", w, "client", _, _), + expr_child("client", oc, "ARGUMENT", pos, a). +rd_demand(a) :- rm_wrap_arg(_, _, a). + +// (site, sending method, transport, key) +.decl rm_send(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +rm_send(ce, from, tr, k) :- rm_send_call(ce, _, tr), rm_key_arg(ce, a), rm_key(a, k), expr_enclosing_method(ce, from). +rm_send(oc, from, tr, k) :- rm_send_call(ce, _, tr), rm_wrap_arg(ce, oc, a), rd_val(a, k), k != "", !contains("{}", k), + expr_enclosing_method(oc, from). + +// ── the handler end ───────────────────────────────────────────────────────── +// a decorated method: `@EventPattern(key, Transport.KAFKA)`; the argument's expression +// is read like any other +.decl rm_handler_key_arg(c0:symbol, c1:symbol, c2:symbol) +rm_handler_key_arg(m, d, a) :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_msg_handler_decorator(n), + ts_decorator_argument(_, _, _, "0", d, _, _, _, _, _, a, _, _), a != "". +rd_demand(a) :- rm_handler_key_arg(_, _, a). +.decl rm_handler_tr(c0:symbol, c1:symbol) +rm_handler_tr(d, tr) :- rm_handler_key_arg(_, d, _), annotation_arg("client", _, v, _, "1", _, d, _), + ts_msg_transport_member(v, tr). +.decl rm_handler_has_tr(c0:symbol) +rm_handler_has_tr(d) :- rm_handler_tr(d, _). +// (handler, transport, key) +.decl rm_serve(c0:symbol, c1:symbol, c2:symbol) +rm_serve(m, tr, k) :- rm_handler_key_arg(m, d, a), rm_key(a, k), rm_handler_tr(d, tr). +rm_serve(m, tr, k) :- rm_handler_key_arg(m, d, a), rm_key(a, k), !rm_handler_has_tr(d), ts_remote_transport_message(tr). +// a subscription: the function that subscribes is where the messages arrive +rm_serve(m, tr, k) :- rm_sub_call(ce, _, tr), rm_key_arg(ce, a), rm_key(a, k), expr_enclosing_method(ce, m). + +// ── the join ──────────────────────────────────────────────────────────────── +// Same key; the transports agree, or one end does not say. The edge is reported under +// the end that names its broker. +.decl rm_edge_tr(c0:symbol, c1:symbol, c2:symbol) +rm_edge_tr(a, a, a) :- rm_send(_, _, a, _). +rm_edge_tr(a, b, b) :- rm_send(_, _, a, _), rm_serve(_, b, _), ts_remote_transport_message(a), a != b. +rm_edge_tr(a, b, a) :- rm_send(_, _, a, _), rm_serve(_, b, _), ts_remote_transport_message(b), a != b. +remote_edge_via(e, from, to, tr, k, conf) :- rm_send(e, from, st, k), rm_serve(to, ht, k), rm_edge_tr(st, ht, tr), + from != to, ts_remote_confidence_exact(conf). +remote_serves(m, tr, k) :- rm_serve(m, tr, k). +remote_send_at(e, from, tr, k) :- rm_send(e, from, tr, k). +// a send whose key cannot be read, and that no caller names either +.decl rm_send_keyed(c0:symbol) +rm_send_keyed(ce) :- rm_send(ce, _, _, _). +rm_send_keyed(ce) :- rm_wrap_arg(ce, oc, _), rm_send(oc, _, _, _). +remote_undetermined(from, tr, "unresolved_destination") :- rm_send_call(ce, _, tr), !rm_send_keyed(ce), + expr_enclosing_method(ce, from). diff --git a/tests/cases/typescript/cross-process-message/case.json b/tests/cases/typescript/cross-process-message/case.json new file mode 100644 index 00000000..f9b9ad41 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/case.json @@ -0,0 +1,27 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a handler registered on a message key is depended on by the method that emits on the same key, read through an imported const-object member: a remote hop naming the transport and the key", + "run": ["impact", "ItemsController.onCreated"], + "want": ["[remote] ItemPublisher.announce", "across a process boundary (kafka) at item.created.v1"], + "avoid": ["LocalBus.fire", "ItemPublisher.shout"]}, + {"why": "path joins a request/reply send on a plain const to the handler of the same pattern", + "run": ["path", "ItemPublisher.place", "ItemsController.placeItem"], + "want": ["connected across a process: ItemPublisher.place → ItemsController.placeItem", "[amqp] at item.place (exact)"], + "expect_error": true}, + {"why": "a key passed into a private wrapper that sends it: the edge starts at the caller that names the key", + "run": ["path", "CommandClient.cancel", "ItemsController.cancelItem"], + "want": ["connected across a process: CommandClient.cancel → ItemsController.cancelItem", "at item.cancel (exact)"], + "expect_error": true}, + {"why": "kafkajs: the topic of a producer record joins the function that subscribes to it", + "run": ["impact", "listenRemovals"], + "want": ["[remote] RawProducer.removed", "at item.removed.v1"]}, + {"why": "CONTROL: a Node event emitter's emit is not a broker send, even on the same key", + "run": ["impact", "LocalBus.fire"], + "avoid": ["[remote]", "onCreated"]}, + {"why": "CONTROL: a handler on a key nothing here sends to has no sender", + "run": ["impact", "ItemsController.unused"], + "avoid": ["[remote]"]}, + {"why": "test-impact crosses the key: the test that drives the producer is selected on the remote rung", + "run": ["impact", "ItemsController.onCreated", "--tests-only"], + "want": ["producer.test.ts"]} + ]} diff --git a/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts b/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts new file mode 100644 index 00000000..d601de60 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/consumer/items.controller.ts @@ -0,0 +1,23 @@ +import { EventPattern, MessagePattern, Transport } from '@nestjs/microservices'; +import type { Consumer } from 'kafkajs'; +import { CMD, COMMANDS, TOPICS } from '@acme/shared'; + +export class ItemsController { + @EventPattern(TOPICS.created, Transport.KAFKA) + onCreated(event: unknown) { return event; } + + @MessagePattern(CMD, Transport.RMQ) + placeItem(command: unknown) { return command; } + + @MessagePattern(COMMANDS.cancel) + cancelItem(command: unknown) { return command; } + + // no sender here: an unsent handler + @EventPattern('other.topic') + unused(event: unknown) { return event; } +} + +export async function listenRemovals(consumer: Consumer) { + await consumer.subscribe({ topic: TOPICS.removed }); + await consumer.run({ eachMessage: async () => undefined }); +} diff --git a/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts b/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts new file mode 100644 index 00000000..adb789e9 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/microservices.d.ts @@ -0,0 +1,13 @@ +declare module '@nestjs/microservices' { + export class ClientProxy { + emit(pattern: unknown, data: unknown): unknown; + send(pattern: unknown, data: unknown): unknown; + } + export enum Transport { KAFKA, RMQ } + export function EventPattern(pattern: unknown, transport?: unknown): MethodDecorator; + export function MessagePattern(pattern: unknown, transport?: unknown): MethodDecorator; +} +declare module 'kafkajs' { + export interface Producer { send(record: { topic: string; messages: unknown[] }): Promise; } + export interface Consumer { subscribe(s: { topic?: string; topics?: string[] }): Promise; run(c: unknown): Promise; } +} diff --git a/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts b/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts new file mode 100644 index 00000000..0c25016a --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/producer.test.ts @@ -0,0 +1,7 @@ +import { it, expect } from 'vitest'; +import { ItemPublisher } from './producer'; + +it('announces an item', () => { + const publisher = new ItemPublisher({ emit: () => 1, send: () => 1 } as never); + expect(publisher.announce('a')).toBeDefined(); +}); diff --git a/tests/cases/typescript/cross-process-message/src/producer/producer.ts b/tests/cases/typescript/cross-process-message/src/producer/producer.ts new file mode 100644 index 00000000..60c09c98 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/producer/producer.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from 'events'; +import type { ClientProxy } from '@nestjs/microservices'; +import type { Producer } from 'kafkajs'; +import { CMD, COMMANDS, TOPICS } from '../shared/topics'; + +export class ItemPublisher { + constructor(private readonly client: ClientProxy) {} + + // emit on a const-object member: joins the handler that spells the same member + announce(id: string) { return this.client.emit(TOPICS.created, { id }); } + + // send on a plain const: joins the request/reply handler + place(id: string) { return this.client.send(CMD, { id }); } + + // a key nothing here handles: an unserved send + shout() { return this.client.emit('nobody.listens', {}); } +} + +export class CommandClient { + constructor(private readonly proxy: ClientProxy) {} + + // the key is the wrapper's argument: the send joins from here + cancel(id: string) { return this.dispatch(COMMANDS.cancel, { id }); } + + private dispatch(pattern: string, body: unknown) { return this.proxy.send(pattern, body); } +} + +export class RawProducer { + constructor(private readonly producer: Producer) {} + + // kafkajs: the topic is a property of the record + removed(id: string) { return this.producer.send({ topic: TOPICS.removed, messages: [{ value: id }] }); } +} + +// CONTROL: a Node event emitter's emit is not a broker send, however its key reads +export class LocalBus { + private readonly emitter = new EventEmitter(); + fire() { this.emitter.emit(TOPICS.created, {}); } +} diff --git a/tests/cases/typescript/cross-process-message/src/shared/package.json b/tests/cases/typescript/cross-process-message/src/shared/package.json new file mode 100644 index 00000000..6a3a4605 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/shared/package.json @@ -0,0 +1 @@ +{ "name": "@acme/shared", "version": "0.0.0", "main": "./dist/topics.js", "types": "./dist/topics.d.ts" } diff --git a/tests/cases/typescript/cross-process-message/src/shared/topics.ts b/tests/cases/typescript/cross-process-message/src/shared/topics.ts new file mode 100644 index 00000000..dd3684d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-message/src/shared/topics.ts @@ -0,0 +1,4 @@ +// The keys both ends spell: a const-object member, and a plain const. +export const TOPICS = { created: 'item.created.v1', removed: 'item.removed.v1' } as const; +export const CMD = 'item.place'; +export const COMMANDS = { cancel: 'item.cancel' } as const; From ad56060fed957481343ac6702e3dba2b187ca216 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:08:42 -0700 Subject: [PATCH 091/258] javascript: a model made by an ORM factory carries its schema's statics `connection.model(name, schema)` builds the model class at runtime, so a call such as `this.Doc.findLive()` on it had an untyped receiver and was only matched by name, with same-named methods elsewhere as equal candidates. The schema argument now gives the call a value: a `new Schema(...)` reached through an import of the ORM package (named, default, namespace, require, or a local destructure) is tracked, and whatever its `statics` holds (member writes, `static('n', f)`, `static({...})`, the `statics` option) is a member of every model made from it; its `methods` are members of `new Model(...)`. The package's own members stay receiver_untyped as before. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../callee-resolution.dl | 9 ++- .../engine/resolution/frameworks.dl | 64 +++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 6 ++ .../cases/74-orm-model-factory/src/models.js | 32 ++++++++++ .../cases/74-orm-model-factory/src/repo.js | 31 +++++++++ .../expected/74-orm-model-factory.diag | 17 +++++ .../expected/74-orm-model-factory.edges | 36 +++++++++++ .../expected/74-orm-model-factory.oracle | 12 ++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 graph/test/javascript/cases/74-orm-model-factory/src/models.js create mode 100644 graph/test/javascript/cases/74-orm-model-factory/src/repo.js create mode 100644 graph/test/javascript/expected/74-orm-model-factory.diag create mode 100644 graph/test/javascript/expected/74-orm-model-factory.edges create mode 100644 graph/test/javascript/expected/74-orm-model-factory.oracle diff --git a/graph/javascript/engine/expression-resolution/callee-resolution.dl b/graph/javascript/engine/expression-resolution/callee-resolution.dl index 946d7cb3..a59664b2 100644 --- a/graph/javascript/engine/expression-resolution/callee-resolution.dl +++ b/graph/javascript/engine/expression-resolution/callee-resolution.dl @@ -133,7 +133,10 @@ call_target_is_implicit_ctor(ce) :- call_site(_, "SUPER_CALL", _, _, _, em, ce, // Splits the unresolved population into "the receiver is untyped" (a staging gap // or a genuinely dynamic value) and "the receiver is known and has no such // member" (an engine or parser defect — the rows worth reading first). +// A value a package model builds (frameworks.dl, package_model_kind) carries only the +// members the project wrote onto it; the rest are the uninstalled package's, so a +// site that finds nothing on it is as untyped as it was before the model existed. receiver_value_known(ce) :- call_site(_, _, _, "SYNTACTIC", _, _, ce, _, _), - expr_child(_, ce, "RECEIVER", _, r), expr_value(r, _, _). -callee_value_known(ce) :- callee_value(ce, _, _). -callee_value_known(ce) :- new_callee_value(ce, _, _). + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, k, _), !package_model_kind(k). +callee_value_known(ce) :- callee_value(ce, k, _), !package_model_kind(k). +callee_value_known(ce) :- new_callee_value(ce, k, _), !package_model_kind(k). diff --git a/graph/javascript/engine/resolution/frameworks.dl b/graph/javascript/engine/resolution/frameworks.dl index dc932573..a7361097 100644 --- a/graph/javascript/engine/resolution/frameworks.dl +++ b/graph/javascript/engine/resolution/frameworks.dl @@ -153,6 +153,70 @@ express_error_value(k, i) :- expr_root("client", "THROW", e), expr_value(e, k, i express_error_value(k, i) :- call_site("client", _, _, _, _, _, ce, _, _), callee_value(ce, "func", m), model_express_value("next", "func", m), call_arg(ce, 0, arg), expr_value(arg, k, i). +// ── ORM model factories: a model carries its schema's statics ─────────────── +// `const s = new Schema({...}); s.statics.findLive = function () {...}; +// const Doc = connection.model('Doc', s); Doc.findLive()` — the package builds the +// model class at runtime from the schema, so nothing in the tree declares it, and +// `connection` is usually a parameter nobody visible passes. The SCHEMA is the value +// that says what the model has: whatever `schema.statics` holds (member writes, +// `schema.static('n', f)`, `schema.static({ n: f })`, `new Schema(def, { statics })`) +// is a member of every model made from it, and whatever `schema.methods` holds is a +// member of every document `new Model(...)` makes. The factory call is recognised by +// its schema ARGUMENT, not by its receiver. +// +// A schema is a `new` of the package's `Schema`, reached through an import of a +// modelled package — named, default, namespace or require, directly or through a +// local alias or destructure (`const { Schema } = mongoose`). The package need not be +// installed. The five values are all keyed by the `new Schema(...)` site, so every +// model of one schema shares its members. Every other member of them (`find`, +// `create`, `save`) is the package's and stays unknown: package_model_kind keeps +// those sites receiver_untyped (callee-resolution.dl), as they were. +orm_schema_package("mongoose"). +orm_ref(e, "") :- expr_kind(_, "MODULE_EDGE_CALL", _, e), expr_module_edge(_, imp, e), + import_decl(_, spec, _, _, _, _, _, _, imp), orm_schema_package(spec). +orm_ref(e, "") :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, spec, _, bf, _, _, _, _, imp), import_binds_whole_module(bf), orm_schema_package(spec). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, spec, _, bf, n, _, _, _, imp), import_binding_is_named(bf), n != "", orm_schema_package(spec). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), !var_import(_, _, v), + var_init(_, _, init, v), var_binding_path(_, n, v), orm_ref(init, ""). +orm_ref(e, n) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), !var_import(_, _, v), + var_init(_, _, init, v), !var_binding_path(_, _, v), orm_ref(init, n). +orm_ref(e, n) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_name(_, n, e), n != "", + expr_child(_, e, "ACCESS_TARGET", _, r), orm_ref(r, ""). +orm_schema_site(ne) :- call_site(_, "CONSTRUCTOR_CALL", _, _, _, _, ne, _, _), + expr_child(_, ne, "CALLEE", _, c), orm_ref(c, "Schema"). +orm_schema_site(ne) :- call_site(_, "CONSTRUCTOR_CALL", "Schema", "SYNTACTIC", _, _, ne, _, _), + expr_child(_, ne, "RECEIVER", _, r), orm_ref(r, ""). +package_model_kind("orm_schema"). +package_model_kind("orm_statics"). +package_model_kind("orm_methods"). +package_model_kind("orm_model"). +package_model_kind("orm_doc"). +orm_part("statics", "orm_statics", "static"). +orm_part("methods", "orm_methods", "method"). +expr_value(ne, "orm_schema", ne) :- orm_schema_site(ne). +prop_value("orm_schema", s, part, pk, s) :- orm_schema_site(s), orm_part(part, pk, _). +prop_value("orm_schema", s, part, k, i) :- orm_schema_site(s), orm_part(part, _, _), + call_arg(s, 1, o), expr_value(o, "obj", l), prop_value("obj", l, part, k, i). +member_write(pk, s, n, k, i) :- call_site(_, ck, cn, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + orm_part(_, pk, cn), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "orm_schema", s), + call_arg(ce, 0, a), expr_value(a, "str", n), call_arg(ce, 1, f), expr_value(f, k, i). +member_write(pk, s, n, k, i) :- call_site(_, ck, cn, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + orm_part(_, pk, cn), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "orm_schema", s), + call_arg(ce, 0, a), expr_value(a, "obj", l), prop_value("obj", l, n, k, i). +// `x.model(name, schema)` / `model(name, schema)`: the model. `new Model(...)`: a document. +orm_model_call(ce, s) :- call_site(_, ck, "model", _, _, _, ce, _, _), + (call_kind_is_member_form(ck) ; call_kind_is_callee_form(ck)), + call_arg(ce, 1, a), expr_value(a, "orm_schema", s). +expr_value(ce, "orm_model", s) :- orm_model_call(ce, s). +expr_value(ne, "orm_doc", s) :- expr_kind(_, "NEW", _, ne), new_callee_value(ne, "orm_model", s). +prop_value("orm_model", s, n, k, i) :- prop_value("orm_schema", s, "statics", k0, i0), prop_value(k0, i0, n, k, i). +prop_value("orm_doc", s, n, k, i) :- prop_value("orm_schema", s, "methods", k0, i0), prop_value(k0, i0, n, k, i). +// A static runs with the model as `this`, a method with the document. +this_value(m, "orm_model", s) :- prop_value("orm_model", s, _, "func", m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). +this_value(m, "orm_doc", s) :- prop_value("orm_doc", s, _, "func", m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). + // ── property descriptors (#489) ───────────────────────────────────────────── // `Object.defineProperty(o, 'm', { value: f })`, `Object.defineProperties(o, { m: {...} })`, // `Object.create(proto, { m: {...} })`, and the export form a bundler-compiled CommonJS diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index d02c17d9..7f73c459 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -503,6 +503,12 @@ .decl express_error_slot(c0:symbol, c1:symbol) .decl express_param_slot(c0:symbol, c1:symbol) .decl express_error_value(c0:symbol, c1:symbol) +.decl orm_schema_package(c0:symbol) +.decl orm_ref(c0:symbol, c1:symbol) +.decl orm_schema_site(c0:symbol) +.decl package_model_kind(c0:symbol) +.decl orm_part(c0:symbol, c1:symbol, c2:symbol) +.decl orm_model_call(c0:symbol, c1:symbol) .decl heritage_text(c0:symbol, c1:symbol, c2:symbol) .decl type_own_getter(c0:symbol, c1:symbol, c2:symbol) .decl type_own_static_getter(c0:symbol, c1:symbol, c2:symbol) diff --git a/graph/test/javascript/cases/74-orm-model-factory/src/models.js b/graph/test/javascript/cases/74-orm-model-factory/src/models.js new file mode 100644 index 00000000..c5b18612 --- /dev/null +++ b/graph/test/javascript/cases/74-orm-model-factory/src/models.js @@ -0,0 +1,32 @@ +'use strict'; +// A model made by an ORM factory, `connection.model(name, schema)`, is a class the +// package builds at runtime from the schema: its statics are the functions written +// onto `schema.statics` (or handed to `schema.static(...)`), and a document it +// constructs has the schema's methods. The package is not installed, so nothing in +// the tree declares the model; the schema argument is what carries its members. +const mongoose = require('mongoose'); +const { Schema } = mongoose; + +const docSchema = new Schema({ title: String }); +docSchema.statics.findLive = function () { return this.find({}); }; +docSchema.static('claim', function () { return this.findLive(); }); +docSchema.static({ purge() { return 0; } }); +docSchema.methods.toRecord = function () { return this.title; }; +docSchema.method('touch', function () { return this.toRecord(); }); + +// The options form, on the package's namespace. +const jobSchema = new mongoose.Schema({}, { statics: { due() { return 1; } } }); + +// Control: a schema-like object from a package that is not modelled stays unknown. +const { Schema: OtherSchema } = require('other-orm'); +const otherSchema = new OtherSchema({}); +otherSchema.statics.findLive = function () { return 2; }; + +function registerModels(connection) { + return { + Doc: connection.models.Doc || connection.model('Doc', docSchema), + Job: mongoose.model('Job', jobSchema), + Other: connection.model('Other', otherSchema), + }; +} +module.exports = { registerModels }; diff --git a/graph/test/javascript/cases/74-orm-model-factory/src/repo.js b/graph/test/javascript/cases/74-orm-model-factory/src/repo.js new file mode 100644 index 00000000..55f3b77d --- /dev/null +++ b/graph/test/javascript/cases/74-orm-model-factory/src/repo.js @@ -0,0 +1,31 @@ +'use strict'; +const { registerModels } = require('./models'); + +class DocRepository { + constructor(models) { this.Doc = models.Doc; this.Job = models.Job; this.Other = models.Other; } + list() { return this.Doc.findLive(); } + take() { return this.Doc.claim(); } + purge() { return this.Doc.purge(); } + due() { return this.Job.due(); } + record() { return new this.Doc({}).touch(); } + save() { return this.Doc.create({}); } // the package's own member: stays unknown + other() { return this.Other.findLive(); } // control: the unmodelled package +} + +// Control: an unrelated repository with same-named methods, reached by nothing above. +class MemoryRepository { + findLive() { return []; } + claim() { return null; } +} + +// Control: a project `.model(name, x)` whose second argument is not a schema keeps +// its own return value. +const registry = { model(name, def) { return def; } }; +const plain = registry.model('x', { findLive() { return 3; } }); + +function start(connection) { + const repo = new DocRepository(registerModels(connection)); + return [repo.list(), repo.take(), repo.purge(), repo.due(), repo.record(), repo.save(), repo.other(), + plain.findLive(), new MemoryRepository()]; +} +module.exports = { start }; diff --git a/graph/test/javascript/expected/74-orm-model-factory.diag b/graph/test/javascript/expected/74-orm-model-factory.diag new file mode 100644 index 00000000..f191f565 --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.diag @@ -0,0 +1,17 @@ +import_cause models.js:21:17 other-orm not_staged +import_cause models.js:7:18 mongoose not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved models.js:10:19 CONSTRUCTOR_CALL Schema callee_untyped +unresolved models.js:11:51 METHOD_CALL find receiver_untyped +unresolved models.js:12:1 METHOD_CALL static receiver_untyped +unresolved models.js:13:1 METHOD_CALL static receiver_untyped +unresolved models.js:15:1 METHOD_CALL method receiver_untyped +unresolved models.js:18:19 CONSTRUCTOR_CALL Schema receiver_untyped +unresolved models.js:22:21 CONSTRUCTOR_CALL OtherSchema callee_untyped +unresolved models.js:27:35 METHOD_CALL model receiver_untyped +unresolved models.js:28:10 METHOD_CALL model receiver_untyped +unresolved models.js:29:12 METHOD_CALL model receiver_untyped +unresolved repo.js:10:21 CONSTRUCTOR_CALL Doc member_absent +unresolved repo.js:11:19 METHOD_CALL create receiver_untyped +unresolved repo.js:12:20 METHOD_CALL findLive receiver_untyped +value_callee models.js:10:19 Schema module_variable diff --git a/graph/test/javascript/expected/74-orm-model-factory.edges b/graph/test/javascript/expected/74-orm-model-factory.edges new file mode 100644 index 00000000..f6b6bf7a --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.edges @@ -0,0 +1,36 @@ +models.js:10:19 CONSTRUCTOR_CALL Schema -> ambiguous_unknown - +models.js:11:51 METHOD_CALL this.find -> ambiguous_unknown - +models.js:12:1 METHOD_CALL docSchema.static -> ambiguous_unknown - +models.js:12:1 METHOD_CALL docSchema.static -> callback_registered models.js:12:27 +models.js:12:48 METHOD_CALL this.findLive -> known_edge models.js:11:30 +models.js:13:1 METHOD_CALL docSchema.static -> ambiguous_unknown - +models.js:13:1 METHOD_CALL docSchema.static -> callback_registered models.js:13:20 purge +models.js:15:1 METHOD_CALL docSchema.method -> ambiguous_unknown - +models.js:15:1 METHOD_CALL docSchema.method -> callback_registered models.js:15:27 +models.js:15:48 METHOD_CALL this.toRecord -> known_edge models.js:14:30 +models.js:18:19 CONSTRUCTOR_CALL mongoose.Schema -> ambiguous_unknown - +models.js:18:19 CONSTRUCTOR_CALL mongoose.Schema -> callback_registered models.js:18:56 due +models.js:22:21 CONSTRUCTOR_CALL OtherSchema -> ambiguous_unknown - +models.js:27:35 METHOD_CALL connection.model -> ambiguous_unknown - +models.js:28:10 METHOD_CALL mongoose.model -> ambiguous_unknown - +models.js:29:12 METHOD_CALL connection.model -> ambiguous_unknown - +repo.js:10:21 CONSTRUCTOR_CALL this.Doc -> ambiguous_unknown - +repo.js:10:21 METHOD_CALL new this.Doc({}).touch -> known_edge models.js:15:27 +repo.js:11:19 METHOD_CALL this.Doc.create -> ambiguous_unknown - +repo.js:12:20 METHOD_CALL this.Other.findLive -> ambiguous_unknown - +repo.js:24:15 METHOD_CALL registry.model -> known_edge repo.js:23:20 model +repo.js:27:16 CONSTRUCTOR_CALL DocRepository -> known_edge repo.js:5:3 +repo.js:27:34 FUNCTION_CALL registerModels -> known_edge models.js:25:1 registerModels +repo.js:28:11 METHOD_CALL repo.list -> known_edge repo.js:6:3 list +repo.js:28:24 METHOD_CALL repo.take -> known_edge repo.js:7:3 take +repo.js:28:37 METHOD_CALL repo.purge -> known_edge repo.js:8:3 purge +repo.js:28:51 METHOD_CALL repo.due -> known_edge repo.js:9:3 due +repo.js:28:63 METHOD_CALL repo.record -> known_edge repo.js:10:3 record +repo.js:28:78 METHOD_CALL repo.save -> known_edge repo.js:11:3 save +repo.js:28:91 METHOD_CALL repo.other -> known_edge repo.js:12:3 other +repo.js:29:23 CONSTRUCTOR_CALL MemoryRepository -> implicit_constructor - +repo.js:29:5 METHOD_CALL plain.findLive -> known_edge repo.js:24:37 findLive +repo.js:6:19 METHOD_CALL this.Doc.findLive -> known_edge models.js:11:30 +repo.js:7:19 METHOD_CALL this.Doc.claim -> known_edge models.js:12:27 +repo.js:8:20 METHOD_CALL this.Doc.purge -> known_edge models.js:13:20 purge +repo.js:9:18 METHOD_CALL this.Job.due -> known_edge models.js:18:56 due diff --git a/graph/test/javascript/expected/74-orm-model-factory.oracle b/graph/test/javascript/expected/74-orm-model-factory.oracle new file mode 100644 index 00000000..914865e5 --- /dev/null +++ b/graph/test/javascript/expected/74-orm-model-factory.oracle @@ -0,0 +1,12 @@ +repo.js:24:15 METHOD_CALL model EXACT repo.js:23:20 +repo.js:27:16 CONSTRUCTOR_CALL DocRepository EXACT repo.js:5:3 +repo.js:27:34 FUNCTION_CALL registerModels EXACT models.js:25:1 +repo.js:28:11 METHOD_CALL list EXACT repo.js:6:3 +repo.js:28:24 METHOD_CALL take EXACT repo.js:7:3 +repo.js:28:37 METHOD_CALL purge EXACT repo.js:8:3 +repo.js:28:51 METHOD_CALL due EXACT repo.js:9:3 +repo.js:28:63 METHOD_CALL record EXACT repo.js:10:3 +repo.js:28:78 METHOD_CALL save EXACT repo.js:11:3 +repo.js:28:91 METHOD_CALL other EXACT repo.js:12:3 +repo.js:29:23 CONSTRUCTOR_CALL MemoryRepository SYNTHESIZED_OK +# defects: 0 From 3a36902b9b9e70b8c29a6f2579c20ab54e5e9b53 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:42:54 -0700 Subject: [PATCH 092/258] Declare the data keys of module-level const objects A key of an exported const object literal whose value is data (TOPICS.CREATED in Object.freeze({ CREATED: 'a.b' }), a CommonJS const table) was no declaration, so impact refused it and its readers were word matches. - index: declare each data key of a module-level const literal under its key chain (JS and TS), seeing through Object.freeze/seal; function-valued keys inside Object.freeze are now owned by the variable too - rules: a read behind the object's own name is in scope; behind any other name, or a bare name, it is not this object's key Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 13 ++-- .../skills/axiomcode/scripts/axiomcode-index | 70 ++++++++++++++++--- .../skills/axiomcode/scripts/dl/impact.dl | 27 +++++-- .../const-object-data-keys/case.json | 55 +++++++++++++++ .../const-object-data-keys/ports.cjs | 17 +++++ .../const-object-data-keys/service.js | 39 +++++++++++ .../const-object-data-keys/topics.js | 21 ++++++ .../const-object-data-keys/case.json | 43 ++++++++++++ .../const-object-data-keys/service.ts | 28 ++++++++ .../const-object-data-keys/topics.ts | 20 ++++++ 10 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 tests/cases/javascript/const-object-data-keys/case.json create mode 100644 tests/cases/javascript/const-object-data-keys/ports.cjs create mode 100644 tests/cases/javascript/const-object-data-keys/service.js create mode 100644 tests/cases/javascript/const-object-data-keys/topics.js create mode 100644 tests/cases/typescript/const-object-data-keys/case.json create mode 100644 tests/cases/typescript/const-object-data-keys/service.ts create mode 100644 tests/cases/typescript/const-object-data-keys/topics.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..60ffa68c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -117,7 +117,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # it obtained itself, so an added proxied annotation does not apply") — the differential test caught that immediately. # `ref` is not here either: the alongside layer reads it to tell a sibling that touches the same field from one that does # not, for EVERY target kind. What is left is the layer only a field or type target can reach. -_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'field_holder', 'accessor', 'faccess', 'gen_table', 'base_name', 'field_type', 'reexport', 'reexport_from', 'switch_over'} _CONFIG = {'config', 'config_key_known', 'config_site'} # `reexport` is NOT out of a method's reach: rules 396 and 398 both start at target(q,"method",m,_) — the barrel @@ -1175,10 +1175,13 @@ class Impact: mod_of = {} for i, sy in g.sym.items(): if sy['kind'] == 'module' and sy.get('file'): mod_of.setdefault(sy['file'], i) - modules_used = set() + modules_used = set(); holders = []; key_names = set() for rid, f in self.fields.items(): # the field's own registry hash when the index has it, so a decoration ON the field joins (#750) fid = f['id'] or f"f:{rid}"; t = owner_tid(f['owner'] or '', f.get('file')) + # a key of a module-level object (`TOPICS.CREATED`) is owned by an OBJECT, not a type: its module owns it, and + # the object's name is what a read of it is written behind (`TOPICS.CREATED`, `nested.depth`) + if not t and f['owner']: holders.append((fid, f['owner'].rsplit('.', 1)[-1])); key_names.add(f['name']) if not t: t = mod_of.get(f.get('file') or '') if not t: continue if t in mod_of.values(): modules_used.add(t) @@ -1200,7 +1203,7 @@ class Impact: (fid, 'init_' + f['name'], 'write'), (fid, 'get_' + cap, 'read'), (fid, 'set_' + cap, 'write'), (fid, 'init_' + cap, 'write')] if f['name'].startswith('_') and len(f['name']) > 1: acc.append((fid, f['name'].lstrip('_'), 'read')) - W('member', members); W('owner', owners); W('field', fields); W('accessor', acc) + W('member', members); W('owner', owners); W('field', fields); W('accessor', acc); W('field_holder', sorted(set(holders))) # the accesses the ENGINE resolved, joined on the same id the `field` fact uses: symbols.id for a # field IS the bundle's fields.id. #1071 — the relation shipped populated and no rule read it, so a # resolved access was answered as a name match, with its read/write direction discarded. @@ -1328,13 +1331,15 @@ class Impact: for r in g.q("""SELECT DISTINCT s.caller_id, x.c1 t, s.file_path, s.start_line FROM ext_ctor_implicit_type x JOIN call_sites s ON s.id = x.c0""")] if g.has('ext_ctor_implicit_type') else []) refs = []; quals = [] + # an object's key names: TypeScript records the member of `TOPICS.CREATED` as a bare name, and the qualifier + # written in front of it is what tells that object's key from another's if g.has('refs'): for r in g.q("SELECT name, file, line, kind, entity_kind FROM refs WHERE line > 0"): c = self.at(r['file'], r['line']) if not c: continue rk = 'qualified' if r['kind'] in QUALIFIED_KINDS else 'bare'; ek = 'CLASS_LITERAL' if r['kind'] == 'CLASS_LITERAL' else (r['entity_kind'] or '') refs.append((c, r['name'], rk, ek, r['file'], r['line'])) - if rk == 'qualified': + if rk == 'qualified' or r['name'] in key_names: L = self.code(r['file']); text = L[r['line'] - 1] if r['line'] <= len(L) else '' for qn in re.findall(rf'([A-Za-z_$][\w$]*)\s*\.\s*{re.escape(r["name"])}\b', text): quals.append((r['file'], r['line'], r['name'], qn)) W('ref', refs); W('qualifier', sorted(set(quals))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 830ba9a5..10a7bfec 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -20,9 +20,10 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) -INDEX_VERSION = '6' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; +INDEX_VERSION = '7' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; - # v6: TypeScript class-property arrows named after their field); the query + # v6: TypeScript class-property arrows named after their field; + # v7: data keys of module-level const objects declared, Object.freeze seen through); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -114,7 +115,8 @@ A = { # `obj.x = function …`, `{ all: (p) => … }`: named for the property, as JavaScript's are (#1585). The rows are # read through `ts_member_rows`, which gives them JavaScript's expression shape memberNames=dict(file='all-typescript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-typescript-variables.csv', shape='typescript'), + vars='all-typescript-variables.csv', shape='typescript', + consts=lambda r: r.get('scopeKind') == 'MODULE_SCOPE' and r.get('isConst') == 'true'), skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -150,7 +152,9 @@ A = { # `exports.getUser = function …`, `obj.x = () => …`, `{ all: page => … }`: the function is named for the property it # is the value of, the way the language's own name inference does it, and is owned by the object's variable memberNames=dict(file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-javascript-variables.csv'), + vars='all-javascript-variables.csv', + # the module-level consts whose object literal's data keys are declarations (`TOPICS.CREATED`) + consts=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime', '').startswith('CONST') and not r.get('importLinkHash')), # `static [Symbol.hasInstance](x) {…}`: the parser leaves the name empty and links the key expression; the member is # named `[Symbol.hasInstance]`, as written, instead of displaying as `Tagged.` with nothing to ask for computedNames=dict(file='all-javascript-methods.csv', id='jsMethodUniqueHash', key='computedNameExpressionLinkHash', @@ -322,8 +326,12 @@ def ts_member_rows(file): p, role, pos = up(r) o = dict(jsExpressionUniqueHash=i, parentExpressionLinkHash=p['tsExpressionUniqueHash'] if p else '', edgeRole=TS_ROLES.get(role, role), childIndex=pos, operatorString=r.get('operatorString', ''), - expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT'}.get(kind, kind), - introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', '')) + expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT', 'CALL_EXPRESSION': 'CALL'}.get(kind, kind), + introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', ''), + ownerModuleLinkHash=r.get('tsModuleLinkHash', ''), startLine=r.get('startLine', '')) + if kind == 'CALL_EXPRESSION': # the callee as written, the way JavaScript's `text` starts: `Object.freeze(` + callee = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'METHOD_NAME'), None) + o['text'] = (dotted(callee) or '') + '(' if callee else '' if role == 'OBJECT_PROPERTY_KEY' and (kind == 'IDENTIFIER_REFERENCE' or (kind == 'LITERAL' and r.get('literalType') in ('STRING', 'NUMBER'))): o['name'] = r.get('literalValue', '') @@ -334,19 +342,35 @@ def ts_member_rows(file): out[i] = o return out.values(), {i: inner(i)['tsExpressionUniqueHash'] for i, r in src.items() if r.get('kind') in TS_WRAPPERS and inner(i)} bound_owner = {}; cls_named = {} +data_keys = [] # (key, owner key chain, the const's variable row, key row): see below if A.get('memberNames'): mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) ts_shape = mn.get('shape') == 'typescript' mrows, unwrap = ts_member_rows(mn['file']) if ts_shape else (rows(mn['file']), {}) for r in mrows: ex[r[mn['id']]] = r; kids[r.get('parentExpressionLinkHash', '')].append(r) - lit_var = {unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash']): r['name'] - for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} DOTTED = re.compile(r'^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$') def key_of(parent, role, idx): for k in kids.get(parent, ()): if k.get('edgeRole') == role and (idx is None or k.get('childIndex') == idx): return k + # `const T = Object.freeze({…})` holds the literal it is handed, as `const T = {…}` does: the variable's initializer + # is the CALL, and the literal it names is the call's argument. Without this the literal had no variable, so its + # function-valued keys lost their owner and its data keys had nothing to be declared under + FREEZE = re.compile(r'^Object\s*\.\s*(freeze|seal|preventExtensions)\s*\(') + def held_literal(i): + r = ex.get(i) + if r is not None and r.get('expressionKind') == 'CALL' and FREEZE.match(r.get('text') or ''): + a = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'ARGUMENT'), None) + a = ex.get(unwrap.get(a[mn['id']], a[mn['id']])) if a else None + if a is not None and a.get('expressionKind') == 'OBJECT_LITERAL': return a[mn['id']] + return i + lit_var = {}; const_lit = {} # literal -> variable name; literal -> variable row, module-level consts only + for r in rows(mn['vars']): + if not (r.get('initializerExpressionLinkHash') and r.get('name')): continue + i = held_literal(unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash'])) + lit_var[i] = r['name'] + if mn.get('consts') and mn['consts'](r): const_lit[i] = r def literal_owner(lit, depth=0): if lit.get(mn['id']) in lit_var: return lit_var[lit[mn['id']]] p = ex.get(lit.get('parentExpressionLinkHash', '')) @@ -389,6 +413,28 @@ if A.get('memberNames'): if name: bound[m_] = name if owner: bound_owner[m_] = owner + # A DATA KEY OF A MODULE-LEVEL CONST OBJECT is a declaration: `export const TOPICS = Object.freeze({ CREATED: 'a.b' })` + # declares TOPICS.CREATED, and `bus.publish(TOPICS.CREATED)` reads it. Only a function-valued key was a declaration (a + # method, above), so `impact TOPICS.CREATED` answered "nothing named" and its readers were word matches. A nested + # literal's keys follow the key chain (`LIMITS.nested.depth`). Not declared: a function (already the method), a bare + # name or shorthand (`{ create }` is another declaration, not a value of its own), a computed key, and any literal a + # function body builds or a `let` holds -- those are values, not the module's named constants + def const_root(lit, depth=0): + if lit[mn['id']] in const_lit: return const_lit[lit[mn['id']]] + p = ex.get(lit.get('parentExpressionLinkHash', '')) + if depth > 8 or not p or lit.get('edgeRole') != 'PROPERTY_VALUE' or p.get('expressionKind') != 'OBJECT_LITERAL': return None + return const_root(p, depth + 1) + NOT_DATA = {'FUNCTION_EXPRESSION', 'CLASS_EXPRESSION', 'IDENTIFIER', 'IDENTIFIER_REFERENCE'} + for k in list(ex.values()) if const_lit else (): + if k.get('edgeRole') != 'PROPERTY_KEY' or k.get('isComputedName') == 'true' or not k.get('name'): continue + p = ex.get(k.get('parentExpressionLinkHash', '')) + if not p or p.get('expressionKind') != 'OBJECT_LITERAL': continue + v = key_of(p[mn['id']], 'PROPERTY_VALUE', k.get('childIndex')) + v = ex.get(unwrap.get(v[mn['id']], v[mn['id']])) if v else None + if v is None or v.get('expressionKind') in NOT_DATA: continue + root = const_root(p); owner = literal_owner(p) if root else None + if not owner: continue + data_keys.append((k['name'], owner, root, k)) # ── types: display names with nesting recovered by line containment ─────────────────────────────── types = {r['id']: dict(r) for r in c.execute("SELECT id, name, qualified_name, category, file_path, start_line, end_line, provenance FROM types")} byfile = {} @@ -570,6 +616,14 @@ for d in A['decls']: qn = ((ot['qualified_name'] + '.' + name) if ot and ot.get('qualified_name') else None) or r.get('qualifiedName') or \ (((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name) sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) +# the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key +# chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes +for name, od, v, k in data_keys: + fp = rel(modules.get(k.get('ownerModuleLinkHash', ''), '')) or rel(v.get('filePath', '')) + ln = int(k.get('startLine') or 0) + vq = v.get('qualifiedName') or v.get('potentialQualifiedName') + qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" + sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..8b24bdd5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -82,6 +82,8 @@ .decl reexport(c:symbol, n:symbol, f:symbol, l:number) .input reexport .decl reexport_from(f:symbol, l:number, src:symbol) .input reexport_from .decl field(fl:symbol, t:symbol, n:symbol, f:symbol, l:number) .input field +// field_holder(fl, h): fl is a key of an OBJECT named h, not a member of a type (`TOPICS` of `TOPICS.CREATED`) +.decl field_holder(fl:symbol, h:symbol) .input field_holder .decl accessor(fl:symbol, n:symbol, rw:symbol) .input accessor .decl faccess(c:symbol, fl:symbol, acc:symbol, tier:symbol, f:symbol, l:number) .input faccess .decl gen_table(d:symbol, what:symbol) .input gen_table @@ -468,7 +470,10 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), - (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"), (ek != "OBJECT_PROPERTY_KEY" ; !key_decl_at(n, f, l)). +// the key that DECLARES another object's same-named key (`QUEUES = { CREATED: … }`) is that declaration, not a use of this one +.decl key_decl_at(n:symbol, f:symbol, l:number) +key_decl_at(n, f, l) :- field_holder(fl, _), field(fl, _, n, f, l). // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) @@ -555,7 +560,7 @@ direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, rol direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and @@ -567,13 +572,25 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). +// A KEY OF A MODULE-LEVEL OBJECT, read behind the object's own name: `TOPICS.CREATED` reads TOPICS's key wherever it is +// written, as `Order.TAX` reads Order's constant. The object is one value, read by its own name, so behind any OTHER +// name (`QUEUES.CREATED`, `upstreams.workspaces`) it is another object's member, and the by-name rules above leave it +// out (field_holder); a field of a TYPE read behind an object that holds a key of that name is left out too (holds_key) +.decl holds_key(h:symbol, n:symbol) +holds_key(h, n) :- field_holder(fl, h), field(fl, _, n, _, _). +// TypeScript records the member of `TOPICS.CREATED` as a BARE name, so the qualifier decides for a bare reference too. +// A bare name with no such qualifier is never an object's key -- a key is only ever read behind its object -- so the +// bare rules below leave object keys out: `workspaces.close()` is a variable, not OFFSETS.workspaces +direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, _, f, l), target(q, "field", fl, _), field(fl, t, n, _, _), field_holder(fl, h), + !scope(t, c), qualifier(f, l, n, h), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l), !field_holder(fl, _). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f), + !field_holder(fl, _). // a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in // one module and a wrapped handler of the same name in another are two declarations, and each answer carried the // other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` diff --git a/tests/cases/javascript/const-object-data-keys/case.json b/tests/cases/javascript/const-object-data-keys/case.json new file mode 100644 index 00000000..d970ff56 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/case.json @@ -0,0 +1,55 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object literal, through Object.freeze, is a declaration: its readers are the functions that read TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.js:4"], + "avoid": ["nothing named", "remove", "enqueue"] + }, + { + "why": "a same-named key of ANOTHER const object is another declaration: QUEUES.CREATED is read by enqueue only, and TOPICS.CREATED's reader is not its reader", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.js:12"], + "avoid": ["nothing named", "create service.js"] + }, + { + "why": "CONTROL: a key read behind another name (`settings.page`, `counts.page`) is another object's member, not LIMITS.page", + "run": ["impact", "LIMITS.page"], + "want": ["change: field LIMITS.page [field]", "pageSize service.js:16"], + "avoid": ["mirror", "local"] + }, + { + "why": "a plain const object literal, and a nested literal's key, are declared under their key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "pageSize service.js:16"], + "avoid": ["nothing named"] + }, + { + "why": "a CommonJS const object's key is declared the same way, and a member read of it is its reader; a captured local of the same name (`api.close()`) is not", + "run": ["impact", "OFFSETS.api"], + "want": ["change: field OFFSETS.api [field]", "apiPort ports.cjs:12"], + "avoid": ["nothing named", "closeLater", ""] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.js:29"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.js:20"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing: its key is not a module-level declaration", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/javascript/const-object-data-keys/ports.cjs b/tests/cases/javascript/const-object-data-keys/ports.cjs new file mode 100644 index 00000000..bfcb10b4 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/ports.cjs @@ -0,0 +1,17 @@ +'use strict'; +const OFFSETS = Object.freeze({ + web: 0, + api: 1, +}); + +function portFor(name) { + return 1000 + OFFSETS[name]; +} + +function apiPort() { + return 1000 + OFFSETS.api; +} + +exports.OFFSETS = OFFSETS; +exports.portFor = portFor; +exports.apiPort = apiPort; diff --git a/tests/cases/javascript/const-object-data-keys/service.js b/tests/cases/javascript/const-object-data-keys/service.js new file mode 100644 index 00000000..d24764b5 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/service.js @@ -0,0 +1,39 @@ +import { TOPICS, LIMITS, QUEUES, HANDLERS, helpers } from './topics.js'; + +export function create(bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function remove(bus) { + bus.publish(TOPICS.DELETED, {}); +} + +export function enqueue(q) { + q.send(QUEUES.CREATED); +} + +export function pageSize() { + return LIMITS.page + LIMITS.nested.depth; +} + +export function loud(s) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} + +export function dispatch(job) { + return HANDLERS.run(job); +} + +export function closeLater(open) { + const api = open(); + return () => api.close(); +} + +export function mirror(settings) { + return settings.page; +} diff --git a/tests/cases/javascript/const-object-data-keys/topics.js b/tests/cases/javascript/const-object-data-keys/topics.js new file mode 100644 index 00000000..1fd78023 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/topics.js @@ -0,0 +1,21 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const LIMITS = { + page: 50, + nested: { depth: 3 }, +}; + +export const QUEUES = Object.freeze({ + CREATED: 'queue.created', +}); + +export const helpers = { + shout: (s) => s.toUpperCase(), +}; + +export const HANDLERS = Object.freeze({ + run: (job) => job.id, +}); diff --git a/tests/cases/typescript/const-object-data-keys/case.json b/tests/cases/typescript/const-object-data-keys/case.json new file mode 100644 index 00000000..e8893670 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/case.json @@ -0,0 +1,43 @@ +{ + "lang": "typescript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object, through Object.freeze, is a declaration read by the function that reads TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.ts:6"], + "avoid": ["nothing named", "enqueue"] + }, + { + "why": "through `as const` too, and a same-named key of another object is another declaration", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.ts:10"], + "avoid": ["nothing named", "create service.ts"] + }, + { + "why": "a nested literal's key is declared under its key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "nestedDepth service.ts:14"], + "avoid": ["nothing named"] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.ts:18"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.ts:22"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/typescript/const-object-data-keys/service.ts b/tests/cases/typescript/const-object-data-keys/service.ts new file mode 100644 index 00000000..38525808 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/service.ts @@ -0,0 +1,28 @@ +import { TOPICS, QUEUES, LIMITS, HANDLERS, helpers } from './topics'; + +interface Bus { publish(topic: string, body: object): void; } + +export function create(bus: Bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function enqueue(bus: Bus) { + bus.publish(QUEUES.CREATED, {}); +} + +export function nestedDepth(): number { + return LIMITS.nested.depth; +} + +export function dispatch(id: string) { + return HANDLERS.run(id); +} + +export function loud(s: string) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} diff --git a/tests/cases/typescript/const-object-data-keys/topics.ts b/tests/cases/typescript/const-object-data-keys/topics.ts new file mode 100644 index 00000000..1ba99e1d --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/topics.ts @@ -0,0 +1,20 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const QUEUES = { + CREATED: 'queue.created', +} as const; + +export const LIMITS = { + nested: { depth: 3 }, +}; + +export const HANDLERS = Object.freeze({ + run: (id: string) => id.length, +}); + +export const helpers = { + shout: (s: string) => s.toUpperCase(), +}; From 58c0be5cb6637d650df9c469db72a5c8e0488d23 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:30:56 -0700 Subject: [PATCH 093/258] TypeScript: join a keyed in-process registry's publish to the functions filed under the same key A class that stores a function parameter in a field under a key parameter (slot.add(fn), map.set(k, fn), obj[k] = fn) and another method that reads that field by key and calls what it gets is now joined caller to caller: each publish site reaches the functions registered under an equal key (tier event_dispatch), a run-time key reaches all of them, and a constant the dispatch also looks up (a wildcard) matches every publish. Wrappers that forward the key or the function, and a subscribe-all method taking a table of { [KEY]: fn } entries, are followed. The function-type envelope row that made every registered function a candidate of the handler type (key-blind) is withdrawn for such a store's parameter, its overload signatures and forwarding wrappers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../86-keyed-callback-registry/src/app.ts | 68 +++++ .../86-keyed-callback-registry/src/bus.ts | 57 ++++ .../86-keyed-callback-registry/src/topics.ts | 4 + .../expected/86-keyed-callback-registry.edges | 60 ++++ .../86-keyed-callback-registry.entries | 13 + .../86-keyed-callback-registry.envelope | 1 + .../86-keyed-callback-registry.fields | 12 + .../86-keyed-callback-registry.fields-oracle | 14 + .../86-keyed-callback-registry.known-missing | 10 + .../86-keyed-callback-registry.oracle | 3 + .../86-keyed-callback-registry.type-use | 14 + .../86-keyed-callback-registry.types-oracle | 7 + .../test/typescript/tools/normalize_edges.py | 5 +- .../call-edge-generation/keyed_registry.dl | 267 ++++++++++++++++++ .../engine/resolution/value-flow.dl | 5 +- .../keyed-callback-registry/case.json | 46 +++ .../keyed-callback-registry/src/app.test.ts | 10 + .../keyed-callback-registry/src/app.ts | 68 +++++ .../keyed-callback-registry/src/bus.ts | 59 ++++ .../keyed-callback-registry/src/topics.ts | 4 + 22 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.edges create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.entries create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.envelope create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.fields create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.known-missing create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.oracle create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.type-use create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.types-oracle create mode 100644 graph/typescript/engine/call-edge-generation/keyed_registry.dl create mode 100644 tests/cases/typescript/keyed-callback-registry/case.json create mode 100644 tests/cases/typescript/keyed-callback-registry/src/app.test.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/app.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/bus.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/topics.ts diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 65f9a6c0..2c17b391 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -557,6 +557,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | | `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | +| `event_dispatch` | typescript | A publish on a project class's keyed registry reaching each function filed under the same key: `bus.publish('x', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe('x', f)` stored there, or an entry `{ ['x']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl). | | `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity's type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | @@ -577,7 +578,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row. - **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 00335314..2e9af322 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -514,6 +514,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: T, meaning: 'A publish on a project class\'s keyed registry reaching each function filed under the same key: `bus.publish(\'x\', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe(\'x\', f)` stored there, or an entry `{ [\'x\']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl).' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity\'s type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, @@ -755,7 +756,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..208bd4bc --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts @@ -0,0 +1,57 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const; diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.edges b/graph/test/typescript/expected/86-keyed-callback-registry.edges new file mode 100644 index 00000000..38603009 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.edges @@ -0,0 +1,60 @@ +ambiguous_unknown CONSTRUCTOR_CALL Bus#subscribe(string,Handler) @L12 -> - +ambiguous_unknown CONSTRUCTOR_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L47 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L7 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#(?) @L55 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL Bus#() @L17 -> - +ambiguous_unknown METHOD_CALL Bus#count(string) @L31 -> - +ambiguous_unknown METHOD_CALL Bus#dispatch(string,unknown) @L40 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L10 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L13 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L15 -> - +ambiguous_unknown METHOD_CALL Bus#subscribeAll(Record) @L23 -> - +ambiguous_unknown METHOD_CALL Indexer#dropped(unknown) @L30 -> - +ambiguous_unknown METHOD_CALL Indexer#indexed(unknown) @L27 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L55 -> - +ambiguous_unknown METHOD_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown METHOD_CALL app#onA() @L55 -> - +ambiguous_unknown METHOD_CALL app#onAll() @L58 -> - +ambiguous_unknown METHOD_CALL app#onAnything(unknown) @L13 -> - +ambiguous_unknown METHOD_CALL app#onB() @L61 -> - +ambiguous_unknown METHOD_CALL app#onCreated(unknown) @L7 -> - +ambiguous_unknown METHOD_CALL app#onRemoved(unknown) @L10 -> - +callback_registered METHOD_CALL MiniBus#fire(string,unknown) @L55 -> MiniBus#(?) +event_dispatch METHOD_CALL app#create() @L40 -> Indexer#(?) +event_dispatch METHOD_CALL app#create() @L40 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#create() @L40 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#go() @L67 -> app#onA() +event_dispatch METHOD_CALL app#go() @L67 -> app#onAll() +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onRemoved(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> Indexer#(?) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onRemoved(unknown) +known_edge CONSTRUCTOR_CALL app#() @L37 -> Indexer#(Bus) +known_edge CONSTRUCTOR_CALL app#() @L4 -> Bus#() +known_edge CONSTRUCTOR_CALL app#() @L53 -> MiniBus#() +known_edge FUNCTION_CALL Bus#dispatch(string,unknown) @L41 -> bus#(unknown) +known_edge FUNCTION_CALL Bus#publish(string,unknown) @L36 -> Bus#run() +known_edge METHOD_CALL Bus#run() @L35 -> Bus#dispatch(string,unknown) +known_edge METHOD_CALL Bus#subscribeAll(Record) @L25 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL Indexer#(?) @L21 -> Indexer#indexed(unknown) +known_edge METHOD_CALL Indexer#(?) @L22 -> Indexer#dropped(unknown) +known_edge METHOD_CALL Indexer#start() @L20 -> Bus#subscribeAll(Record) +known_edge METHOD_CALL app#() @L34 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L35 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L36 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L37 -> Indexer#start() +known_edge METHOD_CALL app#() @L63 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L64 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L65 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#create() @L40 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#go() @L67 -> MiniBus#fire(string,unknown) +known_edge METHOD_CALL app#relay({ type: string }) @L47 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#remove() @L43 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#size() @L50 -> Bus#count(string) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.entries b/graph/test/typescript/expected/86-keyed-callback-registry.entries new file mode 100644 index 00000000..afec440b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + exported_from_entry_module app#create app.ts:39 + exported_from_entry_module app#go app.ts:66 + exported_from_entry_module app#onA app.ts:54 + exported_from_entry_module app#onAll app.ts:57 + exported_from_entry_module app#onAnything app.ts:12 + exported_from_entry_module app#onB app.ts:60 + exported_from_entry_module app#onCreated app.ts:6 + exported_from_entry_module app#onRemoved app.ts:9 + exported_from_entry_module app#relay app.ts:46 + exported_from_entry_module app#remove app.ts:42 + exported_from_entry_module app#size app.ts:49 + unimported_module app# app.ts:1 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.envelope b/graph/test/typescript/expected/86-keyed-callback-registry.envelope new file mode 100644 index 00000000..0a65686b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.envelope @@ -0,0 +1 @@ +value bus#@9:41 -> bus#Bus.@16 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields b/graph/test/typescript/expected/86-keyed-callback-registry.fields new file mode 100644 index 00000000..02ad1620 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields @@ -0,0 +1,12 @@ +ambiguous_unknown read Bus#count(string) -> - +ambiguous_unknown read Indexer#start() -> - +ambiguous_unknown read app#() -> - +ambiguous_unknown read app#create() -> - +ambiguous_unknown read app#size() -> - +known_edge read Bus#count(string) -> Bus#handlers +known_edge read Bus#dispatch(string,unknown) -> Bus#handlers +known_edge read Bus#subscribe(string,Handler) -> Bus#handlers +known_edge read Indexer#start() -> Indexer#b +known_edge read MiniBus#fire(string,unknown) -> MiniBus#h +known_edge read MiniBus#on(string,(p: unknown) =) -> MiniBus#h +known_edge read app#relay({ type: string }) -> { type: string }#type diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle new file mode 100644 index 00000000..9148ff26 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle @@ -0,0 +1,14 @@ +86-keyed-callback-registry [fields] + precision 0.8571 (6 correct, 1 wrong) + recall 0.5000 (6 of 12 the compiler resolved) + sites 17 resolved 11 (64.7%) + tiers ambiguous_unknown=6 known_edge=11 + access read=17 + not scored: 6 rows whose target is not a client declaration + WRONG app#relay({ type: string }) READ { type: string }#type + MISSING Indexer#start() READ topics#created + MISSING Indexer#start() READ topics#removed + MISSING app#() READ topics#created + MISSING app#create() READ topics#created + MISSING app#relay({ type: string }) READ app#type + MISSING app#size() READ topics#created diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.known-missing b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing new file mode 100644 index 00000000..5a8e9f31 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing @@ -0,0 +1,10 @@ +# Accepted gaps for 86-keyed-callback-registry — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# The call through an element of a Set held in a Map resolves, for the compiler, to the element's +# function type; with no standard library staged the engine cannot type the Set's elements, so the +# call site stays unresolved (Bus.dispatch annotates its array, so its call resolves). The functions that call RUNS are reached by the keyed-registry edges +# (event_dispatch) from each publisher, which is what this case is about. +MiniBus#(?) -> MiniBus#(unknown) +MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.oracle b/graph/test/typescript/expected/86-keyed-callback-registry.oracle new file mode 100644 index 00000000..ee8df0d7 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.oracle @@ -0,0 +1,3 @@ +oracle=20 engine=18 agree=18 missing=2 (known 2, NEW 0) extra=0 + known MiniBus#(?) -> MiniBus#(unknown) + known MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.type-use b/graph/test/typescript/expected/86-keyed-callback-registry.type-use new file mode 100644 index 00000000..1f405a87 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.type-use @@ -0,0 +1,14 @@ +ambiguous_unknown AS_TARGET 0 topics [EXPRESSION] -> - +ambiguous_unknown METHOD_PARAM 0 Bus [METHOD_PARAM] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 Bus [EXPRESSION] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 MiniBus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 Bus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 MiniBus [EXPRESSION] -> - +known_edge METHOD_PARAM 0 Bus [METHOD_PARAM] -> Handler +known_edge METHOD_PARAM 0 Indexer [METHOD_PARAM] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Indexer +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> MiniBus +known_edge TYPE_ARGUMENT 1 Bus [EXPRESSION] -> Handler +known_edge TYPE_ARGUMENT 1 Bus [METHOD_PARAM] -> Handler +known_edge TYPE_ELEMENT 1 Bus [VARIABLE] -> Handler diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle new file mode 100644 index 00000000..82d34b05 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle @@ -0,0 +1,7 @@ +86-keyed-callback-registry [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 16 resolved 8 (50.0%) + tiers ambiguous_unknown=8 known_edge=8 + contexts AS_TARGET=1 METHOD_PARAM=3 METHOD_TYPE_ARGUMENT=2 OBJECT_CREATION_TYPE=7 TYPE_ARGUMENT=2 TYPE_ELEMENT=1 + not scored: 8 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py index 291ccaa4..9807d757 100644 --- a/graph/test/typescript/tools/normalize_edges.py +++ b/graph/test/typescript/tools/normalize_edges.py @@ -283,8 +283,9 @@ def main(): if f[1] not in n.m or f[3] not in n.m: continue # a function the site HANDS OVER (`xs.map(cb)`) is not a call the compiler lists at that - # site: the pairs are scored against the compiler, the .edges golden still carries it - if f[5] == 'callback_registered': + # site, nor is the handler a publish reaches through a keyed registry (`event_dispatch`): the + # pairs are scored against the compiler, the .edges golden still carries both + if f[5] in ('callback_registered', 'event_dispatch'): continue seen.add(f"{n.label(f[1])} -> {n.label(f[3])}") else: diff --git a/graph/typescript/engine/call-edge-generation/keyed_registry.dl b/graph/typescript/engine/call-edge-generation/keyed_registry.dl new file mode 100644 index 00000000..b1eabc1d --- /dev/null +++ b/graph/typescript/engine/call-edge-generation/keyed_registry.dl @@ -0,0 +1,267 @@ +// ============================================================================ +// CALL-EDGE-GEN · A CALLBACK KEPT IN A KEYED REGISTRY (the in-process bus) +// +// An in-process publish/subscribe is a class that keeps functions in a field keyed by a +// name and runs the ones under a name when that name is published: +// +// subscribe(name, fn) { (this.handlers.get(name) ?? …).add(fn); } +// publish(name, p) { for (const h of this.handlers.get(name) ?? []) h(p); } +// bus.subscribe('order.placed', onPlaced); … bus.publish('order.placed', o); +// +// Neither end calls the other. The call inside `publish` goes through a variable the +// registry filled, and the only rule that reached a handler from there was the function-type +// envelope (value-flow.dl, typed_holder_signature): every function ever passed where the +// handler type is expected became a candidate of that type's signature, so a publish on one +// name reached the handlers of every name, and of every other registry typed the same way. +// +// What ties the two ends together is the KEY both callers write. So the join is made between +// the CALLERS of the two methods, as the messaging rules join a producer to a consumer +// (framework-behavior/destinations.dl), but in process and on the project's own class: +// +// * a STORE: a method puts its function parameter into a field under its key parameter — +// `slot.add(fn)` / `push` / `unshift` on the slot `this.F.get(k)` or `this.F[k]` (directly, +// through `??`, `!`, `as`, or a variable it initialises), `this.F.set(k, fn)`, `this.F[k] = fn`; +// * a DISPATCH: a method reads the same field under its key parameter and CALLS what it gets — +// `for (const h of slot)`, a spread of the slot into an array it iterates, `slot.forEach(h => h())`. +// A read that calls nothing (`this.F.get(k)?.size`) is not a dispatch; +// * each is followed through wrappers that pass their own parameter on (`publish` forwarding the +// name to a private `dispatch`, from inside a closure too), and a method that subscribes each +// entry of an object parameter under its own key (`subscribeAll({ [K]: fn })`) registers every +// entry of the literal its callers pass; +// * a key is a string literal, a const holding one, or a member of a const object literal +// (`as const` too), local or imported. A key known only at run time matches every key. A constant +// key the dispatch ALSO looks up (`this.F.get('*')`) makes a registration under it match every +// publish. Not destinations.dl's rd_val, which reads more: that reader is fed by call_chain_edge +// (its wrapper hop) and negates inside, so an edge here built on it could not be stratified. +// +// The edge runs from the PUBLISH call site to the registered function, tier event_dispatch: the +// site does not call the handler, the registry does, as the JavaScript engine records an emitter's +// `emit` reaching an `on` handler. Receivers are not told apart: two instances of one bus class +// share its registrations, the precision the class gives. +// +// And the envelope row that made every handler a candidate of the handler type is withdrawn for the +// store's function parameter, but only where the registry has a dispatch this file recognised — +// otherwise it is the only route there is. +// ============================================================================ + +.decl kr_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_read(c0:symbol, c1:symbol, c2:symbol) +.decl kr_yields(c0:symbol, c1:symbol) +.decl kr_slot(c0:symbol, c1:symbol, c2:symbol) +.decl kr_adds(c0:symbol) +.decl kr_store(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_elem_holder(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatch_lit(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatched(c0:symbol) +.decl kr_site_runs(c0:symbol, c1:symbol) +.decl kr_disp(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_reg(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_table_elem(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table(c0:symbol, c1:symbol, c2:symbol) +.decl kr_key_arg(c0:symbol) +.decl kr_keyed(c0:symbol) +.decl kr_keyval(c0:symbol, c1:symbol) +.decl kr_fn(c0:symbol, c1:symbol) +.decl kr_forwards(c0:symbol) +.decl kr_registers(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table_lit(c0:symbol) +.decl kr_at(c0:symbol, c1:number, c2:number) +.decl kr_entry(c0:symbol, c1:symbol, c2:symbol) +.decl kr_static_key(c0:symbol, c1:symbol) +.decl kr_computed_root(c0:symbol, c1:symbol) +.decl kr_key_candidate(c0:symbol, c1:symbol, c2:number) +.decl kr_entry_key(c0:symbol, c1:symbol) +.decl kr_dispatch_site(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) +.decl kr_match(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl keyed_dispatch(c0:symbol, c1:symbol) +.decl kr_registry_param(c0:symbol) + +// an expression that names a parameter: (expr, the function declaring it, its position) +kr_param(e, owner, pos) :- expr_referenced("client", "PARAMETER", p, e), param_decl("client", _, pos, _, owner, p). + +// ── the registry, read under a key: `this.F.get(k)`, `this.F[k]` ───────────── +kr_read(ce, f, k) :- call_site("client", _, "get", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k). +kr_read(e, f, k) :- expr_kind("client", "ELEMENT_ACCESS", _, e), expr_child("client", e, "RECEIVER", _, r), + ts_field_access_target(r, f), expr_child("client", e, "INDEX_ARGUMENT", _, k). + +// kr_yields(X, Read): iterating or adding to X reaches the collection Read found: X is the read, +// an operand `??` / `||` / `&&` may yield, a transparent wrapper, or an array spreading it +kr_yields(e, e) :- kr_read(e, _, _). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", "BINARY_EXPRESSION", _, p), + expr_operator("client", op, p), binary_op_yields_operand(op, r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", k, _, p), + expr_kind_is_transparent(k), edge_role_is_operand(r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, "SPREAD_OPERAND", _, c), expr_kind("client", "SPREAD_ELEMENT", _, p). +kr_yields(p, e) :- kr_yields(c, e), expr_kind("client", "SPREAD_ELEMENT", _, c), expr_child("client", p, "ARRAY_ELEMENT", _, c). +// kr_slot(X, F, K): X holds the collection registry F keeps under key expression K +kr_slot(x, f, k) :- kr_yields(x, e), kr_read(e, f, k). +kr_slot(x, f, k) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_slot(i, f, k). + +// ── the store: (method, key parameter position, function parameter position, field) ── +kr_adds("add"). +kr_adds("push"). +kr_adds("unshift"). +kr_store(s, kp, fp, f) :- call_site("client", _, mn, _, recv, ce, _), kr_adds(mn), recv != "", + kr_slot(recv, f, k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "0", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- call_site("client", _, "set", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "1", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- expr_kind("client", "ASSIGNMENT_EXPRESSION", _, asg), + expr_child("client", asg, "LEFT_OPERAND", _, t), kr_read(t, f, k), kr_param(k, s, kp), + expr_child("client", asg, "RIGHT_OPERAND", _, a), kr_param(a, s, fp). + +// ── the dispatch: a method calls what the registry holds under a key ───────── +// kr_elem_holder(H, F, K): the variable or parameter H is bound to an element of the slot +kr_elem_holder(v, f, k) :- for_binding_iterable(v, it), kr_slot(it, f, k). +kr_elem_holder(p, f, k) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +kr_elem_holder(p, f, k) :- call_site("client", "OPTIONAL_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +// (method that reads the key, field, key expression) — a call is made through the element +kr_runs(d, f, k) :- kr_elem_holder(h, f, k), called_through(_, h), kr_read(e, f, k), expr_enclosing_method(e, d). +// the key is a parameter: whoever calls its function names the key +kr_runs_param(owner, pos, f) :- kr_runs(_, f, k), kr_param(k, owner, pos). +kr_dispatched(f) :- kr_runs(_, f, _). +// the key is a constant the dispatch also looks up (`this.F.get(ANY)`) +kr_demand(k) :- kr_runs(_, _, k), !kr_param(k, _, _). +kr_dispatch_lit(d, f, v) :- kr_runs(d, f, k), !kr_param(k, _, _), kr_str(k, v). + +// ── through wrappers ───────────────────────────────────────────────────────── +kr_site_runs(ce, m) :- expr_resolves_to_method(ce, m). +kr_site_runs(ce, m) :- call_runs_method(ce, m). +// kr_disp(W, Pos, F, D): calling W with a key at Pos runs, in D, what F holds under it +kr_disp(o, pos, f, d) :- kr_runs_param(o, pos, f), kr_runs(d, f, k), kr_param(k, o, pos). +kr_disp(w, pos, f, d) :- kr_disp(w2, p2, f, d), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), w != w2. +// kr_reg(W, KeyPos, FnPos, F): calling W stores the function at FnPos in F under the key at KeyPos +kr_reg(s, kp, fp, f) :- kr_store(s, kp, fp, f). +kr_reg(w, kp2, fp2, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), kr_param(ka, w, kp2), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_param(fa, w, fp2), w != s. +// kr_table(W, Pos, F): W stores each entry of the object at Pos under the entry's key: +// the function it stores is `table[k]` of that parameter, directly or through a const +kr_table_elem(x, w, tp) :- expr_kind("client", "ELEMENT_ACCESS", _, x), expr_child("client", x, "RECEIVER", _, r), + kr_param(r, w, tp). +kr_table_elem(x, w, tp) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_table_elem(i, w, tp). +kr_table(w, tp, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), !expr_kind("client", "LITERAL", _, ka), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_table_elem(fa, w, tp). + +// ── the string a key expression holds (on demand) ──────────────────────────── +.decl kr_demand(c0:symbol) +.decl kr_str(c0:symbol, c1:symbol) +.decl kr_denotes_var(c0:symbol, c1:symbol) +.decl kr_objlit(c0:symbol, c1:symbol) +.decl kr_obj(c0:symbol, c1:symbol) +.decl kr_member(c0:symbol, c1:symbol) +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "VARIABLE", v, e). +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "IMPORT_BINDING", ih, e), import_binds(ih, "client", "VARIABLE", v). +kr_str(e, v) :- kr_demand(e), expr_kind("client", "LITERAL", _, e), expr_literal_type("client", "STRING", e), + expr_literal_value("client", v, e). +// a const holding it +kr_demand(i) :- kr_denotes_var(_, v), var_initializer("client", _, i, v), i != "". +kr_str(e, s) :- kr_denotes_var(e, v), var_initializer("client", _, i, v), i != "", kr_str(i, s). +// `x as const`, `x!` +kr_demand(c) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r). +kr_str(e, s) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r), kr_str(c, s). +// `TOPIC.created` over `const TOPIC = { created: '…' } as const` +kr_objlit(i, i) :- expr_kind("client", "OBJECT_LITERAL", _, i). +kr_objlit(i, l) :- expr_kind("client", k, _, i), expr_kind_is_transparent(k), expr_child("client", i, r, _, c), + edge_role_is_operand(r), kr_objlit(c, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "VARIABLE", v, q), var_initializer("client", _, i, v), kr_objlit(i, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "IMPORT_BINDING", ih, q), import_binds(ih, "client", "VARIABLE", v), + var_initializer("client", _, i, v), kr_objlit(i, l). +kr_member(e, val) :- kr_demand(e), property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), kr_obj(q, l), + expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), expr_literal_value("client", n, kk), + expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, val). +kr_demand(val) :- kr_member(_, val). +kr_str(e, s) :- kr_member(e, val), kr_str(val, s). + +// ── the key a call writes ──────────────────────────────────────────────────── +kr_key_arg(a) :- kr_reg(s, kp, _, _), kr_site_runs(oc, s), expr_child("client", oc, "ARGUMENT", kp, a). +kr_key_arg(a) :- kr_disp(w, pos, _, _), kr_site_runs(oc, w), expr_child("client", oc, "ARGUMENT", pos, a). +kr_demand(a) :- kr_key_arg(a). +kr_keyed(a) :- kr_key_arg(a), kr_str(a, _). +kr_keyval(a, v) :- kr_key_arg(a), kr_str(a, v). +kr_keyval(a, "{}") :- kr_key_arg(a), !kr_keyed(a). + +// the function an argument hands over where it is written: a function, an arrow, an import, +// `this.onX`, `onX.bind(this)`, or a const holding one. A parameter passed on is not: the +// wrapper that passes it is followed instead (kr_reg), so its callers are the registrations. +kr_fn(x, m) :- expr_callable(x, m), !expr_kind("client", "CALL_EXPRESSION", _, x). +kr_fn(x, m) :- method_value(x, m). +kr_fn(x, m) :- expr_referenced("client", "VARIABLE", v, x), holder_holds_function(v, m), !kr_table_elem(x, _, _). +// (`const fn = table[name]` inside a wrapper that files each entry is a table hop, kr_table, not a +// registration of every entry under a key only known at run time) + +// a call inside a wrapper that passes its own parameter on is a hop of the wrapper, not a site +kr_forwards(oc) :- kr_disp(w2, p2, _, _), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), kr_disp(w, pos, _, _). + +// ── registrations: (field, key, function) ──────────────────────────────────── +kr_registers(f, kv, m) :- kr_reg(s, kp, fp, f), kr_site_runs(rc, s), + expr_child("client", rc, "ARGUMENT", kp, ka), kr_keyval(ka, kv), + expr_child("client", rc, "ARGUMENT", fp, fa), kr_fn(fa, m). +// a table: each entry of the object literal passed (or the const it initialises) +kr_table_lit(l) :- kr_table(w, tp, _), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l). +.decl value_branch_lit(c0:symbol, c1:symbol) +value_branch_lit(a, a) :- expr_kind("client", "OBJECT_LITERAL", _, a). +value_branch_lit(a, l) :- expr_referenced("client", "VARIABLE", v, a), var_initializer("client", _, l, v), + expr_kind("client", "OBJECT_LITERAL", _, l). +kr_entry(l, pos, v) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, v). +kr_static_key(l, pos) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, _). +kr_entry_key(v, kv) :- kr_entry(l, pos, v), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), + expr_literal_value("client", kv, kk). +// A COMPUTED key (`[TOPIC.created]: fn`) is its own root in the IR, with no link to its property +// (the walker reaches it as COMPUTED_PROPERTY_NAME). It is the last computed-name root of the same +// module that starts inside the literal and ends before the entry's value begins. +kr_at(e, to_number(sl) * 100000 + to_number(sc), to_number(el) * 100000 + to_number(ec)) :- + expr_location("client", sl, sc, el, ec, e), (kr_table_lit(e) ; kr_entry(_, _, e) ; kr_computed_root(e, _)). +kr_computed_root(c, mod) :- expr_root_context("client", "COMPUTED_PROPERTY_NAME", c), expr_kind("client", _, "ROOT", c), + expr_module("client", mod, c). +kr_key_candidate(v, c, cs) :- kr_entry(l, pos, v), !kr_static_key(l, pos), expr_module("client", mod, l), + kr_computed_root(c, mod), kr_at(l, ls, le), kr_at(c, cs, ce), kr_at(v, vs, _), + cs > ls, ce <= vs, vs < le. +kr_entry_key(v, kv) :- kr_key_candidate(v, c, cs), cs = max s : { kr_key_candidate(v, _, s) }, + kr_str(c, kv). +kr_demand(c) :- kr_key_candidate(_, c, _). +kr_registers(f, kv, m) :- kr_table(w, tp, f), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l), kr_entry(l, _, v), kr_entry_key(v, kv), kr_fn(v, m). + +// ── the join ───────────────────────────────────────────────────────────────── +kr_dispatch_site(dc, from, f, d, kv) :- kr_disp(w, pos, f, d), kr_site_runs(dc, w), !kr_forwards(dc), + expr_child("client", dc, "ARGUMENT", pos, a), kr_keyval(a, kv), call_from(dc, from). +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = kr. +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr_dispatch_lit(d, f, kr). +keyed_dispatch(dc, m) :- kr_dispatch_site(dc, _, f, d, kd), kr_registers(f, kr, m), kr_match(kd, kr, f, d). + +call_chain_edge(dc, caller, "-", m, "client", "event_dispatch", kind) :- keyed_dispatch(dc, m), + method_prov(m, "client"), call_from(dc, caller), caller != m, invocation_site(dc, kind). + +// the store's function parameter, in a registry this file dispatches: the envelope row for it +// is withdrawn (value-flow.dl, typed_holder_signature) — on the method, on each overload signature +// a call selects instead of it, and on a wrapper that passes its own parameter on +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), param_decl("client", _, fp, _, s, p). +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), group_implementation(g, s), method_group_of(sig, g), + param_decl("client", _, fp, _, sig, p). + +// A registry's slot is a collection: `slot.add(fn)`, `slot.delete(fn)` store or drop the function and +// never call it, as for a Map or Set the types name (value-flow.dl, collection_store_site). Without +// the library staged the slot has no type, and every function ever subscribed was "handed" to the +// store and reached from every caller of subscribe. +collection_store_site(ce) :- call_site("client", _, mn, _, recv, ce, _), recv != "", mn != "forEach", kr_slot(recv, _, _). diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 442303de..496892d3 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -406,7 +406,10 @@ ref_call_signature(r, sig) :- ref_type_target(r, _, t), call_signature_in_scope( typed_holder_signature(h, sig) :- field_type_ref("client", r, h), ref_call_signature(r, sig). typed_holder_signature(h, sig) :- var_type_ref("client", r, h), ref_call_signature(r, sig). -typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig). +// …except the function parameter of a method that files it in a keyed registry a dispatch reads: +// what runs it is the publish on the same key (call-edge-generation/keyed_registry.dl), and the +// type would hand it to every call of that type, whatever the key +typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig), !kr_registry_param(h). method_dispatch_candidate(sig, m, "value") :- typed_holder_signature(h, sig), holder_holds_function(h, m), diff --git a/tests/cases/typescript/keyed-callback-registry/case.json b/tests/cases/typescript/keyed-callback-registry/case.json new file mode 100644 index 00000000..56538eae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/case.json @@ -0,0 +1,46 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a callback stored in a Map under a key is run by the publish on the same key: the publisher reaches it through the registry, the key read through a const-object member", + "run": ["path", "create", "onCreated"], + "want": ["1 of 1 target(s) reached", "onCreated"], + "avoid": ["no chain of resolved calls"]}, + {"why": "a handler registered under the wildcard the dispatch also looks up runs for every key", + "run": ["path", "create", "onAnything"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a handler registered under another key is not run by that publish", + "run": ["path", "create", "onRemoved"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a handler table handed to a method that subscribes each entry under its own key: the entry under the published key is reached", + "run": ["path", "create", "Indexer.indexed"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the table entry under another key is not", + "run": ["path", "create", "Indexer.dropped"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a key only known at run time may be any key: every handler of that registry is reached", + "run": ["path", "relay", "onRemoved"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a method that reads the registry by key but never calls what it finds reaches no handler", + "run": ["path", "size", "onCreated"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "the terse form: the set created inline in the store, the lookup iterated directly and through forEach on a literal wildcard", + "run": ["path", "go", "onA"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "and the wildcard registration of the terse form", + "run": ["path", "go", "onAll"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the terse form's other key", + "run": ["path", "go", "onB"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "test-impact crosses the registry: the test that publishes the key is selected for the handler", + "run": ["impact", "onCreated", "--tests-only"], + "want": ["app.test.ts"]} + ]} diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.test.ts b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts new file mode 100644 index 00000000..f05f678a --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts @@ -0,0 +1,10 @@ +import { create, remove } from './app'; + +describe('bus', () => { + it('creates', () => { + create(); + }); + it('removes', () => { + remove(); + }); +}); diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.ts b/tests/cases/typescript/keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/bus.ts b/tests/cases/typescript/keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..ee7fad81 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/bus.ts @@ -0,0 +1,59 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: typeof ANY, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/topics.ts b/tests/cases/typescript/keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const; From 8c91375877c54e6000956a0c561729f6ce2dd02f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:05:18 -0700 Subject: [PATCH 094/258] Graph provenance: diff a no-commit graph against its file table; a later engine is newer A graph built where there was no git (a mirror synced without .git) stamps no commit and no tree. `changed` and the edit hooks then read the working tree against HEAD, so every uncommitted edit the index had read came back as changed right after it, and a copy without git could not answer at all. - changed: with no commit recorded, a file whose hash matches the table the index wrote is not an edit; one that differs is (read against HEAD, or counted whole without git). No table: the old refusal. - build: an explicit no-git index keeps its table as base-files.json; a background refresh leaves it, as base-tree is kept for git. - hooks: say "the files the graph was indexed from", never "commit nogit"; the Read stale note uses the table hash when there is no commit. - newer check: a graph ahead on either IMPACT_VERSION or engine version is newer (a lower IMPACT_VERSION no longer hides a later engine), and the note names AXIOMCODE_ENGINE when it is not the checkout answering. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/changes.py | 5 +- plugins/axiomcode/hooks/enrich.py | 6 ++- .../skills/axiomcode/scripts/ax_fresh.py | 27 +++++++++-- .../skills/axiomcode/scripts/axiomcode-build | 11 +++++ .../axiomcode/scripts/axiomcode-changed | 46 +++++++++++++++++-- tests/changed_range.py | 31 +++++++++++-- tests/freshness.py | 19 ++++++-- 7 files changed, 125 insertions(+), 20 deletions(-) diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 42856045..26ccd942 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -176,7 +176,8 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} moved = _graphline.base_moved_line(cwd, st) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) - since = f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) else f"the graph's commit {(j.get('built_at') or '')[:10]}" + since = (f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) + else "the files the graph was indexed from" if j.get('against_index') else f"the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against {since}) —") st['reported'] = list(seen | {key(d) for d in new}) @@ -189,7 +190,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) since = (f"against {head_at} (the commits that came in are not counted)" if (AGAINST or j.get('base_moved')) - else f"since the graph's commit {(j.get('built_at') or '')[:10]}") + else "since the graph was indexed" if j.get('against_index') else f"since the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree {since} and were not reported yet —") st['reported'] = list(seen | {key(d) for d in new}) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 0b49ea84..036e7f4a 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -207,7 +207,7 @@ def impact(d): except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) - base = (ch.get('built_at') or '')[:10] + base = '' if ch.get('against_index') else (ch.get('built_at') or '')[:10] # no commit recorded: nothing to name if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base and before is None else '') + " —") for d, j in results: head = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') @@ -263,6 +263,10 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' try: against = open(os.path.join(cwd, '.axiomcode', 'out', 'indexed-tree')).read().strip() or built except OSError: against = built if against != 'nogit' and subprocess.run(['git', 'diff', '--quiet', against, '--', rel], cwd=cwd, capture_output=True).returncode == 1: stale = f" — this file changed since the graph was built at {built[:10]}: lines are the graph's, not the file's" + elif against == 'nogit': # no commit recorded: the hash the index read it with + import ax_fresh + rec = ((ax_fresh.load_table(cwd) or {}).get('files') or {}).get(rel) + if rec and ax_fresh.digest(os.path.join(cwd, rel)) != rec[2]: stale = " — this file changed since the graph was indexed: lines are the graph's, not the file's" except Exception: pass if rows: st = load_state(); ctx = set(context_ids()) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 415c183d..cce1ea3c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -504,15 +504,34 @@ def _vnum(v): except (TypeError, ValueError): return None def _newer(old, impact, now_e): - """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id)""" + """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id). + AHEAD ON EITHER IS NEWER. A lower IMPACT_VERSION returned '' before the engines were compared, so a graph a 0.1.8 + engine built, read by these scripts with AXIOMCODE_ENGINE naming a 0.1.3 checkout, was called "built by an older + axiomcode (0.1.8 -> 0.1.3)" and rebuilt with the older engine""" oi, ni = _vnum(old.get('impact')), _vnum(impact) - if oi and ni and oi != ni: - return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" if oi > ni else '' + if oi and ni and oi > ni: + return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" ov, nv = old.get('engine_version'), (now_e[0] if now_e else None) if _vnum(ov) and _vnum(nv) and _vnum(ov) > _vnum(nv): return f"graph built by a newer axiomcode (engine {ov}, this one is {nv})" return '' +def _answering(): + """the engine checkout these scripts sit in, the one `axiomcode --version` reports; '' when they sit in none""" + w = H + while os.path.dirname(w) != w and not os.path.isfile(os.path.join(w, 'bin', 'axiomcode')): w = os.path.dirname(w) + return w if engine_ok(w) else '' + +def _whose(eng): + """WHICH ENGINE "THIS ONE" IS, when it is not the one answering: AXIOMCODE_ENGINE names another checkout, and a + rebuild here uses that one. Unsaid, "0.1.8 -> 0.1.3" read as a version going backwards under `axiomcode --version` + printing 0.1.8. '' when the engine compared is the checkout answering""" + a = _answering() + if not eng or not a or os.environ.get('AXIOMCODE_ENGINE') != eng or os.path.realpath(a) == os.path.realpath(eng): return '' + try: v = json.load(open(os.path.join(a, 'package.json'))).get('version', '?') + except (OSError, ValueError): v = '?' + return f"; compared with AXIOMCODE_ENGINE={eng}, which a rebuild here uses, not the axiomcode answering ({v} at {a})" + def built_by_state(repo, t=None): """(older, newer): what engine_change and newer_build return, found with one look at the engine. NEVER A DOWNGRADE comes first: a graph a newer axiomcode built is newer whatever else differs, and only a graph that is not is judged @@ -532,7 +551,7 @@ def built_by_state(repo, t=None): langs = old.get('engine_langs') now_e = engine_id(eng, langs) if eng else None newer = _newer(old, impact, now_e) - if newer: return '', newer + if newer: return '', newer + _whose(eng) diff = [] if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: h = _seen_hash(repo, now_e) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index e393ae73..60068b7b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -219,6 +219,15 @@ set_base(){ # the commit the baseline follows, written LAST: `changed` waits while it differs from HEAD, so it must not say "moved" # before the baseline's graph exists (keep_base_graph), or `changed` reads a baseline with no graph for it commit_base(){ [ -n "$GD" ] && echo "$HEAD_SHA" > "$OUT/base-commit"; return 0; } +# THE BASELINE WITHOUT GIT (a mirror synced without .git, an export): no tree to record, so `changed` reads an edit as a +# file whose hash differs from the table an explicit index wrote (axiomcode-changed, since_index). Kept as base-files.json, +# since every build, a background refresh too, rewrites files.json: read from it, an edit left `changed` a minute after it +# was made. An explicit index moves it; a refresh keeps it; a build with git has a tree and drops it. +set_base_files(){ + if [ -n "$GD" ]; then rm -f "$OUT/base-files.json" + elif [ -z "$KEEP_BASE" ] || [ ! -f "$OUT/base-files.json" ]; then [ -f "$OUT/files.json" ] && cp "$OUT/files.json" "$OUT/base-files.json"; fi + return 0 +} # THE BASELINE GRAPH. `changed`, `test-impact` and the change reports ask about an EDIT: which declarations it touched and # who depended on them before it, a removed method's callers included. After a background refresh the graph no longer # has the removed method, and its spans are in the new text. So the graph that describes the baseline is kept, moved @@ -287,6 +296,7 @@ if [ -z "${AXIOMCODE_REINDEX:-}" ] && [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/grap fi if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi + set_base_files has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 @@ -457,6 +467,7 @@ publish_main(){ if [ -n "$OTHER_LANGS" ]; then S="${SOLVE#*,}"; for l in ${S//,/ }; do echo "$l $MODE"; done > "$PENDING"; fi # before the pointer: the first query may follow at once point "$DB" || { restore; exit 1; }; rm -f "$OUT/.live.sqlite" if [ -f "$OUT/.files.json.new" ]; then mv "$OUT/.files.json.new" "$OUT/files.json"; else rm -f "$OUT/files.json"; fi + set_base_files # the graph now matches the files: record how long that took, and whether it included compiling the engine's rules (a # one-time cost a refresh does not pay, so such a duration predicts nothing). With other languages still to come this # is the time to the MAIN graph, which is what a query waits for. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index c97b818d..45b023f0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -42,6 +42,14 @@ CODE_EXT = r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$' def is_git(repo): return sh('git', 'rev-parse', '--git-dir', cwd=repo) is not None +def base_table(repo): + """the file table a graph with no commit is diffed against: the one the last explicit index kept (base-files.json, + which a background refresh leaves as it is), else the current one""" + try: return json.load(open(os.path.join(repo, '.axiomcode', 'out', 'base-files.json'))) + except (OSError, ValueError): + sys.path.insert(0, HERE); import ax_fresh + return ax_fresh.load_table(repo) + def resolve_range(repo, rng): """(old rev, new rev, note) for `--range`. `a..b` means what an agent asking about ITS commits means: what b added since it forked from a, so the old side is `git merge-base a b`, as `a...b` already is. Diffing the two TIPS, as this did, @@ -202,6 +210,20 @@ class Changed: b = os.path.join(self.repo, '.axiomcode', 'out', 'base-tree') b = open(b).read().strip() if os.path.exists(b) else '' self.base_tree = b if b and sh('git', 'cat-file', '-e', b, cwd=self.repo) is not None else self.indexed_tree + # NO COMMIT RECORDED: a graph built where there was no git (a mirror synced without .git, an export) stamps 'nogit' + # and writes no tree. Read against HEAD, every uncommitted edit the index had READ came back as changed right after + # it (a fresh index of a dirty mirror: 208 declarations), and in a copy without git nothing could be answered. The + # file table the build wrote holds each file's hash as the parser read it: a file whose bytes still match is + # unchanged against the graph, whatever git says; one that differs, is new or is gone is the edit (since_index). + self.since_index = None + if (not self.built_at or self.built_at == 'nogit') and not self.base_tree: + sys.path.insert(0, HERE); import ax_fresh + t = base_table(self.repo) + if t and t.get('files'): + c = ax_fresh.changes(self.repo, t) + if c is not None: + self.since_index = set(c[0]) | set(c[1]) | set(c[2]); self.indexed_files = set(t['files']) + self.index_built = float(t.get('built') or 0) # THE BASE MOVED UNDER THE BASELINE: a rebase, a pull, a checkout, a reset or a commit since it was set, and the # refresher has not caught up (it builds HEAD's text first, which takes minutes on a dirty tree, and never runs # with refresh off). Read against the old baseline, every change the new commits brought in (upstream's) came @@ -419,6 +441,13 @@ class Changed: # dropped here, an edit to them answered "no change" and test-impact "no test can be selected", which reads as # "nothing to test". They are kept apart, named, and test-impact looks for the tests that load them. lines = [l for l in (out or '').split('\n') if l.strip()] + if self.since_index is not None and mode == 'worktree': + # no commit to diff against: the files the table says changed since the index, and a file the table does not + # watch (a fixture, a schema) that git lists and that was written after the index + def later(l): + try: return os.path.getmtime(os.path.join(self.repo, l)) > self.index_built + except OSError: return False + lines = sorted(self.since_index | {l for l in lines if l not in self.indexed_files and not self.is_code(l) and later(l)}) self.outside = [l for l in lines if not self.is_code(l)] return [l for l in lines if self.is_code(l)] def is_code(self, rel): @@ -1381,7 +1410,9 @@ def main(argv): # function as added at line 1 and docstring words as methods. Without named files there is no answer to give: say so # and say what to pass. With named files, each counts whole (below). git = is_git(rrepo) - if not git and not (old_f or new_f) and not a: + sys.path.insert(0, HERE); import ax_fresh + # ...unless the graph's file table holds what the index read: an edit is a file that differs from it (Changed.since_index) + if not git and not (old_f or new_f) and not a and mode == 'worktree' and not (base_table(rrepo) or {}).get('files'): msg = NO_GIT.format(repo=rrepo) if as_json: print(json.dumps({'built_at': None, 'changed': [], 'notes': [], 'refused': msg, 'no_git': True}, indent=1)) else: print(msg) @@ -1392,7 +1423,6 @@ def main(argv): # A caller that already chose it (test-impact, the change hook) passes it in AXIOMCODE_GRAPH; either way it is the # baseline's graph, and its spans are not mapped again. baseline = None; fanout_empty = False - sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): bg = ax_fresh.baseline_graph(rrepo); given = os.environ.get('AXIOMCODE_GRAPH') # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a @@ -1439,9 +1469,13 @@ def main(argv): for rel in files: if a and not git: # a named file, no git: every declaration in it results += C.whole_file(rel, 'no git base to diff against'); continue + since_index = C.since_index is not None and mode == 'worktree' and rel in C.since_index + if since_index and not git: # changed since the index, no text of it before + results += C.whole_file(rel, 'changed since the index, and no git to diff its declarations against'); continue old, new = C.texts(rel, mode, rng) if old == new: if a and whole: results += C.whole_file(rel, f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + elif since_index: results += C.whole_file(rel, 'changed since the index, back to the text HEAD has: which declarations changed cannot be told') continue if flipped and not old.strip() and new.strip(): # a file the range adds: one line, not its decls reversed results += C.new_file(rel, new, C.decl_spans(rel, mode), rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython', new))) @@ -1470,14 +1504,16 @@ def main(argv): # A BASELINE THAT HOLDS EDITS (an explicit index of an edited tree, or a rebuild run as one) hides them: said, with how # to count them, rather than a bare "no change" over a real edit. Only when the question is the working tree as a whole base_note = C.baseline_note() if mode == 'worktree' and not a and not (old_f or new_f) else '' - base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else + by_index = C.since_index is not None and mode == 'worktree' and not (old_f or new_f) + base_desc = ("working tree against the files the graph was indexed from (no commit recorded)" if by_index else + f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None mv = C.moved if mode in ('worktree', 'head') and not (old_f or new_f) else None moved_json = dict(mv, note=moved_note(mv)) if mv else None if as_json: - print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), + print(json.dumps({'built_at': C.built_at, 'against_index': by_index, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, 'baseline_note': base_note, 'suggest_range': suggest, 'suggest_note': sugg_line, 'base_moved': moved_json}, indent=1)) return 3 if fanout_empty else 0 @@ -1489,6 +1525,8 @@ def main(argv): if sugg_line: print(f"next: {sugg_line}") return 3 if fanout_empty else 0 against = (f" — against HEAD {mv['new'][:10]} (the base moved: see the note)" if mv else + " — against the files the graph was indexed from (it recorded no commit): a file unchanged since the index is " + "not an edit; a changed one is read " + ("against HEAD" if git else "whole") if by_index else f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] diff --git a/tests/changed_range.py b/tests/changed_range.py index 5b661576..d82a0203 100644 --- a/tests/changed_range.py +++ b/tests/changed_range.py @@ -10,8 +10,10 @@ control on the base branch itself: no suggestion a new module one `added ` line; no docstring word, no parameter, nothing at line 1 control a function added to an existing file keeps its own line - a copy without git a refusal naming what to pass, not invented "added" declarations - control the same copy with a named file: every declaration in it counts, and its tests are named + a copy without git against the file table its index kept: unchanged is "no change", an edited file counts + whole; read later from a git checkout, a file as the index read it is not an edit + control an edit after that index is reported; with no table, a refusal naming what to pass; a + named file: every declaration in it counts, and its tests are named a changed fixture named as outside the index; it lies in a tree a test reads by path, so it is case data for that test, with that test's own pytest line; files inside the tree are its data control a data file no test names: said so, never "no change" @@ -200,11 +202,32 @@ def ax(repo, *a): shutil.copytree(repo, copy, ignore=shutil.ignore_patterns('.git', '.axiomcode')) built = sh(copy, AX, 'index', '.', '--lang', 'python', env=env) check(built.returncode == 0, 'the copy builds', built.stdout + built.stderr) + # no commit recorded: the baseline is the file table the index wrote (each file's hash as it was read) + rc, out = ax(copy, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'indexed from' in out, 'no git: an unchanged copy right after its index is no change', out) write(copy, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 3')) rc, out = ax(copy, 'changed', '.') - check(rc != 0 and 'no git base' in out and 'added' not in out, 'no git: changed refuses, naming what to pass', out) + check(rc == 0 and 'named' in out and 'price' in out and 'discount' in out and 'level' not in out and 'added' not in out, + 'no git: the file that differs from the index counts whole, the others not at all', out) + rc, out = ax(copy, 'test-impact', '.') + check(rc == 0 and 'test_pricing' in out and 'test_stock' not in out and 'page 1 of' not in out, 'no git: test-impact selects the edited file\'s tests only', out) + # a graph with no commit read from a git checkout (a mirror synced without .git): HEAD is not what was indexed + copy2 = os.path.join(work, 'copy2') + shutil.copytree(copy, copy2, symlinks=True) + write(copy2, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 5')) + for c in (['init', '-q'], ['add', 'app', 'tests'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'older']): sh(copy2, 'git', *c) + write(copy2, 'app/pricing.py', FILES['app/pricing.py']) # the text the copy's index read + rc, out = ax(copy2, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'price' not in out, + 'no commit recorded, read in a git checkout: a file as the index read it is not an edit against HEAD', out) + write(copy2, 'app/stock.py', FILES['app/stock.py'].replace('return n', 'return n + 0')) + rc, out = ax(copy2, 'changed', '.') + check('level' in out and 'price' not in out, 'control: an edit made after that index is still reported, and only it', out) + for t in ('files.json', 'base-files.json'): os.remove(os.path.join(copy, '.axiomcode', 'out', t)) + rc, out = ax(copy, 'changed', '.') + check(rc != 0 and 'no git base' in out and 'added' not in out, 'control: no git and no file table: changed refuses, naming what to pass', out) rc, out = ax(copy, 'test-impact', '.') - check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'no git: test-impact refuses, with no page footer', out) + check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'control: no git and no file table: test-impact refuses, with no page footer', out) rc, out = ax(copy, 'test-impact', '.', 'app/pricing.py') check(rc == 0 and 'tests/test_pricing.py' in out and 'test_stock' not in out, 'control: test-impact on the copy names that file\'s tests', out) rc, out = ax(copy, 'changed', '.', 'app/pricing.py') diff --git a/tests/freshness.py b/tests/freshness.py index c106676f..08bcd425 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -470,7 +470,7 @@ def query(repo): repo = repo_by('newer', impact=up) s = ax_fresh.status(repo); want = f"graph built by a newer axiomcode (IMPACT_VERSION {up}, this one has {mine})" check("newer: a higher IMPACT_VERSION is a newer build, not an older one to rebuild; with no edit the graph is fresh", - s.get('state') == 'fresh' and s.get('newer') == want and ax_fresh.engine_change(repo) == '', s) + s.get('state') == 'fresh' and s.get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '', s) out, err, took = query(repo) check(f"newer: the answer comes from it at once ({took:.1f}s), unmarked, and says a newer axiomcode built it and it is not rebuilt", out.strip() == ROWS.strip() and want in err and 'not rebuild' in err and 'rebuilding' not in err and took < 10, (out, err)) @@ -482,24 +482,33 @@ def query(repo): # an edit: still never rebuilt; the answer marks the edited file's rows, waits for nothing, and says why no refresh comes open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') s = ax_fresh.status(repo); out, err, took = query(repo) - check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer') == want, s) + check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer', '').startswith(want), s) check("newer: with a file edited the refresher still does not rebuild it", not worker(repo), '') check(f"newer: with a file edited the answer marks that file's rows, does not wait ({took:.1f}s), and names no rebuild", 'shop/api.py:5 - calls it' + ax_fresh.MARK in out and want in err and 'predates edits to shop/api.py' in err and 'queued' not in err and 'rebuilding' not in err and 'waiting' not in err and took < 10, (out, err)) w = ax_fresh.wait(repo, 5) - check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", w.get('newer') == want, w) + check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", (w.get('newer') or '').startswith(want), w) # the same IMPACT_VERSION and a later engine is newer too repo = repo_by('newer-engine', engine_version='1.0.1') check("newer: the same IMPACT_VERSION and a later engine version is a newer build", - ax_fresh.status(repo).get('newer') == "graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)" and not worker(repo), + ax_fresh.status(repo).get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") and not worker(repo), ax_fresh.status(repo)) + # AHEAD ON EITHER IS NEWER: a later engine whose IMPACT_VERSION is lower (these scripts newer than the engine that + # AXIOMCODE_ENGINE names) was "built by an older axiomcode (engine 1.0.1 -> 1.0.0)" and rebuilt with the older engine + repo = repo_by('newer-engine-older-export', impact=down, engine_version='1.0.1', engine_hash='0' * 40, engine_stat='0' * 40) + s = ax_fresh.status(repo) + check("newer: a later engine is a newer build even with a lower IMPACT_VERSION, not an older one to rebuild", + s.get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") + and ax_fresh.engine_change(repo) == '' and not worker(repo), s) + check("newer: the note says the engine compared is AXIOMCODE_ENGINE, not the axiomcode answering", + f"AXIOMCODE_ENGINE={e1}" in s.get('newer', '') and 'not the axiomcode answering' in s.get('newer', ''), s) # ── composed with the per-language key: NEVER A DOWNGRADE is decided first ── # a newer graph whose own language's rules differ from this engine's is still not rebuilt: that difference is # the newer axiomcode's, not a staleness this one can fix repo = repo_by('newer-own-rules', impact=up, engine_hash='0' * 40, engine_stat='0' * 40) check("newer: a newer build whose own language's engine files differ is still a newer build, not an older one", - ax_fresh.status(repo).get('newer') == want and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) + ax_fresh.status(repo).get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) # a higher IMPACT_VERSION is not re-exported either (rewarm): that would record this older version over it worker(repo) check("newer: the refresher does not re-export a newer build's facts; the table keeps the newer IMPACT_VERSION", From 83179c31e18de96583b30f5877b5d34e4c88845a Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:18:40 -0700 Subject: [PATCH 095/258] Compile the engine binaries at build time, not at the first index On a cold cache the first index compiled the language's engine with the C++ compiler: minutes regardless of repository size, while the index itself takes seconds. run-souffle.sh gains --prepare (the same program, id, cache entry and lock as a run, without a project), bin/axiomcode gains `prepare` (every language in parallel), and npm's postbuild starts it in the background, so an install or a refresh compiles the engines before the first index needs them. Skipped under CI or AXIOMCODE_NO_PREPARE. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 37 ++- graph/pipeline/run-souffle.sh | 304 +++++++++++++----------- graph/test/tools/engine-package-test.sh | 38 ++- package.json | 1 + 4 files changed, 238 insertions(+), 142 deletions(-) diff --git a/bin/axiomcode b/bin/axiomcode index f5a73ca2..6b902cf0 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -37,6 +37,10 @@ # Parse only; writes //. # engine --language L --client-ir / --out
    [options] # Solve previously parsed IR. +# prepare [--language L[,…]] [--background] +# Make each language's engine binary ready (packaged, cached, or compiled +# now), so the first index does not pay the compile. `npm run build` runs it +# in the background; AXIOMCODE_NO_PREPARE=1 or CI skips that. # test [java|typescript|python|javascript|csharp|parser|all] [suite options] # Run the test suites. # mcp Serve the graph to an agent as MCP tools over stdio. Any MCP client @@ -144,7 +148,7 @@ esac # for `axiomcode ./src out/` and wrong for everything else: a misspelled verb used to be parsed as a # source tree and fail with advice about building the parser. case "$cmd" in - parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; + parser|engine|prepare|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; *) [ -d "$cmd" ] || { echo "axiomcode: '$cmd' is neither a verb nor a directory." >&2 echo " ask: $(public_verbs | tr '\n' ' ')" >&2 echo " \`axiomcode help\` for what each one does." >&2; exit 2; } @@ -189,6 +193,37 @@ case "$cmd" in has --library || args+=(--library "") bash "$ROOT/graph/pipeline/run-souffle.sh" "${args[@]}" ;; + prepare) + # EVERY LANGUAGE'S ENGINE BINARY, BEFORE THE FIRST INDEX ASKS FOR IT. On a cache miss the engine is compiled to C++ + # and built by the C++ compiler: minutes, whatever the size of the repository, while the index it serves takes + # seconds. Left to the first index, that cost landed on whoever indexed first after an install or a rule change, + # and on a query that had to build a graph. `npm run build` runs this in the background (postbuild), so an install + # or a refresh starts the compiles; an index that starts meanwhile waits on the same lock instead of compiling twice. + langs=""; bg=false + while [ $# -gt 0 ]; do case "$1" in + --language) langs="$2"; shift 2;; --background) bg=true; shift;; + *) die "usage: axiomcode prepare [--language L[,…]] [--background]";; esac; done + if [ -z "$langs" ]; then + for d in "$ROOT"/graph/*/engine; do [ -d "$d" ] && { d="${d%/engine}"; langs="$langs,${d##*/}"; }; done + langs="${langs#,}" + fi + IFS=',' read -ra want <<< "$langs" + for l in ${want[@]+"${want[@]}"}; do [ -d "$ROOT/graph/$l/engine" ] || die "prepare: no engine for --language=$l"; done + if $bg; then + # a CI job compiles the engines it tests itself, with its own cache and -march; a background compile there only + # competes with the job for the machine + if [ -n "${AXIOMCODE_NO_PREPARE:-}" ] || [ -n "${CI:-}" ]; then echo "▶ engines not prepared (${AXIOMCODE_NO_PREPARE:+AXIOMCODE_NO_PREPARE}${CI:+CI} is set)"; exit 0; fi + log="${TMPDIR:-/tmp}/axiomcode-prepare-$(id -u 2>/dev/null || echo 0).log" + nohup bash "$0" prepare --language "$langs" > "$log" 2>&1 < /dev/null & + echo "▶ preparing the engines for ${langs//,/, } in the background (log: $log)" + exit 0 + fi + # one compile per language, side by side: each is one single-threaded compiler process of a few hundred MB + ncpu="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)"; jobs="${AXIOM_PREPARE_JOBS:-$(( ncpu / 2 ))}" + [ "$jobs" -ge 1 ] 2>/dev/null || jobs=1; [ "$jobs" -le ${#want[@]} ] || jobs=${#want[@]} + printf '%s\n' "${want[@]}" | xargs -P "$jobs" -I{} bash -c \ + 'set -o pipefail; bash "$1" --language "$2" --prepare 2>&1 | sed "s/^/[$2] /"' _ "$ROOT/graph/pipeline/run-souffle.sh" {} + ;; all) need_parser lang=""; src=""; out=""; version=""; libs=""; progress=""; popts=(); rest=(); pos=() diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 0d58ba94..c26c8774 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -5,6 +5,7 @@ # Usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L] [--debug] # run-souffle.sh --language L --print-engine-id the canonical id of L's compiled engine # run-souffle.sh --language L --emit-program FILE the Soufflé program CI compiles for L +# run-souffle.sh --language L --prepare make L's engine binary ready (packaged, cached or compiled) # # NO SOUFFLÉ NEEDED TO RUN. The rules compile to one self-contained executable that is # project-independent; CI builds it for every platform and publishes it on npm as @@ -48,7 +49,7 @@ TAINT="" # --taint on → gate lib→lib GROW on client-seeded data flow (d CLOSED_WORLD="" # --closed-world on → narrow the dispatch fan to types the program constructs (RTA), # and record every edge that drops as an assumption row. Env AXIOM_DISPATCH_CLOSED_WORLD=on. # Empty = the fan is every declared override (default). See #473. -MODE="run" # run | print-engine-id | emit-program — the last two need no IR and no souffle +MODE="run" # run | print-engine-id | emit-program | prepare — none but run needs IR; the id and the program need no souffle EMIT="" while [ $# -gt 0 ]; do case "$1" in --client-ir) CLIENT="$2"; shift 2;; --library) LIB="$2"; shift 2;; @@ -61,6 +62,7 @@ while [ $# -gt 0 ]; do case "$1" in --language) LANG_ARG="$2"; shift 2;; --print-engine-id) MODE="print-engine-id"; shift;; --emit-program) MODE="emit-program"; EMIT="$2"; shift 2;; + --prepare) MODE="prepare"; shift;; # graph.sqlite is the deliverable; csv/*.csv is a debugging view of the same core # tables. --debug asks for both. (An older Node with no node:sqlite writes the CSVs # regardless, because otherwise the run would produce no consumer-facing output.) @@ -334,21 +336,7 @@ engine_id_of(){ fi ENGINE_ID="$h" } -case "$MODE" in - print-engine-id) - _pd="$(mktemp -d "${TMPDIR:-/tmp}/axiom-program.XXXXXX")" && [ -d "$_pd" ] || { echo "❌ mktemp failed" >&2; exit 1; } - if program_file "$_pd/program.dl" && { engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl"; }; then rm -rf "$_pd" - else rm -rf "$_pd"; exit 1; fi - printf '%s\n' "$ENGINE_ID" || exit 1 - exit 0;; - emit-program) program_file "$EMIT" || exit 1; exit 0;; -esac - -[ -n "${CLIENT:-}" ] && [ -n "${INT:-}" ] && [ -n "${OUT:-}" ] || { echo "usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L]" >&2; exit 1; } -FACTS="$INT/souffle-facts"; rm -rf "$FACTS"; mkdir -p "$FACTS" "$OUT" -# raw/ is OWNED: wiped per run so a relation that left the manifest cannot linger from an -# earlier run and be mistaken for this one's output. -RAW="$OUT/raw"; rm -rf "$RAW"; mkdir -p "$RAW" +set_cache_root(){ # Shared, machine-scoped cache root. Holds BOTH project-independent artefacts: the # compiled engine binary, and the staged library signature facts. # @@ -370,6 +358,165 @@ else || CACHE_ROOT="$SRC/../.souffle-cache" fi mkdir -p "$CACHE_ROOT" +} +# engine_binary: the engine binary for $PROG (whose id is $ENGINE_ID), in $BIN — the packaged one, the cached one, +# or one compiled here into the cache. Exits when there is none. Uses $INT for the generated C++. +engine_binary(){ +# What we cache is OUR engine compiled to a native binary (souffle -g turns the .dl rules +# into C++, c++ compiles it) — NOT the souffle tool. It depends only on the engine (rules + +# decls) and is PROJECT-INDEPENDENT (relative .input/.output), so one binary serves every +# project. It lives in a shared, machine-scoped cache keyed by the engine id — NOT in the +# per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is +# gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. +CACHE_DIR="$CACHE_ROOT" +# -march: `native` by default, tuned for the machine that compiles and runs it. A binary +# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs +# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline +# target instead, as the published engines are (build-engines.yml). Any other value is +# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a +# cache across machines keys it on the setting (ci.yml does). +case "${AXIOM_ENGINE_MARCH:-native}" in + portable) MARCH_FLAG=();; + *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; +esac +EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac +BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" + +# The platform string, in npm's spelling (process.platform-process.arch), because that is +# how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. +engine_platform(){ + local os arch + case "$(uname -s)" in + Linux) os=linux;; Darwin) os=darwin;; MINGW*|MSYS*|CYGWIN*) os=win32;; + *) echo "unsupported platform: $(uname -s)" >&2; return 1;; + esac + case "$(uname -m)" in + x86_64|amd64) arch=x64;; arm64|aarch64) arch=arm64;; + *) echo "unsupported architecture: $(uname -m)" >&2; return 1;; + esac + # a bash started from an Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64; npm installed + # the arm64 engine, and an arm64 binary runs natively even from a translated process. + if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; fi + printf '%s-%s\n' "$os" "$arch" +} +# 1. the engine package npm installed for this machine, if it was built from exactly these +# rules. Found by walking up from the package root the way node would, so a checkout's own +# node_modules and a global install both work. +PACKAGED="" +platform="$(engine_platform 2>/dev/null || true)" +# this machine's package first, then the same OS's other architecture: npm installs exactly one per machine, so when +# the first is absent the installed one is the one npm chose here. +if [ -n "$platform" ]; then + case "$platform" in *-arm64) other="${platform%-arm64}-x64";; *) other="${platform%-x64}-arm64";; esac + for p in "$platform" "$other"; do + d="$PKG" + while [ "$d" != / ] && [ ! -d "$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$p" ]; do d="$(dirname "$d")"; done + if [ "$d" != / ]; then platform="$p"; break; fi + done + d="$PKG" + while [ "$d" != / ]; do + pkgdir="$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$platform" + if [ -d "$pkgdir" ]; then + have="$(tr -d '[:space:]' < "$pkgdir/$LANG_ARG/ENGINE_ID" 2>/dev/null || true)" + cand="$pkgdir/$LANG_ARG/axiomcode-engine-$LANG_ARG$EXE" + if [ "$have" = "$ENGINE_ID" ] && [ -f "$cand" ]; then PACKAGED="$cand"; chmod +x "$cand" 2>/dev/null || true + elif [ -n "$have" ]; then echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform holds $LANG_ARG at ${have:0:12}…, these rules are ${ENGINE_ID:0:12}… — not using it (publish a new engine version for these rules)" + else echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform has no $LANG_ARG engine"; fi + break + fi + d="$(dirname "$d")" + done +fi + +if [ -n "$PACKAGED" ]; then + BIN="$PACKAGED"; echo "▶ using packaged engine $ENGINE_PACKAGE_SCOPE/engine-$platform ($LANG_ARG)" +elif [ -x "$BIN" ]; then + echo "▶ reusing cached binary" +elif command -v souffle >/dev/null 2>&1; then + # ONE COMPILE PER ENGINE ID, under a lock whose owner must be dead, not merely old, before + # another run takes it over (compile-lock.sh). + COMPILE_LOCK="$BIN.lock" + compile_lock_take "$COMPILE_LOCK" + trap 'compile_lock_drop "$COMPILE_LOCK"' EXIT +fi +if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then + echo "▶ reusing the binary another run compiled" +elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then + echo "▶ compiling souffle program (cache miss)..." + INNER="$(find_souffle_include)" + # Assert the HEADER, not the directory: `[ -d ]` is the test #216 established cannot tell + # the two install layouts apart, so it would pass a path that then fails at the compiler. + if [ -z "$INNER" ] || [ ! -f "$INNER/souffle/CompiledSouffle.h" ]; then + echo "❌ soufflé is on PATH but its headers are not. Set AXIOM_SOUFFLE_INCLUDE." >&2; exit 1 + fi + have="$(souffle --version 2>/dev/null | sed -n 's/^Version: *\([0-9][0-9.]*\).*/\1/p' | head -1)" + [ "$have" = "$SOUFFLE_VERSION" ] || echo " ! local souffle is $have, the pinned version is $SOUFFLE_VERSION — a locally compiled engine may differ from CI's" + # Generate C++. souffle's "No rules/facts defined" warnings (for the intentionally + # unstaged lib-body relations — inert paths) aren't silenced by -w, so filter those 3- + # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w + # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then + # atomically rename, so a concurrent/aborted run never leaves a half-written binary. + if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then + cat "$INT/.souffle-gen.log" >&2; exit 1 + fi + awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 + [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } + CXX_PLATFORM="" + case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 + fi + # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may + # land under $ENGINE_ID unless it is a whole binary built from the program that id names: + # the temp binary must be a non-empty executable, and the program must still hash to the id + # (a program or rule file that changed during the compile would otherwise be cached under + # the old id). Only then the atomic rename. + _built_id="$ENGINE_ID" + if [ ! -s "$BIN.tmp.$$" ] || [ ! -x "$BIN.tmp.$$" ] || ! engine_id_of "$PROG" || [ "$ENGINE_ID" != "$_built_id" ]; then + rm -f "$BIN.tmp.$$" + echo "❌ the compiled engine did not verify (program now hashes to ${ENGINE_ID:-nothing}, built as $_built_id); not caching it" >&2 + exit 1 + fi + mv -f "$BIN.tmp.$$" "$BIN" +fi +if [ -n "${COMPILE_LOCK:-}" ]; then compile_lock_drop "$COMPILE_LOCK"; trap - EXIT +elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then + echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 + echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 + echo " • install souffle $SOUFFLE_VERSION to compile locally (macOS: brew install souffle; Ubuntu: the .deb from souffle-lang/souffle releases)." >&2 + exit 1 +fi +} +case "$MODE" in + print-engine-id) + _pd="$(mktemp -d "${TMPDIR:-/tmp}/axiom-program.XXXXXX")" && [ -d "$_pd" ] || { echo "❌ mktemp failed" >&2; exit 1; } + if program_file "$_pd/program.dl" && { engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl"; }; then rm -rf "$_pd" + else rm -rf "$_pd"; exit 1; fi + printf '%s\n' "$ENGINE_ID" || exit 1 + exit 0;; + emit-program) program_file "$EMIT" || exit 1; exit 0;; + # The engine binary without a project: what an install or a build runs (`axiomcode prepare`), so the first index + # finds it cached instead of paying the C++ compile — minutes, against seconds for the index itself. The same + # program text, id, cache entry and lock as a run, so a run that starts meanwhile waits for this compile. + prepare) + set_cache_root + INT="$(mktemp -d "${TMPDIR:-/tmp}/axiom-prepare.XXXXXX")" && [ -d "$INT" ] || { echo "❌ mktemp failed" >&2; exit 1; } + PROG="$INT/souffle-program.dl"; _t0=$(date +%s) + program_file "$PROG" || { rm -rf "$INT"; exit 1; } + engine_id_of "$PROG" || engine_id_of "$PROG" || engine_id_of "$PROG" \ + || { rm -rf "$INT"; echo "❌ could not compute the engine id of $PROG" >&2; exit 1; } + engine_binary + rm -rf "$INT" + echo "✓ $LANG_ARG engine ready in $(( $(date +%s) - _t0 )) s: $BIN" + exit 0;; +esac + +[ -n "${CLIENT:-}" ] && [ -n "${INT:-}" ] && [ -n "${OUT:-}" ] || { echo "usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L]" >&2; exit 1; } +FACTS="$INT/souffle-facts"; rm -rf "$FACTS"; mkdir -p "$FACTS" "$OUT" +# raw/ is OWNED: wiped per run so a relation that left the manifest cannot linger from an +# earlier run and be mistaken for this one's output. +RAW="$OUT/raw"; rm -rf "$RAW"; mkdir -p "$RAW" +set_cache_root START_EPOCH=$(date +%s); START_TS=$(date '+%Y-%m-%d %H:%M:%S') # Library roots: --library is a comma-separated list of IR roots (each with jdk-style @@ -546,130 +693,7 @@ engine_id_of "$PROG" || engine_id_of "$PROG" || engine_id_of "$PROG" \ || { echo "❌ could not compute the engine id of $PROG; refusing to guess a cache entry" >&2; exit 1; } echo "▶ engine id = $ENGINE_ID (rules + souffle $SOUFFLE_VERSION)" -# What we cache is OUR engine compiled to a native binary (souffle -g turns the .dl rules -# into C++, c++ compiles it) — NOT the souffle tool. It depends only on the engine (rules + -# decls) and is PROJECT-INDEPENDENT (relative .input/.output), so one binary serves every -# project. It lives in a shared, machine-scoped cache keyed by the engine id — NOT in the -# per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is -# gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. -CACHE_DIR="$CACHE_ROOT" -# -march: `native` by default, tuned for the machine that compiles and runs it. A binary -# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs -# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline -# target instead, as the published engines are (build-engines.yml). Any other value is -# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a -# cache across machines keys it on the setting (ci.yml does). -case "${AXIOM_ENGINE_MARCH:-native}" in - portable) MARCH_FLAG=();; - *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; -esac -EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac -BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" - -# The platform string, in npm's spelling (process.platform-process.arch), because that is -# how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. -engine_platform(){ - local os arch - case "$(uname -s)" in - Linux) os=linux;; Darwin) os=darwin;; MINGW*|MSYS*|CYGWIN*) os=win32;; - *) echo "unsupported platform: $(uname -s)" >&2; return 1;; - esac - case "$(uname -m)" in - x86_64|amd64) arch=x64;; arm64|aarch64) arch=arm64;; - *) echo "unsupported architecture: $(uname -m)" >&2; return 1;; - esac - # a bash started from an Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64; npm installed - # the arm64 engine, and an arm64 binary runs natively even from a translated process. - if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; fi - printf '%s-%s\n' "$os" "$arch" -} -# 1. the engine package npm installed for this machine, if it was built from exactly these -# rules. Found by walking up from the package root the way node would, so a checkout's own -# node_modules and a global install both work. -PACKAGED="" -platform="$(engine_platform 2>/dev/null || true)" -# this machine's package first, then the same OS's other architecture: npm installs exactly one per machine, so when -# the first is absent the installed one is the one npm chose here. -if [ -n "$platform" ]; then - case "$platform" in *-arm64) other="${platform%-arm64}-x64";; *) other="${platform%-x64}-arm64";; esac - for p in "$platform" "$other"; do - d="$PKG" - while [ "$d" != / ] && [ ! -d "$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$p" ]; do d="$(dirname "$d")"; done - if [ "$d" != / ]; then platform="$p"; break; fi - done - d="$PKG" - while [ "$d" != / ]; do - pkgdir="$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$platform" - if [ -d "$pkgdir" ]; then - have="$(tr -d '[:space:]' < "$pkgdir/$LANG_ARG/ENGINE_ID" 2>/dev/null || true)" - cand="$pkgdir/$LANG_ARG/axiomcode-engine-$LANG_ARG$EXE" - if [ "$have" = "$ENGINE_ID" ] && [ -f "$cand" ]; then PACKAGED="$cand"; chmod +x "$cand" 2>/dev/null || true - elif [ -n "$have" ]; then echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform holds $LANG_ARG at ${have:0:12}…, these rules are ${ENGINE_ID:0:12}… — not using it (publish a new engine version for these rules)" - else echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform has no $LANG_ARG engine"; fi - break - fi - d="$(dirname "$d")" - done -fi - -if [ -n "$PACKAGED" ]; then - BIN="$PACKAGED"; echo "▶ using packaged engine $ENGINE_PACKAGE_SCOPE/engine-$platform ($LANG_ARG)" -elif [ -x "$BIN" ]; then - echo "▶ reusing cached binary" -elif command -v souffle >/dev/null 2>&1; then - # ONE COMPILE PER ENGINE ID, under a lock whose owner must be dead, not merely old, before - # another run takes it over (compile-lock.sh). - COMPILE_LOCK="$BIN.lock" - compile_lock_take "$COMPILE_LOCK" - trap 'compile_lock_drop "$COMPILE_LOCK"' EXIT -fi -if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then - echo "▶ reusing the binary another run compiled" -elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then - echo "▶ compiling souffle program (cache miss)..." - INNER="$(find_souffle_include)" - # Assert the HEADER, not the directory: `[ -d ]` is the test #216 established cannot tell - # the two install layouts apart, so it would pass a path that then fails at the compiler. - if [ -z "$INNER" ] || [ ! -f "$INNER/souffle/CompiledSouffle.h" ]; then - echo "❌ soufflé is on PATH but its headers are not. Set AXIOM_SOUFFLE_INCLUDE." >&2; exit 1 - fi - have="$(souffle --version 2>/dev/null | sed -n 's/^Version: *\([0-9][0-9.]*\).*/\1/p' | head -1)" - [ "$have" = "$SOUFFLE_VERSION" ] || echo " ! local souffle is $have, the pinned version is $SOUFFLE_VERSION — a locally compiled engine may differ from CI's" - # Generate C++. souffle's "No rules/facts defined" warnings (for the intentionally - # unstaged lib-body relations — inert paths) aren't silenced by -w, so filter those 3- - # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w - # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then - # atomically rename, so a concurrent/aborted run never leaves a half-written binary. - if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then - cat "$INT/.souffle-gen.log" >&2; exit 1 - fi - awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 - [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } - CXX_PLATFORM="" - case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then - rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 - fi - # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may - # land under $ENGINE_ID unless it is a whole binary built from the program that id names: - # the temp binary must be a non-empty executable, and the program must still hash to the id - # (a program or rule file that changed during the compile would otherwise be cached under - # the old id). Only then the atomic rename. - _built_id="$ENGINE_ID" - if [ ! -s "$BIN.tmp.$$" ] || [ ! -x "$BIN.tmp.$$" ] || ! engine_id_of "$PROG" || [ "$ENGINE_ID" != "$_built_id" ]; then - rm -f "$BIN.tmp.$$" - echo "❌ the compiled engine did not verify (program now hashes to ${ENGINE_ID:-nothing}, built as $_built_id); not caching it" >&2 - exit 1 - fi - mv -f "$BIN.tmp.$$" "$BIN" -fi -if [ -n "${COMPILE_LOCK:-}" ]; then compile_lock_drop "$COMPILE_LOCK"; trap - EXIT -elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then - echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 - echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 - echo " • install souffle $SOUFFLE_VERSION to compile locally (macOS: brew install souffle; Ubuntu: the .deb from souffle-lang/souffle releases)." >&2 - exit 1 -fi +engine_binary # --- STAGE↔SOLVE loop: solve → stage the bodies of methods reached so far → re-solve, until # reachable_method stops growing. Soufflé loads facts up front and can't fetch bodies mid- # solve, so the driver feeds them in reachability order. Each round loads the bodies of ALL diff --git a/graph/test/tools/engine-package-test.sh b/graph/test/tools/engine-package-test.sh index a4bbd62c..eec6de84 100755 --- a/graph/test/tools/engine-package-test.sh +++ b/graph/test/tools/engine-package-test.sh @@ -61,4 +61,40 @@ if run; then bad "a run with no engine package and no souffle succeeded"; else grep -q "npm install" "$W/log" || bad "the no-package error does not point at npm install" fi -if [ "$fail" -eq 0 ]; then echo "engine-package: ok (packaged engine by id, stale package refused, absence explained)"; else echo "engine-package: $fail failure(s)"; exit 1; fi +# 4-7. `--prepare` (what `axiomcode prepare` runs at build time) puts the binary where a run looks for it, so the first +# index reuses it instead of compiling. A stub souffle and c++ stand in for the real ones: c++ "compiles" the fake +# engine above and counts its calls. Control: a background prepare under CI compiles nothing. +fake="$W/fake-engine" +{ echo '#!/usr/bin/env bash' + echo 'while [ $# -gt 0 ]; do case "$1" in -D) D="$2"; shift 2;; -F) shift 2;; *) shift;; esac; done' + cut -f2 "$ROOT/graph/$lang/souffle/export_manifest.tsv" | sed 's|^|: > "$D/|; s|$|"|'; } > "$fake" +mkdir -p "$W/stub" "$W/inc/souffle"; : > "$W/inc/souffle/CompiledSouffle.h"; : > "$W/cc-calls" +{ echo '#!/usr/bin/env bash' + echo "[ \"\$1\" = --version ] && { echo 'Version: $SOUFFLE_VERSION'; exit 0; }" + echo 'while [ $# -gt 0 ]; do case "$1" in -g) : > "$2"; echo "// c++" > "$2"; shift 2;; *) shift;; esac; done'; } > "$W/stub/souffle" +{ echo '#!/usr/bin/env bash' + echo "echo x >> '$W/cc-calls'" + echo 'while [ $# -gt 0 ]; do case "$1" in -o) o="$2"; shift 2;; *) shift;; esac; done' + echo "cp '$fake' \"\$o\"; chmod +x \"\$o\""; } > "$W/stub/c++" +chmod +x "$W/stub/souffle" "$W/stub/c++" +prep(){ PATH="$W/stub:$SANDBOX_PATH" AXIOM_SOUFFLE_INCLUDE="$W/inc" AXIOM_SOUFFLE_CACHE="$W/cache" bash "$RUN" --language $lang --prepare > "$W/log" 2>&1; } +calls(){ wc -l < "$W/cc-calls" | tr -d ' '; } +if prep; then + [ -x "$W/cache/souffle-engine-$lang-$id" ] || bad "prepare left no binary under the run's cache name (souffle-engine-$lang-${id:0:12}…)" + [ "$(calls)" = 1 ] || bad "prepare compiled $(calls) time(s), expected 1" + grep -q "engine ready" "$W/log" || bad "prepare did not report the engine ready" +else bad "prepare failed:"; tail -8 "$W/log" | sed 's/^/ /'; fi +prep || bad "a second prepare failed" +grep -q "reusing cached binary" "$W/log" && [ "$(calls)" = 1 ] || bad "a second prepare compiled again ($(calls) compiles)" +# the run that follows: no souffle, no package, only the prepared binary — and it is used, not recompiled +rm -rf "$W/out" +if run; then grep -q "reusing cached binary" "$W/log" || bad "the run after prepare did not reuse the prepared binary" +else bad "the run after prepare failed:"; tail -8 "$W/log" | sed 's/^/ /'; fi +# control: the build's background prepare is skipped under CI, so nothing is compiled +rm -rf "$W/cache" +out="$(PATH="$W/stub:$SANDBOX_PATH" CI=1 AXIOM_SOUFFLE_CACHE="$W/cache" bash "$ROOT/bin/axiomcode" prepare --language $lang --background 2>&1)" +sleep 1 +case "$out" in *"not prepared"*) ;; *) bad "background prepare under CI did not say it was skipped: $out";; esac +[ "$(calls)" = 1 ] && [ ! -e "$W/cache/souffle-engine-$lang-$id" ] || bad "background prepare under CI compiled anyway" + +if [ "$fail" -eq 0 ]; then echo "engine-package: ok (packaged engine by id, stale package refused, absence explained, prepared binary reused)"; else echo "engine-package: $fail failure(s)"; exit 1; fi diff --git a/package.json b/package.json index 15a3a4e8..4fd812a5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clean": "rm -rf dist", "prebuild": "npm run clean", "build": "npm --prefix parser run build && tsc && tsc-alias", + "postbuild": "node bin/axiomcode.js prepare --background || echo \"engines not prepared; the first index compiles them\"", "prepare": "npm run build", "typecheck": "tsc --noEmit", "schema-doc": "tsx graph/bundle/cli.ts --print-schema > graph/bundle/SCHEMA.md" From 1d38dddb5ab6313b9d06d5f2b0d243c6c3d92fbd Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:45:21 -0700 Subject: [PATCH 096/258] java: a project exception built in the code reaches its @ExceptionHandler An @ExceptionHandler method had no dependent at all. The web framework runs it when an exception of its type leaves a request handler, and no call site names it, so impact on a handler said nothing depends on it, path '*' to it found no chain, and a change to how an exception is answered looked local to the advice class. The change: call-edge-generation/exception_handlers.dl links each place a project exception is built, `new X(..)` or the `X::new` handed to orElseThrow, to the handler declared for X or for an ancestor of X. A handler in a @ControllerAdvice or @RestControllerAdvice class serves every caller; a handler declared in any other class serves the methods of that class and its subtypes. The handled types are the classes the annotation names, or the parameter's type when it names none. The edge is a call_chain_edge of tier event_dispatch, the tier the application-event and entity-callback rules already use for a type-keyed hop in one JVM, with kind exception_handler; the constructor row of the `new` is kept beside it. Annotation names are knobs in config-resolution/knobs.dl (22). Not linked: a handler for a library type (Exception, RuntimeException, a framework or validation exception), since the framework throws those and every `new IllegalStateException` would reach it; a handler for a subtype from a base built; a controller's own handler from another controller building the same type. Known over-approximation, stated in the rule file: a method that builds the exception outside any request (a job) is linked too, both of two matching handlers are linked, and an advice's basePackages or assignableTypes filter is not read. Case tests/cases/java/exception-reaches-its-handler: 6 checks, the advice handler from `new` and from `X::new` of a subtype, the parameter-typed handler, a controller-local handler with a near miss in another controller, and controls for a subtype handler and a library-typed handler. Before the change 2 of 6 pass (the two controls), after it 6 of 6. Suites, run on the release branch tip plus this change and the python fixture change that follows it, compared by FAIL line against the tip: tests/run.py java 304/304 -> 310/310 (the new case's 6 checks). python and csharp keep the same FAIL lines as the tip (python 1, csharp 2). tests/fastpath.py: 8/8 in python, java and csharp, before and after. Engine suites: java 79 passed 0 failed (oracle, no torture, JDK 24) before and after; python and csharp pass before and after. Smoke, three Spring Boot projects (fresh index of a copy, before and after): (exception_handler edges; all call edges) project A, 50 files: 0 -> 2; 2939 -> 2941 project B, 750 files: 0 -> 174; 21872 -> 22046 project C, 310 files: 0 -> 38; 9681 -> 9719 No other edge changed. 14 of the 214 new edges read against the source: 13 are a throw of the handled project type (or a subtype) under an advice class. One, on project A, is a GraphQL data fetcher throwing the type, which a separate GraphQL handler answers rather than the MVC advice: the stated over-approximation. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../exception_handlers.dl | 86 +++++++++++++++++++ graph/java/engine/config-resolution/knobs.dl | 14 +++ graph/java/souffle/decls_all.dl | 11 +++ .../exception-reaches-its-handler/case.json | 25 ++++++ .../src/probe/Errors.java | 25 ++++++ .../src/probe/ExpressOrderNotFound.java | 5 ++ .../src/probe/LocalProblem.java | 5 ++ .../src/probe/OrderController.java | 38 ++++++++ .../src/probe/OrderNotFound.java | 5 ++ .../src/probe/OtherController.java | 13 +++ .../src/probe/PaymentFailed.java | 5 ++ .../src/probe/Service.java | 7 ++ 12 files changed, 239 insertions(+) create mode 100644 graph/java/engine/call-edge-generation/exception_handlers.dl create mode 100644 tests/cases/java/exception-reaches-its-handler/case.json create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/Errors.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/ExpressOrderNotFound.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/LocalProblem.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/OrderController.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/OrderNotFound.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/OtherController.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/PaymentFailed.java create mode 100644 tests/cases/java/exception-reaches-its-handler/src/probe/Service.java diff --git a/graph/java/engine/call-edge-generation/exception_handlers.dl b/graph/java/engine/call-edge-generation/exception_handlers.dl new file mode 100644 index 00000000..63759521 --- /dev/null +++ b/graph/java/engine/call-edge-generation/exception_handlers.dl @@ -0,0 +1,86 @@ +// ============================================================================ +// Call-edge generation · EXCEPTION HANDLERS (a built exception -> the handler it runs) +// +// throw new OrderNotFound(id); // written here +// repo.find(id).orElseThrow(OrderNotFound::new); // or here +// @RestControllerAdvice class Errors { +// @ExceptionHandler(OrderNotFound.class) ResponseEntity missing(OrderNotFound e) { … } +// } // run from there +// +// When an exception leaves a request handler, the web framework looks up the handler +// method declared for the exception's type and runs it to build the response: one in +// the same controller, or one in an advice class, which serves every controller. No +// call site names the handler, so without this file `impact` on a handler listed no +// dependent, `path '*' ` found nothing, and a change to the exception's +// handling looked local to the advice class. +// +// WHY A CALL EDGE WITH TIER event_dispatch. It is the shape event_dispatch.dl and +// entity_lifecycle.dl already model: one JVM, keyed on a TYPE, the handler running in +// the same request before the response is written. The KIND column, `exception_handler`, +// tells it apart from an application event or an entity callback. The constructor row +// of the `new` itself is kept: the edge is ADDED beside it. +// +// THE KEY IS THE BUILT EXCEPTION'S TYPE, with the subtype rule the framework applies: a +// handler for OrderNotFound runs for an ExpressOrderNotFound, and a handler for the +// subtype does not run for the base. The handled types are the classes the annotation +// names, or, when it names none, the handler's parameter type. +// +// THE SITE IS WHERE THE EXCEPTION IS BUILT: `new X(..)` and the `X::new` handed to an +// orElseThrow. That is where a change in handling is felt, and a project exception type +// is built to be thrown. +// +// WHAT IS NOT MODELLED, and stays unlinked rather than guessed: +// - a handler for a LIBRARY type (Exception, RuntimeException, a validation or +// framework exception): the framework or a library throws those, not a client +// `new`, and every `new IllegalStateException` in the project would reach it. Only a +// type the project declares is a key; +// - whether the building method runs under a request at all: a job that throws the +// same type is linked too, which is the static answer to "which handler would take +// this"; +// - which of two matching handlers is closer: both are linked; +// - an advice's basePackages / assignableTypes / annotations filter: it is read as +// serving every controller. +// ============================================================================ + +// ── the handlers, and the types each one takes ────────────────────────────── +exh_ann_method(m, a) :- ann_on_method(_, a, n, m, _), cfg_exh_ann(n), method_owner("client", _, m). +exh_names_classes(m) :- exh_ann_method(m, a), + ann_arg(_, a, arg, _, "CLASS_REFERENCE", _), cfg_exh_arg(arg). +exh_handles(m, t) :- exh_ann_method(m, a), + ann_arg(_, a, arg, v, "CLASS_REFERENCE", _), cfg_exh_arg(arg), + config_class_ref("annotation", a, v, t, "client"). +exh_handles(m, t) :- exh_ann_method(m, _), !exh_names_classes(m), + event_param_type(m, t), java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, t). + +// a handler of an advice class serves every controller; any other serves its own type +exh_global(m) :- exh_ann_method(m, _), method_owner("client", t, m), + ann_on_type(_, _, n, t), cfg_exh_advice_ann(n). + +// ── where a project exception is built ────────────────────────────────────── +exh_built(call, caller, t) :- + java_expression("OBJECT_CREATION", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, call), + expr_type("client", call, t), call_from(call, caller). +exh_built(mr, caller, t) :- + java_expression("METHOD_REFERENCE", _, _, _, _, _, _, _, _, _, _, "CONSTRUCTOR", _, _, _, _, _, _, _, _, _, _, _, _, mr), + java_expression(_, "QUALIFIER", _, _, _, _, mr, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, q), + expr_type("client", q, t), call_from(mr, caller). +exh_built(mr, caller, t) :- + java_expression("METHOD_REFERENCE", _, _, _, _, _, _, _, _, _, _, "CONSTRUCTOR", _, _, _, _, _, _, _, _, _, _, _, _, mr), + method_ref_qualifier_resolves("client", mr, _, t), call_from(mr, caller). + +// the handler takes the built type itself, or an ancestor of it +exh_handles_built(m, t) :- exh_handles(m, t). +exh_handles_built(m, bt) :- exh_built(_, _, bt), type_ancestor(bt, t), exh_handles(m, t). + +// ── the edge ──────────────────────────────────────────────────────────────── +exh_edge(call, caller, m) :- exh_built(call, caller, bt), exh_handles_built(m, bt), + exh_global(m), caller != m. +exh_edge(call, caller, m) :- exh_built(call, caller, bt), exh_handles_built(m, bt), + !exh_global(m), caller != m, + method_owner("client", ht, m), method_owner("client", ht, caller). +exh_edge(call, caller, m) :- exh_built(call, caller, bt), exh_handles_built(m, bt), + !exh_global(m), caller != m, + method_owner("client", ht, m), method_owner("client", ct, caller), type_ancestor(ct, ht). + +call_chain_edge(call, caller, "-", m, "client", "event_dispatch", "exception_handler") :- + exh_edge(call, caller, m). diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index b86654c0..51bb3376 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -513,3 +513,17 @@ cfg_entity_callback_ann("PreUpdate", "update"). cfg_entity_callback_ann("Post cfg_entity_callback_ann("PreRemove", "remove"). cfg_entity_callback_ann("PostRemove", "remove"). // a type annotation whose class arguments are listener classes cfg_entity_listeners_ann("EntityListeners"). + +// ── (22) EXCEPTION HANDLERS: a built exception runs the @ExceptionHandler for it ── +// A web framework hands an exception that leaves a request handler to the handler +// method declared for its type: one in the same controller, or one in an advice class +// for every controller. No call site names the handler +// (call-edge-generation/exception_handlers.dl). +// +// cfg_exh_ann(Name): a method annotation that makes the method an exception handler +// for the types named in cfg_exh_arg, or, with none named, for its parameter's type. +cfg_exh_ann("ExceptionHandler"). +cfg_exh_arg("value"). cfg_exh_arg("exception"). +// cfg_exh_advice_ann(Name): a type annotation whose handlers serve every controller. +cfg_exh_advice_ann("ControllerAdvice"). +cfg_exh_advice_ann("RestControllerAdvice"). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 60412b40..57f51bdd 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -1110,3 +1110,14 @@ .decl cb_writes_override(c0:symbol) .decl cb_marker_type(c0:symbol) .decl cb_direct_external(c0:symbol) +// call-edge-generation/exception_handlers.dl + knobs.dl (22): exception handler methods +.decl cfg_exh_ann(c0:symbol) +.decl cfg_exh_arg(c0:symbol) +.decl cfg_exh_advice_ann(c0:symbol) +.decl exh_ann_method(c0:symbol,c1:symbol) +.decl exh_names_classes(c0:symbol) +.decl exh_handles(c0:symbol,c1:symbol) +.decl exh_global(c0:symbol) +.decl exh_built(c0:symbol,c1:symbol,c2:symbol) +.decl exh_handles_built(c0:symbol,c1:symbol) +.decl exh_edge(c0:symbol,c1:symbol,c2:symbol) diff --git a/tests/cases/java/exception-reaches-its-handler/case.json b/tests/cases/java/exception-reaches-its-handler/case.json new file mode 100644 index 00000000..7565a8f8 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/case.json @@ -0,0 +1,25 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a project exception built in a controller reaches the advice's @ExceptionHandler for its type: one event hop, not 'independent'", + "run": ["path", "OrderController.get", "Errors.missing"], + "want": ["event_dispatch", "Errors.missing"], + "avoid": ["independent in this graph"]}, + {"why": "the handler's dependents are the methods that build its exception, a subtype built through X::new included", + "run": ["impact", "Errors.missing"], + "want": ["[registered] OrderController.get ", "[registered] OrderController.getExpress "], + "avoid": ["the change is local"]}, + {"why": "control: a handler for a SUBTYPE does not run for the base type built", + "run": ["path", "OrderController.get", "Errors.missingExpress"], + "expect_error": true, + "want": ["no chain"]}, + {"why": "with no class named on the annotation, the handled type is the parameter's", + "run": ["impact", "Errors.declined"], + "want": ["[registered] OrderController.pay "]}, + {"why": "a handler declared in a controller serves that controller, and not another one building the same exception (near miss)", + "run": ["impact", "OrderController.onLocal"], + "want": ["[registered] OrderController.check "], + "avoid": ["OtherController.check2"]}, + {"why": "control: a handler for a library type is not linked to every `new` of a library exception", + "run": ["impact", "Errors.anything"], + "want": ["Errors.anything"], + "avoid": ["Service.fail", "OrderController.get", "OrderController.pay"]}]} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/Errors.java b/tests/cases/java/exception-reaches-its-handler/src/probe/Errors.java new file mode 100644 index 00000000..7179f617 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/Errors.java @@ -0,0 +1,25 @@ +package probe; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class Errors { + @ExceptionHandler(OrderNotFound.class) + public ResponseEntity missing(OrderNotFound e) { return reply(404); } + + // a handler for the subtype only + @ExceptionHandler({ExpressOrderNotFound.class}) + public ResponseEntity missingExpress(RuntimeException e) { return reply(410); } + + // the handled type is the parameter's when the annotation names none + @ExceptionHandler + public ResponseEntity declined(PaymentFailed e) { return reply(402); } + + // a library type: the framework and libraries throw it, not a project `new` + @ExceptionHandler(Exception.class) + public ResponseEntity anything(Exception e) { return reply(500); } + + private ResponseEntity reply(int status) { return ResponseEntity.status(status).body("x"); } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/ExpressOrderNotFound.java b/tests/cases/java/exception-reaches-its-handler/src/probe/ExpressOrderNotFound.java new file mode 100644 index 00000000..c22a3180 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/ExpressOrderNotFound.java @@ -0,0 +1,5 @@ +package probe; + +public class ExpressOrderNotFound extends OrderNotFound { + public ExpressOrderNotFound() { super(); } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/LocalProblem.java b/tests/cases/java/exception-reaches-its-handler/src/probe/LocalProblem.java new file mode 100644 index 00000000..1890a8a4 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/LocalProblem.java @@ -0,0 +1,5 @@ +package probe; + +public class LocalProblem extends RuntimeException { + public LocalProblem() { super("LocalProblem"); } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/OrderController.java b/tests/cases/java/exception-reaches-its-handler/src/probe/OrderController.java new file mode 100644 index 00000000..3e2c64c7 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/OrderController.java @@ -0,0 +1,38 @@ +package probe; + +import java.util.Optional; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OrderController { + private final Optional none = Optional.empty(); + + @GetMapping("/orders") + public String get(String id) { + if (id == null) throw new OrderNotFound(); + return id; + } + + @GetMapping("/express") + public String getExpress() { + return none.orElseThrow(ExpressOrderNotFound::new); + } + + @GetMapping("/pay") + public String pay(int cents) { + if (cents < 0) throw new PaymentFailed(); + return "ok"; + } + + @GetMapping("/check") + public String check(String s) { + if (s.isEmpty()) throw new LocalProblem(); + return s; + } + + // a handler declared in the controller serves this controller only + @ExceptionHandler(LocalProblem.class) + public String onLocal(LocalProblem e) { return "local"; } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/OrderNotFound.java b/tests/cases/java/exception-reaches-its-handler/src/probe/OrderNotFound.java new file mode 100644 index 00000000..48bc1f36 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/OrderNotFound.java @@ -0,0 +1,5 @@ +package probe; + +public class OrderNotFound extends RuntimeException { + public OrderNotFound() { super("OrderNotFound"); } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/OtherController.java b/tests/cases/java/exception-reaches-its-handler/src/probe/OtherController.java new file mode 100644 index 00000000..4c646429 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/OtherController.java @@ -0,0 +1,13 @@ +package probe; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OtherController { + @GetMapping("/other") + public String check2(String s) { + if (s.isEmpty()) throw new LocalProblem(); + return s; + } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/PaymentFailed.java b/tests/cases/java/exception-reaches-its-handler/src/probe/PaymentFailed.java new file mode 100644 index 00000000..f8afe677 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/PaymentFailed.java @@ -0,0 +1,5 @@ +package probe; + +public class PaymentFailed extends RuntimeException { + public PaymentFailed() { super("PaymentFailed"); } +} diff --git a/tests/cases/java/exception-reaches-its-handler/src/probe/Service.java b/tests/cases/java/exception-reaches-its-handler/src/probe/Service.java new file mode 100644 index 00000000..0880f582 --- /dev/null +++ b/tests/cases/java/exception-reaches-its-handler/src/probe/Service.java @@ -0,0 +1,7 @@ +package probe; + +public class Service { + public void fail() { + throw new IllegalStateException("x"); + } +} From 236c09e0c91e50bc0d33d33586ddaeb976ce3e7a Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:45:21 -0700 Subject: [PATCH 097/258] python: a fixture requested with request.getfixturevalue() reaches the test A test or fixture that asks the runner for a fixture by name in its body, request.getfixturevalue("admin_user"), was no request: only a parameter name joined a test to a fixture, so impact and test-impact on the fixture (and on everything the fixture calls) left that test out, and path called the two independent. The commoner form hands the name in through the test's own parametrize, @parametrize("server", ["local_srv", "remote_srv"]) and then request.getfixturevalue(server), and was missed the same way. The change: framework-behavior/dispatch.dl reads a getfixturevalue call in a requester's own body as a fixture request, with the name written as a string literal, or as a parameter the test's own parametrize supplies: with one argument name, one request per string value; with several (a comma-separated string or a list), the value in that argument's own column of each row. The request then goes through the same visibility rules as a parameter (class, module, nearest conftest, plugin). The method name is a knob in config-resolution/knobs.dl. Not a request: a getfixturevalue call in a helper function that is no test or fixture, a fixture's name passed to some other call, a parametrize value the test only uses as a value, and a fixture's name that sits in another column of the same parametrize rows. Case tests/cases/python/fixture-requested-by-name-in-the-body: 9 checks, the literal and the parametrized request (one, several and list-form argument names), the fixture's callee reaching the test, and three near misses with a positive control each. Before the change 3 of 9 pass (the three controls), after it 9 of 9. Suites, run on the release branch tip plus this change and the java exception handler change before it, compared by FAIL line against the tip: tests/run.py python 268/269 -> 277/278 (the new case's 9 checks); the one FAIL is the same check on the tip. java 310/310 (the other change's case), csharp the same 2 FAIL lines as the tip. tests/fastpath.py: 8/8 in python, java and csharp, before and after. Engine suites: python 42 passed 0 failed, java 79/0, csharp pass, before and after. Smoke (fresh index of a copy, before and after, fixture_injection edges): project A, 450 files: 112 -> 123 (+11, none removed): one fixture 2 -> 8 through a one-name parametrize, one +1 through a two-name parametrize, three +4 through a parametrize of names; checked against the source. project B, 650 files: 1760 -> 1762 (+2), both correct (a literal request of two fixtures in one test). Measured before the several-names support was added. project C, 700 files: 1073 -> 1073; it never calls getfixturevalue. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../python/engine/config-resolution/knobs.dl | 6 + .../engine/framework-behavior/dispatch.dl | 70 +++++++++++ graph/python/souffle/decls_all.dl | 1 + .../case.json | 115 ++++++++++++++++++ .../shop/__init__.py | 0 .../shop/svc.py | 18 +++ .../tests/conftest.py | 43 +++++++ .../tests/test_users.py | 49 ++++++++ 8 files changed, 302 insertions(+) create mode 100644 tests/cases/python/fixture-requested-by-name-in-the-body/case.json create mode 100644 tests/cases/python/fixture-requested-by-name-in-the-body/shop/__init__.py create mode 100644 tests/cases/python/fixture-requested-by-name-in-the-body/shop/svc.py create mode 100644 tests/cases/python/fixture-requested-by-name-in-the-body/tests/conftest.py create mode 100644 tests/cases/python/fixture-requested-by-name-in-the-body/tests/test_users.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 0d001dcb..19231f11 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -151,6 +151,12 @@ py_fixture_name_kw("name"). // the session, not only to the directory of the module that listed them. py_fixture_plugins_var("pytest_plugins"). +// ── py_fixture_value_method(Name): a fixture requested by name from the body ── +// `request.getfixturevalue("admin_user")` asks the runner for the fixture at run time, +// on the request object every test and fixture can take. The name is a string, so no +// parameter carries the request and the fixture reached no test (#1514). +py_fixture_value_method("getfixturevalue"). + // ── py_parametrize_deco(Tail) and its keywords: a value supplied DIRECTLY ──── // `@pytest.mark.parametrize("user", [...])` passes the value in, and the fixture called // `user` is NOT run for that test. With `indirect=True`, or with the name in an diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index 8fecb08e..b542838d 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -286,6 +286,76 @@ py_parametrize_direct(req, name) :- py_parametrize_argname(req, h, name), py_fixture_request(req, name) :- py_fixture_param(req, name), !py_parametrize_direct(req, name). +// A REQUEST WRITTEN IN THE BODY (#1514). `request.getfixturevalue("admin_user")` makes +// the runner build that fixture for this test while it runs, by the same visibility +// rules as a parameter, so it is a request like one and goes through the same scoping +// below. Only in a requester's own body: a helper function calling it is not collected, +// and which test calls the helper is a call edge's question, not this rule's. +.decl py_fixture_value_call(req:symbol, e:symbol) +py_fixture_value_call(req, e) :- py_fixture_requester(req), + call_decl("client", "METHOD_CALL", fn, _, e, site), py_fixture_value_method(fn), + call_context("client", _, req, _, _, site). +// (a) the name written as a string literal; +py_fixture_request(req, name) :- py_fixture_value_call(req, e), + call_arg(e, "0", a), expr_node("client", "LITERAL", _, name, a), name != "". +// (b) the name a parametrize decorator on the same test hands in, the commoner form: +// `@parametrize("server", ["local_server", "remote_server"])` and then +// `request.getfixturevalue(server)` requests each listed fixture in turn. Only for a +// decorator with that ONE argument name, whose values are then plain strings; with +// several names each value is a tuple, and a value that is no literal names nothing. +py_fixture_request(req, name) :- py_fixture_value_call(req, e), + call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), + py_parametrize_direct(req, p), py_parametrize_on(req, h), py_parametrize_argnames(h, p), + decorator_expr("client", de, h), call_arg(de, "1", vs), + expr_parent("client", vs, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el), name != "". +// (c) the same with several argument names, `@parametrize("server, expected", [("local_srv", +// 200), ...])`: each value is a tuple and the name is the element at the argument's own +// position, so only that column is read. The position is the number of separators +// before the name in the argnames string, or its place in an argnames list. +.decl py_argnames_sep_at(h:symbol, s:symbol, i:number) +py_argnames_sep_at(h, s, i) :- py_parametrize_argnames(h, s), py_argnames_sep(sep), + i = range(0, strlen(s)), substr(s, i, 1) = sep. +.decl py_argnames_name_at(h:symbol, name:symbol, i:number) +// A whole token: led by the string's start, a separator or a space, and followed by its +// end, a separator or a space, so `server` is not found inside `server_url`. +.decl py_argnames_found(h:symbol, name:symbol, i:number) +py_argnames_found(h, p, i) :- py_fixture_value_param(h, p), py_parametrize_argnames(h, s), + strlen(s) > strlen(p), i = range(0, strlen(s) - strlen(p) + 1), substr(s, i, strlen(p)) = p. +.decl py_argnames_led(h:symbol, name:symbol, i:number) +py_argnames_led(h, p, 0) :- py_argnames_found(h, p, 0). +py_argnames_led(h, p, i) :- py_argnames_found(h, p, i), i > 0, py_parametrize_argnames(h, s), + py_argnames_boundary(c), substr(s, i - 1, 1) = c. +py_argnames_name_at(h, p, i) :- py_argnames_led(h, p, i), py_parametrize_argnames(h, s), + i + strlen(p) = strlen(s). +py_argnames_name_at(h, p, i) :- py_argnames_led(h, p, i), py_parametrize_argnames(h, s), + i + strlen(p) < strlen(s), py_argnames_boundary(c), substr(s, i + strlen(p), 1) = c. +.decl py_argnames_boundary(c:symbol) +py_argnames_boundary(","). +py_argnames_boundary(" "). +.decl py_value_row_kind(k:symbol) +py_value_row_kind("TUPLE"). +py_value_row_kind("LIST"). +.decl py_argnames_pos(h:symbol, name:symbol, k:number) +py_argnames_pos(h, p, k) :- py_argnames_name_at(h, p, i), py_parametrize_argnames(h, s), + k = count : { py_argnames_sep_at(h, s, j), j < i }. +// ... or, for argnames written as a list or tuple of strings, its element position there. +py_argnames_pos(h, p, k) :- py_fixture_value_param(h, p), + decorator_expr("client", de, h), call_arg(de, "0", a), + expr_parent("client", a, "ELEMENT", ks, el), expr_node("client", "LITERAL", _, p, el), + k = to_number(ks). +.decl py_fixture_value_param(h:symbol, p:symbol) +py_fixture_value_param(h, p) :- py_fixture_value_call(req, e), + call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), + py_parametrize_direct(req, p), py_parametrize_on(req, h), py_parametrize_argname(req, h, p). +py_fixture_request(req, name) :- py_fixture_value_call(req, e), + call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), + py_parametrize_direct(req, p), py_parametrize_on(req, h), py_argnames_pos(h, p, k), + decorator_expr("client", de, h), call_arg(de, "1", vs), + expr_parent("client", vs, "ELEMENT", _, tup), py_value_row_kind(rk), expr_node("client", rk, _, _, tup), + expr_parent("client", tup, "ELEMENT", to_string(k), el), + expr_node("client", "LITERAL", _, name, el), name != "". + // (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, // and nothing else; for them it wins over the module's fixture of the name. Read as a // plain same-module fixture it was also handed to a sibling class's tests, and the diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4cb014d5..9aa06420 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -49,6 +49,7 @@ .decl py_test_name_prefix(v:symbol) .decl py_fixture_name_kw(v:symbol) .decl py_fixture_plugins_var(v:symbol) +.decl py_fixture_value_method(v:symbol) .decl py_parametrize_deco(v:symbol) .decl py_parametrize_argnames_kw(v:symbol) .decl py_parametrize_indirect_kw(v:symbol) diff --git a/tests/cases/python/fixture-requested-by-name-in-the-body/case.json b/tests/cases/python/fixture-requested-by-name-in-the-body/case.json new file mode 100644 index 00000000..af071417 --- /dev/null +++ b/tests/cases/python/fixture-requested-by-name-in-the-body/case.json @@ -0,0 +1,115 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "request.getfixturevalue(\"name\") in a test's body requests that fixture, which reaches the test (#1514)", + "run": [ + "impact", + "admin_user", + "--tests" + ], + "want": [ + "test_users.py::test_admin_by_name" + ] + }, + { + "why": "the fixture's own callee reaches the test through the by-name request too", + "run": [ + "impact", + "make_admin", + "--tests" + ], + "want": [ + "test_users.py::test_admin_by_name" + ] + }, + { + "why": "a getfixturevalue call in a helper that is no test requests nothing, and a fixture's name in some other call is no request (near miss for #1514)", + "run": [ + "impact", + "guest_user", + "--tests" + ], + "avoid": [ + "test_names_guest_in_a_message", + "test_admin_by_name" + ], + "want": [ + "test_users.py::test_guest_as_a_parameter" + ] + }, + { + "why": "getfixturevalue() requests each fixture the test's parametrize lists for that one argument (#1514)", + "run": [ + "impact", + "remote_srv", + "--tests" + ], + "want": [ + "test_users.py::test_each_server" + ] + }, + { + "why": "the other listed value is requested too, and reaches the test through its fixture", + "run": [ + "impact", + "local_server", + "--tests" + ], + "want": [ + "test_users.py::test_each_server" + ] + }, + { + "why": "a parametrized string the test only uses as a value requests no fixture, though it is a fixture's name (control)", + "run": [ + "impact", + "audit_log", + "--tests" + ], + "avoid": [ + "test_label_is_only_a_value" + ], + "want": [ + "test_users.py::test_audit_as_a_parameter" + ] + }, + { + "why": "with several argument names, getfixturevalue() requests the value in that argument's own column of each row", + "run": [ + "impact", + "staging_srv", + "--tests" + ], + "want": [ + "test_users.py::test_server_and_code" + ] + }, + { + "why": "a fixture's name in another column of the same rows is only a value, not a request (near miss for the column rule)", + "run": [ + "impact", + "mirror_srv", + "--tests" + ], + "want": [ + "test_users.py::test_mirror_as_a_parameter" + ], + "avoid": [ + "test_server_and_code" + ] + }, + { + "why": "argnames written as a list: the column is the name's place in the list", + "run": [ + "impact", + "backup_srv", + "--tests" + ], + "want": [ + "test_users.py::test_target_from_a_name_list" + ] + } + ] +} diff --git a/tests/cases/python/fixture-requested-by-name-in-the-body/shop/__init__.py b/tests/cases/python/fixture-requested-by-name-in-the-body/shop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/fixture-requested-by-name-in-the-body/shop/svc.py b/tests/cases/python/fixture-requested-by-name-in-the-body/shop/svc.py new file mode 100644 index 00000000..ed9ca5bb --- /dev/null +++ b/tests/cases/python/fixture-requested-by-name-in-the-body/shop/svc.py @@ -0,0 +1,18 @@ +def make_admin(): + return {"role": "admin"} + + +def make_guest(): + return {"role": "guest"} + + +def local_server(): + return "http://127.0.0.1" + + +def remote_server(): + return "https://example.test" + + +def make_audit(): + return [] diff --git a/tests/cases/python/fixture-requested-by-name-in-the-body/tests/conftest.py b/tests/cases/python/fixture-requested-by-name-in-the-body/tests/conftest.py new file mode 100644 index 00000000..4d28141a --- /dev/null +++ b/tests/cases/python/fixture-requested-by-name-in-the-body/tests/conftest.py @@ -0,0 +1,43 @@ +import pytest + +from shop.svc import local_server, make_admin, make_audit, make_guest, remote_server + + +@pytest.fixture +def admin_user(): + return make_admin() + + +@pytest.fixture +def guest_user(): + return make_guest() + + +@pytest.fixture +def local_srv(): + return local_server() + + +@pytest.fixture +def remote_srv(): + return remote_server() + + +@pytest.fixture +def audit_log(): + return make_audit() + + +@pytest.fixture +def staging_srv(): + return "https://staging.test" + + +@pytest.fixture +def mirror_srv(): + return "https://mirror.test" + + +@pytest.fixture +def backup_srv(): + return "https://backup.test" diff --git a/tests/cases/python/fixture-requested-by-name-in-the-body/tests/test_users.py b/tests/cases/python/fixture-requested-by-name-in-the-body/tests/test_users.py new file mode 100644 index 00000000..814a7c01 --- /dev/null +++ b/tests/cases/python/fixture-requested-by-name-in-the-body/tests/test_users.py @@ -0,0 +1,49 @@ +import pytest + + +def test_admin_by_name(request): + user = request.getfixturevalue("admin_user") + assert user["role"] == "admin" + + +def lookup(request): + return request.getfixturevalue("guest_user") + + +def test_names_guest_in_a_message(request): + marker = request.node.get_closest_marker("guest_user") + assert marker is None + + +@pytest.mark.parametrize("server", ["local_srv", "remote_srv"]) +def test_each_server(server, request): + url = request.getfixturevalue(server) + assert url + + +@pytest.mark.parametrize("label", ["audit_log"]) +def test_label_is_only_a_value(label): + assert label == "audit_log" + + +def test_guest_as_a_parameter(guest_user): + assert guest_user["role"] == "guest" + + +def test_audit_as_a_parameter(audit_log): + assert audit_log == [] + + +@pytest.mark.parametrize("expected, server", [(200, "staging_srv"), ("mirror_srv", "local_srv")]) +def test_server_and_code(expected, server, request): + url = request.getfixturevalue(server) + assert url and expected + + +def test_mirror_as_a_parameter(mirror_srv): + assert mirror_srv + + +@pytest.mark.parametrize(["retries", "target"], [(1, "backup_srv"), (2, "local_srv")]) +def test_target_from_a_name_list(retries, target, request): + assert request.getfixturevalue(target) and retries From f115f5d8893c5dcb980551afaf65a5145a0441cc Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:25:40 -0700 Subject: [PATCH 098/258] engine: resolve reads of module-level consts through their import binding A module-scope const used as `X.m`, `f(X)`, in a template or under `typeof X` had no data edge to its declaration, so impact matched it by name, including same-named consts in sibling packages whose files import their own. - TypeScript and JavaScript engines export field_access rows for an identifier the binder ties to a module variable, directly or through an import binding (across `export *` barrels); `typeof X` type queries bind through module scope, with a same-named parameter or local shadowing it. - The bundle positions a field-access site that is a type reference. - impact: a const the engine bound on a route line is still a registration. - Goldens re-blessed: module consts read by name now carry known_edge reads. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/build.ts | 15 ++++ graph/bundle/languages.ts | 10 +++ .../call-edge-generation/field_access.dl | 60 +++++++++++++ graph/javascript/souffle/decls_all.dl | 12 +++ graph/javascript/souffle/export_manifest.tsv | 1 + .../86-module-variable-reads/src/a/src/cfg.ts | 21 +++++ .../src/a/src/index.ts | 1 + .../86-module-variable-reads/src/a/test/t.ts | 47 ++++++++++ .../src/b/src/settings.ts | 3 + .../src/b/test/sibling.ts | 5 ++ .../src/tsconfig.json | 10 +++ .../expected/17-constrained-generics.fields | 4 + .../17-constrained-generics.fields-oracle | 8 +- .../36-function-value-containers.fields | 1 + ...36-function-value-containers.fields-oracle | 8 +- .../expected/49-indexed-access-return.fields | 3 + .../49-indexed-access-return.fields-oracle | 8 +- .../62-field-access-and-type-use.fields | 1 + ...62-field-access-and-type-use.fields-oracle | 8 +- .../63-annotated-callable-member.fields | 1 + ...63-annotated-callable-member.fields-oracle | 8 +- .../expected/64-array-dispatch.fields | 3 + .../expected/64-array-dispatch.fields-oracle | 8 +- ...annotated-callable-fewer-parameters.fields | 3 + ...ed-callable-fewer-parameters.fields-oracle | 8 +- .../69-named-and-inline-handlers.fields | 3 + ...69-named-and-inline-handlers.fields-oracle | 8 +- ...ject-literal-and-expression-callees.fields | 1 + ...teral-and-expression-callees.fields-oracle | 8 +- .../expected/74-jsx-component-forms.fields | 5 ++ .../74-jsx-component-forms.fields-oracle | 8 +- .../expected/75-jsx-wrapper-guards.fields | 10 +++ .../75-jsx-wrapper-guards.fields-oracle | 8 +- .../76-jsx-default-nested-wrappers.fields | 4 + ...-jsx-default-nested-wrappers.fields-oracle | 8 +- .../77-package-published-entries.fields | 1 + ...77-package-published-entries.fields-oracle | 8 +- .../expected/78-callable-collections.fields | 15 ++++ .../78-callable-collections.fields-oracle | 8 +- .../78-cross-process-destinations.fields | 13 +++ ...8-cross-process-destinations.fields-oracle | 8 +- ...78-hof-callback-at-library-boundary.fields | 12 +++ ...callback-at-library-boundary.fields-oracle | 8 +- .../79-package-published-members.fields | 3 + ...79-package-published-members.fields-oracle | 8 +- .../80-generic-return-receiver.fields | 8 ++ .../80-generic-return-receiver.fields-oracle | 8 +- .../80-object-literal-member-receivers.fields | 6 ++ ...ect-literal-member-receivers.fields-oracle | 8 +- .../80-vue-definecomponent-jsx.fields | 9 ++ .../80-vue-definecomponent-jsx.fields-oracle | 8 +- .../typescript/expected/80-vue-sfc.fields | 2 + .../expected/80-vue-sfc.fields-oracle | 8 +- .../expected/81-vue-component-tag.fields | 1 + .../81-vue-component-tag.fields-oracle | 8 +- .../expected/83-closed-world-dispatch.fields | 2 + .../83-closed-world-dispatch.fields-oracle | 8 +- .../85-workspace-package-import.fields | 2 + .../85-workspace-package-import.fields-oracle | 8 +- .../expected/86-module-variable-reads.edges | 7 ++ .../expected/86-module-variable-reads.entries | 15 ++++ .../expected/86-module-variable-reads.fields | 20 +++++ .../86-module-variable-reads.fields-oracle | 15 ++++ .../expected/86-module-variable-reads.oracle | 1 + .../86-module-variable-reads.type-use | 5 ++ .../86-module-variable-reads.types-oracle | 7 ++ .../typescript/tools/normalize_members.py | 13 ++- .../call-edge-generation/field_access.dl | 85 +++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 15 ++++ .../skills/axiomcode/scripts/axiomcode-index | 6 +- .../skills/axiomcode/scripts/dl/impact.dl | 6 +- .../javascript/module-level-const/case.json | 18 ++++ .../module-level-const/src/barrelUser.js | 3 + .../module-level-const/src/consts.js | 5 ++ .../module-level-const/src/index.js | 1 + .../module-level-const/src/sibling/consts.js | 3 + .../module-level-const/src/sibling/reader.js | 3 + .../javascript/module-level-const/src/user.js | 9 ++ .../wrapped-handler-route/case.json | 6 +- .../typescript/module-level-const/case.json | 30 ++++++- .../module-level-const/src/consts.ts | 4 + .../module-level-const/src/sibling/consts.ts | 3 + .../module-level-const/src/sibling/reader.ts | 3 + .../typescript/module-level-const/src/user.ts | 12 +++ .../wrapped-handler-route/case.json | 2 +- 85 files changed, 680 insertions(+), 107 deletions(-) create mode 100644 graph/javascript/engine/call-edge-generation/field_access.dl create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json create mode 100644 graph/test/typescript/expected/86-module-variable-reads.edges create mode 100644 graph/test/typescript/expected/86-module-variable-reads.entries create mode 100644 graph/test/typescript/expected/86-module-variable-reads.fields create mode 100644 graph/test/typescript/expected/86-module-variable-reads.fields-oracle create mode 100644 graph/test/typescript/expected/86-module-variable-reads.oracle create mode 100644 graph/test/typescript/expected/86-module-variable-reads.type-use create mode 100644 graph/test/typescript/expected/86-module-variable-reads.types-oracle create mode 100644 tests/cases/javascript/module-level-const/case.json create mode 100644 tests/cases/javascript/module-level-const/src/barrelUser.js create mode 100644 tests/cases/javascript/module-level-const/src/consts.js create mode 100644 tests/cases/javascript/module-level-const/src/index.js create mode 100644 tests/cases/javascript/module-level-const/src/sibling/consts.js create mode 100644 tests/cases/javascript/module-level-const/src/sibling/reader.js create mode 100644 tests/cases/javascript/module-level-const/src/user.js create mode 100644 tests/cases/typescript/module-level-const/src/sibling/consts.ts create mode 100644 tests/cases/typescript/module-level-const/src/sibling/reader.ts diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts index 072e9594..84c9fcb9 100644 --- a/graph/bundle/build.ts +++ b/graph/bundle/build.ts @@ -529,6 +529,21 @@ export async function buildCore(inp: BuildInputs): Promise { } } } + // a field access whose site is no expression: a module variable read in a type (`typeof X`) + if (A.ir.fieldSites && fieldSites.size > 0) { + const L = A.ir.fieldSites; + const src = await clientSource(inp.clientIrDir, L.file); + if (src) { + const h = src.header; + const ci = h.col(L.id), cl = h.col(L.startLine), cel = h.col(L.endLine); + for await (const r of rowsOf(src)) { + const fa = fieldSites.get(r[ci] ?? ''); + if (!fa) continue; + const file = fileOf(L.fileVia, h, r); + for (const fr of fa) { fill(fr, 6, file); fill(fr, 7, int(r[cl])); fill(fr, 9, int(r[cel])); } + } + } + } // a site the IR writes no name for, where the engine derived the accessor it calls (#1441) if (A.raw.siteNames) { for (const [site, name] of await readSource(rawDir, A.raw.siteNames)) { diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index 286ec18a..e4ec00d9 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -166,6 +166,8 @@ export interface LanguageAdapter { callSites?: CallSitesIR; decorators?: DecoratorsIR; localSites?: LocalSitesIR; + /** field access sites that are not expressions (TypeScript: a `typeof X` type reference), positioned from this table */ + fieldSites?: LocalSitesIR; /** absent where the front end writes no skipped-files report */ skipped?: SkippedIR; }; @@ -277,6 +279,11 @@ const TYPESCRIPT: LanguageAdapter = { startLine: 'startLine', startColumn: 'startColumn', fileVia: { column: 'tsModuleLinkHash', through: 'modules' }, }, + // a module variable read in a TYPE (`typeof X`): the field_access site is the type reference + fieldSites: { + file: 'all-typescript-type-references.csv', id: 'tsTypeReferenceUniqueHash', startLine: 'startLine', endLine: 'endLine', + fileVia: { column: 'tsModuleLinkHash', through: 'modules' }, + }, skipped: { file: 'skipped-typescript-files.csv', filePath: 'filePath', reason: 'reason', detail: 'detail' }, }, }; @@ -337,6 +344,9 @@ const JAVASCRIPT: LanguageAdapter = { typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [0, 1] }, entryPoints: { file: 'entry-point.csv', columns: [0, 1] }, entryReachable: { file: 'entry-reachable.csv', columns: [0] }, + // site, caller, variable, provenance, tier, access: a module variable an identifier reads, + // directly or through an import binding (the field is the variable's own hash) + fieldAccess: { file: 'field-access.csv', columns: [0, 1, 2, 3, 4, 5] }, }, ir: { // No signature and no owner qualified name: JavaScript declares neither. diff --git a/graph/javascript/engine/call-edge-generation/field_access.dl b/graph/javascript/engine/call-edge-generation/field_access.dl new file mode 100644 index 00000000..066a5aee --- /dev/null +++ b/graph/javascript/engine/call-edge-generation/field_access.dl @@ -0,0 +1,60 @@ +// ============================================================================ +// CALL-EDGE-GEN · A MODULE VARIABLE READ BY NAME, AS A DATA EDGE +// +// `CFG.name`, `use(SCHEMA)`, `${TOKENS.store}`: the identifier is bound by the binder +// (expr_binding) to a variable -- the module's own top-level one, or an import binding. +// This engine follows an import to the VALUE the export holds (import_value), which is +// what a call needs, and never to the exported VARIABLE, which is what "who reads this +// const" needs. So a read of an imported const was answered by name, and the name +// matched every same-named const in every sibling package. +// +// The export surface is walked here once more, keeping the variable: export_decl names +// it (`export const x` and `export { x }` carry target kind VARIABLE), and a re-export +// (`export * from`, `export { x } from`) passes it on exactly as module_export_value +// passes a value on. A variable holding a function or a class is left out: its uses +// are calls and constructions, which call edges already carry. +// +// field_access(Site, Caller, Variable, "client", "known_edge", Access), the shape the +// Java and TypeScript engines export, so the bundle reads one relation for all three. +// ============================================================================ + +js_module_var(v) :- var_decl("client", _, reg, _, _, _, v), !var_owner_method("client", _, v), + !js_not_a_data_binding(reg), + !var_init("client", "FUNCTION", _, v), !var_init("client", "CLASS", _, v), + !var_import("client", _, v). +js_not_a_data_binding("IMPORT_BINDING"). +js_not_a_data_binding("FUNCTION_DECLARATION_HOISTED"). +js_not_a_data_binding("CLASS_TDZ"). +js_not_a_data_binding("CATCH_PARAMETER"). + +// ── export_var(Module, ExportedName, Variable) — the surface, by variable ─── +export_var(mod, n, v) :- export_decl("client", n, _, _, _, "VARIABLE", v, _, mod, _), n != "", js_module_var(v). +export_var(mod, n, v) :- export_decl("client", en, _, "EXPORT_ALL", _, _, _, _, mod, x), export_all_is_spread(en), + export_reexport(_, _, imp, x), import_module(imp, src), + export_var(src, n, v), n != "default". +export_var(mod, en, v) :- export_decl("client", en, ln, form, _, _, _, _, mod, x), form != "EXPORT_ALL", + export_reexport(_, _, imp, x), import_module(imp, src), ln != "", + export_var(src, ln, v). +// ── import_var(ImportHash, Variable) — what a named import binding stands for ─ +import_var(imp, v) :- import_decl("client", _, _, bf, n, _, _, _, imp), import_binding_is_named(bf), n != "", + !contains(".", n), import_module(imp, mod), export_var(mod, n, v). + +// ── the site ──────────────────────────────────────────────────────────────── +js_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER", _, e), expr_binding("client", v, e), js_module_var(v). +js_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER", _, e), expr_binding("client", lv, e), + var_import("client", imp, lv), import_var(imp, v). +// A plain `=` never reads the old value; `+=` and `++` read and then write. +js_var_written(e) :- js_var_read_target(e, _), expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, e). +js_var_written(e) :- js_var_read_target(e, _), expr_kind(_, "UNARY", _, u), expr_operator(_, op, u), update_operator(op), + expr_child(_, u, _, _, e). +js_var_write_only(e) :- js_var_read_target(e, _), expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, "=", a), + expr_child(_, a, "ASSIGNMENT_TARGET", _, e). +js_var_access(e, "write") :- js_var_written(e), js_var_write_only(e). +js_var_access(e, "readwrite") :- js_var_written(e), !js_var_write_only(e). +js_var_access(e, "read") :- js_var_read_target(e, _), !js_var_written(e). +// the caller: the callable the expression is written in, else the module initializer +js_var_read_from(e, m) :- js_var_read_target(e, _), expr_owner("client", m, _, e), m != "". +js_var_read_from(e, init) :- js_var_read_target(e, _), expr_owner("client", "", mod, e), module_init(_, init, mod). + +field_access(e, caller, v, "client", "known_edge", acc) :- js_var_read_target(e, v), + js_var_read_from(e, caller), js_var_access(e, acc). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..66bbbbab 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -232,6 +232,18 @@ .decl var_owner_method(c0:symbol, c1:symbol, c2:symbol) .decl var_type_name(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl var_pattern_root(c0:symbol, c1:symbol, c2:symbol) + +// ── call-edge-generation/field_access.dl ── +.decl js_module_var(c0:symbol) +.decl js_not_a_data_binding(c0:symbol) +.decl export_var(c0:symbol, c1:symbol, c2:symbol) +.decl import_var(c0:symbol, c1:symbol) +.decl js_var_read_target(c0:symbol, c1:symbol) +.decl js_var_written(c0:symbol) +.decl js_var_write_only(c0:symbol) +.decl js_var_access(c0:symbol, c1:symbol) +.decl js_var_read_from(c0:symbol, c1:symbol) +.decl field_access(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:symbol) .decl var_binding_form(c0:symbol, c1:symbol, c2:symbol) // ── resolution/module-graph.dl ── diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..cd4d00c1 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -42,3 +42,4 @@ package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv jsx_renders jsx-renders.csv +field_access field-access.csv diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts new file mode 100644 index 00000000..f78a28dc --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts @@ -0,0 +1,21 @@ +// A module-scope const read through member access, as a call argument, in a template and +// under a type query: every use of CFG / S / TOKENS below binds to THIS file's declaration. +export const CFG = { name: 'a', port: 1 } as const; +export const S = mk({}); +export const TOKENS = { Store: 'store' } as const; +export let counter = 0; +// a const that holds a function is a function: its uses are calls, not reads (control) +export const make = (n: number) => n + 1; + +export function mk(o: object): object { + return o; +} + +export function localUse(): string { + return CFG.name; +} + +export function bump(): void { + counter += 1; + counter = 0; +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts new file mode 100644 index 00000000..348509d6 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts @@ -0,0 +1 @@ +export * from './cfg'; diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts new file mode 100644 index 00000000..97b17b45 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts @@ -0,0 +1,47 @@ +import { CFG, S, TOKENS, make } from '../src'; + +function use(x: unknown): void {} + +export function viaMember(): void { + use(CFG.name); +} + +export function asArgument(): void { + use(S); +} + +export function inTemplate(): string { + return `${CFG.name}-${TOKENS.Store}`; +} + +export function callsTheFunction(): number { + return make(1); +} + +export function shadowed(): string { + // a local of the same name: the read is the local's, not the module's (control) + const CFG = { name: 'local' }; + return CFG.name; +} + +// a read in a type: at module level it is the module's, in a signature the function's +type T = typeof S; + +export function annotated(x: typeof CFG): string { + return x.name; +} + +// a parameter of the same name: `typeof TOKENS` here is the parameter's type (control) +export function shadowedInType(TOKENS: number): typeof TOKENS { + return TOKENS; +} + +function Route(x: unknown): MethodDecorator { return () => {}; } +function Body(x: unknown): ParameterDecorator { return () => {}; } +class Pipe { constructor(readonly s: unknown) {} } + +export class Handler { + // a read in a decorator is the decorated method's: on the method, and on its parameter + @Route(TOKENS.Store) + handle(@Body(new Pipe(S)) body: unknown): void {} +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts b/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts new file mode 100644 index 00000000..74ad08dd --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts @@ -0,0 +1,3 @@ +// The same names, declared again in a sibling package: reads here bind here (control). +export const CFG = { name: 'b', port: 2 } as const; +export const S = {}; diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts b/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts new file mode 100644 index 00000000..90a49c65 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts @@ -0,0 +1,5 @@ +import { CFG, S } from '../src/settings'; + +export function siblingRead(): unknown { + return [CFG.port, S]; +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json b/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json new file mode 100644 index 00000000..65ed3564 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "experimentalDecorators": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/expected/17-constrained-generics.fields b/graph/test/typescript/expected/17-constrained-generics.fields index 51e56615..f0dbf2a4 100644 --- a/graph/test/typescript/expected/17-constrained-generics.fields +++ b/graph/test/typescript/expected/17-constrained-generics.fields @@ -1,3 +1,7 @@ ambiguous_unknown read consumer#ownConstraint(T) -> - ambiguous_unknown read consumer#ownConstraintNamed(T) -> - ambiguous_unknown read curried-local#widest(unknown) -> - +known_edge read consumer#dataFirst() -> consumer#rows +known_edge read consumer#localDataFirst() -> consumer#rows +known_edge read consumer#localWidest() -> consumer#rows +known_edge read consumer#widestFirst() -> consumer#rows diff --git a/graph/test/typescript/expected/17-constrained-generics.fields-oracle b/graph/test/typescript/expected/17-constrained-generics.fields-oracle index 320aafb9..d6d71ba2 100644 --- a/graph/test/typescript/expected/17-constrained-generics.fields-oracle +++ b/graph/test/typescript/expected/17-constrained-generics.fields-oracle @@ -1,7 +1,7 @@ 17-constrained-generics [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 3 resolved 0 (0.0%) - tiers ambiguous_unknown=3 - access read=3 - not scored: 3 rows whose target is not a client declaration + sites 7 resolved 4 (57.1%) + tiers ambiguous_unknown=3 known_edge=4 + access read=7 + not scored: 7 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/36-function-value-containers.fields b/graph/test/typescript/expected/36-function-value-containers.fields index e69de29b..741ac6a1 100644 --- a/graph/test/typescript/expected/36-function-value-containers.fields +++ b/graph/test/typescript/expected/36-function-value-containers.fields @@ -0,0 +1 @@ +known_edge read r229#callObjectLiteral(number) -> r229#handlers diff --git a/graph/test/typescript/expected/36-function-value-containers.fields-oracle b/graph/test/typescript/expected/36-function-value-containers.fields-oracle index 6fb4ee24..152dfcc5 100644 --- a/graph/test/typescript/expected/36-function-value-containers.fields-oracle +++ b/graph/test/typescript/expected/36-function-value-containers.fields-oracle @@ -1,9 +1,9 @@ 36-function-value-containers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 2 rows whose target is not a client declaration MISSING r229#callClassMembers(number) READ Holder#viaField MISSING r229#callObjectLiteral(number) READ r229#viaArrow diff --git a/graph/test/typescript/expected/49-indexed-access-return.fields b/graph/test/typescript/expected/49-indexed-access-return.fields index 64f7525f..3f375c0b 100644 --- a/graph/test/typescript/expected/49-indexed-access-return.fields +++ b/graph/test/typescript/expected/49-indexed-access-return.fields @@ -1,2 +1,5 @@ known_edge read main#getDog() -> Zoo#dog +known_edge read main#getDog() -> main#zoo known_edge read main#getDogPlain() -> Zoo#dog +known_edge read main#getDogPlain() -> main#zoo +known_edge read main#pick(T) -> main#zoo diff --git a/graph/test/typescript/expected/49-indexed-access-return.fields-oracle b/graph/test/typescript/expected/49-indexed-access-return.fields-oracle index 2ff6d466..198a9f5b 100644 --- a/graph/test/typescript/expected/49-indexed-access-return.fields-oracle +++ b/graph/test/typescript/expected/49-indexed-access-return.fields-oracle @@ -1,7 +1,7 @@ 49-indexed-access-return [fields] 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 - access read=2 - not scored: 0 rows whose target is not a client declaration + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + access read=5 + not scored: 3 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/62-field-access-and-type-use.fields b/graph/test/typescript/expected/62-field-access-and-type-use.fields index 32b614fb..5659500c 100644 --- a/graph/test/typescript/expected/62-field-access-and-type-use.fields +++ b/graph/test/typescript/expected/62-field-access-and-type-use.fields @@ -8,6 +8,7 @@ known_edge read Box#run() -> Box#label known_edge read Box#staticRead() -> Box#static total known_edge read Box#viaInterface(Shaped) -> Shaped#width known_edge read Narrow#own() -> Narrow#width +known_edge read members#Shaped2() -> members#Marker2 known_edge readwrite Box#compound(number) -> Box#width known_edge readwrite Box#decrement() -> Box#hidden known_edge readwrite Box#increment() -> Box#hidden diff --git a/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle b/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle index 5964a26b..e23a07d7 100644 --- a/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle +++ b/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle @@ -1,7 +1,7 @@ 62-field-access-and-type-use [fields] precision 1.0000 (20 correct, 0 wrong) recall 1.0000 (20 of 20 the compiler resolved) - sites 19 resolved 18 (94.7%) - tiers ambiguous_unknown=1 known_edge=18 - access read=11 readwrite=3 write=5 - not scored: 1 rows whose target is not a client declaration + sites 20 resolved 19 (95.0%) + tiers ambiguous_unknown=1 known_edge=19 + access read=12 readwrite=3 write=5 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/63-annotated-callable-member.fields b/graph/test/typescript/expected/63-annotated-callable-member.fields index e69de29b..fe88d80b 100644 --- a/graph/test/typescript/expected/63-annotated-callable-member.fields +++ b/graph/test/typescript/expected/63-annotated-callable-member.fields @@ -0,0 +1 @@ +known_edge read members#callVarAmbient() -> members#varAmbient diff --git a/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle b/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle index 8e35ea27..146bd47f 100644 --- a/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle +++ b/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle @@ -1,9 +1,9 @@ 63-annotated-callable-member [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 1 rows whose target is not a client declaration MISSING members#callPropInline(PropInline) READ PropInline#run MISSING members#callPropNamed(PropNamed) READ PropNamed#run diff --git a/graph/test/typescript/expected/64-array-dispatch.fields b/graph/test/typescript/expected/64-array-dispatch.fields index b1252eaf..12ff9d9f 100644 --- a/graph/test/typescript/expected/64-array-dispatch.fields +++ b/graph/test/typescript/expected/64-array-dispatch.fields @@ -1 +1,4 @@ +known_edge read pipeline#runByName(Node) -> pipeline#byName +known_edge read pipeline#runIndexed(Node) -> pipeline#steps +known_edge read pipeline#runIterated(Node) -> pipeline#steps known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/64-array-dispatch.fields-oracle b/graph/test/typescript/expected/64-array-dispatch.fields-oracle index cedeaacf..86bafa38 100644 --- a/graph/test/typescript/expected/64-array-dispatch.fields-oracle +++ b/graph/test/typescript/expected/64-array-dispatch.fields-oracle @@ -1,10 +1,10 @@ 64-array-dispatch [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 3 rows whose target is not a client declaration WRONG steps#measure(Node) READ { width: number }#width MISSING pipeline#runByName(Node) READ pipeline#square MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields index e69de29b..5310006c 100644 --- a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields +++ b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields @@ -0,0 +1,3 @@ +known_edge read fewer#callAmbient() -> fewer#ambient +known_edge read fewer#callSiblingsOne() -> fewer#siblings +known_edge read fewer#callSiblingsTwo() -> fewer#siblings diff --git a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle index 0d95ec28..a5e21475 100644 --- a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle +++ b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle @@ -1,8 +1,8 @@ 65-annotated-callable-fewer-parameters [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 + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + access read=3 + not scored: 3 rows whose target is not a client declaration MISSING fewer#callHolderRun(Holder) READ Holder#run diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.fields b/graph/test/typescript/expected/69-named-and-inline-handlers.fields index e69de29b..ff8c71ee 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.fields +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.fields @@ -0,0 +1,3 @@ +known_edge read routes#() -> routes#app +known_edge read routes#() -> routes#cache +known_edge read routes#() -> routes#headers diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle b/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle index ba7a4326..f63426fd 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle @@ -1,7 +1,7 @@ 69-named-and-inline-handlers [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 + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 4 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields index 97e25270..c12de5c7 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields @@ -1 +1,2 @@ known_edge read fetcher#get(string,Options) -> Options#fetch +known_edge read pool#main() -> pool#fixed diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle index 91b3cf30..7a227b92 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle @@ -1,8 +1,8 @@ 72-object-literal-and-expression-callees [fields] precision 1.0000 (1 correct, 0 wrong) recall 0.5000 (1 of 2 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 1 rows whose target is not a client declaration MISSING registry#price(number) READ Registry#static format diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields b/graph/test/typescript/expected/74-jsx-component-forms.fields index 1aa6da98..6dcde21d 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.fields +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields @@ -1,6 +1,11 @@ ambiguous_unknown read parts#Badge(?) -> - known_edge read Panel#render() -> Panel#props known_edge read Panel#render() -> { title: string }#title +known_edge read app#App() -> app#Settings +known_edge read app#App() -> parts#Field +known_edge read app#App() -> parts#UserCard +known_edge read app#App() -> parts#ui +known_edge read app#useFactory() -> parts#factory known_edge read parts#Avatar({ src: string }) -> { src: string }#src known_edge read parts#Chip({ text: string }) -> { text: string }#text known_edge read parts#FieldImpl({ label: string },unknown) -> { label: string }#label diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle index da9a8ee2..e2cf577f 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle @@ -1,10 +1,10 @@ 74-jsx-component-forms [fields] precision 0.2857 (2 correct, 5 wrong) recall 0.2500 (2 of 8 the compiler resolved) - sites 8 resolved 7 (87.5%) - tiers ambiguous_unknown=1 known_edge=7 - access read=7 write=1 - not scored: 1 rows whose target is not a client declaration + sites 13 resolved 12 (92.3%) + tiers ambiguous_unknown=1 known_edge=12 + access read=12 write=1 + not scored: 6 rows whose target is not a client declaration WRONG Panel#render() READ { title: string }#title WRONG parts#Avatar({ src: string }) READ { src: string }#src WRONG parts#Chip({ text: string }) READ { text: string }#text diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields index 3da15a75..82a233a4 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields @@ -2,4 +2,14 @@ ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#() -> - +known_edge read app#Page() -> app#DynAwait +known_edge read app#Page() -> app#DynDefault +known_edge read app#Page() -> app#DynNamed +known_edge read app#Page() -> app#LazyBlock +known_edge read app#Page() -> app#LazyDefault +known_edge read app#Page() -> app#LazyNamed +known_edge read app#Page() -> app#Made +known_edge read app#Page() -> app#Memoed +known_edge read app#Page() -> app#Observed +known_edge read app#Page() -> app#Thing known_edge read ui/Button#Button({ label: string }) -> { label: string }#label diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle index 3617bfdb..aaddbd64 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle @@ -1,9 +1,9 @@ 75-jsx-wrapper-guards [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 5 resolved 1 (20.0%) - tiers ambiguous_unknown=4 known_edge=1 - access read=5 - not scored: 4 rows whose target is not a client declaration + sites 15 resolved 11 (73.3%) + tiers ambiguous_unknown=4 known_edge=11 + access read=15 + not scored: 14 rows whose target is not a client declaration WRONG ui/Button#Button({ label: string }) READ { label: string }#label MISSING ui/Button#Button({ label: string }) READ ui/Button#label diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields index ce8e4343..da638d2c 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields @@ -1,2 +1,6 @@ ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#useName() -> - +known_edge read app#App() -> app#LazyDefault +known_edge read app#App() -> app#LazyNamedObj +known_edge read app#App() -> app#MemoPicked +known_edge read app#App() -> parts#MemoFwd diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle index c4ca47b6..371b7582 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle @@ -1,7 +1,7 @@ 76-jsx-default-nested-wrappers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 2 resolved 0 (0.0%) - tiers ambiguous_unknown=2 - access read=2 - not scored: 2 rows whose target is not a client declaration + sites 6 resolved 4 (66.7%) + tiers ambiguous_unknown=2 known_edge=4 + access read=6 + not scored: 6 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/77-package-published-entries.fields b/graph/test/typescript/expected/77-package-published-entries.fields index 0233e076..ccc83f66 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields +++ b/graph/test/typescript/expected/77-package-published-entries.fields @@ -1,2 +1,3 @@ ambiguous_unknown read index#useStore(number) -> - ambiguous_unknown read plugins/logger#() -> - +known_edge read vanilla#() -> vanilla#DEFAULT_INITIAL 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 85d59b99..72c9bcde 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 2 resolved 0 (0.0%) - tiers ambiguous_unknown=2 - access read=2 - not scored: 2 rows whose target is not a client declaration + sites 3 resolved 1 (33.3%) + tiers ambiguous_unknown=2 known_edge=1 + access read=3 + not scored: 3 rows whose target is not a client declaration MISSING index#useStore(number) READ vanilla#value diff --git a/graph/test/typescript/expected/78-callable-collections.fields b/graph/test/typescript/expected/78-callable-collections.fields index b5e742ff..a43f3a76 100644 --- a/graph/test/typescript/expected/78-callable-collections.fields +++ b/graph/test/typescript/expected/78-callable-collections.fields @@ -1,2 +1,17 @@ ambiguous_unknown read pipeline#countIdle() -> - +known_edge read pipeline#() -> pipeline#registry +known_edge read pipeline#() -> pipeline#steps +known_edge read pipeline#countIdle() -> pipeline#idle +known_edge read pipeline#keysOnly() -> pipeline#table +known_edge read pipeline#runByKey(Node,'grow' | 'shrink') -> pipeline#table +known_edge read pipeline#runByName(Node) -> pipeline#table +known_edge read pipeline#runForEach(Node) -> pipeline#steps +known_edge read pipeline#runFrozen(Node) -> pipeline#frozen +known_edge read pipeline#runImported(Node) -> steps#exportedSteps +known_edge read pipeline#runIndexed(Node,number) -> pipeline#steps +known_edge read pipeline#runIterated(Node) -> pipeline#steps +known_edge read pipeline#runReduce(Node) -> pipeline#frozen +known_edge read pipeline#runRegistry(Node) -> pipeline#extended +known_edge read pipeline#runRegistry(Node) -> pipeline#registry +known_edge read pipeline#runValues(Node) -> pipeline#table known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/78-callable-collections.fields-oracle b/graph/test/typescript/expected/78-callable-collections.fields-oracle index 8fc5cf9a..0fe6772f 100644 --- a/graph/test/typescript/expected/78-callable-collections.fields-oracle +++ b/graph/test/typescript/expected/78-callable-collections.fields-oracle @@ -1,10 +1,10 @@ 78-callable-collections [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 2 resolved 1 (50.0%) - tiers ambiguous_unknown=1 known_edge=1 - access read=2 - not scored: 1 rows whose target is not a client declaration + sites 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + access read=18 + not scored: 17 rows whose target is not a client declaration WRONG steps#measure(Node) READ { width: number }#width MISSING pipeline#runByName(Node) READ pipeline#shrink MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields index 4fea0459..ea9edbd9 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -1 +1,14 @@ ambiguous_unknown read client/client#dailyReport(string) -> - +known_edge read client/axios#() -> axios.d#axios +known_edge read client/client#() -> axios.d#axios +known_edge read client/client#audit() -> axios.d#axios +known_edge read client/client#cached() -> client#cache +known_edge read client/client#dailyReport(string) -> axios.d#axios +known_edge read client/client#dailyReport(string) -> config#config +known_edge read client/client#invoice(string) -> client#billing +known_edge read client/client#listAll() -> paths#ORDERS +known_edge read client/client#placeOrder(unknown) -> axios.d#axios +known_edge read client/client#placeOrder(unknown) -> paths#ORDERS +known_edge read server/orders#() -> orders#app +known_edge read server/orders#() -> orders#router +known_edge read server/orders#() -> paths#ORDERS diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle index bd6298b8..1a7206f1 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle @@ -1,8 +1,8 @@ 78-cross-process-destinations [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 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + access read=18 + not scored: 18 rows whose target is not a client declaration MISSING client/client#dailyReport(string) READ shared/config#reportsUrl 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 index e69de29b..ca48bcea 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields @@ -0,0 +1,12 @@ +known_edge read app#(?) -> rate#RATE +known_edge read app#() -> rate#RATE +known_edge read app#(?) -> rate#RATE +known_edge read app#boot() -> app#host +known_edge read app#boot() -> plugin#plugin +known_edge read app#bootBare() -> app#host +known_edge read app#bootSettings() -> app#host +known_edge read app#bootSettings() -> plugin#settings +known_edge read app#record(number) -> app#seen +known_edge read store#known() -> store#subs +known_edge read store#store() -> store#handlers +known_edge read store#store() -> store#subs 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 index be4f8be6..8c2412a1 100644 --- 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 @@ -1,7 +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 + sites 12 resolved 12 (100.0%) + tiers known_edge=12 + access read=12 + not scored: 12 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-package-published-members.fields b/graph/test/typescript/expected/79-package-published-members.fields index e69de29b..ec69ab45 100644 --- a/graph/test/typescript/expected/79-package-published-members.fields +++ b/graph/test/typescript/expected/79-package-published-members.fields @@ -0,0 +1,3 @@ +known_edge read src/bound#() -> bound#engine +known_edge read src/index.test#() -> api#api +known_edge read src/index.test#() -> bound#produce diff --git a/graph/test/typescript/expected/79-package-published-members.fields-oracle b/graph/test/typescript/expected/79-package-published-members.fields-oracle index bf1e0d9b..f5234231 100644 --- a/graph/test/typescript/expected/79-package-published-members.fields-oracle +++ b/graph/test/typescript/expected/79-package-published-members.fields-oracle @@ -1,7 +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 + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + access read=5 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields b/graph/test/typescript/expected/80-generic-return-receiver.fields index b7d27851..e6f9deac 100644 --- a/graph/test/typescript/expected/80-generic-return-receiver.fields +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields @@ -1,2 +1,10 @@ known_edge read Circ#area() -> Circ#r known_edge read Sq#area() -> Sq#s +known_edge read use#() -> use#WARRIOR +known_edge read use#() -> use#container +known_edge read use#ctlAnyIndex(string) -> use#anyBag +known_edge read use#indexValue(string) -> use#shapes +known_edge read use#recordArea(string,number) -> use#makers +known_edge read use#run() -> use#WARRIOR +known_edge read use#run() -> use#container +known_edge read use#totalArea(string,number) -> use#factories diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle index f770da18..09fc58b0 100644 --- a/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle @@ -1,7 +1,7 @@ 80-generic-return-receiver [fields] precision 1.0000 (2 correct, 0 wrong) recall 1.0000 (2 of 2 the compiler resolved) - sites 4 resolved 4 (100.0%) - tiers known_edge=4 - access read=4 - not scored: 0 rows whose target is not a client declaration + sites 12 resolved 12 (100.0%) + tiers known_edge=12 + access read=12 + not scored: 8 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields b/graph/test/typescript/expected/80-object-literal-member-receivers.fields index 4b531045..f2c88fe3 100644 --- a/graph/test/typescript/expected/80-object-literal-member-receivers.fields +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields @@ -1,3 +1,9 @@ ambiguous_unknown read local#drive() -> - ambiguous_unknown read local#trimName() -> - ambiguous_unknown read use#callNested() -> - +known_edge read local#drive() -> local#handlers +known_edge read local#trimName() -> local#cfg +known_edge read use#callGreeter() -> api#greeter +known_edge read use#callNested() -> api#api +known_edge read use#callTop() -> api#api +known_edge read use#keepUnrelated() -> use#unrelated diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle index 66475c28..70242926 100644 --- a/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle @@ -1,10 +1,10 @@ 80-object-literal-member-receivers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 9 the compiler resolved) - sites 6 resolved 0 (0.0%) - tiers ambiguous_unknown=6 - access read=6 - not scored: 6 rows whose target is not a client declaration + sites 17 resolved 11 (64.7%) + tiers ambiguous_unknown=6 known_edge=11 + access read=17 + not scored: 17 rows whose target is not a client declaration MISSING local#drive() READ local#jobs MISSING local#drive() READ local#run MISSING local#trimName() READ local#name diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields index e69de29b..d5c739df 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields @@ -0,0 +1,9 @@ +known_edge read app#() -> parts#Child +known_edge read app#() -> parts#FnForm +known_edge read app#() -> parts#NuxtCard +known_edge read app#() -> parts#Panel +known_edge read app#() -> parts#Store +known_edge read app#() -> reg#Button +known_edge read app#() -> reg#Local +known_edge read card#render() -> parts#Child +known_edge read reg#() -> reg#_Local diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle index 7aa79d05..cc14c676 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle @@ -1,7 +1,7 @@ 80-vue-definecomponent-jsx [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 + sites 9 resolved 9 (100.0%) + tiers known_edge=9 + access read=9 + not scored: 9 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-vue-sfc.fields b/graph/test/typescript/expected/80-vue-sfc.fields index 2c586263..7fae20e7 100644 --- a/graph/test/typescript/expected/80-vue-sfc.fields +++ b/graph/test/typescript/expected/80-vue-sfc.fields @@ -1 +1,3 @@ +known_edge read Comp.vue#() -> Comp#total known_edge read child#Child({ msg: string }) -> { msg: string }#msg +known_edge read shims-vue#() -> shims-vue.d#component diff --git a/graph/test/typescript/expected/80-vue-sfc.fields-oracle b/graph/test/typescript/expected/80-vue-sfc.fields-oracle index 706b5d61..4288e38b 100644 --- a/graph/test/typescript/expected/80-vue-sfc.fields-oracle +++ b/graph/test/typescript/expected/80-vue-sfc.fields-oracle @@ -1,9 +1,9 @@ 80-vue-sfc [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 3 rows whose target is not a client declaration WRONG child#Child({ msg: string }) READ { msg: string }#msg MISSING child#Child({ msg: string }) READ child#msg diff --git a/graph/test/typescript/expected/81-vue-component-tag.fields b/graph/test/typescript/expected/81-vue-component-tag.fields index e69de29b..d2a02654 100644 --- a/graph/test/typescript/expected/81-vue-component-tag.fields +++ b/graph/test/typescript/expected/81-vue-component-tag.fields @@ -0,0 +1 @@ +known_edge read shims-vue#() -> shims-vue.d#component diff --git a/graph/test/typescript/expected/81-vue-component-tag.fields-oracle b/graph/test/typescript/expected/81-vue-component-tag.fields-oracle index 7a65896b..21219347 100644 --- a/graph/test/typescript/expected/81-vue-component-tag.fields-oracle +++ b/graph/test/typescript/expected/81-vue-component-tag.fields-oracle @@ -1,7 +1,7 @@ 81-vue-component-tag [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 + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 1 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields b/graph/test/typescript/expected/83-closed-world-dispatch.fields index e69de29b..aa725fb8 100644 --- a/graph/test/typescript/expected/83-closed-world-dispatch.fields +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields @@ -0,0 +1,2 @@ +known_edge read handlers#main() -> handlers#registry +known_edge read handlers#register(HandlerClass) -> handlers#registry diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle index 3355e740..966f7948 100644 --- a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle @@ -1,7 +1,7 @@ 83-closed-world-dispatch [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields b/graph/test/typescript/expected/85-workspace-package-import.fields index 565a7ee6..be580e37 100644 --- a/graph/test/typescript/expected/85-workspace-package-import.fields +++ b/graph/test/typescript/expected/85-workspace-package-import.fields @@ -1 +1,3 @@ known_edge read S#run() -> S#bus +known_edge read S#run() -> index#K +known_edge read S#run() -> zod#z diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle index 44b1f6d7..346e6652 100644 --- a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle +++ b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle @@ -1,7 +1,7 @@ 85-workspace-package-import [fields] precision 1.0000 (1 correct, 0 wrong) recall 1.0000 (1 of 1 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + access read=3 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/86-module-variable-reads.edges b/graph/test/typescript/expected/86-module-variable-reads.edges new file mode 100644 index 00000000..c94f0255 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.edges @@ -0,0 +1,7 @@ +known_edge CONSTRUCTOR_CALL Handler#handle(unknown) @L46 -> Pipe#(unknown) +known_edge DECORATOR_CALL Handler#handle(unknown) @L45 -> a/test/t#Route(unknown) +known_edge DECORATOR_CALL Handler#handle(unknown) @L46 -> a/test/t#Body(unknown) +known_edge FUNCTION_CALL a/src/cfg#() @L4 -> a/src/cfg#mk(object) +known_edge FUNCTION_CALL a/test/t#asArgument() @L10 -> a/test/t#use(unknown) +known_edge FUNCTION_CALL a/test/t#callsTheFunction() @L18 -> a/src/cfg#make(number) +known_edge FUNCTION_CALL a/test/t#viaMember() @L6 -> a/test/t#use(unknown) diff --git a/graph/test/typescript/expected/86-module-variable-reads.entries b/graph/test/typescript/expected/86-module-variable-reads.entries new file mode 100644 index 00000000..9b291f2d --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.entries @@ -0,0 +1,15 @@ +── entry_point (14) ── + exported_from_entry_module a/src/cfg#bump cfg.ts:18 + exported_from_entry_module a/src/cfg#localUse cfg.ts:14 + exported_from_entry_module a/src/cfg#mk cfg.ts:10 + exported_from_entry_module a/test/t#annotated t.ts:30 + exported_from_entry_module a/test/t#asArgument t.ts:9 + exported_from_entry_module a/test/t#callsTheFunction t.ts:17 + exported_from_entry_module a/test/t#inTemplate t.ts:13 + exported_from_entry_module a/test/t#shadowed t.ts:21 + exported_from_entry_module a/test/t#shadowedInType t.ts:35 + exported_from_entry_module a/test/t#viaMember t.ts:5 + exported_from_entry_module b/test/sibling#siblingRead sibling.ts:3 + unimported_module a/src/cfg# cfg.ts:1 + unimported_module a/test/t# t.ts:1 + unimported_module b/test/sibling# sibling.ts:1 diff --git a/graph/test/typescript/expected/86-module-variable-reads.fields b/graph/test/typescript/expected/86-module-variable-reads.fields new file mode 100644 index 00000000..b5727e5d --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.fields @@ -0,0 +1,20 @@ +ambiguous_unknown read a/src/cfg#localUse() -> - +ambiguous_unknown read a/test/t#() -> - +ambiguous_unknown read a/test/t#annotated(typeof CFG) -> - +ambiguous_unknown read a/test/t#inTemplate() -> - +ambiguous_unknown read a/test/t#shadowed() -> - +ambiguous_unknown read a/test/t#viaMember() -> - +ambiguous_unknown read b/test/sibling#siblingRead() -> - +known_edge read Handler#handle(unknown) -> cfg#S +known_edge read Handler#handle(unknown) -> cfg#TOKENS +known_edge read a/src/cfg#localUse() -> cfg#CFG +known_edge read a/test/t#() -> cfg#S +known_edge read a/test/t#annotated(typeof CFG) -> cfg#CFG +known_edge read a/test/t#asArgument() -> cfg#S +known_edge read a/test/t#inTemplate() -> cfg#CFG +known_edge read a/test/t#inTemplate() -> cfg#TOKENS +known_edge read a/test/t#viaMember() -> cfg#CFG +known_edge read b/test/sibling#siblingRead() -> settings#CFG +known_edge read b/test/sibling#siblingRead() -> settings#S +known_edge readwrite a/src/cfg#bump() -> cfg#counter +known_edge write a/src/cfg#bump() -> cfg#counter diff --git a/graph/test/typescript/expected/86-module-variable-reads.fields-oracle b/graph/test/typescript/expected/86-module-variable-reads.fields-oracle new file mode 100644 index 00000000..70fbca4e --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.fields-oracle @@ -0,0 +1,15 @@ +86-module-variable-reads [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 8 the compiler resolved) + sites 21 resolved 13 (61.9%) + tiers ambiguous_unknown=8 known_edge=13 + access read=19 readwrite=1 write=1 + not scored: 21 rows whose target is not a client declaration + MISSING Handler#handle(unknown) READ a/src/cfg#Store + MISSING a/src/cfg#localUse() READ a/src/cfg#name + MISSING a/test/t#annotated(typeof CFG) READ a/src/cfg#name + MISSING a/test/t#inTemplate() READ a/src/cfg#Store + MISSING a/test/t#inTemplate() READ a/src/cfg#name + MISSING a/test/t#shadowed() READ a/test/t#name + MISSING a/test/t#viaMember() READ a/src/cfg#name + MISSING b/test/sibling#siblingRead() READ b/src/settings#port diff --git a/graph/test/typescript/expected/86-module-variable-reads.oracle b/graph/test/typescript/expected/86-module-variable-reads.oracle new file mode 100644 index 00000000..fa4000b6 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.oracle @@ -0,0 +1 @@ +oracle=7 engine=7 agree=7 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/86-module-variable-reads.type-use b/graph/test/typescript/expected/86-module-variable-reads.type-use new file mode 100644 index 00000000..a403269b --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.type-use @@ -0,0 +1,5 @@ +ambiguous_unknown AS_TARGET 0 a/src/cfg [EXPRESSION] -> - +ambiguous_unknown AS_TARGET 0 b/src/settings [EXPRESSION] -> - +ambiguous_unknown DECORATOR_TYPE 0 Handler [DECORATOR] -> - +ambiguous_unknown METHOD_RETURN 0 a/test/t [METHOD] -> - +known_edge OBJECT_CREATION_TYPE 0 Handler [EXPRESSION] -> Pipe diff --git a/graph/test/typescript/expected/86-module-variable-reads.types-oracle b/graph/test/typescript/expected/86-module-variable-reads.types-oracle new file mode 100644 index 00000000..e0d90aa4 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.types-oracle @@ -0,0 +1,7 @@ +86-module-variable-reads [types] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 8 resolved 1 (12.5%) + tiers ambiguous_unknown=7 known_edge=1 + contexts AS_TARGET=3 DECORATOR_TYPE=2 METHOD_RETURN=2 OBJECT_CREATION_TYPE=1 + not scored: 7 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_members.py b/graph/test/typescript/tools/normalize_members.py index 3a47baf3..a969547c 100644 --- a/graph/test/typescript/tools/normalize_members.py +++ b/graph/test/typescript/tools/normalize_members.py @@ -35,6 +35,14 @@ def field_names(ir, prefix='typescript'): return out +def module_var_names(ir, prefix='typescript'): + """variable hash -> (module label, name), for a module-scope variable read by name: its owner is its + module, labelled by the file's stem as the module initializer is (`members#()`).""" + return {r['tsVariableUniqueHash']: (os.path.splitext(os.path.basename(r.get('filePath') or '?'))[0], r['name']) + for r in rows(f'{ir}/all-{prefix}-variables.csv') + if r.get('scopeKind') in ('MODULE_SCOPE', 'GLOBAL_SCOPE', 'AMBIENT_SCOPE', 'NAMESPACE_SCOPE')} + + def type_names(ir, prefix='typescript'): return {t['tsTypeUniqueHash']: (t.get('name') or t.get('qualifiedName') or '?') for t in rows(f'{ir}/all-{prefix}-types.csv')} @@ -53,6 +61,9 @@ def main(): ir, out = args[0], args[1] n = Names(ir, lib_ir) fields = field_names(ir) + # the compiler oracle scores member accesses only: a module variable's read is golden-checked, not oracle-paired + mvars = module_var_names(ir) + fields.update({k: v for k, v in mvars.items() if k not in fields}) types = type_names(ir) if lib_ir and os.path.isdir(lib_ir): fields.update({k: v for k, v in field_names(lib_ir).items() if k not in fields}) @@ -72,7 +83,7 @@ def main(): f"{fields[field][0]}#{fields[field][1]}" if field in fields else f"") if pairs: - if field == '-' or field not in fields: + if field == '-' or field not in fields or field in mvars: continue for d in (['READ'] if access == 'read' else ['WRITE'] if access == 'write' else ['READ', 'WRITE']): diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index d7d0057f..ea00e4c9 100644 --- a/graph/typescript/engine/call-edge-generation/field_access.dl +++ b/graph/typescript/engine/call-edge-generation/field_access.dl @@ -150,3 +150,88 @@ ts_field_access_has_row(e) :- ts_field_access_target(e, f), ts_field_prov(_, f). field_access(e, caller, "-", "-", "ambiguous_unknown", acc) :- ts_field_access_site(e), !ts_field_access_has_row(e), ts_field_access_from(e, caller), ts_field_access_kind(e, acc). + +// ── A MODULE-SCOPE VARIABLE, READ BY NAME ─────────────────────────────────── +// `SERVICE.name`, `f(EnvSchema)`, `TOKENS.IntentStore`: the receiver or argument is an +// IDENTIFIER_REFERENCE that the binder already tied to one declaration -- a module-scope +// variable of its own file (VARIABLE), or an import whose binding import_binds follows, +// through every re-export, to the exported variable (IMPORT_BINDING). Nothing recorded +// that tie as a data edge, so "who reads this exported const" was answered by name, and +// the name matched every same-named const in every sibling package. +// +// The variable is the field of the row: a module is the owner of its variables the way a +// class is the owner of its fields, and consumers read one relation for both. A variable +// that holds a function is a function (`const f = () => ...`): its uses are calls, which +// call_edges already carries, so it is left out here. +ts_module_var(v) :- var_decl("client", _, _, sk, _, _, v), scope_is_module_level(sk), + !var_is_function(v, _). +ts_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER_REFERENCE", _, e), + expr_referenced("client", "VARIABLE", v, e), ts_module_var(v). +ts_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER_REFERENCE", _, e), + expr_referenced("client", "IMPORT_BINDING", ih, e), + import_binds(ih, "client", "VARIABLE", v), ts_module_var(v). +// the direction, by the same law property_written applies to a member +ts_var_written(e) :- ts_var_read_target(e, _), expr_child("client", asg, "LEFT_OPERAND", _, e), + expr_kind("client", ak, _, asg), assignment_kind(ak). +ts_var_written(e) :- ts_var_read_target(e, _), expr_child("client", u, "UNARY_OPERAND", _, e), + expr_kind("client", "UNARY_EXPRESSION", _, u), expr_operator("client", op, u), update_operator(op). +ts_var_write_only(e) :- ts_var_read_target(e, _), expr_child("client", asg, "LEFT_OPERAND", _, e), + expr_kind("client", "ASSIGNMENT_EXPRESSION", _, asg). +ts_var_read_kind(e, "write") :- ts_var_written(e), ts_var_write_only(e). +ts_var_read_kind(e, "readwrite") :- ts_var_written(e), !ts_var_write_only(e). +ts_var_read_kind(e, "read") :- ts_var_read_target(e, _), !ts_var_written(e). +// the caller, as for a property access: the enclosing function, else the module. +// A DECORATOR on a method or on one of its parameters is that method's: `place(@Payload(new +// Pipe(CommandSchema)) c)` reads CommandSchema for place(). The parser gives an expression +// inside a decorator no owner hash, and expr_enclosing_method routes it to the module +// initializer, so the read is found by walking down from the decorator's own expression. +ts_decorated_method(dx, m) :- decorator_expr("client", dx, d), + annotation_on("client", _, _, "METHOD_DECLARATION", m, d), m != "". +ts_decorated_method(dx, m) :- decorator_expr("client", dx, d), + annotation_on("client", _, _, "PARAMETER_DECLARATION", p, d), param_decl("client", _, _, _, m, p), m != "". +ts_under_decorator(dx, dx) :- ts_decorated_method(dx, _). +ts_under_decorator(dx, c) :- ts_under_decorator(dx, p), expr_child("client", p, _, _, c). +ts_var_read_decorated(e, m) :- ts_var_read_target(e, _), expr_owner("client", "DECORATOR", _, _, e), + ts_under_decorator(dx, e), ts_decorated_method(dx, m). +ts_var_read_named(e) :- ts_var_read_decorated(e, _). +ts_var_read_named(e) :- ts_var_read_target(e, _), expr_enclosing_method(e, m), m != "". +ts_var_read_from(e, m) :- ts_var_read_decorated(e, m). +ts_var_read_from(e, m) :- ts_var_read_target(e, _), !ts_var_read_decorated(e, _), + expr_enclosing_method(e, m), m != "". +ts_var_read_from(e, mod) :- ts_var_read_target(e, _), !ts_var_read_named(e), + expr_module("client", mod, e), mod != "". +// one declaration, bound by the binder: known_edge +field_access(e, caller, v, "client", "known_edge", acc) :- ts_var_read_target(e, v), + ts_var_read_from(e, caller), ts_var_read_kind(e, acc). + +// ── THE SAME VARIABLE, READ IN A TYPE: `typeof X` ─────────────────────────── +// `type OrderDto = z.infer` depends on OrderSchema exactly as +// `f(OrderSchema)` does, but the name sits in a TYPE_QUERY type reference, not in an +// expression, so the binder's expression tie above never sees it. The name is bound +// through the module's own scope (name_binds_any: a declaration of this file, or a named +// import followed to the exported variable). Only the bare form `typeof X`: in +// `typeof A.b` the variable read is A, and the reference names b. A parameter or a local +// of the same name in the enclosing callable shadows the module's (control). +// The site is the type reference; the bundle positions it from the type-reference table. +ts_type_query_site(ref, tn, mod) :- type_ref("client", "TYPE_QUERY", _, tn, ctn, _, _, ref), + tn != "", ctn = cat("typeof ", tn), type_ref_module("client", mod, ref). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, m, "METHOD", ref). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, p, "METHOD_PARAM", ref), + param_decl("client", _, _, _, m, p). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, lv, "VARIABLE", ref), + var_decl("client", _, _, _, m, _, lv), m != "". +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, x, "EXPRESSION", ref), + expr_enclosing_method(x, m), m != "". +ts_type_query_shadowed(ref) :- ts_type_query_site(ref, tn, _), ts_type_query_method(ref, m), + param_decl("client", tn, _, _, m, _). +ts_type_query_shadowed(ref) :- ts_type_query_site(ref, tn, _), ts_type_query_method(ref, m), + var_decl("client", tn, _, _, m, _, _). +ts_type_query_read(ref, v) :- ts_type_query_site(ref, tn, mod), !ts_type_query_shadowed(ref), + name_binds_any(mod, tn, "client", "VARIABLE", v), ts_module_var(v). +ts_type_query_has_method(ref) :- ts_type_query_method(ref, _). +field_access(ref, m, v, "client", "known_edge", "read") :- ts_type_query_read(ref, v), + ts_type_query_method(ref, m). +// a type alias, an interface, a class member's annotation: the module's initializer, as for +// any module-level read +field_access(ref, init, v, "client", "known_edge", "read") :- ts_type_query_read(ref, v), + !ts_type_query_has_method(ref), ts_type_query_site(ref, _, mod), module_init("client", init, mod). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 467f2e70..e7ef8989 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -640,6 +640,21 @@ .decl ts_field_access_from(c0:symbol,c1:symbol) .decl ts_field_access_has_row(c0:symbol) .decl field_access(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) +.decl ts_module_var(c0:symbol) +.decl ts_var_read_target(c0:symbol,c1:symbol) +.decl ts_var_read_from(c0:symbol,c1:symbol) +.decl ts_var_read_named(c0:symbol) +.decl ts_var_written(c0:symbol) +.decl ts_var_write_only(c0:symbol) +.decl ts_var_read_kind(c0:symbol,c1:symbol) +.decl ts_decorated_method(c0:symbol,c1:symbol) +.decl ts_under_decorator(c0:symbol,c1:symbol) +.decl ts_var_read_decorated(c0:symbol,c1:symbol) +.decl ts_type_query_site(c0:symbol,c1:symbol,c2:symbol) +.decl ts_type_query_method(c0:symbol,c1:symbol) +.decl ts_type_query_shadowed(c0:symbol) +.decl ts_type_query_read(c0:symbol,c1:symbol) +.decl ts_type_query_has_method(c0:symbol) // ── TYPE USE (#663) — call-edge-generation/type_use.dl .decl ts_type_use_kind_names_a_type(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 830ba9a5..7578fa70 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -100,7 +100,9 @@ A = { litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), - decls=[dict(file='all-typescript-variables.csv', only=lambda r: r.get('scopeKind') == 'MODULE_SCOPE', + # the variable's own hash: the engine's field_access names a module variable by it when an identifier the binder + # tied to it (directly, or through an import binding) reads it + decls=[dict(file='all-typescript-variables.csv', id='tsVariableUniqueHash', only=lambda r: r.get('scopeKind') == 'MODULE_SCOPE', kind=lambda r: 'const' if r.get('isConst') == 'true' else 'variable', name='name', owner=None, filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-fields.csv', id='tsFieldUniqueHash', kind=lambda r: 'field', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine')], @@ -137,7 +139,7 @@ A = { # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and # each made the function or class it names look declared as a second kind, so `impact C` refused as ambiguous - decls=[dict(file='all-javascript-variables.csv', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), + decls=[dict(file='all-javascript-variables.csv', id='jsVariableUniqueHash', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), kind=lambda r: 'const' if r.get('bindingRegime', '').startswith('CONST') else 'variable', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='endLine'), # a field's owner is its class by hash (`this.x = …` in a constructor, a class field, `F.prototype.x = …`), so it # displays as `Store.items`, not `items`; a computed key (`[Symbol.iterator] = …`) is named by its key expression diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..2b336d60 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -509,6 +509,10 @@ 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). +// the same where the engine bound the name to this const (an imported or module-level const read through its binding): +// fref leaves such a line to fa_bound, and the hand-off is still a registration, not a read +const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), + fa_bound(c, fl, _, 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) :- 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) @@ -530,7 +534,7 @@ const_routed(q, c, f, l) :- const_route_byname(q, c, f, l). fa_bound(c, fl, acc, f, l) :- faccess(c, fl, acc, tier, f, l), tier != "ambiguous_unknown". direct(q, c, "produces", "writes it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "write", f, l). direct(q, c, "produces", "writes it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "readwrite", f, l). -direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "read", f, l). +direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "read", f, l), !const_routed(q, c, f, l). direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "readwrite", f, l). // a caller the engine bound — to this field, or to a DIFFERENT field of the same name. Either way its // name matches say nothing further: the first is already reported above with its direction, and the diff --git a/tests/cases/javascript/module-level-const/case.json b/tests/cases/javascript/module-level-const/case.json new file mode 100644 index 00000000..85110780 --- /dev/null +++ b/tests/cases/javascript/module-level-const/case.json @@ -0,0 +1,18 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "an imported const read through a member access, as an argument, or through an `export *` barrel is resolved to the declaration the import binds, not matched by name", + "run": ["impact", "src/consts.js:2"], + "want": ["[resolved] memberRead", "[resolved] throughBarrel"], + "avoid": ["[by name"]}, + {"why": "the same for a const that holds a call's result, handed to a call", + "run": ["impact", "src/consts.js:3"], + "want": ["[resolved] argRead"], + "avoid": ["[by name"]}, + {"why": "a bare read of an imported const", + "run": ["impact", "src/consts.js:1"], + "want": ["[resolved] topLevelUse"], + "avoid": ["siblingRead"]}, + {"why": "control: a file that imports the same name from ANOTHER declaration is not a reader of this one, and that declaration keeps its own reader", + "run": ["impact", "src/sibling/consts.js:3"], + "want": ["[resolved] siblingRead"], + "avoid": ["memberRead", "throughBarrel"]}]} diff --git a/tests/cases/javascript/module-level-const/src/barrelUser.js b/tests/cases/javascript/module-level-const/src/barrelUser.js new file mode 100644 index 00000000..455a0720 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/barrelUser.js @@ -0,0 +1,3 @@ +import { CFG } from './index.js'; + +export function throughBarrel() { return CFG.port; } diff --git a/tests/cases/javascript/module-level-const/src/consts.js b/tests/cases/javascript/module-level-const/src/consts.js new file mode 100644 index 00000000..ea851142 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/consts.js @@ -0,0 +1,5 @@ +export const LIMIT = 10; +export const CFG = { name: 'a', port: 1 }; +export const SCHEMA = shape({ id: 'string' }); + +export function shape(o) { return o; } diff --git a/tests/cases/javascript/module-level-const/src/index.js b/tests/cases/javascript/module-level-const/src/index.js new file mode 100644 index 00000000..83aef343 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/index.js @@ -0,0 +1 @@ +export * from './consts.js'; diff --git a/tests/cases/javascript/module-level-const/src/sibling/consts.js b/tests/cases/javascript/module-level-const/src/sibling/consts.js new file mode 100644 index 00000000..d1a12e29 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/sibling/consts.js @@ -0,0 +1,3 @@ +// the same names, declared again: a reader here reads THIS file's declarations +export const LIMIT = 3; +export const CFG = { name: 'b', port: 2 }; diff --git a/tests/cases/javascript/module-level-const/src/sibling/reader.js b/tests/cases/javascript/module-level-const/src/sibling/reader.js new file mode 100644 index 00000000..7d41995c --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/sibling/reader.js @@ -0,0 +1,3 @@ +import { CFG, LIMIT } from './consts.js'; + +export function siblingRead() { return CFG.port + LIMIT; } diff --git a/tests/cases/javascript/module-level-const/src/user.js b/tests/cases/javascript/module-level-const/src/user.js new file mode 100644 index 00000000..18914f01 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/user.js @@ -0,0 +1,9 @@ +import { LIMIT, CFG, SCHEMA } from './consts.js'; + +function use(x) { return x; } + +export function topLevelUse() { return LIMIT + 1; } + +export function memberRead() { return use(CFG.name); } + +export function argRead() { return use(SCHEMA); } diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json index 6b329131..e8dd8894 100644 --- a/tests/cases/javascript/wrapped-handler-route/case.json +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -10,7 +10,7 @@ "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 (at src/routes/other.js:2) [field]", "[in scope] other. src/routes/other.js:3"], + "want": ["const listAdmins (at src/routes/other.js:2) [field]", "[resolved] 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"], @@ -73,7 +73,7 @@ "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"], + "want": ["[resolved] 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"], @@ -95,7 +95,7 @@ "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"], + "want": ["[resolved] 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"], diff --git a/tests/cases/typescript/module-level-const/case.json b/tests/cases/typescript/module-level-const/case.json index 74c67b03..113ffec2 100644 --- a/tests/cases/typescript/module-level-const/case.json +++ b/tests/cases/typescript/module-level-const/case.json @@ -1,10 +1,34 @@ {"lang": "typescript", "src": "src", "checks": [ {"why": "a module-level exported const has the modules that import it as dependents, instead of nothing at all", - "run": ["impact", "LIMIT", "--kind", "field"], + "run": ["impact", "src/consts.ts:1"], "want": ["topLevelUse", "Holder", "arrowUse"], "avoid": ["nothing the graph can see"]}, {"why": "and --delete does not call it safe", - "run": ["impact", "LIMIT", "--kind", "field", "--delete"], + "run": ["impact", "src/consts.ts:1", "--delete"], "want": ["NOT SAFE"], - "avoid": ["no dependent at any certainty"]}]} + "avoid": ["no dependent at any certainty"]}, + {"why": "an imported const read through a member access or passed as an argument is resolved to the declaration the import binds, not matched by name", + "run": ["impact", "src/consts.ts:2"], + "want": ["[resolved] memberRead"], + "avoid": ["[by name"]}, + {"why": "the same for a const that holds a call's result, handed to a call", + "run": ["impact", "src/consts.ts:3"], + "want": ["[resolved] argRead"], + "avoid": ["[by name"]}, + {"why": "a const read in a type (`typeof X`): in a parameter's type it is the function's read, in a type alias the module's, each at its own line", + "run": ["impact", "src/consts.ts:3"], + "want": ["[resolved] typedRead", "src/user.ts:17"], + "avoid": ["[by name"]}, + {"why": "and at module level", + "run": ["impact", "src/consts.ts:2"], + "want": ["src/user.ts:19"], + "avoid": ["[by name"]}, + {"why": "control: a file that imports the same name from ANOTHER declaration is not a reader of this one", + "run": ["impact", "src/consts.ts:2"], + "want": [], + "avoid": ["siblingRead", "sibling/reader.ts"]}, + {"why": "control: and the sibling declaration keeps its own reader", + "run": ["impact", "src/sibling/consts.ts:3"], + "want": ["[resolved] siblingRead"], + "avoid": ["memberRead", "src/user.ts"]}]} diff --git a/tests/cases/typescript/module-level-const/src/consts.ts b/tests/cases/typescript/module-level-const/src/consts.ts index 4a3dc15e..28a387c8 100644 --- a/tests/cases/typescript/module-level-const/src/consts.ts +++ b/tests/cases/typescript/module-level-const/src/consts.ts @@ -1 +1,5 @@ export const LIMIT = 10; +export const CFG = { name: 'a', port: 1 } as const; +export const SCHEMA = shape({ id: 'string' }); + +export function shape(o: object): object { return o; } diff --git a/tests/cases/typescript/module-level-const/src/sibling/consts.ts b/tests/cases/typescript/module-level-const/src/sibling/consts.ts new file mode 100644 index 00000000..f1d033d2 --- /dev/null +++ b/tests/cases/typescript/module-level-const/src/sibling/consts.ts @@ -0,0 +1,3 @@ +// the same names, declared again: a reader here reads THIS file's declarations +export const LIMIT = 3; +export const CFG = { name: 'b', port: 2 } as const; diff --git a/tests/cases/typescript/module-level-const/src/sibling/reader.ts b/tests/cases/typescript/module-level-const/src/sibling/reader.ts new file mode 100644 index 00000000..ea3fbcf3 --- /dev/null +++ b/tests/cases/typescript/module-level-const/src/sibling/reader.ts @@ -0,0 +1,3 @@ +import { CFG, LIMIT } from './consts.js'; + +export function siblingRead(): number { return CFG.port + LIMIT; } diff --git a/tests/cases/typescript/module-level-const/src/user.ts b/tests/cases/typescript/module-level-const/src/user.ts index 8be77cbf..4d1263ae 100644 --- a/tests/cases/typescript/module-level-const/src/user.ts +++ b/tests/cases/typescript/module-level-const/src/user.ts @@ -5,3 +5,15 @@ export function topLevelUse(): number { return LIMIT + 1; } export class Holder { cap(): number { return LIMIT * 2; } } export const arrowUse = () => LIMIT - 1; + +import { CFG, SCHEMA } from './consts.js'; + +function use(x: unknown): void {} + +export function memberRead(): void { use(CFG.name); } + +export function argRead(): void { use(SCHEMA); } + +export function typedRead(s: typeof SCHEMA): void { use(s); } + +export type CfgShape = typeof CFG; diff --git a/tests/cases/typescript/wrapped-handler-route/case.json b/tests/cases/typescript/wrapped-handler-route/case.json index 73fff518..cb82b081 100644 --- a/tests/cases/typescript/wrapped-handler-route/case.json +++ b/tests/cases/typescript/wrapped-handler-route/case.json @@ -24,7 +24,7 @@ "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"], + "want": ["[resolved] server. src/server.ts:3 — reads it"], "avoid": ["registered as a"]}, {"why": "…nor the app", "run": ["impact", "src/app.ts:3"], From 699d90e7f6453a2eed986e8332ede0f60c118510 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:57:40 -0700 Subject: [PATCH 099/258] impact: a callback or dependency given to one instance reaches callers only through that instance - JavaScript engine: a new expression of a class whose instance code calls a value carries its allocation beside the instance; instance-state.dl names the edges that hold only for instances given the callee (constructor option, subscription, injected dependency) and the allocations each caller's receiver may be - a callee that also reaches the class another way (named inside it, returned to it, written onto it from outside, a class hierarchy, an entry with an unknown receiver) is not gated - impact walks a gated edge through the class's own code and leaves it only for a caller whose receiver may be an allocation given the callee, or is unknown; the SQL port does the same Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/README.md | 1 + .../engine/resolution/instance-state.dl | 129 ++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 23 ++++ graph/javascript/souffle/export_manifest.tsv | 5 + .../skills/axiomcode/scripts/axiomcode-impact | 11 +- .../skills/axiomcode/scripts/dl/impact.dl | 45 +++++- .../skills/axiomcode/scripts/graph_sql.py | 52 +++++-- .../per-instance-registration/case.json | 106 ++++++++++++++ .../per-instance-registration/package.json | 1 + .../per-instance-registration/src/a.js | 6 + .../per-instance-registration/src/bus.js | 13 ++ .../per-instance-registration/src/c.js | 3 + .../per-instance-registration/src/d.js | 23 ++++ .../per-instance-registration/src/deps.js | 13 ++ .../per-instance-registration/test/a.test.js | 3 + .../per-instance-registration/test/c.test.js | 3 + .../per-instance-registration/test/r.test.js | 3 + 17 files changed, 426 insertions(+), 14 deletions(-) create mode 100644 graph/javascript/engine/resolution/instance-state.dl create mode 100644 tests/cases/javascript/per-instance-registration/case.json create mode 100644 tests/cases/javascript/per-instance-registration/package.json create mode 100644 tests/cases/javascript/per-instance-registration/src/a.js create mode 100644 tests/cases/javascript/per-instance-registration/src/bus.js create mode 100644 tests/cases/javascript/per-instance-registration/src/c.js create mode 100644 tests/cases/javascript/per-instance-registration/src/d.js create mode 100644 tests/cases/javascript/per-instance-registration/src/deps.js create mode 100644 tests/cases/javascript/per-instance-registration/test/a.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/c.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/r.test.js diff --git a/graph/javascript/README.md b/graph/javascript/README.md index 6a26b2ab..1336eecd 100644 --- a/graph/javascript/README.md +++ b/graph/javascript/README.md @@ -103,6 +103,7 @@ arrays. That is the engine, entirely. | module graph | `resolution/module-graph.dl` | one export surface for both systems, keyed by name with `default` for `module.exports = X`; a CommonJS default value's properties ARE its members | | hierarchy | `resolution/type-hierarchy.dl` | ONE closure — every heritage form inherits members, there is no `implements` | | value flow | `resolution/value-flow.dl` | the may-analysis above | +| instance state | `resolution/instance-state.dl` | an `("alloc", new-expression)` value beside `("inst", T)`, and the facts that let impact walk a callback or dependency given to ONE instance (a constructor option, a subscription) only from callers whose receiver may be that instance | | arrays | `resolution/arrays.dl` | the one platform type modelled: `push`, `[i]`, `map`, `forEach`, `for..of`, `T[]`; `Map` / `Set` as collections, including an instance of a class that extends one (#619) | | ambient | `resolution/ambient.dl` | platform names as values, so a site reached through one is classified from the value, not the syntax | | JSDoc types | `resolution/reference-types.dl` | `@param`/`@type`/`@returns`, `import()` types, typedef aliases, wrappers | diff --git a/graph/javascript/engine/resolution/instance-state.dl b/graph/javascript/engine/resolution/instance-state.dl new file mode 100644 index 00000000..95409a3c --- /dev/null +++ b/graph/javascript/engine/resolution/instance-state.dl @@ -0,0 +1,129 @@ +// ============================================================================ +// Resolution · INSTANCE STATE (what ONE object was given, not what its class was) +// +// `new Bus({ validate: check })` and `b.on(handleA)` hand a callback to ONE bus. The +// flow layer keys an instance by its class, ("inst", Bus), so `this.v` inside +// `Bus.emit` holds every callback any bus was ever given, and the call edge +// emit -> check is right for SOME bus. The edge is right; what is wrong is reading it +// as "every caller of emit reaches check": a caller that emits on a bus built without +// `validate` does not. A context-insensitive call graph cannot say that, so this file +// says it beside the graph, as facts the impact walk reads (dl/impact.dl): +// +// state_world(M, T) callable M runs with an instance of T as its `this`: +// an instance member of T, or a function nested in one +// state_gate(M, F, T) every call M makes to F reaches F only through what +// the receiver instance was given +// state_gate_alloc(T, F, S) the allocation S (a `new T(...)` expression) is one +// that was given F +// state_call_alloc(C, M, S) C calls the T member M on a receiver that may be S +// state_call_open(C, M) C calls M on a receiver whose allocation is unknown +// +// ── THE ALLOCATION VALUE ──────────────────────────────────────────────────── +// ("alloc", NewExpr) is a value of its own, carried BESIDE ("inst", T) wherever the +// instance goes (a variable, a parameter, a field, a return, `x ?? new T()`), exactly +// as ("wrap", site) travels beside a wrapper's closure (value-flow.dl). Nothing +// resolves through it: members are still read off ("inst", T). It is minted only for +// a class whose instance code CALLS A VALUE — a parameter (`f(e)`, `handler(env)`) or +// a property of `this` that the class does not declare as a method (`this.v?.(e)`) — +// which is the shape whose targets depend on what the instance was given. Every +// other class carries no allocation, and so costs nothing here. +// +// ── WHEN A CALLBACK IS GATED ──────────────────────────────────────────────── +// F is gated in T when the only way F enters T's instance code is as an argument of +// a call made from OUTSIDE that code on a T member (the constructor or a method) — +// an ENTRY — and every such entry's receiver has a known allocation. It is not gated +// (every caller keeps it, as before) when F: +// · is declared inside T's instance code; +// · is read inside that code through a name bound outside it (an import, a +// module-level variable, a function declaration), or returned to it by a call +// to a function declared outside it; +// · is written onto an instance of T, or onto the static side of T, from outside; +// · reaches T whose hierarchy has a client class above or below it, or whose own +// code builds another T (`new T()`, `new this.constructor()`): what one instance +// holds could then flow into another; +// · enters through an entry whose receiver's allocation is unknown. +// The allocations are the closed-world answer the engine gives for parameters too: a +// receiver's allocations are the `new` expressions that reach it through resolved +// flow. A receiver with none (a documented `@param {Bus}`, a library's return) is open. +// ============================================================================ + +// ── the instance code of a class ──────────────────────────────────────────── +state_world(m, t) :- method_decl("client", _, k, _, "false", t, _, m), t != "", k != "STATIC_BLOCK". +state_world(m, t) :- type_ctor("client", m, t). +state_world(m, t) :- method_enclosing(m, e), state_world(e, t). +state_type_declares(t, n) :- method_decl(_, n, _, _, _, t, _, _), n != "". + +// ── the classes whose instance code calls a value ─────────────────────────── +state_type(t) :- state_world(em, t), call_site("client", ck, n, "SYNTACTIC", _, em, ce, _, _), call_kind_is_member_form(ck), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "THIS", _, r), n != "", !state_type_declares(t, n). +state_type(t) :- state_world(em, t), call_site("client", ck, _, _, _, em, ce, _, _), call_kind_is_callee_form(ck), + expr_child(_, ce, "CALLEE", _, c), expr_param(_, _, c). + +// ── the allocation value, minted at `new T(...)` ──────────────────────────── +expr_value(e, "alloc", e) :- expr_kind("client", "NEW", _, e), new_constructs(e, t), state_type(t). +state_alloc_type(s, t) :- expr_value(s, "alloc", s), new_constructs(s, t). + +// ── eligibility: nothing one instance holds can move into another ─────────── +state_type_mixed(t) :- state_type(t), type_super(t, s), type_decl("client", _, _, _, _, s). +state_type_mixed(t) :- state_type(t), type_super(u, t), type_decl("client", _, _, _, _, u). +state_type_mixed(t) :- state_type(t), state_world(em, t), expr_kind(_, "NEW", _, e), expr_owner(_, em, _, e), new_constructs(e, t). +state_type_mixed(t) :- state_type(t), state_world(em, t), own_class_new(em, _). +state_type_ok(t) :- state_type(t), !state_type_mixed(t). + +// ── entries: a call from outside the instance code onto a member of T ─────── +state_entry(t, ce) :- state_type_ok(t), expr_resolves_to_method(ce, m), state_world(m, t), + call_site("client", _, _, _, _, em, ce, _, _), !state_world(em, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_kind(_, "NEW", _, ce), expr_value(ce, "alloc", s), state_alloc_type(s, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "alloc", s), state_alloc_type(s, t). +state_entry_open(ce) :- state_entry(_, ce), !state_entry_alloc(ce, _). + +// What an entry's arguments carry: the values themselves and, a few levels down, what +// their properties and elements hold (`{ validate: check }`, `[a, b]`). Depth-bounded: +// a carry is evidence FOR gating, so a callback found deeper than this is simply not +// counted as entering through the entry, and then it is not gated at all. +state_carry(ce, k, i, 0) :- state_entry(_, ce), call_arg(ce, _, a), expr_value(a, k, i), k != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_enters(t, f, ce) :- state_entry(t, ce), state_carry(ce, "func", f, _). + +// ── leaks: F reaches the instance code some other way ─────────────────────── +// declared inside it +state_leak(t, f) :- state_enters(t, f, _), state_world(f, t). +// a value the instance code has from anywhere but an entry: a name bound outside it +// (an import, a module-level variable or function, a class it constructs itself: `dep ?? +// new DefaultDep()` names DefaultDep, whose prototype holds `run`), or what a function +// declared outside it returns. What those hold, a few levels down, is F leaking in. A +// literal the code writes itself needs no rule: a function in it is either named (above) +// or written inline, and then it is declared inside the instance code. +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + var_owner_method(_, vm, v), !state_world(vm, t). +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + !var_owner_method(_, _, v). +state_outer_ref(t, ce) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, ce), expr_kind(_, "CALL", _, ce), + expr_resolves_to_method(ce, g), !state_world(g, t). +state_outer_carry(t, k, i, 0) :- state_outer_ref(t, e), expr_value(e, k, i), k != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_leak(t, f) :- state_enters(t, f, _), state_outer_carry(t, "func", f, _). +// written onto an instance of T or onto T itself from outside its instance code +state_leak(t, f) :- state_enters(t, f, _), expr_kind(_, "ASSIGNMENT", _, a), expr_owner(_, em, _, a), !state_world(em, t), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + expr_value(r, k, t), (k = "inst" ; k = "ctor"), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, "func", f). +state_leak(t, f) :- state_enters(t, f, _), prop_value("ctor", t, _, "func", f). +// an entry that says nothing about which instance it gives F to +state_leak(t, f) :- state_enters(t, f, ce), state_entry_open(ce). + +// ── the gate ──────────────────────────────────────────────────────────────── +state_gated(t, f) :- state_enters(t, f, _), !state_leak(t, f). +state_gate_alloc(t, f, s) :- state_gated(t, f), state_enters(t, f, ce), state_entry_alloc(ce, s). +state_gate(m, f, t) :- state_gated(t, f), state_world(m, t), expr_resolves_to_method(ce, f), + call_site("client", _, _, _, _, m, ce, _, _). + +// ── who calls the instance code, on which allocation ──────────────────────── +state_gated_type(t) :- state_gated(t, _). +state_call_alloc(c, m, s) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_alloc(ce, s). +state_call_open(c, m) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_open(ce). +state_world_of_gated(m, t) :- state_gated_type(t), state_world(m, t). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..0faf2204 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -569,3 +569,26 @@ // ── resolution/ambient.dl (the global object) ── .decl global_object_name(c0:symbol) .decl global_name_resolution(c0:symbol) + +// ── resolution/instance-state.dl ── +.decl state_world(c0:symbol, c1:symbol) +.decl state_type_declares(c0:symbol, c1:symbol) +.decl state_type(c0:symbol) +.decl state_alloc_type(c0:symbol, c1:symbol) +.decl state_type_mixed(c0:symbol) +.decl state_type_ok(c0:symbol) +.decl state_entry(c0:symbol, c1:symbol) +.decl state_entry_alloc(c0:symbol, c1:symbol) +.decl state_entry_open(c0:symbol) +.decl state_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_enters(c0:symbol, c1:symbol, c2:symbol) +.decl state_leak(c0:symbol, c1:symbol) +.decl state_outer_ref(c0:symbol, c1:symbol) +.decl state_outer_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_gated(c0:symbol, c1:symbol) +.decl state_gate_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_gate(c0:symbol, c1:symbol, c2:symbol) +.decl state_gated_type(c0:symbol) +.decl state_call_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_call_open(c0:symbol, c1:symbol) +.decl state_world_of_gated(c0:symbol, c1:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..cf9a9c0f 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -42,3 +42,8 @@ package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv jsx_renders jsx-renders.csv +state_gate state-gate.csv +state_gate_alloc state-gate-alloc.csv +state_call_alloc state-call-alloc.csv +state_call_open state-call-open.csv +state_world_of_gated state-world.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..96fa09ff 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1101,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '57' # 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '58' # 58: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1426,6 +1426,15 @@ class Impact: # the callables in test files: a test that publishes a handler table's key drives the handler, and is not # counted against the key the way a production writer is (dl/impact.dl, table_key) W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id'))) + # WHAT ONE INSTANCE WAS GIVEN (JavaScript, engine resolution/instance-state.dl): a callback handed to the + # constructor or subscribed through a method of ONE object is reached from the class's code only for callers + # whose receiver may be that object. The engine names the gated edges, the allocations that were given each + # callback, and the allocation (or none known) of each receiver that calls into the class; the walk in + # dl/impact.dl carries the gate through the class's own code and applies it where a caller leaves it. + for rel, t, cols in (('state_gate', 'ext_state_gate', 'c0, c1, c2'), ('state_gate_alloc', 'ext_state_gate_alloc', 'c0, c1, c2'), + ('state_call_alloc', 'ext_state_call_alloc', 'c0, c1, c2'), ('state_call_open', 'ext_state_call_open', 'c0, c1'), + ('state_world', 'ext_state_world_of_gated', 'c0, c1')): + W(rel, sorted(tuple(r) for r in g.q(f"SELECT DISTINCT {cols} FROM {t}")) if g.has(t) else []) # 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))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..4a5d463b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -845,10 +845,44 @@ seed_byname(q, c) :- valueref(q, c, _, _), !seed(q, c). .decl up(q:symbol, m:symbol, d:number) up(q, m, 0) :- seed(q, m). up(q, c, 1) :- seed_byname(q, c). -up(q, a, d+1) :- up(q, b, d), edge(a, b, _), d < 40. +up(q, a, d+1) :- up(q, b, d), edge(a, b, _), !state_gate(a, b, _), d < 40. up(q, a, d+1) :- up(q, b, d), fw_edge(a, b, _), d < 40. +// ── WHAT ONE INSTANCE WAS GIVEN: the edge holds for some receivers only ────────────────────────────────────────── +// `new Bus({ validate: check })` and `b.on(handleA)` give ONE bus a callback, and `Bus.emit` calls it through what +// that bus holds. The engine's edge emit -> check is one edge for every bus, so the closure used to hand check to +// every caller of emit: a test that emits on a bus built without `validate` reached it. The engine now says which +// edges are of that kind and which allocations were given the callback (JavaScript resolution/instance-state.dl): +// state_gate(a, f, T) every call a makes to f reaches f only through what the receiver instance holds +// state_world(m, T) m runs with an instance of T as `this` (T's instance code, and what it nests) +// state_gate_alloc(T, f, s) the allocation s (a `new T(...)`) was given f +// state_call_alloc(c, m, s) c calls the T member m on a receiver that may be s +// state_call_open(c, m) c calls m on a receiver whose allocation is unknown: it keeps every callback +// The gate travels with the walk through T's own code (`this.deliver()` -> `this.#invoke()` -> `handler()`), and is +// applied where the walk leaves it: a caller outside T keeps the route when one of its receivers may be an +// allocation given f, or when the engine knows none. An edge with no call-site record (a dispatch choice) keeps it. +.decl state_gate(a:symbol, f:symbol, t:symbol) .input state_gate +.decl state_world(m:symbol, t:symbol) .input state_world +.decl state_gate_alloc(t:symbol, f:symbol, s:symbol) .input state_gate_alloc +.decl state_call_alloc(c:symbol, m:symbol, s:symbol) .input state_call_alloc +.decl state_call_open(c:symbol, m:symbol) .input state_call_open +.decl state_call_known(c:symbol, m:symbol) +state_call_known(c, m) :- state_call_alloc(c, m, _). +state_call_known(c, m) :- state_call_open(c, m). +.decl state_exit(c:symbol, a:symbol, t:symbol, f:symbol) +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), state_call_open(c, a). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_call_alloc(c, a, s), state_gate_alloc(t, f, s). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), !state_call_known(c, a). +// upg(q, m, T, f, d): m is reached only on the instances of T that were given f +.decl upg(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +upg(q, a, t, f, d+1) :- up(q, f, d), state_gate(a, f, t), edge(a, f, _), d < 40. +upg(q, c, t, f, d+1) :- upg(q, a, t, f, d), edge(c, a, _), state_world(c, t), d < 40. +up(q, c, d+1) :- upg(q, a, t, f, d), state_exit(c, a, t, f), d < 40. +up(q, a, d+1) :- upg(q, b, _, _, d), fw_edge(a, b, _), d < 40. +.decl up_all(q:symbol, m:symbol, d:number) +up_all(q, m, d) :- up(q, m, d). +up_all(q, m, d) :- upg(q, m, _, _, d). .decl reach(q:symbol, m:symbol, d:number) -reach(q, m, d) :- up(q, m, d), d = min x : up(q, m, x). +reach(q, m, d) :- up_all(q, m, d), d = min x : up_all(q, m, x). // the chain read-back: a is one hop further from the change than b, through edge a → b .decl parent_up(q:symbol, a:symbol, b:symbol, t:symbol) parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, edge(a, b, t). @@ -862,7 +896,12 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), d < 40. +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f +.decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. +up_running_g(q, c, t, f, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_world(c, t), d < 40. +up_running(q, c, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_exit(c, a, t, f), d < 40. .decl import_hop(q:symbol, a:symbol, b:symbol) import_hop(q, a, mod) :- up_running(q, mod, _), kind(mod, "module"), decl_file(mod, g), imports_file(f, g), decl_file(a, f), kind(a, "module"), a != mod. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db50a1a0..344c7f0a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -585,25 +585,57 @@ def _rev(edges): return r -def reach_from(rev, seeds, byname=(), cap=40): +def reach_from(rev, seeds, byname=(), cap=40, gate=None): """up/reach: everything that can reach a seed, at its SHORTEST hop count. `up(q,m,0) :- seed(q,m)` · `up(q,c,1) :- seed_byname(q,c)` · `up(q,a,d+1) :- up(q,b,d), edge(a,b,_), d0, reach(q,b,d-1), edge(a,b,t)` — a is one hop further from the change than b, so following it from any reached node walks down to a seed. @@ -3563,7 +3595,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non out = {k: [] for k 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')} - E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); gate = state_gate(q); 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 @@ -3803,7 +3835,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non _sde.add((c, m)); _de.append((c, m)) out['direct_edge'] += [[c, m, qq] for c, m in _de] out['seed_byname'] += [[c, qq] for c in byname] - depth = reach_from(rev, seeds, byname) + depth = reach_from(rev, seeds, byname, gate=gate) out['reach'] += [[m, str(d), qq] for m, d in depth.items()] # reach_sure: the same closure from the seeds that are an exact edge only — a seed reached ONLY through a # by-name / text / one-of-a-set dependent is weak, and the answer says how much of itself rests on those diff --git a/tests/cases/javascript/per-instance-registration/case.json b/tests/cases/javascript/per-instance-registration/case.json new file mode 100644 index 00000000..9a7d70b6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/case.json @@ -0,0 +1,106 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a callback given to ONE bus's constructor is called by emit only on that bus: impact of it lists the caller that emits on the bus built with it, not the caller that emits on a bus built without it", + "run": [ + "impact", + "check", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "a handler subscribed on one bus is reached from emit on that bus; a bus it was never subscribed on does not reach it", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "control: the registration still reaches callers whose bus is its own allocation through another object's field, through an argument, or whose allocation the graph does not know", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/d.js:11:", + "src/d.js:17:", + "src/d.js:18:", + "src/d.js:6:" + ] + }, + { + "why": "control: a handler the class writes for itself is the same on every instance, so every caller reaches it", + "run": [ + "impact", + "defaultHandler", + "--grep" + ], + "want": [ + "src/d.js:22:", + "src/d.js:23:", + "test/r.test.js" + ] + }, + { + "why": "a test that emits on a bus built without a callback does not run it", + "run": [ + "impact", + "src/c.js:3", + "--tests-only" + ], + "want": [ + "test/c.test.js" + ] + }, + { + "why": "a dependency handed to one holder's constructor is called only for that holder: impact of it lists the caller of the holder built with it, not the callers of holders built with another", + "run": [ + "impact", + "OtherDep.run", + "--grep" + ], + "want": [ + "src/deps.js:6:", + "src/deps.js:13:" + ], + "avoid": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + }, + { + "why": "control: a dependency the class builds itself when given none serves every holder built without one: its callers are kept, not narrowed to the holder given it explicitly", + "run": [ + "impact", + "DefaultDep.run", + "--grep" + ], + "want": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + } + ] +} diff --git a/tests/cases/javascript/per-instance-registration/package.json b/tests/cases/javascript/per-instance-registration/package.json new file mode 100644 index 00000000..18a0b90c --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/package.json @@ -0,0 +1 @@ +{"name":"per-instance-registration","type":"module"} diff --git a/tests/cases/javascript/per-instance-registration/src/a.js b/tests/cases/javascript/per-instance-registration/src/a.js new file mode 100644 index 00000000..b3dfc7f8 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/a.js @@ -0,0 +1,6 @@ +import { Bus } from './bus.js'; +export function check(e) { return e; } +export function handleA(e) { return e; } +const b = new Bus({ validate: check }); +b.on(handleA); +export const runA = () => b.emit(1); diff --git a/tests/cases/javascript/per-instance-registration/src/bus.js b/tests/cases/javascript/per-instance-registration/src/bus.js new file mode 100644 index 00000000..c5cb0a29 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/bus.js @@ -0,0 +1,13 @@ +// A bus holds what it was built with and what was subscribed on it. +export class Bus { + constructor({ validate } = {}) { this.v = validate; this.subs = []; } + on(f) { this.subs.push(f); } + emit(e) { this.v?.(e); this.subs.forEach((f) => f(e)); } +} + +// control: a registry whose handler is written inside the class serves every instance. +export function defaultHandler(x) { return x; } +export class Registry { + constructor() { this.h = defaultHandler; } + run(x) { return this.h(x); } +} diff --git a/tests/cases/javascript/per-instance-registration/src/c.js b/tests/cases/javascript/per-instance-registration/src/c.js new file mode 100644 index 00000000..8bc790a6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/c.js @@ -0,0 +1,3 @@ +import { Bus } from './bus.js'; +const c = new Bus(); +export const runC = () => c.emit(2); diff --git a/tests/cases/javascript/per-instance-registration/src/d.js b/tests/cases/javascript/per-instance-registration/src/d.js new file mode 100644 index 00000000..3be98815 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/d.js @@ -0,0 +1,23 @@ +import { Bus, Registry } from './bus.js'; +import { handleA } from './a.js'; + +// control: a bus whose allocation the graph does not know (a documented parameter) keeps every registration. +/** @param {Bus} bus */ +export const runD = (bus) => bus.emit(3); + +// control: the same allocation reached through a field of another object. +export class Svc { + constructor() { this.bus = new Bus(); this.bus.on(handleA); } + run() { this.bus.emit(4); } +} + +// control: an allocation passed on as an argument is still that allocation. +const shared = new Bus(); +shared.on(handleA); +function relay(bus) { bus.emit(5); } +export const runE = () => relay(shared); + +const r1 = new Registry(); +const r2 = new Registry(); +export const runR1 = () => r1.run(1); +export const runR2 = () => r2.run(2); diff --git a/tests/cases/javascript/per-instance-registration/src/deps.js b/tests/cases/javascript/per-instance-registration/src/deps.js new file mode 100644 index 00000000..3aa90a5e --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/deps.js @@ -0,0 +1,13 @@ +// A holder calls the dependency it was built with, or the one it builds when given none. +export class DefaultDep { run() { return 1; } } +export class OtherDep { run() { return 2; } } +export class Holder { + constructor(dep) { this.dep = dep ?? new DefaultDep(); } + go(f) { f(); return this.dep.run(); } +} +const h1 = new Holder(); +const h2 = new Holder(new DefaultDep()); +const h3 = new Holder(new OtherDep()); +export const goDefault = () => h1.go(() => 0); +export const goExplicit = () => h2.go(() => 0); +export const goOther = () => h3.go(() => 0); diff --git a/tests/cases/javascript/per-instance-registration/test/a.test.js b/tests/cases/javascript/per-instance-registration/test/a.test.js new file mode 100644 index 00000000..0d4d0ad3 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/a.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runA } from '../src/a.js'; +test('runA emits on the validated bus', () => { runA(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/c.test.js b/tests/cases/javascript/per-instance-registration/test/c.test.js new file mode 100644 index 00000000..65a948b9 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/c.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runC } from '../src/c.js'; +test('runC emits on a bare bus', () => { runC(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/r.test.js b/tests/cases/javascript/per-instance-registration/test/r.test.js new file mode 100644 index 00000000..addb356b --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/r.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runR2 } from '../src/d.js'; +test('the second registry runs its handler', () => { runR2(); }); From 0548acdfc1de49f747b0546ccf7e0d194e419d7e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:06:07 -0700 Subject: [PATCH 100/258] context/impact: lockfiles and manifest package lists are never text leads; --in narrows text files A context question with a common word (debug, optional, string) listed package-lock.json and package.json lines as "text files that name these declarations" and made one of them the next step, even when --in named a directory holding neither file. - ax_nonsource: lockfiles of every package manager (npm, pnpm, yarn, NuGet, Gradle, Poetry, uv, Composer, Cargo, Go) are out of scope; a manifest's metadata and dependency lists (package.json, pyproject.toml, PackageReference) and package.json keys are skipped by line. Script commands and tool configuration values are still searched. - context: the caller's --in narrows text bindings as it narrows declarations; a mapper XML whose namespace names the declaring type stays (bound by the declaration, not the word). - ax_pages: a --source answer with no flow and no text row now ends with a next step. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../case.json | 18 ++++++++++++++++++ .../config/app.json | 3 +++ .../package.json | 15 +++++++++++++++ .../packages.lock.json | 8 ++++++++ .../src/a/app.js | 14 ++++++++++++++ .../src/a/routes.json | 3 +++ .../src/b/worker.js | 3 +++ 7 files changed, 64 insertions(+) create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/case.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json new file mode 100644 index 00000000..3f88d68a --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json @@ -0,0 +1,18 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "--in narrows the text files a context answer lists as it narrows the declarations: a JSON under the scope that names a found function is listed, a lockfile, a manifest and a config file outside it are not, and the next step stays inside the scope", + "run": ["context", "app wiring: createApp, config, debug output", "--in", "src/a"], + "want": ["text files that name these declarations", "src/a/routes.json:2", "next: read src/a/"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:", "config/app.json"]}, + {"why": "--source prints each file's declarations as code instead of a name list; the answer still ends with a next step, on the first file shown", + "run": ["context", "app wiring: createApp and its output", "--in", "src/a", "--source"], + "want": ["next: answer from the code of src/a/app.js shown first above"], + "avoid": ["package-lock.json", "package.json:"]}, + {"why": "without --in, a lockfile is never a text lead and a manifest's description, dependency lists and script names are not either; a manifest script command and a config file that name the function are (control: the manifest is filtered by line, not dropped)", + "run": ["context", "app wiring: createApp, config, debug output"], + "want": ["src/a/routes.json:2", "config/app.json:2", "package.json:6"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:4", "package.json:7", "package.json:10", "package.json:13"]}, + {"why": "impact lists the same text files as bound from outside the source: the lockfiles and the manifest's dependency lists are not among them", + "run": ["impact", "debug"], + "want": ["src/a/routes.json", "config/app.json"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10"]}]} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json new file mode 100644 index 00000000..6741e398 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json @@ -0,0 +1,3 @@ +{ + "startup": "debug" +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json new file mode 100644 index 00000000..62b94574 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json @@ -0,0 +1,15 @@ +{ + "name": "wiring-sample", + "version": "1.0.0", + "description": "Sample app: turns debug (verbose) output on from config", + "scripts": { + "trace": "debug", + "debug": "node src/a/app.js" + }, + "dependencies": { + "debug": "^4.1.0" + }, + "optionalDependencies": { + "optional": "^0.1.4" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json new file mode 100644 index 00000000..c7a71061 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "optional": { "type": "Direct", "requested": "[1.0.0, )" } + } + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js new file mode 100644 index 00000000..6662599a --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; + +export function debug(config) { + return config.verbose === true; +} + +export function optional(value, fallback) { + return value === undefined ? fallback : value; +} + +export function createApp(config) { + const routes = JSON.parse(readFileSync(new URL('./routes.json', import.meta.url), 'utf8')); + return { routes, verbose: debug(config), port: optional(config.port, 8080) }; +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json new file mode 100644 index 00000000..67dbe729 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json @@ -0,0 +1,3 @@ +{ + "/health": { "handler": "debug" } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js new file mode 100644 index 00000000..856ee0d5 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js @@ -0,0 +1,3 @@ +export function startWorker(config) { + return { queue: config.queue }; +} From 5e2290d800a6aa5ac9e8438c0b94482161bd758c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:05 -0700 Subject: [PATCH 101/258] context/impact: manifest package lists are never text leads, in any manifest; --in text dirs stay in scope The previous commit on this branch added the case but not the code it checks, and its npm lockfile fixture was gitignored, so CI ran the checks against the old scripts. - ax_nonsource: one manifest filter decided by where a word sits (entry, table, block, element), not by line: package.json, bower.json, deno.json, composer.json by JSON entry (a one-line manifest keeps its scripts); pnpm-workspace.yaml and conda environment.yml by block; pyproject.toml and Pipfile by table; requirements files whole; pom.xml, MSBuild, packages.config and .nuspec by package element. More lockfiles recognised. - context: the word-matched rows of a text-only --in use the same filter; a directory the main graph holds is no longer text to another language's graph; with several graphs, a text-only --in is answered once and never by a package outside it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_nonsource.py | 143 +++++++++++++++++- .../skills/axiomcode/scripts/ax_pages.py | 13 +- .../axiomcode/scripts/axiomcode-context | 63 +++++--- .../context-answers-what-was-asked/case.json | 4 + .../bower.json | 1 + .../case.json | 12 +- .../deno.json | 4 + .../package-lock.json | 19 +++ .../packages/min/package.json | 1 + .../pnpm-workspace.yaml | 2 + .../requirements.txt | 1 + .../tools/lint/package.json | 6 + .../tools/lint/rules.json | 3 + tests/multi_language.py | 13 +- 14 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 10b1e929..2e127ec7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -65,16 +65,151 @@ def _classify(fp): # callable: called (`note(`), quoted as a value (`"note"`, `'note'`), or qualified with `#`, `::` or `->` # (`Owner#note`; a CSS `#note` selector is not one). A dotted `Owner.note` is matched as the qualified name itself and never reaches this rule. The # rest are PROSE: counted and grep-able, not listed as places a rename breaks. -OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl'} +# +# LOCKFILES AND MANIFEST LISTS. A lockfile is written by a package manager, never by hand, and every word in it is a +# package name, a version or a flag: `"optional": true`, `"debug": "^4.1.0"`. A manifest's metadata (name, +# description, keywords) and its dependency lists are the same: they name packages, not callables. A method named +# `debug`, `optional` or `string` matched there is a package or a word, never a binding, and in a JavaScript +# repository it outnumbered every real one (a context question's next step became a line of package-lock.json). +# The rest of a manifest (scripts, tasks, tool configuration) is kept: that is where a name can be referred to. A +# JSON manifest's KEYS are not: `"start":` names an npm script and `"testEnvironment":` an option, so only the values +# are searched. What is skipped is decided by where the word sits in the file's structure (the entry, table, block or +# element that holds it), not by the line alone: a one-line manifest holds its scripts and its dependencies together. +OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl', '.lock', '.lockfile'} SHELL_SHEBANG = re.compile(r'#!\s*\S*(?:/|\s)(?:env\s+)?(?:ba|z|k|da)?sh\b') +LOCKFILES = {'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'deno.lock', + 'packages.lock.json', 'project.assets.json', 'paket.lock', 'gradle.lockfile', 'poetry.lock', 'uv.lock', + 'pipfile.lock', 'pdm.lock', 'conda-lock.yml', 'composer.lock', 'gemfile.lock', 'cargo.lock', 'go.sum'} +# JSON manifests: the top-level keys that describe the package or list other packages +_JSON_META = r'name|version|description|keywords|authors?|contributors|maintainers|license|homepage|repository|bugs|funding|private' +JSON_LIST_KEY = { + 'package.json': re.compile(r'(?i)^(' + _JSON_META + r'|type|engines|os|cpu|publishConfig|workspaces|packageManager|' + r'overrides|resolutions|pnpm|\w*dependencies(Meta)?)$'), + 'bower.json': re.compile(r'(?i)^(' + _JSON_META + r'|ignore|resolutions|\w*dependencies)$'), + 'deno.json': re.compile(r'^(name|version|imports|scopes|importMap|lock|nodeModulesDir|vendor|workspace|patch|links)$'), + 'composer.json': re.compile(r'(?i)^(' + _JSON_META + r'|type|support|require(-dev)?|conflict|replace|provide|suggest|' + r'repositories|minimum-stability|prefer-stable)$')} +JSON_LIST_KEY['deno.jsonc'] = JSON_LIST_KEY['deno.json'] +# YAML manifests: the top-level blocks that list packages (a conda environment, a pnpm workspace and its catalog) +YAML_LIST_KEY = { + 'environment.yml': re.compile(r'^(name|channels|dependencies|prefix)$'), + 'pnpm-workspace.yaml': re.compile(r'^(packages|catalogs?|overrides|patchedDependencies|\w*BuiltDependencies|' + r'peerDependencyRules|allowedDeprecatedVersions|packageExtensions)$')} +YAML_LIST_KEY['environment.yaml'] = YAML_LIST_KEY['environment.yml'] +# TOML manifests: the tables and keys that list packages +PY_DEP_TABLE = re.compile(r'^\[\s*(dependency-groups|project\.optional-dependencies|tool\.poetry(\.group\.[^\]]+)?\.(dev-)?dependencies|' + r'tool\.pdm\.dev-dependencies|tool\.uv)\s*\]') +PIPFILE_TABLE = re.compile(r'^\[\s*(packages|dev-packages|requires|source|[\w-]+-packages)\s*\]') +PY_DEP_KEY = re.compile(r'^\s*(dependencies|requires|dev-dependencies|optional-dependencies)\s*=') +# a file that is nothing but a list of packages +REQUIREMENTS = re.compile(r'^(requirements|constraints)[\w.-]*\.(txt|in)$') +# XML manifests: the elements that name a package (MSBuild, packages.config, .nuspec) and a POM's dependency blocks +XML_PKG_LINE = re.compile(r'<\s*(PackageReference|PackageVersion|package|dependency)\b[^>]*\b(Include|Update|id)\s*=') +POM_BLOCK = re.compile(r'<(/?)(dependencies|dependencyManagement|parent|exclusions)>') +POM_COORD = re.compile(r'^\s*<(groupId|artifactId|version|packaging|name|description|url|scope|type|classifier|optional|' + r'modelVersion|id|tags|authors|owners)>[^<]*\s*$') + + +def is_lockfile(rel): + """a file a package manager writes: every word in it is a package, a version or a flag""" + return os.path.basename(rel).lower() in LOCKFILES + + +def _json_spans(text, list_key): + """{line: [(start col, end col)]} of the strings a word is not matched in: each string under a top-level key that + `list_key` matches, and each key at any depth. A string-aware scan, so a minified manifest is split by entry too.""" + out, depth, want_key, listed, line, bol, i, n = {}, 0, False, False, 1, 0, 0, len(text) + while i < n: + c = text[i] + if c == '"': + j = i + 1 + while j < n and text[j] != '"': j += 2 if text[j] == '\\' else 1 + k = j + 1 + while k < n and text[k] in ' \t\r\n': k += 1 + if depth == 1 and want_key: want_key, listed = False, bool(list_key.match(text[i + 1:j])) + if (k < n and text[k] == ":") or (depth >= 1 and listed): + out.setdefault(line, []).append((i - bol, j + 1 - bol)) + nl = text.count('\n', i, j) + if nl: line += nl; bol = text.rindex('\n', i, j) + 1 + i = j + 1 + continue + if text.startswith('//', i): # a JSONC comment (deno.jsonc) + j = text.find('\n', i); i = n if j < 0 else j + continue + if c in '{[': + depth += 1 + if depth == 1: want_key = c == '{' + elif c in '}]': depth -= 1 + elif c == ',' and depth == 1: want_key = True + elif c == '\n': line += 1; bol = i + 1 + i += 1 + return out + + +def _unquoted(s): + """a TOML line without its strings and its comment: what is left are the brackets that open and close a list""" + return re.sub(r'"(?:\\.|[^"\\])*"|\'[^\']*\'', '""', s).split('#', 1)[0] + + +def _toml_lines(text, table, key=None): + """the lines inside a table `table` matches, and those of a `key = [ ... ]` list outside one""" + out, in_table, open_brackets = set(), False, 0 + for i, ln in enumerate(text.split('\n'), 1): + if open_brackets > 0: # inside a `dependencies = [ ... ]` spread over lines + out.add(i); s = _unquoted(ln); open_brackets += s.count('[') - s.count(']'); continue + if ln.lstrip().startswith('['): in_table = bool(table.match(ln.strip())); continue + if in_table: out.add(i); continue + if key and key.match(ln): + out.add(i); s = _unquoted(ln); open_brackets = s.count('[') - s.count(']') + return out + + +def _yaml_lines(text, block): + """the lines of the top-level YAML blocks `block` matches: the key's line and every indented or list line under it""" + out, inside = set(), False + for i, ln in enumerate(text.split('\n'), 1): + m = re.match(r'([\w.-]+)\s*:', ln) + if m: inside = bool(block.match(m.group(1))) + elif ln[:1] not in ('', ' ', '\t', '-', '#'): inside = False + if inside: out.add(i) + return out + + +def _xml_lines(base, text): + """the lines of an XML manifest that name a package: a POM's dependency blocks and coordinates, a NuGet element""" + lines, out, depth = text.split('\n'), set(), 0 + for i, ln in enumerate(lines, 1): + if base == 'pom.xml': + opened = depth > 0 + for m in POM_BLOCK.finditer(ln): depth += -1 if m.group(1) else 1 + if opened or depth > 0 or POM_COORD.match(ln): out.add(i) + elif XML_PKG_LINE.search(ln) or (base.endswith('.nuspec') and POM_COORD.match(ln)): out.add(i) + return out + + +def manifest_skip(rel, text): + """-> skip(line, col): True where a word written there sits in a lockfile, a manifest's metadata or one of its + dependency lists (see LOCKFILES AND MANIFEST LISTS); None for any other file""" + base = os.path.basename(rel).lower() + if is_lockfile(rel) or REQUIREMENTS.match(base): return lambda _l, _c: True + if base in JSON_LIST_KEY: + spans = _json_spans(text, JSON_LIST_KEY[base]) + return lambda l, c: any(a <= c < b for a, b in spans.get(l, ())) + if base in YAML_LIST_KEY: lines = _yaml_lines(text, YAML_LIST_KEY[base]) + elif base == 'pyproject.toml': lines = _toml_lines(text, PY_DEP_TABLE, PY_DEP_KEY) + elif base == 'pipfile': lines = _toml_lines(text, PIPFILE_TABLE) + elif base in ('pom.xml', 'packages.config') or base.endswith(('.csproj', '.fsproj', '.vbproj', '.props', '.targets', '.nuspec')): + lines = _xml_lines(base, text) + else: return None + return lambda l, _c: l in lines COMMON = re.compile(r'[a-z]+') _QUOTES = '"\'`' QUALIFIER = re.compile(r'[\w$)\]>](?:#|::|->)$') # `Owner#note`, `Owner::note`, `$obj->note`; not a CSS `#note` selector def out_of_scope(rel, text): - """a shell script or a Datalog file: a name matched there is never a binding (owner's rule)""" - if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT: return True + """a shell script, a Datalog file or a lockfile: a name matched there is never a binding (owner's rule)""" + if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT or is_lockfile(rel): return True return not os.path.splitext(rel)[1] and bool(SHELL_SHEBANG.match(text[:120])) @@ -211,9 +346,11 @@ def hits(self, names): try: text = open(os.path.join(self.repo, rel), errors='replace').read() except OSError: continue if not any(n in text for n in names) or out_of_scope(rel, text): continue + skip = manifest_skip(rel, text) for i, line in enumerate(text.split('\n'), 1): shaped = {} for m in pat.finditer(line): + if skip and skip(i, m.start(1)): continue n = m.group(1); hits.append((n, rel, i)) shaped[n] = shaped.get(n, False) or not is_common(n) or code_shaped(line, m.start(1), m.end(1)) self.prose.update((n, rel, i) for n, ok in shaped.items() if not ok) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 94915f7f..28aabf2e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -300,10 +300,17 @@ def next_context(text): "step's BODY, not only the line shown, since the body is the explanation and the flow is only its spine " "(`--source` prints it)." + gap) m = re.search(r'^\s+(?:hop \d+|name only, no call path)\s+(\S+)\s+\(\d+ symbol\(s\)\)[^\n]*\n\s+-> ([^\n]+)', text, re.M) + if m: + f = m.group(1); syms = [x.strip() for x in m.group(2).split(',') if x.strip()][:2] + return (f"next: read {f} first — it holds {' and '.join(syms)}; then `impact ` for what a change " + "to it reaches. The other files are ranked context, not a reading list") + # --source prints each file's declarations as code (`name (file:line)` and its lines) instead of the `->` list + m = re.search(r'^\s+(?:hop \d+|name only, no call path)\s+(\S+)\s+\(\d+ symbol\(s\)\)[^\n]*\n((?:\s+\S+ \(\S+:\d+\)\n(?:\s+(?:\d+|) \| [^\n]*\n)*)+)', + text, re.M) if not m: return '' - f = m.group(1); syms = [x.strip() for x in m.group(2).split(',') if x.strip()][:2] - return (f"next: read {f} first — it holds {' and '.join(syms)}; then `impact ` for what a change " - "to it reaches. The other files are ranked context, not a reading list") + syms = re.findall(r'^\s+(\S+) \(\S+:\d+\)$', m.group(2), re.M)[:2] + return (f"next: answer from the code of {m.group(1)} shown first above — {' and '.join(syms)}; then `impact ` for what a change to it reaches. The other files are ranked context, not a reading list") def next_changed(text): if re.search(r'^(no change|no git base)', text, re.M): return '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index d7077e55..9bd4561d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -482,11 +482,12 @@ def graph_langs(g): def indexed_files(g, langs): - """every file some graph of this repository holds""" + """every file some graph of this repository holds. The main graph's dir is '' in `langs` (the default the verbs + read); asked from another language's graph, it is read from its place, or a directory only it holds reads as text""" import sqlite3 held = {(sy.get('file') or '') for sy in g.all_sym.values()} for l, d in langs.items(): - if not d: continue + if not d: d = os.path.join(g.repo, '.axiomcode') try: con = sqlite3.connect(f"file:{os.path.join(d, 'out', 'graph.sqlite')}?mode=ro", uri=True) held |= {r[0] for r in con.execute("SELECT DISTINCT file FROM symbols")} @@ -578,23 +579,39 @@ def nonsource(g): def text_term_hits(g, dirs, terms, cap=200): - """[(file, first line, [terms])] -- files under `dirs` whose text uses the task's words, most words first""" + """[(file, first line, [terms])] -- files under `dirs` whose text uses the task's words, most words first. A word + in a lockfile or in a manifest's package lists is never one (ax_nonsource.py: it names a package, not the task).""" + import ax_nonsource pats = {t: re.compile(r'(?i)(?= 3} out = [] for d in dirs: for root, _ds, fs in os.walk(os.path.join(g.repo, d)): for fn in fs: rel = os.path.relpath(os.path.join(root, fn), g.repo).replace(os.sep, '/') + if ax_nonsource.is_lockfile(rel): continue ln = source_lines(g.repo, rel) + skip =ax_nonsource.manifest_skip(rel, '\n'.join(ln)) or (lambda _l, _c: False) got, first = [], 0 for t, pt in pats.items(): - k = next((i for i, x in enumerate(ln, 1) if pt.search(x)), 0) + k = next((i for i, x in enumerate(ln, 1) for m in pt.finditer(x) if not skip(i, m.start())), 0) if k: got.append(t); first = min(first or k, k) if got: out.append((rel, first, got)) if len(out) >= cap: break return sorted(out, key=lambda r: (-len(r[2]), r[0])) +def print_text_hits(g, dirs, terms): + """print the text files under `dirs` that use the task's words; -> how many there are""" + hits = text_term_hits(g, dirs, terms) + if hits: + print(f"\ntext files under {', '.join(d + '/' for d in dirs)} that use the task's words (not source; " + "matched by word, not by any declaration):") + for f, l, ts in hits[:BINDINGS_SHOWN]: + print(f" {f + ':' + str(l):56} {', '.join(ts)}") + RESULT['text_bindings'] = [{'file': f, 'line': l, 'terms': ts} for f, l, ts in hits] + return len(hits) + + ASK_EXT = [(re.compile(r'\b(sql|quer(y|ies)|statements?|mapper|mappings?|migrations?|schema)\b', re.I), ('.xml', '.sql')), (re.compile(r'\b(config(uration|ured)?|propert(y|ies)|settings?|ya?ml|toml|ini|env)\b', re.I), ('.properties', '.yml', '.yaml', '.toml', '.ini', '.conf', '.cfg', '.env')), @@ -607,10 +624,12 @@ def asked_exts(text): return tuple(e for rx, es in ASK_EXT if rx.search(text or '') for e in es) -def text_bindings(g, sids, under=(), want_ext=()): +def text_bindings(g, sids, under=(), want_ext=(), scope=None): """[(file, line, declaration, name written there)] -- a non-source file that writes the name of one of `sids` as a whole word. A mapper XML with a namespace binds only the type that namespace names (a statement id repeated - in another mapper's XML is not this method's SQL).""" + in another mapper's XML is not this method's SQL). `scope` (the caller's --in) keeps only the files under it, as + it keeps only the declarations under it, except a mapper whose namespace names the type: that binding is proved + by the declaration, not by the word, wherever the file sits.""" want = collections.defaultdict(set) # name -> {sid} decl_count = collections.Counter(sy.get('name') for sy in g.sym.values() if not (sy.get('file') or '').startswith('<')) for sid in sids: @@ -642,6 +661,7 @@ def text_bindings(g, sids, under=(), want_ext=()): if nm in common and not ns: continue elif nm in common: continue else: cands = sorted(want[nm]) + if scope and not scope(f) and not (f.endswith('.xml') and ns_of.get(f)): continue key = (f, nm) if key in seen: continue seen.add(key) @@ -653,7 +673,7 @@ def text_bindings(g, sids, under=(), want_ext=()): return [r for _k, r in sorted(out)] -def asked_bound(g, scored, text, under=(), cap=300): +def asked_bound(g, scored, text, under=(), cap=300, scope=None): """{symbol id} among the multi-term matches that a text file of the kind the question asks about binds by name. Empty unless the question asks about such a file (SQL, configuration, a template): only then is the tree walked, and only then does it break a tie between equally scored entry points (pick_seeds).""" @@ -662,7 +682,7 @@ def asked_bound(g, scored, text, under=(), cap=300): cands = sorted((sid for sid, (_sc, m) in scored.items() if len(m) >= 2), key=lambda s: -scored[s][0])[:cap] by_disp = collections.defaultdict(set) for sid in cands: by_disp[g.disp(sid)].add(sid) - return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds) + return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds, scope=scope) if f.endswith(kinds) for sid in by_disp.get(d, ())) @@ -1199,8 +1219,14 @@ def main(argv): RESULT['not_indexed'] = list(notice) for ln in notice: print(ln) if notice: print() + text_only_in = False if text_dirs: + asked_in = bool(scopes) scopes = [x for x in scopes if x.strip('/').lstrip('./') not in text_dirs] + text_only_in = asked_in and not scopes and not scope_offered + # every --in names text no graph holds: one graph lists it (the one that says so, above), and another has + # nothing inside the caller's scope to add — its only package is outside it and would become the next step + if fan and text_only_in and not notice: return 3 # AN --in NO GRAPH HOLDS AND NO DIRECTORY IS was refused with a menu, once per language graph, and no answer: the # agent then searched by hand. A typo is not a question about nothing; the task is answered at the root (or under # the scopes that are real) and the line says what was dropped and what is close. A scope another language's graph @@ -1288,12 +1314,21 @@ def main(argv): # is naming a change that spans them, and intersecting those two paths answers nothing (#1029) specs = [scope_spec(sc, g.repo) for sc in scopes] in_scope = lambda f: any(under_scope(f, sp) for sp in specs) + # the text files are narrowed by the CALLER's --in only: the sole package this graph indexes is not a statement + # about where its SQL or configuration lives, and a guessed scope narrows nothing + fixed_specs = [scope_spec(sc, g.repo) for sc in fixed] + text_scope = (lambda f: any(under_scope(f, sp) for sp in fixed_specs)) if fixed_specs else None if scopes and not scope_offered: scored = {sid: v for sid, v in scored.items() if in_scope(g.sym.get(sid, {}).get('file'))} # what the question NAMES comes before what its words match: a declaration spelled out, a route quoted named = named_declarations(g, body) + route_seeds(g, body) if scopes and not scope_offered: named = [(sid, why) for sid, why in named if in_scope(g.sym.get(sid, {}).get('file'))] + if not scored and not named and not have_from and text_only_in: + # the caller's --in holds only text: its files that use the task's words are the answer, and the package this + # graph put in its place holding none of them is no reason to refuse it + if not print_text_hits(g, text_dirs, terms): print(f"no file under {', '.join(d + '/' for d in text_dirs)} uses the task's words") + return 0 if not scored and not named and not have_from: # the scope is real (require_scope proved it) but holds nothing matching — offer what is under it, # ranked the same way the scope-less menu is: by where the words land, never by how big a directory is @@ -1307,7 +1342,7 @@ def main(argv): # themselves, and on Python every class has one, so they can be a third of this list while # naming nowhere to go. scored = {sid: v for sid, v in scored.items() if not is_synthetic(g.sym.get(sid, {}).get('name'))} - seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs)) + seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs, scope=text_scope)) RESULT['terms'] = list(terms) RESULT['scope'], RESULT['scope_offered'] = (scopes[0] if scopes else None), bool(scope_offered) @@ -1525,7 +1560,7 @@ def main(argv): depth[x] = d + 1 near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], g.disp(x) or '', x))[:300] kinds = asked_exts(body) - binds = text_bindings(g, near, under=text_dirs, want_ext=kinds) + binds = text_bindings(g, near, under=text_dirs, want_ext=kinds, scope=text_scope) # the question named a kind of file (SQL: mapper XML or .sql): when some binding is of that kind, the others # (a Postman collection, a README table that spells the method) are not what was asked if kinds and any(f.endswith(kinds) for f, _l, _d, _n in binds): @@ -1538,13 +1573,7 @@ def main(argv): if len(binds) > BINDINGS_SHOWN: print(f" … +{len(binds) - BINDINGS_SHOWN} more") RESULT['text_bindings'] = [{'file': f, 'line': l, 'declaration': d, 'name': nm} for f, l, d, nm in binds] elif text_dirs: - hits = text_term_hits(g, text_dirs, terms) - if hits: - print(f"\ntext files under {', '.join(d + '/' for d in text_dirs)} that use the task's words (not source; " - "matched by word, not by any declaration):") - for f, l, ts in hits[:BINDINGS_SHOWN]: - print(f" {f + ':' + str(l):56} {', '.join(ts)}") - RESULT['text_bindings'] = [{'file': f, 'line': l, 'terms': ts} for f, l, ts in hits] + print_text_hits(g, text_dirs, terms) print("\n" + BOUND) print(" narrow with `impact --in ` or `path '*' --in `.") diff --git a/tests/cases/java/context-answers-what-was-asked/case.json b/tests/cases/java/context-answers-what-was-asked/case.json index 333ae910..777f3f3e 100644 --- a/tests/cases/java/context-answers-what-was-asked/case.json +++ b/tests/cases/java/context-answers-what-was-asked/case.json @@ -5,6 +5,10 @@ "want": ["text files that name these declarations", "src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)", "next: read src/main/resources/mapper/OrderMapper.xml:3"], "avoid": ["InvoiceMapper.xml"]}, + {"why": "control for --in narrowing text files: a mapper XML whose namespace names the declaring type is bound by that declaration, so it is listed even when --in names only the source tree", + "run": ["context", "which SQL runs when an order is loaded by number", "--in", "src/main/java"], + "want": ["src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)"], + "avoid": ["InvoiceMapper.xml"]}, {"why": "--in on a directory that holds no source is accepted: it says the graph cannot see it and lists the text files under it bound to what the question names", "run": ["context", "which SQL runs for findByNumber", "--in", "src/main/resources"], "want": ["not indexed: src/main/resources/", "named in the task (findByNumber)", "src/main/resources/mapper/OrderMapper.xml:3"], diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json new file mode 100644 index 00000000..5721bd26 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json @@ -0,0 +1 @@ +{ "name": "wiring-sample", "dependencies": { "debug": "1.0.0" } } diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json index 3f88d68a..720b5f37 100644 --- a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json @@ -8,11 +8,15 @@ "run": ["context", "app wiring: createApp and its output", "--in", "src/a", "--source"], "want": ["next: answer from the code of src/a/app.js shown first above"], "avoid": ["package-lock.json", "package.json:"]}, + {"why": "--in a directory no graph holds lists its text files by word, and a manifest's dependency line there is not one; its rule file is (control)", + "run": ["context", "lint tooling: which debug rule the linter loads", "--in", "tools/lint"], + "want": ["not indexed: tools/lint/", "tools/lint/rules.json:2", "next: read tools/lint/"], + "avoid": ["tools/lint/package.json", "package-lock.json"]}, {"why": "without --in, a lockfile is never a text lead and a manifest's description, dependency lists and script names are not either; a manifest script command and a config file that name the function are (control: the manifest is filtered by line, not dropped)", "run": ["context", "app wiring: createApp, config, debug output"], - "want": ["src/a/routes.json:2", "config/app.json:2", "package.json:6"], + "want": ["config/app.json:2", "package.json:6"], "avoid": ["package-lock.json", "packages.lock.json", "package.json:4", "package.json:7", "package.json:10", "package.json:13"]}, - {"why": "impact lists the same text files as bound from outside the source: the lockfiles and the manifest's dependency lists are not among them", + {"why": "impact lists the same text files as bound from outside the source: lockfiles, the manifest's dependency lists and every other manifest's package list (a pnpm catalog, bower and deno imports, a requirements file) are not among them; a script or task value that names the function is, in a one-line manifest too (control)", "run": ["impact", "debug"], - "want": ["src/a/routes.json", "config/app.json"], - "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10"]}]} + "want": ["src/a/routes.json", "config/app.json", "deno.json:3", "packages/min/package.json:1"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10", "pnpm-workspace.yaml", "bower.json", "deno.json:2", "requirements.txt"]}]} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json new file mode 100644 index 00000000..817764be --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json @@ -0,0 +1,4 @@ +{ + "imports": { "debug": "npm:debug@4.1.0" }, + "tasks": { "trace": "debug" } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json new file mode 100644 index 00000000..c25c2e2c --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "wiring-sample", + "lockfileVersion": 3, + "packages": { + "node_modules/debug": { + "version": "4.3.4", + "dependencies": { + "ms": "2.1.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "optional": true + } + }, + "dependencies": { + "debug": "^4.1.0" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json new file mode 100644 index 00000000..16825bfa --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json @@ -0,0 +1 @@ +{"name":"min","dependencies":{"debug":"^4"},"scripts":{"trace":"debug"}} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml b/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml new file mode 100644 index 00000000..b91fa7b9 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + debug: ^4.1.0 diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt b/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt new file mode 100644 index 00000000..8058cbc2 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt @@ -0,0 +1 @@ +debug==0.1 diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json new file mode 100644 index 00000000..36702d1f --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json @@ -0,0 +1,6 @@ +{ + "name": "lint-tools", + "devDependencies": { + "debug": "^4.1.0" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json new file mode 100644 index 00000000..64d7d3b4 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json @@ -0,0 +1,3 @@ +{ + "rules": { "no-debug": "warn" } +} diff --git a/tests/multi_language.py b/tests/multi_language.py index 7f0f644d..39e2d5b2 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -50,7 +50,8 @@ 'lib/pytools/probe.py': 'def emit(x):\n return [x]\n', 'src/cli.ts': 'import { run } from \'./main\'\n\nexport function main(): number {\n return run()\n}\n', 'jslib/package.json': '{ "name": "jslib", "version": "1.0.0", "main": "index.js" }\n', - 'jslib/index.js': 'function helper(a) {\n return a + 1\n}\n\nfunction api(a) {\n return helper(a) * 2\n}\n\nmodule.exports = { api }\n', + 'notes/steps.json': '{ "steps": ["total"] }\n', + 'jslib/index.js':'function helper(a) {\n return a + 1\n}\n\nfunction api(a) {\n return helper(a) * 2\n}\n\nmodule.exports = { api }\n', } # a Maven project whose build wrote javadoc: target/ beside the pom.xml, and a copy committed for a docs site @@ -212,6 +213,16 @@ def check(ok, why, detail=''): s = sh(repo, AX, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, 'scope: context --in tools keeps lib/pytools/ out too', s.stdout + s.stderr) + # a directory only the MAIN graph holds, asked from every graph: another language's graph once read it as text + # no graph holds, listed its source files as text rows and ended on a next step outside the scope + s = sh(repo, AX, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) + check(s.returncode == 0 and 'not indexed: src/' not in s.stdout and 'tools/' not in s.stdout and 'src/shape.ts' in s.stdout, + 'scope: a directory the main graph holds is not text to the other graphs', s.stdout + s.stderr) + # a directory no graph holds, named by --in: its text files are listed once, and every next step stays in it + s = sh(repo, AX, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) + nexts = [l for l in s.stdout.splitlines() if l.startswith('next:')] + check(s.returncode == 0 and s.stdout.count('notes/steps.json') == 1 and nexts and all('notes/' in l for l in nexts), + 'scope: --in a text-only directory is listed by one graph, and no next step leaves it', s.stdout + s.stderr) # ── --from ──────────────────────────────────────────────────────────────────────────────────────────── # `main` is declared in the typescript graph and twice in the python one: the flow starts where the task's From b50295042fa3c6cbe08fc0e51425338a0323009b Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:58:07 -0700 Subject: [PATCH 102/258] changed, test-impact, impact: a mapper XML statement is its mapper method's body A MyBatis statement is bound to its mapper method by the mapper's namespace plus the statement id, but the plugin only used that binding one way. An edit inside ` under `` + to a.B.m, so an edit to that SQL is a body change of a.B.m, and `test-impact` asks for the tests of a.B.m (an + edit to a `` fragment or a `` is one to every statement that includes or names it). The row sits + at the statement's line in the XML and targets the method. -> (entries, unplaced edited lines); ([], 0) for a + file that is not a mapper XML or whose namespace names no type of this graph. `whole` (a reason): the file was + named with no edit, so every statement in it counts.""" + import ax_nonsource + if whole: + ns = ax_nonsource.mapper_namespace_of(new) + stm = {e[1]: (e[2], 'named', None) for e in ax_nonsource.mapper_elements(new) if e[0] in ax_nonsource.STATEMENT_TAGS} if ns else {} + unplaced = 0 + else: + ns, stm, unplaced = ax_nonsource.mapper_edits(old, new) + if not ns: return [], 0 + out = [] + for sid, (line, how, via) in sorted(stm.items(), key=lambda x: (x[1][0], x[0])): + what = {'added': 'a statement for it was added', 'removed': 'its statement was removed', 'named': whole}.get(how, 'the SQL of its statement changed') + for i in self.mapper_methods(ns, sid): + sy = self.g.all_sym[i] + out.append(dict(kind='named' if how == 'named' else 'body', symbol=sy['display'], id=i, file=rel, line=line, end=line, old_lines=[], + detail=f"mapper statement {ns}.{sid}: {what}" + (f" (through {via}, which it includes or maps with)" if via else ''), + target=self.at_line(i) or self.qualified(i, 'method') or sy['display'], shown_target=self.qualified(i, 'method') or sy['display'], + target_kind='method', mapper=True)) + return out, unplaced def qualified(self, i, kind): if i is None: return None r = self.g.all_sym.get(i) @@ -1412,10 +1442,31 @@ def main(argv): if old_f or new_f: if not one: die("--old/--new need --file ") rel = C.rel(one); old = open(old_f, errors='replace').read() if old_f else ''; new = open(new_f, errors='replace').read() if new_f else '' - r, n = C.file_changes(rel, old, new); results += r; notes += n + mr = C.mapper_changes(rel, old, new)[0] if not C.is_code(rel) else [] + if mr: results += mr + else: + r, n = C.file_changes(rel, old, new); results += r; notes += n else: files = C.files(mode, rng, a) outside = list(C.outside) + # A MAPPER XML IS NOT OUTSIDE THE GRAPH: its statements are the bodies of the mapper methods their namespace and + # id name (mapper_changes). An edit placed in a statement, a fragment or a result map is that method's change; + # the file stays named as outside only when an edited line lies in no element with an id (its header, say). + mapped = set() + for f in [x for x in outside if x.lower().endswith('.xml')]: + if a and not git: + try: new = open(os.path.join(C.repo, f), errors='replace').read() + except OSError: continue + r, unplaced = C.mapper_changes(f, '', new, whole='no git base to diff against') + else: + old, new = C.texts(f, mode, rng) + if old == new: + if not (a and whole): continue + r, unplaced = C.mapper_changes(f, old, new, whole=f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + else: r, unplaced = C.mapper_changes(f, old, new) + results += r + if r and not unplaced: mapped.add(f) + outside = [x for x in outside if x not in mapped] # a source file in a language no graph here holds (a .java file beside a Python-only index) is outside the index too held = held_extensions(C.repo) if held: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..8606939d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -974,6 +974,7 @@ class Impact: if path in keys: out.append((path, rel, i)) return out KEYFILE_EXT = {'.properties','.yml','.yaml','.conf','.ini','.cfg','.env'} + MAPPER_STATEMENT = 'is its mapper statement' # an extbind row that opens the method's own statement in its mapper's namespace (mapper_scope) def config_key_sites(self, keys): """a key DEFINED or overridden in a settings file, matched on the canonical form so the spelling there need not be the spelling that was asked for. -> [(canonical key, file, line)]""" @@ -1936,7 +1937,7 @@ class Impact: rows = out.get('extbind') if not rows: return out import ax_nonsource - g = self.g; ns_of = {}; accepts = {} + g = self.g; ns_of = {}; accepts = {}; stmt_of = {} methods = collections.defaultdict(list) # query id -> its method targets for r in T: if r[1] == 'method': methods[r[0]].append(r[2]) @@ -1965,6 +1966,14 @@ class Impact: if ns is not None: mids = [m for m in methods.get(qq, ()) if (g.sym.get(m) or {}).get('name') == n] if mids and not any(namespaces(m) is None or norm(ns) in namespaces(m) for m in mids): continue + # THE STATEMENT ITSELF, not a mention: the line opens ` [by key · its mapper statement]\nsrc/test/java/demo/order/OrderLoadTest.java"]}, + {"why": "the same, in the sectioned answer: the statement is a place bound to the method, and the same id in another namespace is not", + "run": ["impact", "OrderMapper.findById", "--page", "all"], + "want": ["[by key] src/main/resources/mapper/OrderMapper.xml:7 — is its mapper statement (findById)", "bound to it: src/main/java/demo/order/OrderService.java:6, src/main/resources/mapper/OrderMapper.xml:7"], + "avoid": ["mapper/UserMapper.xml:4 —"]}, + {"why": "near miss: the other mapper's method gets its own statement, and a text file that only mentions the id is not called its statement", + "run": ["impact", "UserMapper.findById", "--page", "all"], + "want": ["[by key] src/main/resources/mapper/UserMapper.xml:4 — is its mapper statement (findById)"], + "avoid": ["src/main/resources/mapper/OrderMapper.xml", "[by key] edits/"]}]} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/edits/fragment.xml.txt b/tests/cases/java/mapper-statement-edit-is-its-method/edits/fragment.xml.txt new file mode 100644 index 00000000..56f503d6 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/edits/fragment.xml.txt @@ -0,0 +1,14 @@ + + + + + id, number, created_at + + + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/edits/header.xml.txt b/tests/cases/java/mapper-statement-edit-is-its-method/edits/header.xml.txt new file mode 100644 index 00000000..a38f94e6 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/edits/header.xml.txt @@ -0,0 +1,15 @@ + + + + + + id, number + + + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/edits/statement.xml.txt b/tests/cases/java/mapper-statement-edit-is-its-method/edits/statement.xml.txt new file mode 100644 index 00000000..78f5331a --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/edits/statement.xml.txt @@ -0,0 +1,14 @@ + + + + + id, number + + + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/pom.xml b/tests/cases/java/mapper-statement-edit-is-its-method/pom.xml new file mode 100644 index 00000000..376d4c24 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/pom.xml @@ -0,0 +1,7 @@ + + 4.0.0 + demodemo1 + + org.mybatis.spring.bootmybatis-spring-boot-starter3.0.3 + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/Order.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/Order.java new file mode 100644 index 00000000..ebfa748a --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/Order.java @@ -0,0 +1,5 @@ +package demo.order; +public class Order { + public String id; + public String number; +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderMapper.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderMapper.java new file mode 100644 index 00000000..8de8ed19 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderMapper.java @@ -0,0 +1,8 @@ +package demo.order; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +@Mapper +public interface OrderMapper { + Order findById(String id); + List findOpen(); +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderService.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderService.java new file mode 100644 index 00000000..84289ba3 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/order/OrderService.java @@ -0,0 +1,8 @@ +package demo.order; +import java.util.List; +public class OrderService { + private final OrderMapper orders; + public OrderService(OrderMapper orders) { this.orders = orders; } + public Order load(String id) { return orders.findById(id); } + public List open() { return orders.findOpen(); } +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/User.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/User.java new file mode 100644 index 00000000..2a435206 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/User.java @@ -0,0 +1,4 @@ +package demo.user; +public class User { + public String id; +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserMapper.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserMapper.java new file mode 100644 index 00000000..2bc505c5 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserMapper.java @@ -0,0 +1,6 @@ +package demo.user; +import org.apache.ibatis.annotations.Mapper; +@Mapper +public interface UserMapper { + User findById(String id); +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserService.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserService.java new file mode 100644 index 00000000..96827778 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/java/demo/user/UserService.java @@ -0,0 +1,6 @@ +package demo.user; +public class UserService { + private final UserMapper users; + public UserService(UserMapper users) { this.users = users; } + public User load(String id) { return users.findById(id); } +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/OrderMapper.xml b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/OrderMapper.xml new file mode 100644 index 00000000..760b0b84 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/OrderMapper.xml @@ -0,0 +1,14 @@ + + + + + id, number + + + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/UserMapper.xml b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 00000000..d3fa4ca2 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderLoadTest.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderLoadTest.java new file mode 100644 index 00000000..b5a79c8e --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderLoadTest.java @@ -0,0 +1,8 @@ +package demo.order; +import org.junit.jupiter.api.Test; +public class OrderLoadTest { + @Test + void loadsOne() { + new OrderService(null).load("o-1"); + } +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderOpenTest.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderOpenTest.java new file mode 100644 index 00000000..bc4cf219 --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/order/OrderOpenTest.java @@ -0,0 +1,8 @@ +package demo.order; +import org.junit.jupiter.api.Test; +public class OrderOpenTest { + @Test + void listsOpen() { + new OrderService(null).open(); + } +} diff --git a/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/user/UserLoadTest.java b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/user/UserLoadTest.java new file mode 100644 index 00000000..f994cc6e --- /dev/null +++ b/tests/cases/java/mapper-statement-edit-is-its-method/src/test/java/demo/user/UserLoadTest.java @@ -0,0 +1,8 @@ +package demo.user; +import org.junit.jupiter.api.Test; +public class UserLoadTest { + @Test + void loadsOne() { + new UserService(null).load("u-1"); + } +} From 7ca9b404b9931f989cd6d278362bc44db35ca210 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:28:27 -0700 Subject: [PATCH 103/258] impact: mapper parameters, reflective copies and bound keys as field uses Field impact missed three kinds of use that no Java reference names. - A MyBatis parameter property. `#{it.quantity}` in the statement of a mapper method that takes `@Param("it") Item item` reads Item.quantity when the statement runs. The text scan matched a field name only where no `.` came before it, so a dotted parameter path was never found, and a bare one was not typed. - A reflective copy OUT of the type. `BeanUtil.copyProperties(item, ItemView.class)` reads every property of `item` by name. The copy into a type was already a producer (`reflects on T.class`); the value handed out was not a use. - A key bound to the field. A field of a @ConfigurationProperties class is defined by its key's settings line, which only the key form of the question (`impact app.signingKey`) listed. The change, all in impact: - mapper_params types each parameter marker of the statements that a field's name appears in: the statement's namespace and id give the mapper method (ax_nonsource.mapper_methods, now shared with changed), and its parameters type the path's root. The root can be a @Param name, the one unnamed parameter, a over a collection parameter, or the statement's parameterType. A segment before the last is a field of the type so far. A root that names nothing, or a simple type name declared twice, gives no row. The row is the mapper method, at the marker's line in the XML, [resolved]. - bean_copies types each bare-name argument of a reflective copier (copyProperties, copyToList, toBean, fillBean, beanToMap, convertValue, copyBean) by its declaration in the calling method, or by a field of the caller's type. The row is the caller, [by name]. - Both are per-query facts (mapper_param, bean_copy) that dl/impact.dl joins as direct uses, so the closure and the tests start from them. The SQL port takes the same rows. They are part of the solve cache key, so no IMPACT_VERSION bump is needed. - A field target's bound keys (ext_config_binding) add their settings lines to the "bound from outside the source" rows, as a config target's do. Test: tests/cases/java/field-read-by-name-outside-the-source. The near miss is a same-named field on another type, with its own mapper statement and copy. The control is the key form of the question. 4 of its 5 checks fail on the release branch tip, and all 5 pass with the change, with the rules and with the SQL port. Smoke, on a multi-module shop project: - `impact` on a product entity's stock field went from 1 to 3 readers. The new rows are its mapper statement's `#{...}` line and the controller that copies the entity to a DTO. - `impact` on a @ConfigurationProperties field now lists its settings line (0 before, 1 after). - Over all 1254 fields: 74 mapper-parameter rows on 63 fields, 186 bean-copy rows on 128 fields, and 17 bound-key rows. On a smaller web app: 52 mapper-parameter rows on 28 fields, and no copies or bound keys. Nine rows sampled across both were checked by hand; each marker is typed to the right parameter and field. Not yet measured on the full corpus or on held-out projects; the smoke numbers above are the only corpus numbers. --- .../skills/axiomcode/scripts/ax_nonsource.py | 24 ++ .../skills/axiomcode/scripts/axiomcode-impact | 214 ++++++++++++++++-- .../skills/axiomcode/scripts/dl/impact.dl | 12 + .../skills/axiomcode/scripts/graph_sql.py | 4 +- .../case.json | 22 ++ .../pom.xml | 7 + .../java/demo/catalog/CatalogService.java | 18 ++ .../src/main/java/demo/config/AppConfig.java | 13 ++ .../src/main/java/demo/item/Item.java | 7 + .../src/main/java/demo/item/ItemMapper.java | 9 + .../src/main/java/demo/item/ItemView.java | 4 + .../src/main/java/demo/part/Part.java | 5 + .../src/main/java/demo/part/PartMapper.java | 7 + .../src/main/java/demo/part/PartView.java | 4 + .../src/main/resources/app.properties | 2 + .../src/main/resources/mapper/ItemMapper.xml | 13 ++ .../src/main/resources/mapper/PartMapper.xml | 7 + 17 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/case.json create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/pom.xml create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/catalog/CatalogService.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/config/AppConfig.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/item/Item.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/item/ItemMapper.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/item/ItemView.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/part/Part.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/part/PartMapper.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/java/demo/part/PartView.java create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/resources/app.properties create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/resources/mapper/ItemMapper.xml create mode 100644 tests/cases/java/field-read-by-name-outside-the-source/src/main/resources/mapper/PartMapper.xml diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 5577d63f..e4b629ec 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -218,6 +218,30 @@ def mapper_methods(q, ns, sid): return sorted({r[0] for r in own}) +# a parameter marker in a statement's SQL: `#{it.quantity}`, `${orderBy}`, `#{item.id,jdbcType=VARCHAR}` +PARAM_MARKER = re.compile(r'[#$]\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)') +_FOREACH = re.compile(r']*)>', re.S) +_ATTR = lambda name: re.compile(r'(? collection)] for every parameter marker inside a statement element (mapper_elements)""" + tag, sid, first, last, open_tag, body = elem + items = {} + for m in _FOREACH.finditer(body): + c, i = _COLLECTION.search(m.group(1)), _ITEM.search(m.group(1)) + if c and i: items[i.group(1)] = c.group(1) + base = first + open_tag.count('\n') + return [(m.group(1), base + body.count('\n', 0, m.start()), items) for m in PARAM_MARKER.finditer(body)] + + +def statement_param_type(elem): + """the `parameterType` a statement declares, or None""" + m = _PARAM_TYPE.search(elem[4]) + return m.group(1) if m else None + + def statement_at(text, line): """(tag, id) of the statement element whose opening tag is written on `line` of a mapper XML's text, or None""" for e in mapper_elements(text): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8606939d..cd9e0118 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -105,7 +105,7 @@ def program_starters(repo, files): # written per query (or supplied by the path tool's own export), so the cached export is NOT expected to hold them. Leaving # `nonsource` / `qual_name` / `edge` / `named` out of this set made the completeness check below unsatisfiable, so the 15 MB # export re-ran on EVERY query — the cache never hit once. -PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qual_name', 'edge', 'named', 'key_cap', 'key_use_cap'} +PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qual_name', 'edge', 'named', 'key_cap', 'key_use_cap', 'mapper_param', 'bean_copy'} # WHICH FACTS A QUESTION ACTUALLY NEEDS. Soufflé loads and joins every .input relation of the program, so a query about a # METHOD was reading the 4 MB reference layer that only a field or type target can use — and computing `gen`, `holds` and @@ -709,7 +709,21 @@ class Impact: PRIMITIVE = {'int','long','double','float','boolean','char','byte','short','void','String','Object'} def declared_params(self, mid): """the simple type names of mid's declared parameters, in order; None when the header cannot be read""" - s = self.g.sym[mid] + parts = self.param_parts(mid) + if parts is None: return None + out = [] + for raw in parts: + w = raw.strip() + w = re.sub(r'^(?:final\s+|@\w+(?:\([^)]*\))?\s+)*', '', w) # modifiers and annotations + w = re.sub(r'\s+[A-Za-z_$][\w$]*\s*$', '', w).strip() # drop the parameter NAME + w = re.sub(r'<.*?>', '', w) # erase generic arguments + w = w.replace('...', '[]') + w = w.split('.')[-1] if '.' in w and '[' not in w else w.split('.')[-1] + ('[]' if w.endswith('[]') else '') + out.append(w.strip()) + return out + def param_parts(self, mid): + """mid's declared parameters as written, split at the top-level commas; None when the header cannot be read""" + s = self.g.sym.get(mid) or {} if not s.get('line'): return None L = self.lines(s['file']); head = ' '.join(L[s['line'] - 1: min(s.get('end_line') or s['line'], s['line'] + 8)]) i = head.find('(') @@ -730,16 +744,7 @@ class Impact: if ch == ',' and d == 0: parts.append(cur); cur = '' else: cur += ch parts.append(cur) - out = [] - for raw in parts: - w = raw.strip() - w = re.sub(r'^(?:final\s+|@\w+(?:\([^)]*\))?\s+)*', '', w) # modifiers and annotations - w = re.sub(r'\s+[A-Za-z_$][\w$]*\s*$', '', w).strip() # drop the parameter NAME - w = re.sub(r'<.*?>', '', w) # erase generic arguments - w = w.replace('...', '[]') - w = w.split('.')[-1] if '.' in w and '[' not in w else w.split('.')[-1] + ('[]' if w.endswith('[]') else '') - out.append(w.strip()) - return out + return parts def sig_narrow(self, mids, sig, whole): """`Owner.m(A,B)` selects the declarations whose parameter TYPES are written that way. Returns (ids, note). Stops with a diagnostic rather than answering for a spelling that matches nothing.""" @@ -975,6 +980,163 @@ class Impact: return out KEYFILE_EXT = {'.properties','.yml','.yaml','.conf','.ini','.cfg','.env'} MAPPER_STATEMENT = 'is its mapper statement' # an extbind row that opens the method's own statement in its mapper's namespace (mapper_scope) + + # ── a field's uses that no reference names: a mapper statement's parameter, a reflective copy, a bound key ───── + # Each is read from the text the index does not type (a mapper XML, the arguments of a library call), so each is + # written as a per-query fact for the field targets asked about and joined in dl/impact.dl (mapper_param, + # bean_copy), and the bound key's settings lines join extbind as a config target's do (field_config_rows). + # a library call that copies a bean's properties BY NAME, through reflection: BeanUtil/BeanUtils.copyProperties, + # hutool's toBean / copyToList / fillBean / beanToMap, Jackson's convertValue. A value of the field's type handed + # to one of them is read (or written) property by property, so a rename or a retype of the field silently drops it. + BEAN_COPIERS = ('copyProperties', 'copyToList', 'toBean', 'fillBean', 'beanToMap', 'convertValue', 'copyBean') + def field_types(self, fid): + """(field name, the type ids that hold the field: its owner and every subtype, which inherits it) for a field id""" + g = self.g + r = g.q("SELECT name, owner_type_id FROM fields WHERE id = ?", fid) if g.has('fields') else [] + if not r or not r[0][1]: return None, set() + own = r[0][1] + subs = {x[0] for x in g.q("SELECT type_id FROM type_ancestors WHERE ancestor_type_id = ?", own)} if g.has('type_ancestors') else set() + return r[0][0], {own} | subs + def types_named(self, written): + """the client type ids a type name written in source or XML denotes: a qualified name exactly, a simple name when + exactly one type of this graph has it (two of one name are left unresolved rather than guessed)""" + if not written or not self.g.has('types'): return set() + w = re.sub(r'<.*', '', written).strip().replace('$', '.') + if '.' in w: + return {r[0] for r in self.g.q("SELECT id FROM types WHERE REPLACE(qualified_name, '$', '.') = ?", w)} + ids = {r[0] for r in self.g.q("SELECT id FROM types WHERE name = ? AND provenance = 'client'", w)} + return ids if len(ids) == 1 else set() + def mapper_params(self, fids): + """MAPPER PARAMETER PROPERTIES: `#{it.quantity}` in `` under the namespace of a + mapper whose `reserve(@Param("it") Item item)` takes an Item reads Item.quantity: MyBatis resolves + the path against the parameter object by name, when the statement runs. The root of the path is typed from the + mapper method's own parameters (a @Param name, the one unnamed parameter, a over a collection + parameter, or the statement's parameterType); a segment before the last is a field of the type so far. A root that + names nothing, or a type named twice, types nothing. -> [(method id, field id, expression, xml file, line)]""" + import ax_nonsource + want = {} + for fid in fids: + n, ts = self.field_types(fid) + if n and ts: want.setdefault(n, []).append((fid, ts)) + if not want: return [] + out = []; params = {} + for rel in self.nonsource_files(): + if not rel.lower().endswith('.xml'): continue + text = '\n'.join(self.lines(rel)) + if not any(n in text for n in want): continue + ns = ax_nonsource.mapper_namespace_of(text) + if not ns: continue + for e in ax_nonsource.mapper_elements(text): + if e[0] not in ax_nonsource.STATEMENT_TAGS or not any(n in e[5] for n in want): continue + marks = [m for m in ax_nonsource.statement_markers(e) if m[0].split('.')[-1] in want] + if not marks: continue + for mid in ax_nonsource.mapper_methods(self.g.q, ns, e[1]): + if mid not in self.g.sym: continue + if mid not in params: params[mid] = self.mapper_param_decls(mid) + for expr, line, items in marks: + held = self.marker_type(expr, items, params[mid], ax_nonsource.statement_param_type(e)) + for fid, ts in want[expr.split('.')[-1]]: + if held & ts: out.append((mid, fid, expr, rel, line)) + return sorted(set(out)) + def mapper_param_decls(self, mid): + """[(@Param name or None, parameter name, declared type as written)] of a mapper method, from its header""" + out = [] + for raw in self.param_parts(mid) or []: + w = raw.strip() + bind = re.search(r'@Param\s*\(\s*(?:value\s*=\s*)?"([^"]+)"\s*\)', w) + w = re.sub(r'@[\w.]+(?:\s*\([^)]*\))?\s*', '', w) + w = re.sub(r'^\s*final\s+', '', w) + m = re.match(r'(.+?)\s+([A-Za-z_$][\w$]*)\s*$', w) + if m: out.append((bind.group(1) if bind else None, m.group(2), m.group(1).strip())) + return out + def marker_type(self, expr, items, decls, ptype): + """the type ids the object that holds the LAST segment of a parameter marker may be (mapper_params)""" + segs = expr.split('.') + def elem(written): # List / Collection / Sku[] -> Sku + m = re.search(r'<\s*([\w$.]+)\s*>', written) + return m.group(1) if m else written[:-2] if written.endswith('[]') else None + def param(name): + for bind, pname, typ in decls: + if bind == name or (bind is None and pname == name and len(decls) > 1): return typ + return None + root, rest = None, segs + if len(segs) > 1 and segs[0] in items: + coll = items[segs[0]]; t = param(coll) + if t is None and len(decls) == 1 and coll in ('list', 'collection', 'array'): t = decls[0][2] + root, rest = (elem(t) if t else None), segs[1:] + elif len(segs) > 1 and param(segs[0]): + root, rest = param(segs[0]), segs[1:] + elif len(decls) == 1 and decls[0][0] is None: + root = decls[0][2] + elif ptype: + root = ptype + ids = self.types_named(root) if root else set() + for s in rest[:-1]: # a nested path: each segment a field of the type so far + if not ids: break + nxt = set() + for tid in ids: + anc = [tid] + [r[0] for r in self.g.q("SELECT ancestor_type_id FROM type_ancestors WHERE type_id = ?", tid)] + for r in self.g.q(f"SELECT type_name FROM fields WHERE name = ? AND owner_type_id IN ({','.join('?' * len(anc))})", s, *anc): + nxt |= self.types_named(r[0]) + ids = nxt + return ids + def bean_copies(self, fids): + """A VALUE HANDED TO A REFLECTIVE PROPERTY COPY. `BeanUtil.copyProperties(item, ItemView.class)` reads every + property of `item` by name: a rename of Item.quantity leaves ItemView's copy empty, and nothing fails. + The copy INTO a type is already a producer (`reflects on Product.class`); this is the value going OUT. An argument + that is a bare name is typed by the declaration of that name in the calling method (a parameter or a local) or a + field of its type. -> [(caller id, field id, declared type, copier name, file, line)]""" + g = self.g + if not g.has('call_sites'): return [] + want = {} + for fid in fids: + n, ts = self.field_types(fid) + if ts: want[fid] = ts + if not want: return [] + ph = ','.join('?' * len(self.BEAN_COPIERS)) + out = [] + for r in g.q(f"SELECT caller_id, callee_name, file_path, start_line FROM call_sites WHERE callee_name IN ({ph})", *self.BEAN_COPIERS): + c = r[0]; s = g.sym.get(c) + if not s or not s.get('line'): continue + f = g.site_file(r[2]) if r[2] else s['file']; ln = r[3] or 0 + L = self.lines(f); text = ' '.join(L[ln - 1: ln + 2]) if 0 < ln <= len(L) else '' + m = re.search(rf'\b{re.escape(r[1])}\s*\(', text) + if not m: continue + args, depth, cur = [], 0, '' + for ch in text[m.end():]: + if ch in '([{<': depth += 1 + elif ch in ')]}>': + if depth == 0: break + depth -= 1 + if ch == ',' and depth == 0: args.append(cur.strip()); cur = '' + else: cur += ch + args.append(cur.strip()) + body = '\n'.join(L[s['line'] - 1: ln]) + for a in args: + a = re.sub(r'^this\.', '', a) + if not re.fullmatch(r'[a-z_$][\w$]*', a): continue + # the nearest declaration of the name above the call: `Product product = …`, `(Product product)`, `for (Product product :` + decl = None + for dm in re.finditer(rf'([A-Z][\w$.]*)(?:\s*<[^;=()]*>)?\s+{re.escape(a)}\s*[=;,):]', body): decl = dm.group(1) + if decl is None and s.get('owner'): + fr = g.q("SELECT type_name FROM fields WHERE name = ? AND owner_type_id = (SELECT owner_type_id FROM methods WHERE id = ?)", a, s.get('method_id') or c) if g.has('fields') else [] + decl = fr[0][0] if fr and fr[0][0] else None + ids = self.types_named(decl) + for fid, ts in want.items(): + if ids & ts: out.append((c, fid, decl.split('.')[-1], r[1], f, ln)) + return sorted(set(out)) + def field_config_rows(self, T): + """A FIELD BOUND TO A CONFIGURATION KEY (@ConfigurationProperties, @Value on the field) is defined by the settings + line of that key: the field form of the question lists those lines as the key form does. -> [(key, file, line, q)]""" + g = self.g + if not g.has('ext_config_binding'): return [] + out = [] + for qq, k, fid, _x in T: + if k != 'field': continue + keys = {r[0] for r in g.q("SELECT DISTINCT c0 FROM ext_config_binding WHERE c3 = ?", fid)} + if not keys: continue + for key, f, l in self.config_key_sites({ckey(x) for x in keys}): out.append((key, f, l, qq)) + return sorted(set(out)) def config_key_sites(self, keys): """a key DEFINED or overridden in a settings file, matched on the canonical form so the spelling there need not be the spelling that was asked for. -> [(canonical key, file, line)]""" @@ -1851,6 +2013,10 @@ class Impact: g.write('key_cap', [(int(os.environ.get('AXIOMCODE_KEY_CAP', '4')),)], F) g.write('key_use_cap', [(int(os.environ.get('AXIOMCODE_KEY_USE_CAP', '4')),)], F) g.write('target', T, F); g.write('textuse', sorted(set(textuse)), F); g.write('importuse', sorted(set(importuse)), F); g.write('inside_target', sorted(set(inside)), F) + # a field's uses no reference names (field_types and below): a mapper statement's parameter, a reflective copy + fids = sorted({s_ for _q, k_, s_, _x in T if k_ == 'field' and not str(s_).startswith('f:')}) + mparams = self.mapper_params(fids) if fids else []; bcopies = self.bean_copies(fids) if fids else [] + g.write('mapper_param', mparams, F); g.write('bean_copy', bcopies, F); prof(' per-query: mapper parameters and bean copies read') # Answered from graph.sqlite. Same dict, so everything below — the grouping, the # wording, the `verified:` check — is untouched. Returns None for a target kind it does not cover, and the # rules run as before. See graph_sql.solve_from_targets. @@ -1870,13 +2036,13 @@ class Impact: if os.environ.get('AXIOMCODE_SQL') or no_rules: import graph_sql _sql = graph_sql.solve_from_targets(g.q, T, QS, g.site_file, _nonsource, self.code, self.at, - inside, textuse, importuse, self.lines) + inside, textuse, importuse, self.lines, field_rows=self.field_extra_rows(T, mparams, bcopies, inside)) # AXIOMCODE_SQL_STRICT=1: fail instead of falling back, so a coverage run can assert that a kind is # really answered here rather than quietly handed to the rules. if os.environ.get('AXIOMCODE_SQL_STRICT') and _sql is None: die("AXIOMCODE_SQL_STRICT: no SQL rule for target kind(s) " + ','.join(sorted({k for _, k, _, _ in T}))) if _sql is not None: - self.mapper_scope(T, _sql) + self.mapper_scope(T, _sql); self.field_bindings(T, _sql) if os.environ.get('AXIOMCODE_BACKEND'): print('backend=sql', file=sys.stderr) shutil.rmtree(F, ignore_errors=True); shutil.rmtree(O, ignore_errors=True) if os.environ.get('AXIOMCODE_DUMP_SOLVE'): @@ -1908,7 +2074,7 @@ class Impact: if p[0] in QS: rows.append(p[1:] + [p[0]]) # … + the query id, so each row says which target it came from out[n] = rows self.solve_store(D, key, out) - self.mapper_scope(T, out) + self.mapper_scope(T, out); self.field_bindings(T, out) # A KEY IS QUOTED AS IT IS WRITTEN AT THAT LINE (#1477). The join matches a settings line on the canonical form # (ckey: `batch_size`, `batch-size` and `batchSize` are one property), so the row carries that form; printed # as is, `app.orders.batch_size` was quoted as `app.orders.batchsize`, a spelling no file holds. @@ -1926,6 +2092,22 @@ class Impact: def souffle_include(self): return dl_program.souffle_include() + MAPPER_PARAM_WHY = 'its mapper statement binds it as a parameter property (#{%s})' + BEAN_COPY_WHY = 'hands a value of %s to %s(), which copies its properties by name through reflection: a rename or a retype drops the value silently' + def field_extra_rows(self, T, mparams, bcopies, inside): + """the direct rows dl/impact.dl derives from mapper_param and bean_copy, per query id, for the SQL port""" + ins = set(inside); out = {} + for qq, k, fid, _x in T: + if k != 'field': continue + rows = [(m, 'reads', self.MAPPER_PARAM_WHY % e, 'resolved', f, l) for m, fl, e, f, l in mparams if fl == fid and (qq, m) not in ins] + rows += [(c, 'uses', self.BEAN_COPY_WHY % (tn, n), 'by name', f, l) for c, fl, tn, n, f, l in bcopies if fl == fid and (qq, c) not in ins] + if rows: out.setdefault(qq, []).extend(rows) + return out + def field_bindings(self, T, out): + """the settings lines of the configuration key a field target is bound to, as extbind rows (field_config_rows)""" + rows = self.field_config_rows(T) + if rows: out['extbind'] = list(out.get('extbind') or []) + [[f, str(l), k, 'defines or overrides the key', qq] for k, f, l, qq in rows] + return out def mapper_scope(self, T, out): """A STATEMENT ID IN A MAPPER XML IS RESOLVED INSIDE ITS NAMESPACE (#1381). `✕
    - - - - diff --git a/tests/case_runner.py b/tests/case_runner.py index 7d370643..4a3e93c2 100644 --- a/tests/case_runner.py +++ b/tests/case_runner.py @@ -29,6 +29,8 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') +# internal verbs (context, changed, test-impact) left the installed command's surface: ask the dispatcher +DISP = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') RUNNER_PY = '''#!/usr/bin/env python3 """tests/run.py [ ...] [--lang python|java] @@ -203,7 +205,7 @@ def check(ok, why, detail=''): for rel, (old, new) in edits.items(): p = os.path.join(repo, rel); t = open(p).read() check(old in t, f'{why}: the edit applies to {rel}'); open(p, 'w').write(t.replace(old, new, 1)) - r = sh(repo, AX, 'test-impact', '.', *extra, env=env) + r = sh(repo, DISP, 'test-impact', '.', *extra, env=env) out = r.stdout + r.stderr check(r.returncode == 0 and 'Traceback' not in out, f'{why}: test-impact answers', out) for w in want: check(w in out, f'{why}: names {w.strip()!r}', out) @@ -213,14 +215,14 @@ def check(ok, why, detail=''): sh(repo, 'git', 'checkout', '-q', '--', '.') p = os.path.join(repo, 'tests', 'fastcases', 'python', 'shop', 'test_new.py') open(p, 'w').write('def test_new():\n assert 1\n') - r = sh(repo, AX, 'changed', '.', env=env) + r = sh(repo, DISP, 'changed', '.', env=env) out = r.stdout + r.stderr check('case data read by tests/fast.py' in out and 'a test file: run it' not in out, 'changed: a new file in a fixture tree is case data for its runner', out) os.remove(p) p = os.path.join(repo, 'tests', 'test_added.py') open(p, 'w').write('def test_added():\n assert 1\n') - r = sh(repo, AX, 'changed', '.', env=env) + r = sh(repo, DISP, 'changed', '.', env=env) out = r.stdout + r.stderr check('a test file: run it' in out and 'case data' not in out, 'control: changed: a new real test file is a test to run', out) os.remove(p) diff --git a/tests/diff_verb.py b/tests/diff_verb.py deleted file mode 100644 index 74cca032..00000000 --- a/tests/diff_verb.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""tests/diff_verb.py: `axiomcode diff` compares two graphs of one tree by what stays put, never by id. - -Every engine change was measured before and after by hand SQL over call_edges joined to sites and methods, because -ids hash the index directory: two graphs of one tree, built at two paths, share no id, so a join on ids says that -everything changed. The verb joins on file, line, column, qualified name and callee as written. - -Per language (Python, Java, C#), one small tree indexed three times: - - here the tree - there the same tree copied to another path (Java records absolute paths, so this also checks they are made - relative before they are compared) - added `there` with one more call written on an existing line, so no other line moves - (no tree has an entry point, so the new callee does not also become reachable: that would be a - second, correct row) - - control here vs there: no difference in any kind, while both graphs hold call edges (a diff of two empty graphs - would pass this vacuously, so the edge count is asserted too) - one row there vs added: exactly one call edge added, the new callee, and no other row of any kind - --file the same pair scoped to a file that holds no change: nothing - --json the same counts as the text - dirs the index directories and the graph.sqlite paths answer the same - - python3 tests/diff_verb.py [-v] [--lang python,java,csharp] -""" -import json, os, re, shutil, subprocess, sys, tempfile - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') - -TREES = { - 'python': ({ - 'shop/__init__.py': '', - 'shop/orders.py': 'def helper_one():\n return 1\n\n\ndef helper_two():\n return 2\n\n\n' - 'def total():\n a = helper_one(); return a\n', - 'shop/cli.py': 'from shop.orders import total\n\n\ndef main():\n return total()\n', - }, ('shop/orders.py', 'a = helper_one(); return a', 'a = helper_one(); helper_two(); return a'), 'helper_two', 'shop/cli.py'), - 'java': ({ - 'src/main/java/shop/Orders.java': 'package shop;\n\npublic class Orders {\n int helperOne() { return 1; }\n\n' - ' int helperTwo() { return 2; }\n\n' - ' public int total() { int a = helperOne(); return a; }\n}\n', - 'src/main/java/shop/Cli.java': 'package shop;\n\npublic class Cli {\n public int run() {\n' - ' return new Orders().total();\n }\n}\n', - }, ('src/main/java/shop/Orders.java', 'int a = helperOne(); return a;', 'int a = helperOne(); helperTwo(); return a;'), 'helperTwo', 'Cli.java'), - 'csharp': ({ - 'App/App.csproj': '\n net8.0\n\n', - 'App/Orders.cs': 'namespace App;\n\npublic class Orders\n{\n int HelperOne() { return 1; }\n\n int HelperTwo() { return 2; }\n\n' - ' public int Total() { int a = HelperOne(); return a; }\n}\n', - 'App/Cli.cs': 'namespace App;\n\npublic static class Cli\n{\n public static int Run() { return new Orders().Total(); }\n}\n', - }, ('App/Orders.cs', 'int a = HelperOne(); return a;', 'int a = HelperOne(); HelperTwo(); return a;'), 'HelperTwo', 'Cli.cs'), -} -KINDS = ('entry_points', 'reachable', 'remote', 'framework', 'config', 'symbols') - - -def make(root, files): - for rel, text in files.items(): - p = os.path.join(root, rel) - os.makedirs(os.path.dirname(p), exist_ok=True) - open(p, 'w').write(text) - - -def main(argv): - verbose = '-v' in argv - langs = list(TREES) - if '--lang' in argv: - langs = argv[argv.index('--lang') + 1].split(',') - fails = [] - - def check(ok, why, detail=''): - print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) - if not ok: fails.append(why) - - env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') - for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) - - def ax(*a): - return subprocess.run(['bash', AX, *a], capture_output=True, text=True, env=env) - - work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-diffverb-')) - try: - for lang in langs: - files, (edit_file, old, new), callee, other = TREES[lang] - here, there, added = (os.path.join(work, lang, n) for n in ('here', 'there', 'added')) - make(here, files); make(there, files) - make(added, dict(files, **{edit_file: files[edit_file].replace(old, new)})) - assert files[edit_file].count(old) == 1 - built = True - for d in (here, there, added): - r = ax('index', d, '--lang', lang) - if not os.path.isfile(os.path.join(d, '.axiomcode', 'out', 'graph.sqlite')): - check(False, f'{lang}: index {os.path.basename(d)}', r.stdout + r.stderr); built = False; break - if not built: - continue - - # ── control: one tree at two paths ───────────────────────────────────────────────────────────── - r = ax('diff', here, there, '--json') - doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} - res = doc.get('languages', {}).get(lang, {}) - n = res.get('counts', {}) - edges = sum(a for a, _b in res.get('tiers', {}).values()) - check(edges >= 1, f'{lang}: control is not vacuous (the graphs hold {edges} call edge(s))', r.stdout + r.stderr) - check(bool(n) and not any(n['calls'].values()) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), - f'{lang}: the same tree indexed at two paths diffs to nothing', json.dumps(n) + r.stderr) - t = ax('diff', here, there) - check('no difference' in t.stdout, f'{lang}: and the text says so', t.stdout + t.stderr) - - # ── one added call ───────────────────────────────────────────────────────────────────────────── - r = ax('diff', there, added, '--json') - doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} - res = doc.get('languages', {}).get(lang, {}) - n = res.get('counts', {}) - rows = res.get('calls', {}).get('added', []) - check(n.get('calls') == {'added': 1, 'removed': 0, 'retiered': 0, 'changed': 0} - and len(rows) == 1 and callee in rows[0]['callee'] and rows[0]['file'].endswith(edit_file), - f'{lang}: one call added on an existing line shows exactly one call-edge row, to {callee}', r.stdout[-1500:] + r.stderr) - check(bool(n) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), - f'{lang}: and no row of any other kind', json.dumps(n)) - t = ax('diff', there, added) - plus = [l for l in t.stdout.splitlines() if re.match(r'\s+[+\-~>] ', l)] - check(len(plus) == 1 and callee in plus[0] and 'call edges +1 -0 ~0 >0' in t.stdout, - f'{lang}: the text has the same one row and summary', t.stdout + t.stderr) - # the graph.sqlite paths answer as the directories do - g1, g2 = (os.path.join(d, '.axiomcode', 'out', 'graph.sqlite') for d in (there, added)) - t2 = ax('diff', g1, g2) - strip = lambda s: [l for l in s.splitlines() if not re.match(r'^\S+: A |^\s+B ', l)] - check(strip(t2.stdout) == strip(t.stdout), f'{lang}: two graph.sqlite paths answer as the two directories do', t2.stdout + t2.stderr) - # --file: a file with no change in it scopes the diff to nothing - t3 = ax('diff', there, added, '--file', other, '--json') - n3 = json.loads(t3.stdout)['languages'][lang]['counts'] if t3.returncode == 0 else {} - check(bool(n3) and not any(n3['calls'].values()), f'{lang}: --file {other} (no change there) scopes it to nothing', t3.stdout[-800:] + t3.stderr) - t4 = ax('diff', there, added, '--file', edit_file.split('/')[-1], '--json') - n4 = json.loads(t4.stdout)['languages'][lang]['counts'] if t4.returncode == 0 else {} - check(n4.get('calls', {}).get('added') == 1, f'{lang}: --file {edit_file.split("/")[-1]} keeps the row', t4.stdout[-800:] + t4.stderr) - - # a directory with no graph is refused, and says how to make one - empty = os.path.join(work, 'empty'); os.makedirs(empty) - r = ax('diff', empty, empty) - check(r.returncode != 0 and 'axiomcode index' in r.stderr, 'a directory with no graph is refused with the command that makes one', r.stderr) - finally: - shutil.rmtree(work, ignore_errors=True) - print(('\nFAIL' if fails else '\nok') + f': {len(fails)} failure(s)') - return 1 if fails else 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) diff --git a/tests/freshness.py b/tests/freshness.py index 819e0a44..6924fe28 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -722,12 +722,12 @@ def query(repo, *extra, **env): open(os.path.join(shim, 'python3'), 'w').write('#!/bin/sh\necho "NO_REFRESH=${AXIOMCODE_NO_REFRESH:-} $*"\n'); os.chmod(os.path.join(shim, 'python3'), 0o755) env = {k: v for k, v in os.environ.items() if k != 'AXIOMCODE_NO_REFRESH'}; env['PATH'] = shim + os.pathsep + env.get('PATH', '') said = {} - for verb in ('context', 'path', 'impact', 'changed', 'test-impact', 'graph'): + for verb in ('context', 'path', 'impact', 'changed', 'test-impact'): args = {'context': ['a task'], 'path': ['A', 'B'], 'impact': ['X']}.get(verb, []) on = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo, '--no-refresh'], capture_output=True, text=True, env=env).stdout off = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo], capture_output=True, text=True, env=env).stdout said[verb] = (on, off) - check("read-only: `--no-refresh` on each query verb (context, path, impact, changed, test-impact, graph) sets AXIOMCODE_NO_REFRESH and is not passed on as an argument", + check("read-only: `--no-refresh` on each query verb (context, path, impact, changed, test-impact) sets AXIOMCODE_NO_REFRESH and is not passed on as an argument", all('NO_REFRESH=1 ' in on and '--no-refresh' not in on for on, _ in said.values()), said) check("read-only: control: without it the verbs run with refresh on", all(o and 'NO_REFRESH=1' not in o for _, o in said.values()), said) @@ -834,10 +834,11 @@ def mcp_checks(): check("mcp: fresh=true is refused as an unknown argument, not dropped", 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) - check("mcp: an answer's --fresh is written as the parameter", 'fresh=True' in m.mcp_words('ask again with --fresh to wait'), - m.mcp_words('ask again with --fresh to wait')) - w = m.mcp_words('pass --no-refresh (MCP refresh=false) to query without rebuilding') - check("mcp: an answer's --no-refresh is written as refresh=False", 'refresh=False' in w and '--no-refresh' not in w, w) + w = m.plain('stale rows are marked below.\nask again with --fresh to wait') + check("mcp: an answer's clause naming --fresh is dropped at the tool layer (the tools take no options)", + '--fresh' not in w and 'stale rows are marked below.' in w, w) + w = m.plain('pass --no-refresh (MCP refresh=false) to query without rebuilding') + check("mcp: an answer's clause naming --no-refresh is dropped at the tool layer", '--no-refresh' not in w, w) if __name__ == '__main__': prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); read_only_checks(); cap_checks(); lock_checks(); named_checks(); mcp_checks() diff --git a/tests/front_door.py b/tests/front_door.py index c175b99b..57fef9c2 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 -"""tests/front_door.py — the four questions answer as numbered places with their code, at the front door only. +"""tests/front_door.py — the supported questions answer as numbered places with their code, at the front door only. -The product's surface is `find`, `impact`, `path` and `tests` (plus `index`), each answered as a numbered list of places, +The product's surface is `impact`, `path` and `tests` (plus `index`), each answered as a numbered list of places, every place with the code of the function it sits in, in a fenced block. That shape is given at the front doors — the installed command (bin/axiomcode sets AXIOMCODE_FRONT) and the MCP server (AXIOMCODE_SURFACE=mcp) — when no flag is passed. Everything that calls the dispatcher directly (the hooks, the case suite, loops) or passes a flag gets the verb's -own answer, unchanged. +own answer, unchanged; a verb outside the surface (find, context, graph, diff, install) is refused by the installed +command. - a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): find, impact and + a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): impact and path answer with numbered places and a fenced code block; after an edit, impact with no name starts with - `your edits:`, and tests lists the test with its code and ends with a `run:` line. - b. the MCP server lists exactly find, impact, path and tests, each with at most two parameters, and a call to one + `your edits:`, and tests lists the test with its code and ends with a `run:` line. A verb off the surface is + refused with the supported list. + b. the MCP server lists exactly impact, path and tests, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -96,8 +98,11 @@ def main(): if fails: return 1 # ── a. the installed command, no flags ─────────────────────────────────────────────────────────────────── - rc, out, err = cli(repo, 'find', 'how is the invoice total computed') - check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) + for verb, args in (('find', ('how is the invoice total computed',)), ('context', ('the invoice total',)), + ('graph', ()), ('diff', ()), ('install', ())): + rc, out, err = cli(repo, verb, *args) + check(f'{verb}: off the surface, the installed command refuses it and names the supported verbs', + rc != 0 and 'impact' in err and 'path' in err and 'tests' in err, (out + err)[:400]) rc, out, err = cli(repo, 'impact', 'vat_rate') check('impact : its direct caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) diff --git a/tests/graph_verb.py b/tests/graph_verb.py deleted file mode 100644 index 594cf387..00000000 --- a/tests/graph_verb.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""tests/graph_verb.py — `axiomcode graph` draws the graph the index built, and rebuilds it only as it was indexed. - -It used to run axiomcode-build with no flags. That detects every language in the tree, so on a repository indexed with ---lang python the build never matched the recorded graph: every call rebuilt it from scratch in every language -present, a JavaScript or TypeScript compile for a few stray files included, and left a graph for each of them that -later queries answered from. Its output was a raw Python dict and a relative ../../ page path. - -Two throwaway repositories: - - indexed Python under app/, with a stray JavaScript file beside it, indexed with --lang python --src app - current the page is drawn from the graph: no engine run (the graph file is untouched), no graph for another - language, prose counts and the page's absolute path - stale after an edit the graph is rebuilt with --lang python --src app, the page shows the edit, and still no - JavaScript graph appears - control Python and Java, indexed with no --lang (every language): the page draws both, and both graphs stay; - detected languages are not pinned, so a rebuild still detects them - rebuilds C# with a stray TypeScript front end, indexed with --lang csharp: an edit plus a commit (the background - refresh), a bare `index` and a query that repairs a broken graph each solve C# alone - surfaces `axiomcode help graph` and the MCP tool's description describe this, not `axiomcode-graph build` - - python3 tests/graph_verb.py [-v] -""" -import os, shutil, subprocess, sys, tempfile, time - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') -MCP = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') -FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') - -INDEXED = { - 'app/shop/__init__.py': '', - 'app/shop/orders.py': 'def price(n):\n return n * 3\n\n\ndef order_total(n):\n return price(n) + 1\n', - 'app/shop/cli.py': 'from shop.orders import order_total\n\n\ndef main():\n return order_total(2)\n', - 'app/static/widget.js': 'function strayWidget(a) {\n return a + 1\n}\n\nmodule.exports = { strayWidget }\n', - 'tools/other.py': 'def outside_src():\n return 1\n', -} -CSHARP = { - 'App/App.csproj': '\n net8.0\n\n', - 'App/Orders.cs': 'namespace App;\n\npublic class OrderService\n{\n public decimal Total(int widgets) => Price(widgets) * 2;\n\n private decimal Price(int widgets) => widgets * 3m;\n}\n', - 'App/ClientApp/tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', - 'App/ClientApp/main.ts': 'export function strayFrontEnd(): number {\n return 1\n}\n', -} -MIXED = { - 'pom.xml': '\n 4.0.0\n example\n mixed\n 1.0\n\n', - 'src/main/java/app/Billing.java': 'package app;\n\npublic class Billing {\n public int chargeAccount(int n) {\n return fee(n) + n;\n }\n\n int fee(int n) {\n return 2;\n }\n}\n', - 'src/main/java/app/Invoice.java': 'package app;\n\npublic class Invoice {\n public int issueInvoice() {\n return new Billing().chargeAccount(3);\n }\n}\n', - 'scripts/report/__init__.py': '', - 'scripts/report/make.py': 'def render_report(x):\n return str(x)\n\n\ndef main():\n return render_report(1)\n', -} - - -def sh(cwd, *cmd, env=None): - return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) - - -def make(root, files): - for rel, text in files.items(): - os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True) - open(os.path.join(root, rel), 'w').write(text) - for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): - sh(root, *cmd) - - -def main(argv): - verbose = '-v' in argv - fails = [] - - def check(ok, why, detail=''): - print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) - if not ok: fails.append(why) - - work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-graphverb-')) # macOS: /var is /private/var, and the page is named by its real path - # the engine is this checkout's, unless the caller names a built one (a worktree whose parser is not built) - env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') - for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) - try: - # ── indexed with --lang python --src app ────────────────────────────────────────────────────────────── - repo = os.path.join(work, 'indexed'); make(repo, INDEXED) - ax = os.path.join(repo, '.axiomcode'); db = os.path.join(ax, 'out', 'graph.sqlite') - b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) - check(b.returncode == 0 and os.path.exists(db), 'setup: the repository indexes with --lang python --src app', b.stdout + b.stderr) - if b.returncode: return 1 - before = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) - - t0 = time.time(); g = sh(repo, 'bash', AX, 'graph', repo, env=env); took = time.time() - t0 - page = os.path.join(ax, 'graph', 'graph.html') - check(g.returncode == 0 and os.path.exists(page), 'current: the page is written', g.stdout + g.stderr) - check((os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == before and 'no rebuild' in g.stdout and 'building' not in g.stdout, - f'current: the page is drawn from the graph there, the engine does not run ({took:.1f} s)', g.stdout + g.stderr) - check(not os.path.exists(os.path.join(ax, 'lang')) and not os.path.exists(os.path.join(ax, 'out', 'javascript')), - 'current: no graph appears for the stray JavaScript file the index left out', g.stdout) - check(f'page: {page}' in g.stdout and '../' not in g.stdout and "{'" not in g.stdout and ' functions and methods' in g.stdout, - 'current: the output is prose, and names the page by its absolute path', g.stdout) - check('axiomcode-graph build' not in g.stdout + g.stderr, 'current: it advises no command an agent does not have', g.stdout + g.stderr) - html = open(page).read() if os.path.exists(page) else '' - check('order_total' in html and 'strayWidget' not in html and 'outside_src' not in html, - 'current: the page holds the indexed Python and nothing outside --lang / --src', '') - - # an edit: the graph is stale, and is rebuilt as it was indexed - with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') - # read-only first: --no-refresh (and AXIOMCODE_NO_REFRESH=1, which this env sets) draws the stale graph as it is - stamp = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) - g = sh(repo, 'bash', AX, 'graph', repo, '--no-refresh', env=env) - check(g.returncode == 0 and 'refresh off (--no-refresh): drawn from the graph as it is' in g.stdout - and 'predates edits to 1 file(s)' in g.stdout and (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == stamp, - 'stale: --no-refresh draws the graph as it is, says it is out of date, and never rebuilds it', g.stdout + g.stderr) - # the near-miss: asked without it (and with no AXIOMCODE_NO_REFRESH), the stale graph is rebuilt as it was indexed - g = sh(repo, 'bash', AX, 'graph', repo, env={k: v for k, v in env.items() if k != 'AXIOMCODE_NO_REFRESH'}) - html = open(page).read() if os.path.exists(page) else '' - check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, - 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) - check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, - 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) - check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') - # the rebuild is a refresh: the baseline stays HEAD's tree, so the edit is still an edit to `changed`/`test-impact` - c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) - check('refund_order' in c.stdout and 'baseline moved' not in c.stdout + ti.stdout, - 'stale: the rebuild keeps the baseline: `changed` still names the edit, and nothing says the baseline moved', - c.stdout + c.stderr + ti.stdout) - # control: an explicit index of the edited tree DOES move the baseline (#1222), and now says so where the edit vanished - with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef void_order(n):\n return 0\n') - b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) - c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) - check(b.returncode == 0 and 'refund_order' not in c.stdout and 'void_order' not in c.stdout - and 'baseline moved' in c.stdout and 'shop/orders.py' in c.stdout and 'axiomcode index' in c.stdout and 'baseline moved' in ti.stdout, - 'control: an explicit index of an edited tree moves the baseline, and `changed` and `test-impact` say so, naming the file', - b.stdout[-300:] + c.stdout + c.stderr + ti.stdout) - - # ── control: indexed with no --lang, every language present ────────────────────────────────────────── - mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) - b = sh(mixed, 'bash', AX, 'index', mixed, env=env) - mx = os.path.join(mixed, '.axiomcode') - both = [os.path.exists(os.path.join(mx, 'out', 'graph.sqlite')), os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite'))] - check(b.returncode == 0 and all(both), 'control: a Java and Python repository indexed with no --lang has both graphs', b.stdout + b.stderr) - g = sh(mixed, 'bash', AX, 'graph', mixed, env=env) - mpage = os.path.join(mx, 'graph', 'graph.html') - html = open(mpage).read() if os.path.exists(mpage) else '' - check(g.returncode == 0 and 'chargeAccount' in html and 'render_report' in html and any(l.startswith('graph of ') and 'java' in l and 'python' in l for l in g.stdout.splitlines()), - 'control: the page draws every language the repository was indexed in', g.stdout + g.stderr) - check(os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite')) and 'no rebuild' in g.stdout, - 'control: drawing it keeps both graphs and rebuilds neither', g.stdout) - c = sh(mixed, sys.executable, FRESH, 'chosen', mixed, env=env) - check(c.returncode == 0 and c.stdout.strip() == '', 'control: languages that were detected are not pinned; a rebuild detects them again', c.stdout) - - # ── every rebuild path: C# indexed with --lang csharp, a stray TypeScript front end beside it ───────────── - cs = os.path.join(work, 'csharp'); make(cs, CSHARP); cx = os.path.join(cs, '.axiomcode') - b = sh(cs, 'bash', AX, 'index', cs, '--lang', 'csharp', env=env) - check(b.returncode == 0 and os.path.exists(os.path.join(cx, 'out', 'graph.sqlite')) and not os.path.exists(os.path.join(cx, 'lang')), - 'rebuilds: a C# repository indexes with --lang csharp and no TypeScript graph', b.stdout + b.stderr) - c = sh(cs, sys.executable, FRESH, 'chosen', cs, env=env) - check(c.stdout.strip() == 'csharp', 'rebuilds: the file table records csharp as chosen', c.stdout) - # an edit, committed, then the background refresh: the path a hook or the MCP server's timer starts - with open(os.path.join(cs, 'App', 'Orders.cs'), 'a') as f: f.write('\n// committed edit\n') - sh(cs, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'edit') - w = sh(cs, sys.executable, FRESH, 'worker', cs, env=dict(env, AXIOMCODE_REFRESH_DEBOUNCE='0.3')) - stamp = lambda: open(os.path.join(cx, 'out', 'stamp')).read().strip() if os.path.exists(os.path.join(cx, 'out', 'stamp')) else '' - no_ts = lambda: not os.path.exists(os.path.join(cx, 'lang')) and not os.path.exists(os.path.join(cx, 'out', 'typescript')) - check(w.returncode == 0 and 'rebuilding' in w.stdout and '-csharp-' in stamp() and no_ts(), - 'rebuilds: an edit plus a commit refreshes C# alone; no TypeScript graph, no TypeScript solve', w.stdout + stamp()) - # a bare index, as an agent re-runs one "to make sure" - b = sh(cs, 'bash', AX, 'index', cs, env=env) - check(b.returncode == 0 and 'keeping the languages this graph was indexed with (--lang csharp)' in b.stdout and '-csharp-' in stamp() and no_ts(), - 'rebuilds: a bare index keeps --lang csharp and says so', b.stdout + b.stderr) - # a query that finds the graph pointer broken repairs it, with the same languages - ptr = os.path.join(cx, 'out', 'graph.sqlite'); tgt = os.path.realpath(ptr) - os.rename(tgt, tgt + '.gone') - q = sh(cs, 'bash', AX, 'impact', 'OrderService.Total', cs, env=env) - check(q.returncode == 0 and os.path.exists(ptr) and '-csharp-' in stamp() and no_ts(), - 'rebuilds: a query repairing a broken graph builds C# alone', q.stdout[-600:] + q.stderr[-600:]) - - # ── surfaces ────────────────────────────────────────────────────────────────────────────────────────── - h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) - check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, - '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) - # graph is internal: not an MCP tool (tests/surfaces.py holds the list of public verbs) - check('def graph(' not in open(MCP).read(), 'graph is not offered as an MCP tool', '') - finally: - shutil.rmtree(work, ignore_errors=True) - print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') - return 1 if fails else 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv)) diff --git a/tests/mcp.py b/tests/mcp.py index 7412ab72..ac05f5e0 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -134,42 +134,6 @@ def check_arguments(label, cmd, cwd, lax=False): return bad -def check_words(): - """An answer's CLI flags are written as the MCP parameters they are (#1567), and nothing else is touched: quoted - code, flags only the CLI has, a flag's name inside a longer word, and a flag followed by prose rather than a value.""" - sys.path.insert(0, os.path.dirname(SERVER)) - import server - cases = [("pass --in to narrow", "pass in_path= to narrow"), - (" … +12 (--limit N)", " … +12 (limit=N)"), - (" --tests-only lists all 22 by rung and file; --why adds each one's route", - " tests=True lists all 22 by rung and file; why=True adds each one's route"), - ("ask for --page 2", "ask for page=2"), - # `--page all` is the string "all" here, and the footer names one spelling per surface, never both - ("ask for the next with --page 2, or all of it with --page all; --budget N changes the page size", - 'ask for the next with page=2, or all of it with page="all"; budget=N changes the page size'), - ("narrow instead with --in , --depth N or --tests-only", "narrow instead with in_path=, depth=N or tests=True"), - ("--page N|all", 'page=N or page="all"'), - ("narrow with `impact --in ` or `path '*' --in parser/src`.", - "narrow with `impact in_path=` or `path '*' in_path=parser/src`."), - ("start at --from ", "start at from_="), - ("no --in was given, so", "no in_path was given, so"), - # the controls: these must come back unchanged - (" --in parser/src --in-offered 11302 symbol(s)", - " in_path=parser/src --in-offered 11302 symbol(s)"), - ("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"), - ('grep -rnw "all" . lists them', 'grep -rnw "all" . lists them'), - # 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_front_door(): """Each tool asks its verb with no flag, in the session's own directory, so the dispatcher answers as places with their code (it sees AXIOMCODE_SURFACE=mcp); impact with no name asks about the working tree's edits.""" @@ -340,7 +304,6 @@ def main(): bad += check('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo) # 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_front_door() if os.name != 'nt': bad += check_install_move(work) diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index 27b171af..90092481 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -25,7 +25,6 @@ for p in [os.path.join(d, 'SKILL.md')] + glob.glob(os.path.join(d, 'reference', '*.md'))) + \ [os.path.join(ROOT, 'plugins', 'axiomcode', 'AGENTS.md')] + \ sorted(glob.glob(os.path.join(ROOT, 'plugins', 'axiomcode', 'rules', '*.mdc'))) -INSTALL = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-install') VERB = r'(find|impact|path|tests)' # a tool named: a call `impact(`, the Claude Code name mcp__plugin_axiomcode_axiomcode__impact, MCP `impact`, or a command @@ -140,8 +139,7 @@ def main(): tools = schemas() bad = controls(tools) seen = set() - block = subprocess.run([sys.executable, INSTALL, '--print'], capture_output=True, text=True).stdout - for path, text in [(p, open(p, encoding='utf-8').read()) for p in DOCS] + [('the install block', block)]: + for path, text in [(p, open(p, encoding='utf-8').read()) for p in DOCS]: rel = os.path.relpath(path, ROOT) if os.path.isabs(path) else path rows = documented(text) seen |= {(t, a) for ts, a, _v, _u in rows if ts for t in ts} diff --git a/tests/mcp_first.py b/tests/mcp_first.py index 230abb0e..20ba03d0 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -62,13 +62,7 @@ def fire(hook, ev): if m: tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) -# 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants -r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], - capture_output=True, text=True, timeout=30) -check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('impact', 'path', 'tests')) - -# 3. the directive before the first search for a name the graph declares +# 2. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: import sqlite3 os.makedirs(os.path.join(repo, '.axiomcode', 'out')) @@ -82,7 +76,7 @@ def fire(hook, ev): # only the verbs that answer a search: tests is about an edit, not about what a grep looks for tool_first('direct', said, ('impact', 'path')) -# 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question +# 3. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: repo = os.path.join(work, 'case') shutil.copytree(CASE, repo) @@ -99,7 +93,7 @@ def fire(hook, ev): check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) tool_first('orient (how)', said, ('path',)) -# 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be +# 4. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() i = src.find("search these with grep as usual") diff --git a/tests/multi_language.py b/tests/multi_language.py index 39e2d5b2..bd94f231 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -33,6 +33,8 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') +# internal verbs (context, changed, test-impact) left the installed command's surface: ask the dispatcher +DISP = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') BUILD = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-build') @@ -171,24 +173,24 @@ def check(ok, why, detail=''): # a scope only another language's graph holds is honoured, not refused because the main graph lacks it. The # scope is a real directory and comes after the repository: the dispatcher once took it for the repository # and asked the main graph alone - s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/pkg', env=quiet) + s = sh(repo, DISP, 'context', 'add up a total', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and '══ python graph' in s.stdout and 'no indexed file' not in s.stdout, 'scope: context --in a directory only the python graph holds answers from that graph', s.stdout + s.stderr) s = sh(repo, AX, 'impact', 'add', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and 'total' in s.stdout, 'scope: impact --in it too', s.stdout + s.stderr) s = sh(repo, AX, 'path', 'total', 'add', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and 'verified' in s.stdout, 'scope: and path --in it', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'src', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape', '.', '--in', 'src', env=quiet) check(s.returncode == 0 and '══' not in s.stdout and 'src/' in s.stdout, 'scope (control): --in the main graph\'s directory answers as the main graph alone', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) + s = sh(repo, DISP, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) # a directory no graph holds is a typo, not a question about nothing: answered at the root, said ONCE, never one # refusal menu per language (the scope another graph holds, above, is still answered from that graph) check(s.returncode == 0 and s.stdout.count("no indexed file in any graph has 'tools/nosuch'") == 1 and 'answering at the repository root' in s.stdout and 'tools/pkg/calc.py' in s.stdout and 're-run with one of these' not in s.stdout, 'scope: a directory no graph holds is answered at the repository root, and said once', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode != 0 and 'none of these words appear under it' in s.stdout and 'no indexed file' not in s.stdout, 'scope (control): a scope one graph holds, with nothing under it matching, is refused by that graph alone', s.stdout + s.stderr) # #1584: `main` is declared in the typescript graph (src/cli.ts) and twice in the python one. With a scope, only @@ -210,16 +212,16 @@ def check(ok, why, detail=''): s = sh(repo, AX, 'impact', 'emit', '.', '--in', 'pytools', env=quiet) check(s.returncode == 0 and 'lib/pytools/probe.py' in s.stdout and 'tools/gen/make.py' not in s.stdout, 'scope (control): a scope that names no path of the repository still matches anywhere', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) + s = sh(repo, DISP, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, 'scope: context --in tools keeps lib/pytools/ out too', s.stdout + s.stderr) # a directory only the MAIN graph holds, asked from every graph: another language's graph once read it as text # no graph holds, listed its source files as text rows and ended on a next step outside the scope - s = sh(repo, AX, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) check(s.returncode == 0 and 'not indexed: src/' not in s.stdout and 'tools/' not in s.stdout and 'src/shape.ts' in s.stdout, 'scope: a directory the main graph holds is not text to the other graphs', s.stdout + s.stderr) # a directory no graph holds, named by --in: its text files are listed once, and every next step stays in it - s = sh(repo, AX, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) + s = sh(repo, DISP, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) nexts = [l for l in s.stdout.splitlines() if l.startswith('next:')] check(s.returncode == 0 and s.stdout.count('notes/steps.json') == 1 and nexts and all('notes/' in l for l in nexts), 'scope: --in a text-only directory is listed by one graph, and no next step leaves it', s.stdout + s.stderr) @@ -227,19 +229,19 @@ def check(ok, why, detail=''): # ── --from ──────────────────────────────────────────────────────────────────────────────────────────── # `main` is declared in the typescript graph and twice in the python one: the flow starts where the task's # words land, and that language comes first - f = sh(repo, AX, 'context', 'how does the calc tool total its numbers', '.', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the calc tool total its numbers', '.', '--from', 'main', env=quiet) first = f.stdout.split('══')[1] if f.stdout.count('══') >= 2 else f.stdout check(f.returncode == 0 and first.strip().startswith('python graph') and 'tools/pkg/cli.py' in f.stdout and 'tools/gen/make.py' not in f.stdout and 'axiomcode-from-landing' not in f.stdout + f.stderr, '--from: a common name starts in the language and the file the task\'s words land in', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does the calc tool total its numbers', '.', '--in', 'tools/gen', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the calc tool total its numbers', '.', '--in', 'tools/gen', '--from', 'main', env=quiet) check(f.returncode == 0 and 'tools/gen/make.py' in f.stdout and 'tools/pkg/cli.py' not in f.stdout, '--from: a scope the caller gives picks the declaration under it', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does the shape area get run', '.', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the shape area get run', '.', '--from', 'main', env=quiet) first = f.stdout.split('══')[1] if f.stdout.count('══') >= 2 else f.stdout check(f.returncode == 0 and 'src/cli.ts' in f.stdout and not first.strip().startswith('python graph'), '--from (control): a task about the typescript code starts there', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does it work', '.', '--from', 'total', env=quiet) + f = sh(repo, DISP, 'context', 'how does it work', '.', '--from', 'total', env=quiet) check(f.returncode == 0 and 'tools/pkg/calc.py' in f.stdout and '--from total:' not in f.stdout, '--from (control): a name declared once starts there, with nothing narrowed', f.stdout + f.stderr) @@ -258,19 +260,19 @@ def check(ok, why, detail=''): calc = os.path.join(repo, 'tools/pkg/calc.py'); util = os.path.join(repo, 'src/util.ts') open(calc, 'w').write(FILES['tools/pkg/calc.py'].replace('return a + b', 'return b + a')) open(util, 'w').write(FILES['src/util.ts'].replace('return x * x', 'return x * x * 1')) - c = sh(repo, AX, 'changed', '.', env=quiet) + c = sh(repo, DISP, 'changed', '.', env=quiet) check(c.returncode == 0 and c.stdout.count('add ') == 1 and c.stdout.count('square ') == 1 and 'no declarations known here' not in c.stdout and '══ javascript graph' not in c.stdout, 'changed: a Python edit and a TypeScript edit are each reported once, by the graph of their language', c.stdout + c.stderr) - cj = sh(repo, AX, 'changed', '.', '--json', env=quiet) + cj = sh(repo, DISP, 'changed', '.', '--json', env=quiet) try: d = json.loads(cj.stdout) except ValueError: d = {} syms = sorted([e['symbol'] for e in d.get('changed', [])] + [e['symbol'] for o in d.get('other_languages', {}).values() for e in o.get('changed', [])]) check(syms == ['add', 'square'], 'changed --json: both edits, the other language\'s under other_languages', cj.stdout[-800:]) - t = sh(repo, AX, 'test-impact', '.', env=quiet) + t = sh(repo, DISP, 'test-impact', '.', env=quiet) check(t.returncode == 0 and 'add [body]' in t.stdout and 'square' in t.stdout, 'test-impact: starts from the edits in both languages', t.stdout + t.stderr) sh(repo, 'git', 'checkout', '-q', '--', '.') - c = sh(repo, AX, 'changed', '.', env=quiet) + c = sh(repo, DISP, 'changed', '.', env=quiet) check(c.returncode == 0 and c.stdout.count('no change to a declaration') == 1 and '══' not in c.stdout, 'changed: a clean tree is one "no change", as in a repository of one language', c.stdout + c.stderr) diff --git a/tests/python_names.py b/tests/python_names.py index e2283796..ff3965d7 100644 --- a/tests/python_names.py +++ b/tests/python_names.py @@ -54,9 +54,9 @@ def calls(): open(log, 'w').close() # the command npm links: bash's python3 is the placeholder until the launcher puts its own first - r = subprocess.run(['node', AXJS, 'help', 'context'], env=placeholder, capture_output=True, text=True, timeout=60) - check('`axiomcode help context` answers when python3 is the Store placeholder', - r.returncode == 0 and 'context' in r.stdout and 'Python was not found' not in r.stderr, + r = subprocess.run(['node', AXJS, 'help', 'impact'], env=placeholder, capture_output=True, text=True, timeout=60) + check('`axiomcode help impact` answers when python3 is the Store placeholder', + r.returncode == 0 and 'impact' in r.stdout and 'Python was not found' not in r.stderr, f'rc={r.returncode} err={r.stderr[-300:]}') c = calls() check('...and the Python bash ran is the one `python` named, not the placeholder', @@ -74,7 +74,7 @@ def calls(): # AXIOMCODE_PYTHON comes first, as it does for the MCP server chosen = os.path.join(tmp, 'mine') script(chosen, f'#!/bin/sh\necho "mine $*" >> "{log}"\nexec "{sys.executable}" "$@"\n') - r = subprocess.run(['node', AXJS, 'help', 'context'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), + r = subprocess.run(['node', AXJS, 'help', 'impact'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), capture_output=True, text=True, timeout=60) check('AXIOMCODE_PYTHON is the Python bash runs', r.returncode == 0 and 'mine' in calls(), f'rc={r.returncode}') diff --git a/tests/repo_arg.py b/tests/repo_arg.py index 006565c6..eff39118 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -7,7 +7,7 @@ path onto the repository, a tree that is not there. Checks, each run from a working directory with no graph, so a fall-back to it would build one there: - every verb (index, context, path, impact, changed, test-impact, graph) given a missing repository exits non-zero, + every verb (index, context, path, impact, changed, test-impact) given a missing repository exits non-zero, names the path, and builds nothing: no .axiomcode appears in the working directory index --src and --src are refused the same way a verb script run directly (not through the dispatcher) with a missing repository is refused and builds nothing @@ -47,7 +47,7 @@ def no_graph(where, what): for args in (['impact', 'foo', missing], ['impact', 'foo', 'bar', missing], ['impact', 'foo', './not-there'], ['context', 'how does foo work', missing], ['context', 'how does foo work', 'not-there'], ['path', 'bar', 'foo', missing], ['path', 'bar', 'foo', 'not-there'], - ['changed', missing], ['test-impact', missing], ['graph', missing], + ['changed', missing], ['test-impact', missing], ['index', missing], ['index', 'not-there'], ['index', '--src', missing], ['index', '--src', 'not-there'], ['index', '--lang', 'python', '--src', missing]): r = ax(args, cwd) diff --git a/tests/surfaces.py b/tests/surfaces.py index 7e550df0..d7e5cb58 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """tests/surfaces.py — the public verbs are on every caller-facing surface, and nothing else is. -The product offers a small surface: `index` to set up, then four questions — `find`, `impact`, `path`, `tests` — +The product offers a small surface: `index` to set up, then three questions — `impact`, `path`, `tests` — answered as numbered places with the code of the function each sits in. Every public verb must be: · in `axiomcode --help` (the dispatcher's own comment block) and in `bin/axiomcode --help`, the command an install @@ -14,8 +14,8 @@ INTERNAL, with the reason written down, and must appear on none of those surfaces. A verb added to the dispatch table that is in neither list fails, so exposing one is a decision rather than an accident (#1034). -The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, the block `axiomcode install` writes, and -the README's CLI section) name no old MCP tool (`axiomcode_context` …) and no flag other than index's. +The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, and the README's CLI section) name no old +MCP tool (`axiomcode_context` …) and no flag other than index's. python3 tests/surfaces.py """ @@ -34,13 +34,9 @@ # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'search by task words; search is the agent\'s own grep, which a hook annotates', - 'find': 'the front-door spelling of context; dispatched for compatibility, no longer advertised', + 'context': 'search by task words; the orient hook and the suites call it, no caller-facing surface does', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', - 'graph': 'draws the graph as a page for a person; not one of the four questions', - 'diff': 'compares two graphs of one tree; a tool for checking an engine change', - 'install': 'writes the CLAUDE.md block once, at setup', } OLD_TOOLS = re.compile(r'\baxiomcode_(context|impact|path|changed|test_impact|graph|index|diff)\b') INDEX_FLAGS = {'--lang', '--src', '--library'} @@ -67,8 +63,6 @@ def docs(): for p in (SKILL, os.path.join(ROOT, 'skills', 'axiomcode', 'SKILL.md'), os.path.join(PLUG, 'AGENTS.md'), os.path.join(PLUG, 'rules', 'axiomcode.mdc')): out.append((os.path.relpath(p, ROOT), open(p, encoding='utf-8').read())) - r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True) - out.append(('the install block', r.stdout)) out.append(('README.md CLI section', readme_cli())) return out From 679d3575cb6fb74e862c6d55131e7773a619fb65 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:11:33 -0700 Subject: [PATCH 135/258] perf(python): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling showed no single hot stage in a Python parse: read, tree-sitter parse, mirror, extract and hash together are the cost, and all of it is independent between files — everything cross-module sits in linkProject. So the per-file work now runs on worker threads (python-parse-pool.ts, python-parse-worker.ts): each worker runs the same extractor the serial loop runs, and the main thread consumes every outcome in sorted file order through the same body, so skip order, accumulation order and the output bytes do not depend on which worker finishes first. AXIOMCODE_PARSE_JOBS picks the width (default min(4, cores-1); 1 is the strict serial path), and a source-tree run with no compiled worker falls back to serial instead of failing. The byte gate caught a real defect before it caught anything else: the expression extractor's assignedValueByTargetRange was never reset between files, and byte ranges repeat across files, so a pairing left by an earlier file fed a later file's receiver lookup — which call sites resolved depended on extraction order (15 of 35,626 call sites on one corpus subject). It is reset per file now, with returnIndexByMethod beside it, and the field extractor's annotationByField, which pinned every file's syntax tree for the length of the run. Two things decided whether a 1.9 GB subject fit in the default heap: - results stream: a worker's outcome is thawed, consumed and dropped the moment its file's turn comes, and a dispatch window (jobs * 6) caps what can wait out of order — buffering them all held the project's rows twice; - rows rehydrate through a compiled object literal with __proto__, cached per table: Object.create plus one store per property lands in V8's dictionary mode, and 1M 25-field rows measured 1.6 GB that way against 230 MB constructor-built. The literal gets the constructor's shape and the constructor's cost. On a 2,932-file subject the extract phase runs 18.5 s serial, 17.4 s at 2 jobs, 12.9 s at 4, 10.9 s at 6; past 4 the subject's whole run stops improving (GC against more worker heaps), so the default stops at 4. Whole-subject walls: 7 s -> 5 s, 6 s -> 5 s, 6 s -> 5 s on the three smaller corpus subjects. What remains of the big subject's python phase is linkProject (~16 s) and CSV export (~12 s), both untouched serial work. IR is byte-identical to serial on all four corpus subjects at 4 and 6 jobs; tests/run.py --lang python 294/294; tests/front_door.py 21/21. AXIOMCODE_PARSE_DEBUG=1 prints the pool / consume / linkProject split. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../extractors/python-expression-extractor.ts | 6 + .../extractors/python-field-extractor.ts | 4 + .../src/workflows/python/python-parse-pool.ts | 305 ++++++++++++++++++ .../workflows/python/python-parse-worker.ts | 50 +++ .../python/python-project-analyzer.ts | 107 ++++-- 5 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 parser/src/workflows/python/python-parse-pool.ts create mode 100644 parser/src/workflows/python/python-parse-worker.ts diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts index 8405aad5..d6a8763d 100644 --- a/parser/src/parsers/python/extractors/python-expression-extractor.ts +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -240,6 +240,12 @@ export class PythonExpressionExtractor { this.callSites = []; this.worklist = []; this.expressionByByteRange = new Map(); + // Both are PER-FILE: byte ranges repeat across files, so a pairing left + // from an earlier file satisfied a later file's receiver lookup with the + // wrong value expression — which file won depended on extraction order, + // and a worker that had seen different files answered differently. + this.assignedValueByTargetRange = new Map(); + this.returnIndexByMethod = new Map(); this.pendingReceiverLinks = []; const moduleScopeHash = input.scopeHashByNodeId.get(input.rootNode.id) ?? ''; diff --git a/parser/src/parsers/python/extractors/python-field-extractor.ts b/parser/src/parsers/python/extractors/python-field-extractor.ts index 08b87a90..f05b4267 100644 --- a/parser/src/parsers/python/extractors/python-field-extractor.ts +++ b/parser/src/parsers/python/extractors/python-field-extractor.ts @@ -178,6 +178,10 @@ export class PythonFieldExtractor { extract(input: PythonFieldInput): PythonFieldExtraction { this.input = input; this.methodByNodeId = new Map(); + // Field hashes are globally unique, so stale entries never answered a + // lookup — but each holds a SyntaxNode, so an unreset map pinned every + // file's whole tree for the length of the run. + this.annotationByField = new Map(); const methodByHash = new Map(); for (const method of input.methods) { diff --git a/parser/src/workflows/python/python-parse-pool.ts b/parser/src/workflows/python/python-parse-pool.ts new file mode 100644 index 00000000..c3792f1c --- /dev/null +++ b/parser/src/workflows/python/python-parse-pool.ts @@ -0,0 +1,305 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Worker } from 'worker_threads'; + +import { + PyBindingRegistry, + PyBlockRegistry, + PyCallSiteRegistry, + PyCommentRegistry, + PyDecoratorArgumentRegistry, + PyDecoratorRegistry, + PyExpressionRegistry, + PyFieldPositionRegistry, + PyFieldRegistry, + PyImportRegistry, + PyMethodParameterRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyParseGapRegistry, + PyScopeRegistry, + PyTypeBaseRegistry, + PyTypeParameterRegistry, + PyTypeRegistry, + PyTypeReferenceRegistry, +} from '@/analysis-types/python'; +import { PythonFactSet } from '@/parsers/python/extractors/python-fact-extractor'; + +/** + * The per-file parse work (read, tree-sitter parse, mirror, extract, hash) is + * independent between files — everything cross-module happens later, in + * `linkProject` — so it runs on a pool of worker threads. The MAIN thread still + * consumes the results in sorted file order, through the same code the serial + * loop runs, so the accumulated rows, the skip records and therefore the output + * bytes are identical whatever order the workers finish in. + * + * ## What crosses the thread boundary + * + * A worker cannot post class instances: structured clone keeps own properties + * and drops the prototype. The registry rows are flat value holders — every + * field a string, number or enum — so the worker posts `{...row}` and `thaw` + * reattaches the one prototype each table's rows share. The four `Map`s in a + * fact set clone natively. Nothing is re-hashed and nothing is re-parsed: the + * bytes that cross are the bytes the extractor produced. + * + * `linkProject` MUTATES rows after this (that is why it runs before export), + * which is exactly why rows must come back as real instances before it runs — + * a plain snapshot would take the mutation and lose the `toCsv`. + */ +const TABLE_PROTOTYPES = { + scopes: PyScopeRegistry.prototype, + bindings: PyBindingRegistry.prototype, + types: PyTypeRegistry.prototype, + typeBases: PyTypeBaseRegistry.prototype, + methods: PyMethodRegistry.prototype, + methodParameters: PyMethodParameterRegistry.prototype, + imports: PyImportRegistry.prototype, + expressions: PyExpressionRegistry.prototype, + callSites: PyCallSiteRegistry.prototype, + typeReferences: PyTypeReferenceRegistry.prototype, + fields: PyFieldRegistry.prototype, + fieldPositions: PyFieldPositionRegistry.prototype, + blocks: PyBlockRegistry.prototype, + comments: PyCommentRegistry.prototype, + parseGaps: PyParseGapRegistry.prototype, + typeParameters: PyTypeParameterRegistry.prototype, + decorators: PyDecoratorRegistry.prototype, + decoratorArguments: PyDecoratorArgumentRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** What the analyzer sends a worker for one file: strings only. */ +export interface PythonParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + filePath: string; + /** The path recorded on rows — `recordedFilePath`, computed by the caller. */ + recordedFilePath: string; + moduleQualifiedName?: string; + baseMservPath: string; + serviceVersionLinkHash: string; +} + +/** One file's outcome, the same three cases the serial loop distinguishes. */ +export interface PythonParseOutcome { + readError?: string; + extractError?: string; + facts?: PythonFactSet; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface PythonParseReply { + i: number; + readError?: string; + extractError?: string; + facts?: Record; +} + +/** + * Worker side: a fact set as COLUMNS structured clone can carry cheaply. A + * per-object snapshot encodes every property name once per row; a table's + * million rows share one class, so the names go once per table and each row + * crosses as a value array. The difference decided whether a 1.9 GB subject + * fit: the main thread's accumulated rows already peak near V8's default old + * space, and per-object clones of the same rows pushed it over. + */ +interface FrozenTable { + keys: string[]; + rows: unknown[][]; +} + +function freezeTable(rows: object[]): FrozenTable { + if (rows.length === 0) return { keys: [], rows: [] }; + // One pass, no per-row key arrays: a first cut did a union prepass with + // Object.keys per row and it was a third of the worker's CPU. Columns are + // discovered as rows mention them (a conditional assignment can leave a + // property off an instance), so an early row encoded before a late column + // existed is short, and thaw reads the tail as undefined — which is what + // the absent property read as everywhere it is used. + const index = new Map(); + const keys: string[] = []; + const out: unknown[][] = new Array(rows.length); + for (let r = 0; r < rows.length; r++) { + const row = rows[r] as Record; + const vals: unknown[] = []; + for (const key in row) { + let i = index.get(key); + if (i === undefined) { + i = keys.length; + index.set(key, i); + keys.push(key); + } + vals[i] = row[key]; + } + out[r] = vals; + } + return { keys, rows: out }; +} + +// A row built by `Object.create` plus one store per property lands in V8's +// dictionary mode: measured on a 25-field row, 1M of them cost 1.6 GB against +// 230 MB constructor-built — the difference WAS the main thread's OOM on a +// large subject. An object literal with `__proto__` gets the same fast shape +// the constructor makes, so each table gets a compiled literal, cached by its +// key list. Keys come from our own row classes, but they cross a thread as +// data, so anything that is not a plain identifier falls back to the slow +// shape instead of reaching the compiled source. +const IDENT = /^[A-Za-z_$][\w$]*$/; +const factories = new Map object>(); + +function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { + const signature = keys.join('\t'); + const cached = factories.get(signature); + if (cached) return cached; + if (!keys.every(k => IDENT.test(k))) return null; + const body = + 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; + const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; + factories.set(signature, made); + return made; +} + +function thawTable(frozen: FrozenTable, proto: object): object[] { + const { keys, rows } = frozen; + const make = rowFactory(keys); + if (make) return rows.map(values => make(values, proto)); + return rows.map(values => { + const out = Object.create(proto) as Record; + for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; + return out; + }); +} + +export function freezeFactSet(facts: PythonFactSet): Record { + const out: Record = { + module: facts.module ? { ...facts.module } : undefined, + fieldHashByTypeAndName: facts.fieldHashByTypeAndName, + receiverNameByMethodHash: facts.receiverNameByMethodHash, + assignedValueByTargetRange: facts.assignedValueByTargetRange, + expressionByByteRange: facts.expressionByByteRange, + dialect: facts.dialect, + skippedReason: facts.skippedReason, + python2Findings: facts.python2Findings, + }; + for (const key of TABLE_KEYS) out[key] = freezeTable(facts[key]); + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFactSet(frozen: Record): PythonFactSet { + const out = { ...frozen } as unknown as PythonFactSet; + if (frozen.module) { + out.module = Object.assign(Object.create(PyModuleRegistry.prototype), frozen.module); + } + for (const key of TABLE_KEYS) { + (out as unknown as Record)[key] = thawTable( + frozen[key] as FrozenTable, + TABLE_PROTOTYPES[key] + ); + } + return out; +} + +/** + * How many parse workers to run. `AXIOMCODE_PARSE_JOBS` decides; `1` restores + * the strict serial path (the two produce identical bytes — `1` exists for + * memory-tight hosts and for bisecting). The default leaves a core for the + * main thread and caps at 4: consume runs single-threaded on the main thread, + * so workers saturate it — on a 2,932-file subject the extract phase went + * 18.5s serial / 17.4s at 2 / 12.9s at 4 / 10.9s at 6 jobs, but past 4 the + * subject's whole run stopped improving (GC against more worker heaps), so 4 + * is where the default stops. + */ +export function parsePoolJobs(fileCount: number): number { + const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); + const cores = typeof os.availableParallelism === 'function' + ? os.availableParallelism() + : os.cpus().length; + const jobs = Number.isFinite(env) && env >= 1 + ? Math.floor(env) + : Math.max(1, Math.min(4, cores - 1)); + // Under ~2 files per worker the pool's startup (a thread, a module graph, a + // tree-sitter instance each) costs more than it hides. + return fileCount >= jobs * 2 ? jobs : 1; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN FILE + * ORDER as results become available — never after collecting them all. The + * rows of a repository already fill the main thread's heap once, in + * `accumulated`; buffering every worker's snapshot beside them held the whole + * project TWICE and took a 1.9 GB subject over the default heap. So a result + * is thawed, consumed and dropped the moment its turn comes, and the DISPATCH + * WINDOW below caps what can wait out of order: one slow file holds back at + * most `jobs * 6` finished snapshots, not the rest of the repository. + * + * Returns `false` when the compiled worker is not there (a source-tree run + * under a TS test runner has no `dist/`): the caller falls back to the serial + * loop rather than fail, so an environment that cannot pool still answers. + */ +export async function parseFilesInPool( + dispatches: PythonParseDispatch[], + jobs: number, + consume: (i: number, outcome: PythonParseOutcome) => void +): Promise { + const workerPath = path.join(__dirname, 'python-parse-worker.js'); + if (!fs.existsSync(workerPath)) return false; + if (dispatches.length === 0) return true; + + const window = jobs * 6; + const ready = new Map(); + const workers: Worker[] = []; + let nextToDispatch = 0; + let nextToConsume = 0; + const idle: Worker[] = []; + + await new Promise((resolve, reject) => { + const drain = () => { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + consume( + nextToConsume, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, extractError: reply.extractError } + ); + nextToConsume += 1; + } + }; + const feed = (worker: Worker) => { + if (nextToDispatch >= dispatches.length) { + idle.push(worker); + if (nextToConsume >= dispatches.length) resolve(); + return; + } + if (nextToDispatch - nextToConsume >= window) { + idle.push(worker); // drain() wakes it once its result's turn has come + return; + } + worker.postMessage(dispatches[nextToDispatch]); + nextToDispatch += 1; + }; + for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { + const worker = new Worker(workerPath); + workers.push(worker); + worker.on('message', (reply: PythonParseReply) => { + ready.set(reply.i, reply); + drain(); + feed(worker); + while (idle.length > 0 && nextToDispatch - nextToConsume < window + && nextToDispatch < dispatches.length) { + feed(idle.pop() as Worker); + } + if (nextToConsume >= dispatches.length) resolve(); + }); + worker.on('error', reject); + feed(worker); + } + }).finally(() => { + for (const worker of workers) void worker.terminate(); + }); + return true; +} diff --git a/parser/src/workflows/python/python-parse-worker.ts b/parser/src/workflows/python/python-parse-worker.ts new file mode 100644 index 00000000..1261c5df --- /dev/null +++ b/parser/src/workflows/python/python-parse-worker.ts @@ -0,0 +1,50 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { PythonEmissionRegime } from '@/enums/python/modules'; +import { PythonFactExtractor } from '@/parsers/python/extractors/python-fact-extractor'; +import { + freezeFactSet, + PythonParseDispatch, + PythonParseReply, +} from '@/workflows/python/python-parse-pool'; + +/** + * One parse worker: reads a file, runs the SAME extractor the serial loop + * runs, and posts the fact set back as a prototype-less snapshot + * (`freezeFactSet`). The two error cases mirror the serial loop's two catch + * blocks exactly — a read failure and an extractor throw are different facts, + * and the analyzer records them under different reasons. + * + * One extractor per worker, reused across files, as the analyzer reuses its + * one extractor across the whole project: its only cross-call state is a + * per-file scratch field the next `extract` overwrites. + */ +const extractor = new PythonFactExtractor(); +const port = parentPort; +if (!port) throw new Error('python-parse-worker must run as a worker thread'); + +port.on('message', (job: PythonParseDispatch) => { + void (async () => { + let sourceCode: string; + try { + sourceCode = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies PythonParseReply); + return; + } + try { + const facts = extractor.extract({ + sourceCode, + filePath: job.recordedFilePath, + baseMservPath: job.baseMservPath, + moduleQualifiedName: job.moduleQualifiedName, + serviceVersionLinkHash: job.serviceVersionLinkHash, + emissionRegime: PythonEmissionRegime.PY3_0_11, + }); + port.postMessage({ i: job.i, facts: freezeFactSet(facts) } satisfies PythonParseReply); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies PythonParseReply); + } + })(); +}); diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 4a62a860..0c07497a 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -21,6 +21,7 @@ import { } from '@/parsers/python/extractors/python-resolution-linker'; import { Python2Finding } from '@/parsers/python/types'; import { isGitIgnoredDir } from '@/utils/git-ignored'; +import { parseFilesInPool, parsePoolJobs } from '@/workflows/python/python-parse-pool'; /** One rejected or unanalysable file. */ interface SkippedPythonFile { @@ -242,38 +243,40 @@ export class PythonProjectAnalyzer { // been parsed, and file order is not a dependency order. const perModule: ProjectModuleFacts[] = []; + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (python-parse-pool.ts): read, parse, mirror, extract and hash are + // independent between files, and profiling shows no single stage dominates + // — the whole per-file pipeline does. The loop below still CONSUMES every + // outcome in sorted file order through the unchanged body, so the + // accumulated rows, the skip order and the output bytes are byte-identical + // to the serial path (AXIOMCODE_PARSE_JOBS=1), whichever order workers + // finish in. Everything cross-module stays down in `linkProject`. let analysed = 0; - for (const filePath of files) { - const moduleQualifiedName = this.moduleQualifiedNameFor(options.rootDir, filePath); - - let sourceCode: string; - try { - sourceCode = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - this.recordSkip(filePath, options, SkippedFileReason.READ_ERROR, [], String(error)); - continue; - } - let facts; - try { - facts = this.extractor.extract({ - sourceCode, - filePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), - baseMservPath: options.baseMservPath, - moduleQualifiedName, - serviceVersionLinkHash, - emissionRegime: PythonEmissionRegime.PY3_0_11, - }); - } catch (error) { + // One file's outcome, consumed the same way whichever thread produced it. + // The pool calls this in file order as results arrive (never after + // buffering them all — a project's rows fill the heap once, not twice), + // and the serial loop calls it inline, so skip order, accumulation order + // and therefore output bytes are identical across the two paths. + const consumeOutcome = ( + filePath: string, + outcome: { readError?: string; extractError?: string; facts?: ReturnType } + ): void => { + if (outcome.readError !== undefined) { + this.recordSkip(filePath, options, SkippedFileReason.READ_ERROR, [], outcome.readError); + return; + } + if (outcome.extractError !== undefined || !outcome.facts) { this.recordSkip( filePath, options, SkippedFileReason.EXTRACTION_ERROR, [], - String(error) + outcome.extractError ?? 'worker returned no facts' ); - continue; + return; } + const facts = outcome.facts; if (facts.dialect !== PythonDialect.PY3 || !facts.module) { this.recordSkip( @@ -289,7 +292,7 @@ export class PythonProjectAnalyzer { // facts in it. The skipped-files CSV records the DECISION; these record // WHAT could not be represented and where. accumulated.parseGaps.push(...facts.parseGaps); - continue; + return; } analysed += 1; @@ -338,11 +341,67 @@ export class PythonProjectAnalyzer { [...facts.expressionByByteRange].map(([range, hash]) => [hash, range]) ), }); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (python-parse-pool.ts): read, parse, mirror, extract and hash are + // independent between files, and profiling shows no single stage dominates + // — the whole per-file pipeline does. Everything cross-module stays down + // in `linkProject`. AXIOMCODE_PARSE_JOBS=1 restores the strict serial + // path; the pool declining (no compiled worker beside this file) falls + // back to it too. + const debugT = process.env.AXIOMCODE_PARSE_DEBUG ? Date.now() : 0; + const mark = (what: string) => { + if (debugT) process.stderr.write(`[parse-pool] ${what} +${((Date.now() - debugT) / 1000).toFixed(1)}s\n`); + }; + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + pooled = await parseFilesInPool( + files.map((filePath, i) => ({ + i, + filePath, + recordedFilePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), + moduleQualifiedName: this.moduleQualifiedNameFor(options.rootDir, filePath), + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + })), + jobs, + (i, outcome) => consumeOutcome(files[i] as string, outcome) + ); + } + mark(`pool done (${jobs} jobs, ${files.length} files)`); + if (!pooled) { + for (const filePath of files) { + let outcome; + try { + const sourceCode = await fsp.readFile(filePath, 'utf-8'); + try { + outcome = { + facts: this.extractor.extract({ + sourceCode, + filePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), + baseMservPath: options.baseMservPath, + moduleQualifiedName: this.moduleQualifiedNameFor(options.rootDir, filePath), + serviceVersionLinkHash, + emissionRegime: PythonEmissionRegime.PY3_0_11, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + } catch (error) { + outcome = { readError: String(error) }; + } + consumeOutcome(filePath, outcome); + } } // The cross-module pass mutates rows already in `accumulated` — they are the // same objects — so it must run BEFORE export. + mark('consume done'); const resolution = this.resolutionLinker.linkProject(perModule); + mark('linkProject done'); await fsp.mkdir(options.outputDir, { recursive: true }); await this.exportCsv(accumulated.modules, options.outputDir, PYTHON_CSV_FILES.MODULES); From 941a913daea7265d52e12f6a8d973fab3a6c8082 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:28:35 -0700 Subject: [PATCH 136/258] parse pool: the thread machinery moves to parse-pool-core, for every language to share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The columnar freeze/thaw, the compiled-literal rehydration, the in-order streaming pool and the jobs default are language-independent; python-parse-pool keeps only what is Python's — the prototype per table and its fact-set shape. Behaviour is unchanged: the corpus subject re-gates byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool through the refactored path. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/parse-pool-core.ts | 174 +++++++++++++++ .../src/workflows/python/python-parse-pool.ts | 211 +++--------------- 2 files changed, 200 insertions(+), 185 deletions(-) create mode 100644 parser/src/workflows/parse-pool-core.ts diff --git a/parser/src/workflows/parse-pool-core.ts b/parser/src/workflows/parse-pool-core.ts new file mode 100644 index 00000000..1bd162e8 --- /dev/null +++ b/parser/src/workflows/parse-pool-core.ts @@ -0,0 +1,174 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import { Worker } from 'worker_threads'; + +/** + * The language-independent half of a parallel parse stage. A language's + * analyzer keeps its own loop body; what every language shares is: + * + * - rows cross the thread boundary as COLUMNS (`freezeTable` / `thawTable`): + * a per-object snapshot encodes every property name once per row, a + * table's rows share one class, so the names go once per table and each + * row crosses as a value array; + * - rows rehydrate through a compiled object literal with `__proto__` + * (`rowFactory`): `Object.create` plus one store per property lands in + * V8's dictionary mode — measured on a 25-field row, 1M of them cost + * 1.6 GB against 230 MB constructor-built, which was the difference + * between fitting the default heap and not; + * - results stream IN FILE ORDER (`runParsePool`): a worker's outcome is + * consumed and dropped the moment its file's turn comes, and the dispatch + * window caps what can wait out of order, so the project's rows fill the + * main thread's heap once, not twice; + * - `parsePoolJobs` reads AXIOMCODE_PARSE_JOBS, where 1 is the strict + * serial path and the default caps at 4 — consume runs single-threaded on + * the main thread, and past 4 workers it is what saturates (measured on a + * 2,932-file Python subject: extract 18.5s serial, 17.4s at 2, 12.9s at + * 4, 10.9s at 6 jobs, with the whole run flat past 4). + * + * The language half is small: a prototype per table, a freeze/thaw of its + * fact-set shape built on these helpers, and a worker entry that runs the + * same extractor the serial loop runs. + */ +export interface FrozenTable { + keys: string[]; + rows: unknown[][]; +} + +export function freezeTable(rows: object[]): FrozenTable { + if (rows.length === 0) return { keys: [], rows: [] }; + // One pass, no per-row key arrays: a first cut did a union prepass with + // Object.keys per row and it was a third of the worker's CPU. Columns are + // discovered as rows mention them (a conditional assignment can leave a + // property off an instance), so an early row encoded before a late column + // existed is short, and thaw reads the tail as undefined — which is what + // the absent property read as everywhere it is used. + const index = new Map(); + const keys: string[] = []; + const out: unknown[][] = new Array(rows.length); + for (let r = 0; r < rows.length; r++) { + const row = rows[r] as Record; + const vals: unknown[] = []; + for (const key in row) { + let i = index.get(key); + if (i === undefined) { + i = keys.length; + index.set(key, i); + keys.push(key); + } + vals[i] = row[key]; + } + out[r] = vals; + } + return { keys, rows: out }; +} + +// Keys come from our own row classes, but they cross a thread as data, so +// anything that is not a plain identifier falls back to the slow shape +// instead of reaching the compiled source. +const IDENT = /^[A-Za-z_$][\w$]*$/; +const factories = new Map object>(); + +function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { + const signature = keys.join('\t'); + const cached = factories.get(signature); + if (cached) return cached; + if (!keys.every(k => IDENT.test(k))) return null; + const body = + 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; + const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; + factories.set(signature, made); + return made; +} + +export function thawTable(frozen: FrozenTable, proto: object): object[] { + const { keys, rows } = frozen; + const make = rowFactory(keys); + if (make) return rows.map(values => make(values, proto)); + return rows.map(values => { + const out = Object.create(proto) as Record; + for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; + return out; + }); +} + +/** `1` restores the strict serial path; the two produce identical bytes. */ +export function parsePoolJobs(fileCount: number): number { + const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); + const cores = + typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length; + const jobs = + Number.isFinite(env) && env >= 1 ? Math.floor(env) : Math.max(1, Math.min(4, cores - 1)); + // Under ~2 files per worker the pool's startup (a thread, a module graph, a + // parser instance each) costs more than it hides. + return fileCount >= jobs * 2 ? jobs : 1; +} + +/** + * Runs every dispatch on `jobs` workers and calls `consume` once per index, + * in index order, as results arrive. Replies must carry the dispatch's `i`. + * + * Returns `false` when the compiled worker is not there (a source-tree run + * under a TS test runner has no `dist/`): the caller falls back to its serial + * loop rather than fail, so an environment that cannot pool still answers. + */ +export async function runParsePool( + workerPath: string, + dispatches: D[], + jobs: number, + consume: (reply: R) => void +): Promise { + if (!fs.existsSync(workerPath)) return false; + if (dispatches.length === 0) return true; + + const window = jobs * 6; + const ready = new Map(); + const workers: Worker[] = []; + let nextToDispatch = 0; + let nextToConsume = 0; + const idle: Worker[] = []; + + await new Promise((resolve, reject) => { + const drain = () => { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + consume(reply); + nextToConsume += 1; + } + }; + const feed = (worker: Worker) => { + if (nextToDispatch >= dispatches.length) { + idle.push(worker); + if (nextToConsume >= dispatches.length) resolve(); + return; + } + if (nextToDispatch - nextToConsume >= window) { + idle.push(worker); // drain() wakes it once its result's turn has come + return; + } + worker.postMessage(dispatches[nextToDispatch]); + nextToDispatch += 1; + }; + for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { + const worker = new Worker(workerPath); + workers.push(worker); + worker.on('message', (reply: R) => { + ready.set(reply.i, reply); + drain(); + feed(worker); + while ( + idle.length > 0 && + nextToDispatch - nextToConsume < window && + nextToDispatch < dispatches.length + ) { + feed(idle.pop() as Worker); + } + if (nextToConsume >= dispatches.length) resolve(); + }); + worker.on('error', reject); + feed(worker); + } + }).finally(() => { + for (const worker of workers) void worker.terminate(); + }); + return true; +} diff --git a/parser/src/workflows/python/python-parse-pool.ts b/parser/src/workflows/python/python-parse-pool.ts index c3792f1c..cf1946ce 100644 --- a/parser/src/workflows/python/python-parse-pool.ts +++ b/parser/src/workflows/python/python-parse-pool.ts @@ -1,7 +1,4 @@ -import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { Worker } from 'worker_threads'; import { PyBindingRegistry, @@ -25,23 +22,18 @@ import { PyTypeReferenceRegistry, } from '@/analysis-types/python'; import { PythonFactSet } from '@/parsers/python/extractors/python-fact-extractor'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; /** - * The per-file parse work (read, tree-sitter parse, mirror, extract, hash) is - * independent between files — everything cross-module happens later, in - * `linkProject` — so it runs on a pool of worker threads. The MAIN thread still - * consumes the results in sorted file order, through the same code the serial - * loop runs, so the accumulated rows, the skip records and therefore the output - * bytes are identical whatever order the workers finish in. - * - * ## What crosses the thread boundary - * - * A worker cannot post class instances: structured clone keeps own properties - * and drops the prototype. The registry rows are flat value holders — every - * field a string, number or enum — so the worker posts `{...row}` and `thaw` - * reattaches the one prototype each table's rows share. The four `Map`s in a - * fact set clone natively. Nothing is re-hashed and nothing is re-parsed: the - * bytes that cross are the bytes the extractor produced. + * The Python half of the parallel parse stage: which prototype each table's + * rows get back, and the shape of a dispatch and a reply. Everything thread- + * and shape-related lives in parse-pool-core.ts. * * `linkProject` MUTATES rows after this (that is why it runs before export), * which is exactly why rows must come back as real instances before it runs — @@ -98,81 +90,7 @@ export interface PythonParseReply { facts?: Record; } -/** - * Worker side: a fact set as COLUMNS structured clone can carry cheaply. A - * per-object snapshot encodes every property name once per row; a table's - * million rows share one class, so the names go once per table and each row - * crosses as a value array. The difference decided whether a 1.9 GB subject - * fit: the main thread's accumulated rows already peak near V8's default old - * space, and per-object clones of the same rows pushed it over. - */ -interface FrozenTable { - keys: string[]; - rows: unknown[][]; -} - -function freezeTable(rows: object[]): FrozenTable { - if (rows.length === 0) return { keys: [], rows: [] }; - // One pass, no per-row key arrays: a first cut did a union prepass with - // Object.keys per row and it was a third of the worker's CPU. Columns are - // discovered as rows mention them (a conditional assignment can leave a - // property off an instance), so an early row encoded before a late column - // existed is short, and thaw reads the tail as undefined — which is what - // the absent property read as everywhere it is used. - const index = new Map(); - const keys: string[] = []; - const out: unknown[][] = new Array(rows.length); - for (let r = 0; r < rows.length; r++) { - const row = rows[r] as Record; - const vals: unknown[] = []; - for (const key in row) { - let i = index.get(key); - if (i === undefined) { - i = keys.length; - index.set(key, i); - keys.push(key); - } - vals[i] = row[key]; - } - out[r] = vals; - } - return { keys, rows: out }; -} - -// A row built by `Object.create` plus one store per property lands in V8's -// dictionary mode: measured on a 25-field row, 1M of them cost 1.6 GB against -// 230 MB constructor-built — the difference WAS the main thread's OOM on a -// large subject. An object literal with `__proto__` gets the same fast shape -// the constructor makes, so each table gets a compiled literal, cached by its -// key list. Keys come from our own row classes, but they cross a thread as -// data, so anything that is not a plain identifier falls back to the slow -// shape instead of reaching the compiled source. -const IDENT = /^[A-Za-z_$][\w$]*$/; -const factories = new Map object>(); - -function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { - const signature = keys.join('\t'); - const cached = factories.get(signature); - if (cached) return cached; - if (!keys.every(k => IDENT.test(k))) return null; - const body = - 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; - const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; - factories.set(signature, made); - return made; -} - -function thawTable(frozen: FrozenTable, proto: object): object[] { - const { keys, rows } = frozen; - const make = rowFactory(keys); - if (make) return rows.map(values => make(values, proto)); - return rows.map(values => { - const out = Object.create(proto) as Record; - for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; - return out; - }); -} - +/** Worker side: a fact set as columns structured clone can carry cheaply. */ export function freezeFactSet(facts: PythonFactSet): Record { const out: Record = { module: facts.module ? { ...facts.module } : undefined, @@ -204,102 +122,25 @@ export function thawFactSet(frozen: Record): PythonFactSet { } /** - * How many parse workers to run. `AXIOMCODE_PARSE_JOBS` decides; `1` restores - * the strict serial path (the two produce identical bytes — `1` exists for - * memory-tight hosts and for bisecting). The default leaves a core for the - * main thread and caps at 4: consume runs single-threaded on the main thread, - * so workers saturate it — on a 2,932-file subject the extract phase went - * 18.5s serial / 17.4s at 2 / 12.9s at 4 / 10.9s at 6 jobs, but past 4 the - * subject's whole run stopped improving (GC against more worker heaps), so 4 - * is where the default stops. - */ -export function parsePoolJobs(fileCount: number): number { - const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); - const cores = typeof os.availableParallelism === 'function' - ? os.availableParallelism() - : os.cpus().length; - const jobs = Number.isFinite(env) && env >= 1 - ? Math.floor(env) - : Math.max(1, Math.min(4, cores - 1)); - // Under ~2 files per worker the pool's startup (a thread, a module graph, a - // tree-sitter instance each) costs more than it hides. - return fileCount >= jobs * 2 ? jobs : 1; -} - -/** - * Parses every file on `jobs` workers, calling `consume` once per file IN FILE - * ORDER as results become available — never after collecting them all. The - * rows of a repository already fill the main thread's heap once, in - * `accumulated`; buffering every worker's snapshot beside them held the whole - * project TWICE and took a 1.9 GB subject over the default heap. So a result - * is thawed, consumed and dropped the moment its turn comes, and the DISPATCH - * WINDOW below caps what can wait out of order: one slow file holds back at - * most `jobs * 6` finished snapshots, not the rest of the repository. - * - * Returns `false` when the compiled worker is not there (a source-tree run - * under a TS test runner has no `dist/`): the caller falls back to the serial - * loop rather than fail, so an environment that cannot pool still answers. + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. */ export async function parseFilesInPool( dispatches: PythonParseDispatch[], jobs: number, consume: (i: number, outcome: PythonParseOutcome) => void ): Promise { - const workerPath = path.join(__dirname, 'python-parse-worker.js'); - if (!fs.existsSync(workerPath)) return false; - if (dispatches.length === 0) return true; - - const window = jobs * 6; - const ready = new Map(); - const workers: Worker[] = []; - let nextToDispatch = 0; - let nextToConsume = 0; - const idle: Worker[] = []; - - await new Promise((resolve, reject) => { - const drain = () => { - for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { - ready.delete(nextToConsume); - consume( - nextToConsume, - reply.facts - ? { facts: thawFactSet(reply.facts) } - : { readError: reply.readError, extractError: reply.extractError } - ); - nextToConsume += 1; - } - }; - const feed = (worker: Worker) => { - if (nextToDispatch >= dispatches.length) { - idle.push(worker); - if (nextToConsume >= dispatches.length) resolve(); - return; - } - if (nextToDispatch - nextToConsume >= window) { - idle.push(worker); // drain() wakes it once its result's turn has come - return; - } - worker.postMessage(dispatches[nextToDispatch]); - nextToDispatch += 1; - }; - for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { - const worker = new Worker(workerPath); - workers.push(worker); - worker.on('message', (reply: PythonParseReply) => { - ready.set(reply.i, reply); - drain(); - feed(worker); - while (idle.length > 0 && nextToDispatch - nextToConsume < window - && nextToDispatch < dispatches.length) { - feed(idle.pop() as Worker); - } - if (nextToConsume >= dispatches.length) resolve(); - }); - worker.on('error', reject); - feed(worker); - } - }).finally(() => { - for (const worker of workers) void worker.terminate(); - }); - return true; + return runParsePool( + path.join(__dirname, 'python-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, extractError: reply.extractError } + ) + ); } From f86f0e0350a664723232d051dee0268f7467befe Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:33:08 -0700 Subject: [PATCH 137/258] path: the methods behind a [by name] caller are counted, with named nearest examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method that reaches a by-name caller through resolved edges reaches the target too whenever the by-name site is real. The upstream closure listed the by-name callers (#1421) but what reaches THEM was absent and uncounted under the verified: line — on "only X can trigger this" exactly the triggers a spec answer silently dropped. One reverse walk from the by-name caller set, counted as its own bound: line with up-to-3 nearest NAMED examples (an placeholder names nothing a reader can look up). 907 behind a hot name on an engine-sized graph, ~1s. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_grep.py | 7 +++ .../skills/axiomcode/scripts/axiomcode-path | 47 ++++++++++++++++--- .../app/jobs.py | 10 ++++ .../case.json | 6 +++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index a5da4aaa..31c3162c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -185,6 +185,13 @@ def path(d, code): if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False foot = ev_foot(d) + [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']}") + # what reaches a [by name] caller reaches the target too whenever the by-name site is real: counted, or the + # upstream closure reads complete while every chain behind a by-name row is missing from it + bb = d.get('by_name_behind') + if bb: + foot.append(f"bound: {bb['methods']} more method(s) in {bb['files']} file(s) reach a [by name] caller above through" + " the graph's edges — callers of the target too if that by-name site is real; nearest: " + + ', '.join(f"{x['name']} ({x['at']})" for x in bb.get('nearest', []))) return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 55b02956..749516e6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1485,9 +1485,11 @@ def byname_sites(g, ids): on a receiver the engine could not type: `impact X` lists its caller as a lead, and the closure, which walks resolved edges only, left it out, so the two verbs gave different sets of direct callers and `path` gave no hint of the second one. The same rule as dl/impact.dl's `direct(... "by name")`: a callable target, a site that is not - a construction and not inside a mock's stub or verification, and not in the target's own body. Listed apart and - never walked: the name may belong to another method, and the by-name closure stays the search `path A B` runs - only when nothing resolved connects its two ends.""" + a construction and not inside a mock's stub or verification, and not in the target's own body. Listed apart, + not folded into the closure: the name may belong to another method, and the by-name closure stays the search + `path A B` runs only when nothing resolved connects its two ends. What reaches THEM is counted (byname_behind): + a method that reaches a by-name caller reaches the target too whenever the by-name site is real, and left + uncounted those callers-of-callers were missing from "everything that can reach X" with no line saying so.""" names = sorted({g.sym[i]['name'] for i in ids if i in g.sym and g.sym[i].get('method_id') and g.sym[i]['kind'] not in ('library', 'written', 'module') and g.sym[i].get('name')}) if not names or not g.has('unresolved_sites'): return [], set() @@ -1503,7 +1505,29 @@ def byname_sites(g, ids): return sorted(set(out), key=lambda x: (g.sym[x[0]]['is_test'], x[2] or '', x[3] or 0, g.sym[x[0]]['display'])), stubbed - {c for c, *_ in out} -def print_byname(g, found, limit): +def byname_behind(g, named, exclude, depth=40): + """the methods that reach a by-name caller through the graph's edges and are in neither the printed closure nor + the by-name list itself. Each one reaches the target exactly when the by-name site is real, so an upstream + closure that lists the by-name callers but not these is short by every chain behind them — on a spec question + ("only X can trigger this") they are the triggers the answer silently dropped. Counted and named nearest-first, + not folded in: their certainty is the by-name site's, not an edge's.""" + callers = {c for c, *_ in named} + if not callers: return [] + radj = collections.defaultdict(set) + for a, b, _ in g.edges(): radj[b].add(a) + seen = {c: 0 for c in callers}; fr = list(callers); d = 0 + while fr and d < depth: + d += 1; nxt = [] + for x in fr: + for y in radj.get(x, ()): + if y not in seen: seen[y] = d; nxt.append(y) + fr = nxt + out = [(m, dd) for m, dd in seen.items() if dd > 0 and m not in exclude and m in g.sym + and (not g.IN or g.under_in(g.sym[m]['file']))] + return sorted(out, key=lambda x: (x[1], g.sym[x[0]]['display'], x[0])) + + +def print_byname(g, found, limit, sel=None, behind=()): named, stubbed = found if stubbed: print(f" +{len(stubbed)} caller(s) only stub a method of this name on a mock (receiver not typed): they run none of it;" @@ -1516,6 +1540,16 @@ def print_byname(g, found, limit): for c, n, f, ln in named[:limit]: print(f" [by name] {g.disp(c)} {f}:{ln} — calls `{n}` (receiver not typed)") if len(named) > limit: print(f" … +{len(named) - limit} (--limit N)") + if behind: + # the examples: nearest NAMED methods first — an `` placeholder names nothing a reader can look up + show = sorted(behind, key=lambda x: ('<' in g.disp(x[0]), x[1], g.sym[x[0]]['display'], x[0]))[:3] + RESULT['by_name_behind'] = {'methods': len(behind), 'files': len({g.sym[m]['file'] for m, _ in behind}), + 'nearest': [{'name': g.disp(m), 'at': g.loc(m), 'hops': dd} for m, dd in show]} + tgt = f" is really `{sel}`" if sel else " resolves the way its name suggests" + print(f" behind them: {len(behind)} more method(s) in {len({g.sym[m]['file'] for m, _ in behind})} file(s)" + f" reach these by-name caller(s) through the graph's edges — callers of the target too if a by-name site{tgt};" + " nearest: " + ', '.join(f"{g.disp(m)} ({g.loc(m)})" for m, _ in show) + + (f" … +{len(behind) - 3}" if len(behind) > 3 else '')) def closure(g, sel, upstream, limit=40, depth=40): @@ -1576,11 +1610,12 @@ def closure(g, sel, upstream, limit=40, depth=40): if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") named = byname_sites(g, ids) if upstream else ([], set()) + behind = byname_behind(g, named[0], {m for m, _ in rows} | set(ids) | {c for c, *_ in named[0]}, depth) if named[0] else [] if not rows: u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(ids))})", *ids)[0]['n'] if not upstream else 0 print(" none — " + ("its body has %d unresolved call(s), so what it reaches is unknown, not nothing" % u if not upstream and u else "no resolved call " + ("into it; " if upstream else "out of it; ") + (f"{len(named[0])} unresolved site(s) write its name (below)" if named[0] else "an unresolved site elsewhere may still " + ("call it" if upstream else "be it")))) - print_byname(g, named, limit) + print_byname(g, named, limit, sel=sel, behind=behind) # AN EMPTY UPSTREAM CLOSURE IS THE MOST MISLEADING LINE THIS COMMAND CAN PRINT. For a live route handler, # a signal receiver or a CLI command the answer "0 methods reach it" is true of calls and false of the # program: the framework reaches it. Name the registration rather than leave the reader at a dead end. @@ -1655,7 +1690,7 @@ def closure(g, sel, upstream, limit=40, depth=40): print(f" {d:2} hop(s) {g.disp(m)} {g.loc(m)}{at}") if len(np_) > limit: print(f" … +{len(np_) - limit} production (--limit N)") if nt: print(f" +{len(nt)} test caller(s) within 2 hop(s) — `axiomcode test-impact` names them and the command that runs them") - print_byname(g, named, limit) + print_byname(g, named, limit, sel=sel, behind=behind) byhop = collections.Counter(d for _, d in rows); print(" by hop: " + ', '.join(f"{d}:{n}" for d, n in sorted(byhop.items()))) # most_common breaks a tie by insertion order, which is the order the closure rows arrived in: two files with the # same count then swap depending on which engine answered. Sort the tie by name so the line is stable either way. diff --git a/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py index e6007ef3..3aea44d5 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py +++ b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py @@ -12,3 +12,13 @@ def replay(source): def rewind(source): return source.reopen(3) + + +def scheduler(source): + # reaches settle only through replay's by-name site + return replay(source) + + +def rollback(source): + # reaches only rewind, whose by-name site names reopen, not settle + return rewind(source) diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 609428ed..3aef6c65 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -19,6 +19,12 @@ {"why": "#1421: path '*' lists the untyped-receiver call impact lists as [by name]", "run": ["path", "*", "Ledger.settle"], "want": ["1 hop(s) nightly", "[by name] replay app/jobs.py:10 — calls `settle` (receiver not typed)"]}, + {"why": "a method that reaches a by-name caller through resolved calls is counted behind it: it reaches the target too whenever the by-name site is real, and uncounted it was missing from the closure with no line saying so", + "run": ["path", "*", "Ledger.settle"], + "want": ["behind them: 1 more method(s) in 1 file(s)", "scheduler"]}, + {"why": "CONTROL: a caller of a method whose by-name site names ANOTHER method is not behind the target's by-name callers", + "run": ["path", "*", "Ledger.settle"], + "avoid": ["rollback"]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From e1b6d44183c2e4a7e5e5b285f94ce9bc09567bf9 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:33:39 -0700 Subject: [PATCH 138/258] surface: a bound: line survives to the front door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numbered-places surface kept only run: and verified: foot lines, so every bound: honesty line — the closure's lower-bound count and the by-name behind count — was invisible exactly where agents and the CLI read the answer. An agent writing "only X reaches this" from these places needs the bound as much as the verified line. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 731e1d73..0f52f873 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -111,7 +111,11 @@ def render(verb, doc, repo): out.append(' ```') if not out: return None if len(places) > CAP: out.append(f"… {len(places) - CAP} more place(s) not shown — ask a narrower question to see them") - out += [x for x in foot if x.startswith(('run:', 'verified'))][:2] + # a bound: line is the answer saying where it stops being complete — an agent writing "only X reaches this" + # from these places needs it as much as the verified: line, so it is never tidied away here. + # run: stays LAST: the answer ends with the command to run, whatever else the foot carries. + kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3] + out += kept + [x for x in foot if x.startswith('run:')][:1] return out From 26839a28345eaf6be8e43cea14cc0d7e24912e83 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:45:26 -0700 Subject: [PATCH 139/258] perf(csharp): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same move Python made, on the machinery parse-pool-core already shares: the C# per-file work — read, blank the dead #if arms, parse, extract, hash — is independent between files, so it now runs on worker threads (cs-parse-pool.ts, cs-parse-worker.ts). Each worker constructs one CsFactExtractor (one grammar gate per worker, at startup) and runs the same extraction the serial loop runs, once per target-framework emission in the dispatch's order; the main thread consumes every outcome in sorted file order through the same body, so the writers receive every relation's rows exactly as the serial loop wrote them. AXIOMCODE_PARSE_JOBS picks the width (1 is the strict serial path), and a source-tree run with no compiled worker falls back to serial instead of failing. Two things are C#'s rather than Python's: - the governing project's configuration is resolved ON THE MAIN THREAD, before dispatch: cs-project-config's caches stay on one thread, and a worker receives its emissions — module context, define set, implicit usings — as plain data, so pooled and serial extraction start from identical inputs; - C# streams rows to relation writers instead of accumulating them, and the writers are async while the pool's consume callback is not — so each file's appends are CHAINED onto the previous file's, which preserves the serial row order, and the first failure is kept and rethrown after the chain drains rather than rejecting a promise nothing has a handler on yet. Rows cross the thread as columns and rehydrate with each table's prototype, as everywhere; the one new shape is a Set on a row (typeModifiers, fieldModifiers, methodModifiers, xmlDocTags), which structured clone carries natively. The byte gate found NO cross-file state in the C# extractor stack — unlike Python's port, where it caught a byte-range-keyed map leaking between files. Everything per-file in CsFactExtractor.extractFile is minted inside the call, and the only module-level mutable state (the project-config caches) never leaves the main thread. Measured, C# phase alone: an 801-file subject extracts in 5.8 s serial, 2.7 s at 4 jobs, 2.9 s at 6; a 1,145-file subject in 13.2 s serial, 7.3 s at 4, 7.9 s at 6. Whole CLI wall on the first subject: 16.9 s -> 5.0 s, the pool also freeing the main thread for the other analyzers in the same Promise.all. IR is byte-identical to serial on both subjects at the default width and on the first also at 6 jobs. Pooled runs fit the default heap: 1.26 GB and 1.11 GB max RSS with no NODE_OPTIONS. tests/run.py --lang csharp: 208 of 208 checks in 43 cases. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/csharp/cs-parse-pool.ts | 179 ++++++++++++ .../src/workflows/csharp/cs-parse-worker.ts | 63 +++++ .../csharp/csharp-project-analyzer.ts | 264 +++++++++++------- 3 files changed, 409 insertions(+), 97 deletions(-) create mode 100644 parser/src/workflows/csharp/cs-parse-pool.ts create mode 100644 parser/src/workflows/csharp/cs-parse-worker.ts diff --git a/parser/src/workflows/csharp/cs-parse-pool.ts b/parser/src/workflows/csharp/cs-parse-pool.ts new file mode 100644 index 00000000..b8c3b153 --- /dev/null +++ b/parser/src/workflows/csharp/cs-parse-pool.ts @@ -0,0 +1,179 @@ +import * as path from 'path'; + +import { CsAttributeArgumentRegistry } from '@/analysis-types/csharp/CsAttributeArgumentRegistry'; +import { CsAttributeRegistry } from '@/analysis-types/csharp/CsAttributeRegistry'; +import { CsBlockRegistry } from '@/analysis-types/csharp/CsBlockRegistry'; +import { CsCallSiteRegistry } from '@/analysis-types/csharp/CsCallSiteRegistry'; +import { CsCommentRegistry } from '@/analysis-types/csharp/CsCommentRegistry'; +import { CsEnumMemberRegistry } from '@/analysis-types/csharp/CsEnumMemberRegistry'; +import { CsEventRegistry } from '@/analysis-types/csharp/CsEventRegistry'; +import { CsExpressionRegistry } from '@/analysis-types/csharp/CsExpressionRegistry'; +import { CsFieldRegistry } from '@/analysis-types/csharp/CsFieldRegistry'; +import { CsMethodParameterRegistry } from '@/analysis-types/csharp/CsMethodParameterRegistry'; +import { CsMethodRegistry } from '@/analysis-types/csharp/CsMethodRegistry'; +import { CsModuleRegistry } from '@/analysis-types/csharp/CsModuleRegistry'; +import { CsParseGapRegistry } from '@/analysis-types/csharp/CsParseGapRegistry'; +import { CsPreprocRegionRegistry } from '@/analysis-types/csharp/CsPreprocRegionRegistry'; +import { CsPropertyRegistry } from '@/analysis-types/csharp/CsPropertyRegistry'; +import { CsQueryClauseRegistry } from '@/analysis-types/csharp/CsQueryClauseRegistry'; +import { CsTypeHeritageRegistry } from '@/analysis-types/csharp/CsTypeHeritageRegistry'; +import { CsTypeParameterRegistry } from '@/analysis-types/csharp/CsTypeParameterRegistry'; +import { CsTypeReferenceRegistry } from '@/analysis-types/csharp/CsTypeReferenceRegistry'; +import { CsTypeRegistry } from '@/analysis-types/csharp/CsTypeRegistry'; +import { CsUsingRegistry } from '@/analysis-types/csharp/CsUsingRegistry'; +import { CsVariableRegistry } from '@/analysis-types/csharp/CsVariableRegistry'; +import { CsFileFacts } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { CsModuleContext } from '@/parsers/csharp/extractors/cs-module-extractor'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The C# half of the parallel parse stage: which prototype each table's rows + * get back, and the shape of a dispatch and a reply. Everything thread- and + * shape-related lives in parse-pool-core.ts. + * + * C# has no cross-file linking pass — rows go straight from a file's + * extraction to the relation writers — but the writers call `toCsv()` and + * `getCsvHeader()` on every row, so rows still have to come back as REAL + * class instances, prototype reattached, before the main thread appends them. + * + * The one shape Python's tables do not have: a C# row can carry a `Set` + * (`typeModifiers`, `fieldModifiers`, `methodModifiers`, `xmlDocTags`). + * Structured clone carries a Set natively, so it crosses inside the frozen + * column values untouched and `toCsv` reads it as the set it was. + */ +const TABLE_PROTOTYPES = { + modules: CsModuleRegistry.prototype, + types: CsTypeRegistry.prototype, + heritages: CsTypeHeritageRegistry.prototype, + typeParameters: CsTypeParameterRegistry.prototype, + methods: CsMethodRegistry.prototype, + methodParameters: CsMethodParameterRegistry.prototype, + properties: CsPropertyRegistry.prototype, + events: CsEventRegistry.prototype, + typeReferences: CsTypeReferenceRegistry.prototype, + usings: CsUsingRegistry.prototype, + parseGaps: CsParseGapRegistry.prototype, + fields: CsFieldRegistry.prototype, + enumMembers: CsEnumMemberRegistry.prototype, + expressions: CsExpressionRegistry.prototype, + callSites: CsCallSiteRegistry.prototype, + queryClauses: CsQueryClauseRegistry.prototype, + blocks: CsBlockRegistry.prototype, + variables: CsVariableRegistry.prototype, + attributes: CsAttributeRegistry.prototype, + attributeArguments: CsAttributeArgumentRegistry.prototype, + comments: CsCommentRegistry.prototype, + preprocRegions: CsPreprocRegionRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** + * Everything one emission of one file needs beyond the file itself, resolved + * ON THE MAIN THREAD before dispatch: the governing project's configuration + * comes from cs-project-config's process-wide caches, and resolving it once + * on one thread keeps one cache — and one answer — however many workers + * parse. Plain data throughout, so it crosses a thread as-is. + */ +export interface CsEmissionInputs { + readonly context: CsModuleContext; + readonly defineConstants: readonly string[]; + readonly implicitUsings?: readonly string[]; + readonly implicitFrameworkDefines: boolean; +} + +/** What the analyzer sends a worker for one file: strings and flags only. */ +export interface CsParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + absoluteFilePath: string; + /** The path recorded on rows — relative to the mserv, computed by the caller. */ + filePath: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** One per target framework, in the serial loop's framework order. */ + emissions: readonly CsEmissionInputs[]; +} + +/** + * One emission's outcome — the same two cases the serial loop's inner + * try/catch distinguishes. `extractError` is the thrown error's `.message`, + * exactly the string the serial loop prints. + */ +export interface CsEmissionOutcome { + extractError?: string; + facts?: CsFileFacts; +} + +/** + * One file's outcome. `readError` set means the file never reached the + * extractor (the serial loop's outer catch: one `filesRejected`, no + * emissions); otherwise `emissions` has one entry per dispatched emission, + * in order. + */ +export interface CsParseOutcome { + readError?: string; + emissions?: CsEmissionOutcome[]; +} + +/** The worker's reply: each emission's `facts` is the frozen snapshot. */ +export interface CsParseReply { + i: number; + readError?: string; + emissions?: { extractError?: string; facts?: Record }[]; +} + +/** Worker side: one emission's fact set as columns structured clone carries cheaply. */ +export function freezeFileFacts(facts: CsFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = freezeTable(facts[key] as unknown as object[]); + } + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFileFacts(frozen: Record): CsFileFacts { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as CsFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseCsFilesInPool( + dispatches: CsParseDispatch[], + jobs: number, + consume: (i: number, outcome: CsParseOutcome) => void +): Promise { + return runParsePool( + path.join(__dirname, 'cs-parse-worker.js'), + dispatches, + jobs, + (reply) => + consume( + reply.i, + reply.emissions + ? { + emissions: reply.emissions.map((emission) => + emission.facts + ? { facts: thawFileFacts(emission.facts) } + : { extractError: emission.extractError } + ), + } + : { readError: reply.readError ?? 'worker returned no emissions' } + ) + ); +} diff --git a/parser/src/workflows/csharp/cs-parse-worker.ts b/parser/src/workflows/csharp/cs-parse-worker.ts new file mode 100644 index 00000000..7d2550d7 --- /dev/null +++ b/parser/src/workflows/csharp/cs-parse-worker.ts @@ -0,0 +1,63 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { CsFactExtractor } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { + CsParseDispatch, + CsParseReply, + freezeFileFacts, +} from '@/workflows/csharp/cs-parse-pool'; + +/** + * One C# parse worker: reads a file once, runs the SAME extractor the serial + * loop runs — once per emission, in the dispatch's framework order — and + * posts the fact sets back as prototype-less snapshots (`freezeFileFacts`). + * + * The two error cases mirror the serial loop exactly. A read failure is the + * OUTER catch: the whole file is one `filesRejected` and no emission runs. An + * extractor throw is the INNER catch, per emission: it carries the error's + * `.message` — the string the serial loop prints — and the other emissions of + * the same file still run, as they do serially. + * + * One extractor per worker, reused across files, as the analyzer reuses its + * one extractor across the whole project: constructing a CsFactExtractor runs + * the grammar gate, and sharing the instance keeps that a startup cost. The + * governing project's configuration is NOT read here — it arrives resolved in + * the dispatch, so cs-project-config's caches stay on the main thread. + */ +const extractor = new CsFactExtractor(); +const port = parentPort; +if (!port) throw new Error('cs-parse-worker must run as a worker thread'); + +port.on('message', (job: CsParseDispatch) => { + void (async () => { + let sourceText: string; + try { + sourceText = await fsp.readFile(job.absoluteFilePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies CsParseReply); + return; + } + const emissions = job.emissions.map((emission) => { + try { + const facts = extractor.extractFile({ + absoluteFilePath: job.absoluteFilePath, + filePath: job.filePath, + baseMservPath: job.baseMservPath, + sourceText, + serviceVersionLinkHash: job.serviceVersionLinkHash, + context: emission.context, + defineConstants: emission.defineConstants, + implicitUsings: emission.implicitUsings, + implicitFrameworkDefines: emission.implicitFrameworkDefines, + }); + return { facts: freezeFileFacts(facts) }; + } catch (error) { + // `.message`, not String(error): the serial loop's console.error + // interpolates exactly this, and the two paths must print the same. + return { extractError: `${(error as Error).message}` }; + } + }); + port.postMessage({ i: job.i, emissions } satisfies CsParseReply); + })(); +}); diff --git a/parser/src/workflows/csharp/csharp-project-analyzer.ts b/parser/src/workflows/csharp/csharp-project-analyzer.ts index 81b0836d..71b81934 100644 --- a/parser/src/workflows/csharp/csharp-project-analyzer.ts +++ b/parser/src/workflows/csharp/csharp-project-analyzer.ts @@ -1,39 +1,23 @@ import * as fsp from 'fs/promises'; import * as path from 'path'; -import { CsAttributeArgumentRegistry } from '@/analysis-types/csharp/CsAttributeArgumentRegistry'; -import { CsAttributeRegistry } from '@/analysis-types/csharp/CsAttributeRegistry'; -import { CsBlockRegistry } from '@/analysis-types/csharp/CsBlockRegistry'; -import { CsCommentRegistry } from '@/analysis-types/csharp/CsCommentRegistry'; -import { CsPreprocRegionRegistry } from '@/analysis-types/csharp/CsPreprocRegionRegistry'; -import { CsCallSiteRegistry } from '@/analysis-types/csharp/CsCallSiteRegistry'; -import { CsEnumMemberRegistry } from '@/analysis-types/csharp/CsEnumMemberRegistry'; -import { CsExpressionRegistry } from '@/analysis-types/csharp/CsExpressionRegistry'; -import { CsQueryClauseRegistry } from '@/analysis-types/csharp/CsQueryClauseRegistry'; -import { CsFieldRegistry } from '@/analysis-types/csharp/CsFieldRegistry'; -import { CsModuleRegistry } from '@/analysis-types/csharp/CsModuleRegistry'; -import { CsParseGapRegistry } from '@/analysis-types/csharp/CsParseGapRegistry'; -import { CsUsingRegistry } from '@/analysis-types/csharp/CsUsingRegistry'; -import { CsEventRegistry } from '@/analysis-types/csharp/CsEventRegistry'; -import { CsMethodParameterRegistry } from '@/analysis-types/csharp/CsMethodParameterRegistry'; -import { CsMethodRegistry } from '@/analysis-types/csharp/CsMethodRegistry'; -import { CsPropertyRegistry } from '@/analysis-types/csharp/CsPropertyRegistry'; -import { CsTypeHeritageRegistry } from '@/analysis-types/csharp/CsTypeHeritageRegistry'; -import { CsTypeParameterRegistry } from '@/analysis-types/csharp/CsTypeParameterRegistry'; -import { CsTypeReferenceRegistry } from '@/analysis-types/csharp/CsTypeReferenceRegistry'; -import { CsVariableRegistry } from '@/analysis-types/csharp/CsVariableRegistry'; -import { CsTypeRegistry } from '@/analysis-types/csharp/CsTypeRegistry'; import { CSHARP_DEFAULT_TARGET_FRAMEWORK, } from '@/constants/csharp-constants'; import { CsNullableContext } from '@/enums/csharp/modules'; import { CSharpParser } from '@/parsers/csharp/csharp-parser'; -import { CsFactExtractor } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { CsFactExtractor, CsFileFacts } from '@/parsers/csharp/extractors/cs-fact-extractor'; import { CsModuleContext } from '@/parsers/csharp/extractors/cs-module-extractor'; import { implicitFrameworkSymbols } from '@/parsers/csharp/extractors/preproc-context'; import { EntityUtils } from '@/utils/entity-utils'; import { CsRelationWriter } from '@/workflows/csharp/cs-relation-writer'; import { governingProject, readProjectConfig } from '@/workflows/csharp/cs-project-config'; +import { + CsEmissionInputs, + CsParseOutcome, + parseCsFilesInPool, + parsePoolJobs, +} from '@/workflows/csharp/cs-parse-pool'; import { isGitIgnoredDir } from '@/utils/git-ignored'; /** @@ -225,6 +209,14 @@ export class CSharpProjectAnalyzer { let extractionErrors = 0; try { + // EVERY PER-FILE INPUT IS RESOLVED HERE, on the main thread, before any + // file is read — for the pooled path and the serial one alike. The + // governing project comes from cs-project-config's process-wide caches, + // and resolving it once on one thread keeps one cache and one walk, + // however many workers parse. What remains per file — read, blank, + // parse, extract — is independent between files and is what the pool + // distributes. + const prepared: PreparedCsFile[] = []; for (const absoluteFilePath of files) { if (context.seen.has(absoluteFilePath)) { // Reached from another root this run; its rows are already written. @@ -232,13 +224,6 @@ export class CSharpProjectAnalyzer { } context.seen.add(absoluteFilePath); const relativePath = path.relative(options.baseMservPath, absoluteFilePath); - let sourceText: string; - try { - sourceText = await fsp.readFile(absoluteFilePath, 'utf-8'); - } catch { - filesRejected += 1; - continue; - } // THE GOVERNING PROJECT decides the framework and the symbols, unless the // caller fixed them. See cs-project-config.ts for why this is read at all. @@ -250,7 +235,7 @@ export class CSharpProjectAnalyzer { const fileDefines = project !== undefined ? project.defineConstants : defineConstants; const implicitFrameworkDefines = project === undefined || project.implicitFrameworkDefines; - for (const targetFramework of fileFrameworks) { + const emissions = fileFrameworks.map((targetFramework): CsEmissionInputs => { // The RESOLVED set, per framework: what the caller supplied plus what // the SDK injects. Two frameworks therefore differ in the key even // when the .csproj lists the same constants for both, which is what @@ -260,7 +245,7 @@ export class CSharpProjectAnalyzer { ...fileDefines, ...(implicitFrameworkDefines ? implicitFrameworkSymbols(targetFramework) : []), ]; - const context: CsModuleContext = { + const moduleContext: CsModuleContext = { targetFramework, defineConstantsKey: defineConstantsKeyOf(activeSymbols), langVersion: options.langVersion ?? project?.langVersion ?? '', @@ -276,81 +261,125 @@ export class CSharpProjectAnalyzer { assemblyName: '', implicitUsingsEnabled: project?.implicitUsings ?? false, }; + return { + context: moduleContext, + defineConstants: fileDefines, + implicitUsings: options.implicitUsings ?? project?.usings, + implicitFrameworkDefines, + }; + }); + prepared.push({ absoluteFilePath, relativePath, emissions }); + } - try { - const facts = this.extractor.extractFile({ - absoluteFilePath, - filePath: relativePath, - baseMservPath: options.baseMservPath, - sourceText, - serviceVersionLinkHash, - context, - defineConstants: fileDefines, - implicitUsings: options.implicitUsings ?? project?.usings, - implicitFrameworkDefines, - }); - await writers.modules.append(facts.modules as readonly CsModuleRegistry[]); - await writers.types.append(facts.types as readonly CsTypeRegistry[]); - await writers.heritages.append( - facts.heritages as readonly CsTypeHeritageRegistry[] - ); - await writers.typeParameters.append( - facts.typeParameters as readonly CsTypeParameterRegistry[] - ); - await writers.methods.append(facts.methods as readonly CsMethodRegistry[]); - await writers.methodParameters.append( - facts.methodParameters as readonly CsMethodParameterRegistry[] - ); - await writers.properties.append( - facts.properties as readonly CsPropertyRegistry[] - ); - await writers.events.append(facts.events as readonly CsEventRegistry[]); - await writers.typeReferences.append( - facts.typeReferences as readonly CsTypeReferenceRegistry[] - ); - await writers.usings.append(facts.usings as readonly CsUsingRegistry[]); - await writers.parseGaps.append( - facts.parseGaps as readonly CsParseGapRegistry[] - ); - await writers.fields.append(facts.fields as readonly CsFieldRegistry[]); - await writers.enumMembers.append( - facts.enumMembers as readonly CsEnumMemberRegistry[] - ); - await writers.expressions.append( - facts.expressions as readonly CsExpressionRegistry[] - ); - await writers.callSites.append( - facts.callSites as readonly CsCallSiteRegistry[] - ); - await writers.queryClauses.append( - facts.queryClauses as readonly CsQueryClauseRegistry[] - ); - await writers.blocks.append(facts.blocks as readonly CsBlockRegistry[]); - await writers.variables.append( - facts.variables as readonly CsVariableRegistry[] - ); - await writers.attributes.append( - facts.attributes as readonly CsAttributeRegistry[] - ); - await writers.attributeArguments.append( - facts.attributeArguments as readonly CsAttributeArgumentRegistry[] - ); - await writers.comments.append(facts.comments as readonly CsCommentRegistry[]); - await writers.preprocRegions.append( - facts.preprocRegions as readonly CsPreprocRegionRegistry[] - ); - } catch (error) { + // One file's outcome, consumed the same way whichever thread produced + // it. The pool calls this in file order as results arrive and the + // serial loop calls it inline, so the writers receive every relation's + // rows in the same order on both paths — which is what makes the two + // paths byte-identical. + const consumeOutcome = async ( + file: PreparedCsFile, + outcome: CsParseOutcome + ): Promise => { + if (outcome.readError !== undefined) { + filesRejected += 1; + return; + } + const emissionOutcomes = outcome.emissions ?? []; + for (let e = 0; e < file.emissions.length; e++) { + const emission = emissionOutcomes[e]; + if (emission?.facts !== undefined) { + await appendFileFacts(writers, emission.facts); + } else { // An extraction error is always a defect, and it is counted rather // than swallowed. A caller that cannot tell a clean run from a // parser that threw on every file cannot tell anything. extractionErrors += 1; console.error( - `[CSharpProjectAnalyzer] ${relativePath} (${targetFramework}): ` + - `${(error as Error).message}` + `[CSharpProjectAnalyzer] ${file.relativePath} ` + + `(${file.emissions[e]!.context.targetFramework}): ` + + `${emission?.extractError}` ); } } filesAnalysed += 1; + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (cs-parse-pool.ts): each worker runs the same extractor this loop + // runs, and every outcome is consumed in sorted file order through + // `consumeOutcome` above. AXIOMCODE_PARSE_JOBS=1 restores the strict + // serial path; the pool declining (no compiled worker beside this + // file) falls back to it too. + const jobs = parsePoolJobs(prepared.length); + let pooled = false; + if (jobs > 1) { + // The pool's consume callback is synchronous and the writers are not, + // so appends are CHAINED: each file's writes start only when the + // previous file's have finished, preserving the serial row order. The + // chain never rejects — the first failure is kept and rethrown after + // the chain drains, because a rejection parked on `chainTail` with no + // handler attached yet would take the process down from under the + // pool. + let consumeError: unknown; + let chainTail = Promise.resolve(); + try { + pooled = await parseCsFilesInPool( + prepared.map((file, i) => ({ + i, + absoluteFilePath: file.absoluteFilePath, + filePath: file.relativePath, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + emissions: file.emissions, + })), + jobs, + (i, outcome) => { + chainTail = chainTail.then(async () => { + if (consumeError !== undefined) return; + try { + await consumeOutcome(prepared[i]!, outcome); + } catch (error) { + consumeError = error; + } + }); + } + ); + } finally { + await chainTail; + } + if (consumeError !== undefined) throw consumeError; + } + if (!pooled) { + for (const file of prepared) { + let outcome: CsParseOutcome; + try { + const sourceText = await fsp.readFile(file.absoluteFilePath, 'utf-8'); + outcome = { + emissions: file.emissions.map((emission) => { + try { + return { + facts: this.extractor.extractFile({ + absoluteFilePath: file.absoluteFilePath, + filePath: file.relativePath, + baseMservPath: options.baseMservPath, + sourceText, + serviceVersionLinkHash, + context: emission.context, + defineConstants: emission.defineConstants, + implicitUsings: emission.implicitUsings, + implicitFrameworkDefines: emission.implicitFrameworkDefines, + }), + }; + } catch (error) { + return { extractError: `${(error as Error).message}` }; + } + }), + }; + } catch (error) { + outcome = { readError: String(error) }; + } + await consumeOutcome(file, outcome); + } } if (shared === undefined) { @@ -377,6 +406,47 @@ export class CSharpProjectAnalyzer { } } +/** + * One file, every input its extraction needs already resolved: the recorded + * path, and one {@link CsEmissionInputs} per target framework. Built on the + * main thread for both paths, so the governing-project caches are read from + * one thread and the pooled and serial runs extract from identical inputs. + */ +interface PreparedCsFile { + readonly absoluteFilePath: string; + readonly relativePath: string; + readonly emissions: readonly CsEmissionInputs[]; +} + +/** One emission's rows to the writers, in the serial loop's relation order. */ +async function appendFileFacts( + writers: Record, + facts: CsFileFacts +): Promise { + await writers.modules.append(facts.modules); + await writers.types.append(facts.types); + await writers.heritages.append(facts.heritages); + await writers.typeParameters.append(facts.typeParameters); + await writers.methods.append(facts.methods); + await writers.methodParameters.append(facts.methodParameters); + await writers.properties.append(facts.properties); + await writers.events.append(facts.events); + await writers.typeReferences.append(facts.typeReferences); + await writers.usings.append(facts.usings); + await writers.parseGaps.append(facts.parseGaps); + await writers.fields.append(facts.fields); + await writers.enumMembers.append(facts.enumMembers); + await writers.expressions.append(facts.expressions); + await writers.callSites.append(facts.callSites); + await writers.queryClauses.append(facts.queryClauses); + await writers.blocks.append(facts.blocks); + await writers.variables.append(facts.variables); + await writers.attributes.append(facts.attributes); + await writers.attributeArguments.append(facts.attributeArguments); + await writers.comments.append(facts.comments); + await writers.preprocRegions.append(facts.preprocRegions); +} + function countsOf(writers: Record): Record { return { cs_module: writers.modules.rowCount, From 6939e2421ef4c8625bfafbb6c30f9e0084ce9710 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:48:26 -0700 Subject: [PATCH 140/258] perf(java): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Java per-file work — read, tree-sitter parse, the type-registry extract with its fifteen side-channel drains, and the import extract — is independent between files, so it now runs on worker threads (java-parse-pool.ts, java-parse-worker.ts) on the machinery parse-pool-core.ts already gives every language. Each worker wires a CodeExtractor and ImportExtractor exactly as the analyzer wires its own and runs the body the serial loop ran (extractJavaFileFacts — the loop's drain sequence, factored out so both sides call the same code), and the main thread thaws each reply and appends it in file order, so accumulation order and the output bytes do not depend on which worker finishes first. AXIOMCODE_PARSE_JOBS picks the width (default min(4, cores-1); 1 is the strict serial path), and a run with no compiled worker beside the pool falls back to serial instead of failing. In the pooled path a worker reads its own file and applies readFiles' three pre-extraction rejections (unreadable, empty, oversized) itself; the serial path keeps reading everything up front, unchanged. Unlike the Python port, the byte gate found no cross-file extractor state to reset: the one candidate, the enum-constant hash map keyed by byte range and reset per enum rather than per file, is only ever read under an enum_constant node, which the current type's own extractFromEnum has just repopulated — so no stale entry can be consulted across files. One Java-specific wrinkle: analyzeJavaProjects runs projects through Promise.all, and the serial loop is order-safe only because it is synchronous once started. The pooled section awaits between consumes, so each project takes a turn on a chain (poolTurn) and its appends stay as atomic as the serial loop's. Across projects the append order was always timing-emergent (read-completion order) and stays so. On a 969-file single-project subject the Java stage runs 5.2–6.9 s serial and 2.7–2.9 s at 4 jobs (2.4 s best at 6), inside the default heap (max RSS 1.1 GB pooled against 0.8 GB serial). IR is byte-identical to serial at 4 and at 6 jobs on that subject. On a 1,625-file eight-module tree every Java relation is content-identical with cross-project row order differing — an order serial itself does not pin (two serial runs there already reorder the config relations). tests/run.py --lang java: 333/333. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/java/java-parse-pool.ts | 264 ++++++++++++++++++ .../src/workflows/java/java-parse-worker.ts | 77 +++++ .../workflows/java/java-project-analyzer.ts | 229 +++++++-------- 3 files changed, 458 insertions(+), 112 deletions(-) create mode 100644 parser/src/workflows/java/java-parse-pool.ts create mode 100644 parser/src/workflows/java/java-parse-worker.ts diff --git a/parser/src/workflows/java/java-parse-pool.ts b/parser/src/workflows/java/java-parse-pool.ts new file mode 100644 index 00000000..deba226d --- /dev/null +++ b/parser/src/workflows/java/java-parse-pool.ts @@ -0,0 +1,264 @@ +import * as path from 'path'; + +import { ImportRegistry } from '@/analysis-imports/java/ImportRegistry'; +import { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +import { MethodRegistry } from '@/analysis-methods/java/MethodRegistry'; +import { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { CommentRegistry } from '@/analysis-types/java/CommentRegistry'; +import { EnumConstant } from '@/analysis-types/java/EnumConstant'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { FieldRegistry } from '@/analysis-types/java/FieldRegistry'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { ModuleDirective } from '@/analysis-types/java/ModuleDirective'; +import { ModuleRegistry } from '@/analysis-types/java/ModuleRegistry'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeParameter } from '@/analysis-types/java/TypeParameter'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { TypeRegistry } from '@/analysis-types/java/TypeRegistry'; +import { JAVA_ENTITY_TYPES } from '@/constants/consts'; +import { SkippedFileReason } from '@/enums/SkippedFileReason'; +import { CodeExtractor } from '@/parsers/code-extractor'; +import { ImportExtractor } from '@/parsers/java/extractors'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The Java half of the parallel parse stage: which prototype each table's + * rows get back, the shape of a dispatch and a reply, and the one-file + * extraction (`extractJavaFileFacts`) that the serial loop and the worker + * both run. Everything thread- and shape-related lives in parse-pool-core.ts. + * + * Rows must come back as real instances, not snapshots: the analyzer's + * export path calls `toCsv`/`getCsvHeader` on every row, and the field + * position export calls `getTypeRegistryLinkHash`/`getHash` — all prototype + * methods. + */ +const TABLE_PROTOTYPES = { + typeRegistries: TypeRegistry.prototype, + typeParameters: TypeParameter.prototype, + typeReferences: TypeReference.prototype, + annotations: TypeAnnotation.prototype, + annotationArguments: AnnotationArgumentReference.prototype, + methods: MethodRegistry.prototype, + methodParameters: MethodParameter.prototype, + methodTypeParameters: MethodTypeParameter.prototype, + enumConstants: EnumConstant.prototype, + modules: ModuleRegistry.prototype, + moduleDirectives: ModuleDirective.prototype, + fields: FieldRegistry.prototype, + imports: ImportRegistry.prototype, + expressions: ExpressionReference.prototype, + localVariables: LocalVariableRegistry.prototype, + blocks: BlockRegistry.prototype, + comments: CommentRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** Everything one Java file contributes: the type rows plus every side-channel table. */ +export interface JavaFileFacts { + typeRegistries: TypeRegistry[]; + typeParameters: TypeParameter[]; + typeReferences: TypeReference[]; + annotations: TypeAnnotation[]; + annotationArguments: AnnotationArgumentReference[]; + methods: MethodRegistry[]; + methodParameters: MethodParameter[]; + methodTypeParameters: MethodTypeParameter[]; + enumConstants: EnumConstant[]; + modules: ModuleRegistry[]; + moduleDirectives: ModuleDirective[]; + fields: FieldRegistry[]; + imports: ImportRegistry[]; + expressions: ExpressionReference[]; + localVariables: LocalVariableRegistry[]; + blocks: BlockRegistry[]; + comments: CommentRegistry[]; +} + +/** What the analyzer sends a worker for one file: strings only. */ +export interface JavaParseDispatch { + i: number; + /** Absolute path; the worker reads it AND records it on rows, exactly as the serial loop does. */ + filePath: string; + serviceVersionHash: string; +} + +/** + * One file's outcome. `skipReason` mirrors the three rejections the serial + * `readFiles` applies before extraction ever runs — an unreadable, empty or + * oversized file is skipped, never extracted. `facts` is everything else. + */ +export interface JavaParseOutcome { + skipReason?: SkippedFileReason; + /** For the FILE_TOO_LARGE log line, which names the line count. */ + lineCount?: number; + /** For the READ_ERROR log line, which names the error. */ + readErrorDetail?: string; + facts?: JavaFileFacts; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface JavaParseReply { + i: number; + skipReason?: SkippedFileReason; + lineCount?: number; + readErrorDetail?: string; + facts?: Record; +} + +/** + * Runs the extraction for ONE file: the type-registry extract, the drain of + * every per-file side channel the extractor exposes, and the import extract. + * This is the serial loop's body verbatim, factored out so the worker runs + * literally the same code against its own extractor pair — one per worker, + * reused across files, matching the analyzer's single-instance semantics. + * + * The `'getExtracted*' in extractor` guards are kept from the serial loop: + * a caller-supplied CodeExtractor may have a different extractor registered, + * and that extractor contributes only what it exposes. + */ +export function extractJavaFileFacts( + codeExtractor: CodeExtractor, + importExtractor: ImportExtractor, + filePath: string, + fileContent: string, + serviceVersionHash: string +): JavaFileFacts { + const facts: JavaFileFacts = { + typeRegistries: [], + typeParameters: [], + typeReferences: [], + annotations: [], + annotationArguments: [], + methods: [], + methodParameters: [], + methodTypeParameters: [], + enumConstants: [], + modules: [], + moduleDirectives: [], + fields: [], + imports: [], + expressions: [], + localVariables: [], + blocks: [], + comments: [], + }; + + const extractor = codeExtractor.getExtractor( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY + ) as any; + + facts.typeRegistries = codeExtractor.extract( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY, + filePath, + fileContent, + serviceVersionHash + ); + + if (extractor && 'getExtractedTypeParameters' in extractor) { + facts.typeParameters = extractor.getExtractedTypeParameters(); + } + if (extractor && 'getExtractedTypeReferences' in extractor) { + facts.typeReferences = extractor.getExtractedTypeReferences(); + } + if (extractor && 'getExtractedAnnotations' in extractor) { + facts.annotations = extractor.getExtractedAnnotations(); + } + if (extractor && 'getExtractedAnnotationArguments' in extractor) { + facts.annotationArguments = extractor.getExtractedAnnotationArguments(); + } + if (extractor && 'getExtractedMethods' in extractor) { + facts.methods = extractor.getExtractedMethods(); + } + if (extractor && 'getExtractedMethodParameters' in extractor) { + facts.methodParameters = extractor.getExtractedMethodParameters(); + } + if (extractor && 'getExtractedMethodTypeParameters' in extractor) { + facts.methodTypeParameters = extractor.getExtractedMethodTypeParameters(); + } + if (extractor && 'getExtractedEnumConstants' in extractor) { + facts.enumConstants = extractor.getExtractedEnumConstants(); + } + if (extractor && 'getExtractedModules' in extractor) { + facts.modules = extractor.getExtractedModules(); + } + if (extractor && 'getExtractedModuleDirectives' in extractor) { + facts.moduleDirectives = extractor.getExtractedModuleDirectives(); + } + if (extractor && 'getExtractedFields' in extractor) { + facts.fields = extractor.getExtractedFields(); + } + + facts.imports = importExtractor.extract(filePath, fileContent, serviceVersionHash); + + if (extractor && 'getExtractedExpressions' in extractor) { + facts.expressions = extractor.getExtractedExpressions(); + } + if (extractor && 'getExtractedLocalVariables' in extractor) { + facts.localVariables = extractor.getExtractedLocalVariables(); + } + if (extractor && 'getExtractedBlocks' in extractor) { + facts.blocks = extractor.getExtractedBlocks(); + } + if (extractor && 'getExtractedComments' in extractor) { + facts.comments = extractor.getExtractedComments(); + } + + return facts; +} + +/** Worker side: a fact set as columns structured clone can carry cheaply. */ +export function freezeFileFacts(facts: JavaFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) out[key] = freezeTable(facts[key]); + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFileFacts(frozen: Record): JavaFileFacts { + const out = {} as Record; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as JavaFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseFilesInPool( + dispatches: JavaParseDispatch[], + jobs: number, + consume: (i: number, outcome: JavaParseOutcome) => void +): Promise { + return runParsePool( + path.join(__dirname, 'java-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFileFacts(reply.facts) } + : { + skipReason: reply.skipReason, + lineCount: reply.lineCount, + readErrorDetail: reply.readErrorDetail, + } + ) + ); +} diff --git a/parser/src/workflows/java/java-parse-worker.ts b/parser/src/workflows/java/java-parse-worker.ts new file mode 100644 index 00000000..e473fb4b --- /dev/null +++ b/parser/src/workflows/java/java-parse-worker.ts @@ -0,0 +1,77 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { JAVA_ENTITY_TYPES, LARGE_FILE_LINE_THRESHOLD } from '@/constants/consts'; +import { SkippedFileReason } from '@/enums/SkippedFileReason'; +import { CodeExtractor } from '@/parsers/code-extractor'; +import { ImportExtractor, TypeRegistryExtractor } from '@/parsers/java/extractors'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { + extractJavaFileFacts, + freezeFileFacts, + JavaParseDispatch, + JavaParseReply, +} from '@/workflows/java/java-parse-pool'; + +/** + * One parse worker: reads a file, applies the SAME three pre-extraction + * rejections the analyzer's `readFiles` applies (unreadable, empty, + * oversized), runs the same extractor stack the serial loop runs, and posts + * the file's tables back as prototype-less column snapshots + * (`freezeFileFacts`). + * + * One CodeExtractor + ImportExtractor pair per worker, wired exactly as the + * analyzer wires its own (`registerExtractors`), reused across files — the + * extractor resets its per-file arrays at the top of every `extract`, so + * reuse matches the analyzer's single-instance semantics. + */ +const codeExtractor = new CodeExtractor(); +codeExtractor.registerExtractor( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY, + new TypeRegistryExtractor() +); +const importExtractor = new ImportExtractor(); + +const port = parentPort; +if (!port) throw new Error('java-parse-worker must run as a worker thread'); + +port.on('message', (job: JavaParseDispatch) => { + void (async () => { + let content: string; + try { + content = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.READ_ERROR, + readErrorDetail: String(error), + } satisfies JavaParseReply); + return; + } + if (!content || content.trim().length === 0) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.EMPTY_CONTENT, + } satisfies JavaParseReply); + return; + } + const lineCount = content.split('\n').length; + if (lineCount > LARGE_FILE_LINE_THRESHOLD) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.FILE_TOO_LARGE, + lineCount, + } satisfies JavaParseReply); + return; + } + const facts = extractJavaFileFacts( + codeExtractor, + importExtractor, + job.filePath, + content, + job.serviceVersionHash + ); + port.postMessage({ i: job.i, facts: freezeFileFacts(facts) } satisfies JavaParseReply); + })(); +}); diff --git a/parser/src/workflows/java/java-project-analyzer.ts b/parser/src/workflows/java/java-project-analyzer.ts index 625e8880..4b1168b4 100644 --- a/parser/src/workflows/java/java-project-analyzer.ts +++ b/parser/src/workflows/java/java-project-analyzer.ts @@ -27,6 +27,13 @@ import { TypeRegistryExtractor, ImportExtractor } from '@/parsers/java/extractor import { ProjectInfo, ProjectLanguage } from '@/types/ProjectInfo'; import { EntityUtils } from '@/utils/entity-utils'; import { isGitIgnoredDir } from '@/utils/git-ignored'; +import { + extractJavaFileFacts, + JavaFileFacts, + JavaParseOutcome, + parseFilesInPool, + parsePoolJobs, +} from '@/workflows/java/java-parse-pool'; export class JavaProjectAnalyzer { private codeExtractor: CodeExtractor; @@ -50,6 +57,19 @@ export class JavaProjectAnalyzer { private skippedFiles: { filePath: string; baseMservPath: string; serviceVersionHash: string; reason: SkippedFileReason; uniqueFileHash: string }[] = []; private importExtractor: ImportExtractor; private outputDir: string; + /** + * Serializes the pooled extract+consume section across projects. + * + * `analyzeJavaProjects` runs projects through `Promise.all`, and in the + * SERIAL path that is safe for the `all*` arrays because each project's + * per-file loop is synchronous — once it starts, it runs to completion + * before any other project can append. The pooled path awaits between + * consumes, so without this gate two projects' appends would interleave by + * worker timing and the output bytes would change run to run. Each + * project's pooled section therefore takes its turn on this chain, keeping + * a project's appends as atomic as the serial loop's. + */ + private poolTurn: Promise = Promise.resolve(); constructor(codeExtractor?: CodeExtractor, outputDir?: string) { this.codeExtractor = codeExtractor || new CodeExtractor(); @@ -181,128 +201,113 @@ export class JavaProjectAnalyzer { console.log(` 🔍 Found ${javaFiles.length} Java file(s), extracting types...`); - const fileContents = await this.readFiles(javaFiles, projectPath, serviceVersionHash); - - // Extract from each file and collect type parameters after each file const typeRegistries: TypeRegistry[] = []; - const extractor = this.codeExtractor.getExtractor( - ProjectLanguage.JAVA, - JAVA_ENTITY_TYPES.TYPE_REGISTRY - ) as any; - - for (const fileData of fileContents) { - const typesFromFile = this.codeExtractor.extract( - ProjectLanguage.JAVA, - JAVA_ENTITY_TYPES.TYPE_REGISTRY, - fileData.path, - fileData.content, - serviceVersionHash - ); - typeRegistries.push(...typesFromFile); - - // Collect type parameters from this file immediately - if (extractor && 'getExtractedTypeParameters' in extractor) { - const typeParams = extractor.getExtractedTypeParameters(); - this.allTypeParameters.push(...typeParams); - } - - // Collect type references from this file immediately - if (extractor && 'getExtractedTypeReferences' in extractor) { - const typeRefs = extractor.getExtractedTypeReferences(); - this.allTypeReferences.push(...typeRefs); - } - - // Collect annotations from this file immediately - if (extractor && 'getExtractedAnnotations' in extractor) { - const annotations = extractor.getExtractedAnnotations(); - this.allAnnotations.push(...annotations); - } - - // Collect annotation arguments from this file immediately - if (extractor && 'getExtractedAnnotationArguments' in extractor) { - const annotationArgs = extractor.getExtractedAnnotationArguments(); - this.allAnnotationArguments.push(...annotationArgs); - } - - // Collect methods from this file immediately - if (extractor && 'getExtractedMethods' in extractor) { - const methods = extractor.getExtractedMethods(); - this.allMethods.push(...methods); - } - - // Collect method parameters from this file immediately - if (extractor && 'getExtractedMethodParameters' in extractor) { - const methodParams = extractor.getExtractedMethodParameters(); - this.allMethodParameters.push(...methodParams); - } - - // Collect method type parameters from this file immediately - if (extractor && 'getExtractedMethodTypeParameters' in extractor) { - const methodTypeParams = extractor.getExtractedMethodTypeParameters(); - this.allMethodTypeParameters.push(...methodTypeParams); - } - - // Collect enum constants from this file immediately - if (extractor && 'getExtractedEnumConstants' in extractor) { - const enumConstants = extractor.getExtractedEnumConstants(); - this.allEnumConstants.push(...enumConstants); - } - // Collect the module declaration from this file, if it was a module-info.java - if (extractor && 'getExtractedModules' in extractor) { - const modules = extractor.getExtractedModules(); - this.allModules.push(...modules); + // One file's outcome, appended the same way whichever thread produced it. + // The pool calls this in file order as results arrive, and the serial + // loop calls it inline, so the accumulation order — and therefore the + // output bytes — is identical across the two paths. + const consumeOutcome = (filePath: string, outcome: JavaParseOutcome): void => { + if (outcome.skipReason !== undefined) { + this.recordPoolSkip(filePath, projectPath, serviceVersionHash, outcome); + return; } - - // Collect module directives from this file immediately - if (extractor && 'getExtractedModuleDirectives' in extractor) { - const moduleDirectives = extractor.getExtractedModuleDirectives(); - this.allModuleDirectives.push(...moduleDirectives); - } - - // Collect fields from this file immediately - if (extractor && 'getExtractedFields' in extractor) { - const fields = extractor.getExtractedFields(); - this.allFields.push(...fields); + if (!outcome.facts) return; + this.appendFileFacts(typeRegistries, outcome.facts); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (java-parse-pool.ts): read, parse, extract and hash are independent + // between files. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; + // the pool declining (no compiled worker beside this file) falls back to + // it too. In the pooled path the worker reads its own file and applies + // `readFiles`' three rejections itself; the serial path below keeps + // reading everything up front, exactly as before. + const jobs = parsePoolJobs(javaFiles.length); + let pooled = false; + if (jobs > 1) { + // Take this project's turn on the pool chain — see `poolTurn`. + const myTurn = this.poolTurn; + let release!: () => void; + this.poolTurn = new Promise(resolve => (release = resolve)); + await myTurn; + try { + pooled = await parseFilesInPool( + javaFiles.map((filePath, i) => ({ i, filePath, serviceVersionHash })), + jobs, + (i, outcome) => consumeOutcome(javaFiles[i] as string, outcome) + ); + } finally { + release(); } - - // Extract imports from this file - const importsFromFile = this.importExtractor.extract( - fileData.path, - fileData.content, - serviceVersionHash - ); - this.allImports.push(...importsFromFile); - - // Collect expressions from this file immediately - if (extractor && 'getExtractedExpressions' in extractor) { - const expressions = extractor.getExtractedExpressions(); - this.allExpressions.push(...expressions); - } - - // Collect local variables from this file immediately - if (extractor && 'getExtractedLocalVariables' in extractor) { - const localVariables = extractor.getExtractedLocalVariables(); - this.allLocalVariables.push(...localVariables); - } - - // Collect blocks from this file immediately - if (extractor && 'getExtractedBlocks' in extractor) { - const blocks = extractor.getExtractedBlocks(); - this.allBlocks.push(...blocks); - } - - // Collect comments from this file immediately - if (extractor && 'getExtractedComments' in extractor) { - const comments = extractor.getExtractedComments(); - this.allComments.push(...comments); + } + if (!pooled) { + const fileContents = await this.readFiles(javaFiles, projectPath, serviceVersionHash); + for (const fileData of fileContents) { + consumeOutcome(fileData.path, { + facts: extractJavaFileFacts( + this.codeExtractor, + this.importExtractor, + fileData.path, + fileData.content, + serviceVersionHash + ), + }); } - } return typeRegistries; } + /** + * Appends one file's tables to the project's accumulators — the body the + * serial loop ran inline, applied identically to a worker's thawed reply. + */ + private appendFileFacts(typeRegistries: TypeRegistry[], facts: JavaFileFacts): void { + typeRegistries.push(...facts.typeRegistries); + this.allTypeParameters.push(...facts.typeParameters); + this.allTypeReferences.push(...facts.typeReferences); + this.allAnnotations.push(...facts.annotations); + this.allAnnotationArguments.push(...facts.annotationArguments); + this.allMethods.push(...facts.methods); + this.allMethodParameters.push(...facts.methodParameters); + this.allMethodTypeParameters.push(...facts.methodTypeParameters); + this.allEnumConstants.push(...facts.enumConstants); + this.allModules.push(...facts.modules); + this.allModuleDirectives.push(...facts.moduleDirectives); + this.allFields.push(...facts.fields); + this.allImports.push(...facts.imports); + this.allExpressions.push(...facts.expressions); + this.allLocalVariables.push(...facts.localVariables); + this.allBlocks.push(...facts.blocks); + this.allComments.push(...facts.comments); + } + + /** + * Records a skip reported by a worker, with the same row and the same log + * line `readFiles` produces for that rejection in the serial path. + */ + private recordPoolSkip( + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + outcome: JavaParseOutcome + ): void { + const reason = outcome.skipReason as SkippedFileReason; + if (reason === SkippedFileReason.READ_ERROR) { + console.error(`Error reading file ${filePath}:`, outcome.readErrorDetail); + } else if (reason === SkippedFileReason.FILE_TOO_LARGE) { + console.log(` ⏭️ Skipping very large file (${outcome.lineCount} lines): ${filePath}`); + } else { + console.warn(`Skipping ${filePath}: empty or invalid content`); + } + const uniqueFileHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.SKIPPED_FILE, + `${filePath}||${baseMservPath}||${serviceVersionHash}||${reason}` + ); + this.skippedFiles.push({ filePath, baseMservPath, serviceVersionHash, reason, uniqueFileHash }); + } + /** * Recursively finds all Java files in a directory */ From d9b7bbb6fd06432a6f1adca382eabc34787d1e94 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:45 -0700 Subject: [PATCH 141/258] perf(typescript): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript extraction is per-file by design — cross-file call resolution is the engine's, and the module link passes run after every file — so a worker can run extractTypeScriptFile exactly as the serial loop does. The closures extraction needs (toProjectRelative, sourceModuleHashOf, resolveWorkspaceModule) move to ts-resolution-context.ts, built from plain values, so the analyzer and ts-parse-worker construct the same three functions from the same inputs and cannot drift; the worker's own TsConfigResolver reads the same tsconfigs from disk, and the closure walk's prefetched text rides the dispatch so a file is still read once. What crosses the boundary is not the fact set. A first cut shipped rows as columns, like Python, and was SLOWER than serial (18.4s -> 26.0s on a 1,754-file subject): TS rows are cheap to make and expensive to ship, and ts_expression is over half the output. So seventeen of the twenty relations — everything the analyzer streams and never reads back — cross as RENDERED CSV LINES (toCsv runs beside the extraction, on the worker's core; TsRelationWriter.appendRendered runs the same verifyRow on the same strings), and the worker runs the per-file completeness measurement itself, sending counters, gaps and deferred verdicts. Only modules, imports and exports cross as rows, because the link passes mutate them and the deferred verdicts are read against those same rows: identity is kept by sending row indexes and re-binding on the main thread. parse-pool-core's consume may now be async (the writers are awaited per file, in file order), and workers take a workerData init. On the 1,754-file subject the TypeScript phase runs 18.4s serial, 15.5s pooled, and the subject's whole run drops 35.5s -> 20.6s real — the pool also frees the main thread for the other analyzers running in the same extract. IR is byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool at 4 and 6 jobs, except all-property-keys, all-property-value-segments and all-xml-elements, whose row order differs between two SERIAL runs of the same build too (sorted contents equal): the properties and XML analyzers emit in nondeterministic order today, which tests/run.py does not read and a follow-up should pin. tests/run.py --lang typescript: 233 of 258 — the 25 failures (const-object-table, module-level-const, const-object-data-keys, multi-line-initializer, wrapped-handler-route, library-receiver) fail IDENTICALLY on the base commit with this change stashed, and the fixture IR of the worst case is byte-identical base vs this change: they are the integration branch's own, from merges no CI run validated, not this commit's. The python corpus subject re-gates byte-identical through the reworked core (async pump), and tests/run.py --lang python passed on the same core earlier today. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/parse-pool-core.ts | 63 ++-- .../src/workflows/typescript/ts-parse-pool.ts | 307 ++++++++++++++++++ .../workflows/typescript/ts-parse-worker.ts | 78 +++++ .../typescript/ts-relation-writer.ts | 31 ++ .../typescript/ts-resolution-context.ts | 83 +++++ .../typescript/typescript-project-analyzer.ts | 225 ++++++++----- 6 files changed, 693 insertions(+), 94 deletions(-) create mode 100644 parser/src/workflows/typescript/ts-parse-pool.ts create mode 100644 parser/src/workflows/typescript/ts-parse-worker.ts create mode 100644 parser/src/workflows/typescript/ts-resolution-context.ts diff --git a/parser/src/workflows/parse-pool-core.ts b/parser/src/workflows/parse-pool-core.ts index 1bd162e8..6e7000f7 100644 --- a/parser/src/workflows/parse-pool-core.ts +++ b/parser/src/workflows/parse-pool-core.ts @@ -115,7 +115,12 @@ export async function runParsePool void + // may be async (a consumer that streams rows to disk awaits its writes); + // the pool never runs two consumes at once and never out of order + consume: (reply: R) => void | Promise, + // handed to every worker at start (workerData): the shared, clonable inputs + // a language's extraction needs beyond the per-file dispatch + workerData?: Record ): Promise { if (!fs.existsSync(workerPath)) return false; if (dispatches.length === 0) return true; @@ -125,16 +130,10 @@ export async function runParsePool((resolve, reject) => { - const drain = () => { - for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { - ready.delete(nextToConsume); - consume(reply); - nextToConsume += 1; - } - }; const feed = (worker: Worker) => { if (nextToDispatch >= dispatches.length) { idle.push(worker); @@ -142,27 +141,53 @@ export async function runParsePool= window) { - idle.push(worker); // drain() wakes it once its result's turn has come + idle.push(worker); // the pump wakes it once its result's turn has come return; } worker.postMessage(dispatches[nextToDispatch]); nextToDispatch += 1; }; + const wakeIdle = () => { + while ( + idle.length > 0 && + nextToDispatch - nextToConsume < window && + nextToDispatch < dispatches.length + ) { + feed(idle.pop() as Worker); + } + }; + // ONE pump at a time: a consume may await, replies landing meanwhile only + // set `ready` — the running pump picks them up, or the re-check below + // restarts it for a reply that arrived exactly as it finished. + const pump = async () => { + if (pumping) return; + pumping = true; + try { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + await consume(reply); + nextToConsume += 1; + wakeIdle(); + } + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } finally { + pumping = false; + } + if (ready.has(nextToConsume)) { + void pump(); + } else if (nextToConsume >= dispatches.length) { + resolve(); + } + }; for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { - const worker = new Worker(workerPath); + const worker = new Worker(workerPath, workerData === undefined ? undefined : { workerData }); workers.push(worker); worker.on('message', (reply: R) => { ready.set(reply.i, reply); - drain(); + void pump(); feed(worker); - while ( - idle.length > 0 && - nextToDispatch - nextToConsume < window && - nextToDispatch < dispatches.length - ) { - feed(idle.pop() as Worker); - } - if (nextToConsume >= dispatches.length) resolve(); }); worker.on('error', reject); feed(worker); diff --git a/parser/src/workflows/typescript/ts-parse-pool.ts b/parser/src/workflows/typescript/ts-parse-pool.ts new file mode 100644 index 00000000..aefacec9 --- /dev/null +++ b/parser/src/workflows/typescript/ts-parse-pool.ts @@ -0,0 +1,307 @@ +import * as path from 'path'; + +import { TsExportRegistry } from '@/analysis-types/typescript/TsExportRegistry'; +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsModuleRegistry } from '@/analysis-types/typescript/TsModuleRegistry'; +import { TYPESCRIPT_CSV_FILES } from '@/constants/typescript-constants'; +import { TsFileFacts } from '@/parsers/typescript/extractors/ts-fact-extractor'; +import { + accumulateFileCompleteness, + CompletenessAccumulator, + DeferredVerdict, + IrGap, + newCompletenessAccumulator, +} from '@/parsers/typescript/extractors/ts-ir-completeness'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The TypeScript half of the parallel parse stage. Extraction here is + * per-file by DESIGN — cross-file call resolution was retracted to the + * engine, and the module link passes run after every file on the main + * thread — so a worker runs `extractTypeScriptFile` exactly as the serial + * loop does (ts-parse-worker.ts rebuilds the shared closures from plain data + * through the same `tsResolutionContext` the analyzer uses). + * + * WHAT CROSSES THE BOUNDARY IS NOT THE FACT SET. Seventeen of the twenty + * relations are write-only after extraction — the analyzer streams them to + * disk and nothing reads them back — so the worker renders their CSV LINES + * (`toCsv` runs beside the extraction, on the worker's core) and the main + * thread appends strings. The worker also runs the per-file completeness + * measurement itself and sends the counters, gaps and deferred verdicts. + * Only three tables cross as rows — `modules`, `imports`, `exports` — because + * the module link passes MUTATE them after every file is parsed, and the + * deferred verdicts are read against those same mutated rows: identity is + * kept by sending row INDEXES and re-binding on the main thread. + */ +export const STREAMED_TABLES = { + types: TYPESCRIPT_CSV_FILES.TYPES, + heritages: TYPESCRIPT_CSV_FILES.TYPE_HERITAGES, + typeParameters: TYPESCRIPT_CSV_FILES.TYPE_PARAMETERS, + typeReferences: TYPESCRIPT_CSV_FILES.TYPE_REFERENCES, + methods: TYPESCRIPT_CSV_FILES.METHODS, + methodParameters: TYPESCRIPT_CSV_FILES.METHOD_PARAMETERS, + fields: TYPESCRIPT_CSV_FILES.FIELDS, + variables: TYPESCRIPT_CSV_FILES.VARIABLES, + expressions: TYPESCRIPT_CSV_FILES.EXPRESSIONS, + callSites: TYPESCRIPT_CSV_FILES.CALL_SITES, + blocks: TYPESCRIPT_CSV_FILES.BLOCKS, + decorators: TYPESCRIPT_CSV_FILES.DECORATORS, + decoratorArguments: TYPESCRIPT_CSV_FILES.DECORATOR_ARGUMENTS, + enumMembers: TYPESCRIPT_CSV_FILES.ENUM_MEMBERS, + fieldPositions: TYPESCRIPT_CSV_FILES.FIELD_POSITIONS, + comments: TYPESCRIPT_CSV_FILES.COMMENTS, + parseGaps: TYPESCRIPT_CSV_FILES.PARSE_GAPS, +} as const; + +export type StreamedTableKey = keyof typeof STREAMED_TABLES; +export const STREAMED_KEYS = Object.keys(STREAMED_TABLES) as StreamedTableKey[]; + +const HELD_PROTOTYPES = { + modules: TsModuleRegistry.prototype, + imports: TsImportRegistry.prototype, + exports: TsExportRegistry.prototype, +} as const; +type HeldKey = keyof typeof HELD_PROTOTYPES; +const HELD_KEYS = Object.keys(HELD_PROTOTYPES) as HeldKey[]; + +/** What the analyzer sends a worker for one file: strings only. */ +export interface TsParseDispatch { + i: number; + /** Absolute path; the worker reads it unless `sourceText` rode along. */ + file: string; + /** The closure walk's prefetched text, so the file is still read once. */ + sourceText?: string; + filePath: string; + moduleQualifiedName: string; +} + +export interface RenderedTable { + header: string; + lines: string[]; +} + +interface FrozenDeferred { + shape: string; + where: string; + calleeName: string; + missingSoFar: string[]; + hop: DeferredVerdict['hop']; + /** DYNAMIC_IMPORT: index into the file's imports, or -1 for undefined. */ + importRowIndex?: number; + /** IMPORTED_NAME / RECEIVER_TYPE: the file's import map as (name, index). */ + importEntries?: [string, number][]; + localName?: string; + localTypeNames?: string[]; + typeName?: string; +} + +export interface FrozenCompleteness { + report: Record; + gaps: IrGap[]; + deferred: FrozenDeferred[]; +} + +export interface TsParseReply { + i: number; + readError?: string; + extractError?: string; + rendered?: Partial>; + held?: Record; + completeness?: FrozenCompleteness; + filePath?: string; +} + +/** What the main thread consumes per file once a worker reply is re-bound. */ +export interface TsPooledFile { + rendered: Partial>; + modules: TsModuleRegistry[]; + imports: TsImportRegistry[]; + exports: TsExportRegistry[]; + completeness: { gaps: IrGap[]; deferred: DeferredVerdict[]; report: Record }; + filePath: string; +} + +export interface TsParseOutcome { + readError?: string; + extractError?: string; + file?: TsPooledFile; +} + +/** Worker side: render the streamed tables, measure, freeze the held rows. */ +export function freezeTsReply(i: number, facts: TsFileFacts): TsParseReply { + const rendered: Partial> = {}; + for (const key of STREAMED_KEYS) { + const rows = facts[key] as readonly { toCsv(): string; getCsvHeader(): string }[]; + if (rows.length === 0) continue; + rendered[key] = { + header: (rows[0] as { getCsvHeader(): string }).getCsvHeader(), + lines: rows.map(r => r.toCsv()), + }; + } + + // The same call the serial loop makes, against this worker's live facts. + const accumulator = newCompletenessAccumulator(); + accumulateFileCompleteness(facts, accumulator); + + const importIndex = new Map(); + facts.imports.forEach((row, idx) => importIndex.set(row, idx)); + const freezeImportMap = (m: ReadonlyMap): [string, number][] => + [...m].map(([name, row]) => [name, importIndex.get(row) ?? -1]); + + const deferred: FrozenDeferred[] = accumulator.deferred.map(d => { + const base = { + shape: d.shape, + where: d.where, + calleeName: d.calleeName, + missingSoFar: [...d.missingSoFar], + hop: d.hop, + }; + switch (d.hop) { + case 'DYNAMIC_IMPORT': + return { + ...base, + importRowIndex: d.importRow === undefined ? -1 : (importIndex.get(d.importRow) ?? -1), + }; + case 'IMPORTED_NAME': + return { ...base, importEntries: freezeImportMap(d.imports), localName: d.localName }; + case 'RECEIVER_TYPE': + return { + ...base, + importEntries: freezeImportMap(d.imports), + localTypeNames: [...d.localTypeNames], + typeName: d.typeName, + }; + } + }); + + return { + i, + rendered, + held: { + modules: freezeTable(facts.modules as unknown as object[]), + imports: freezeTable(facts.imports as unknown as object[]), + exports: freezeTable(facts.exports as unknown as object[]), + }, + completeness: { + report: accumulator.report as unknown as Record, + gaps: accumulator.gaps, + deferred, + }, + filePath: facts.filePath, + }; +} + +/** Main-thread side: held rows with their prototypes, verdicts re-bound. */ +export function thawTsReply(reply: TsParseReply): TsPooledFile { + const held = reply.held as Record; + const tables = {} as Record; + for (const key of HELD_KEYS) tables[key] = thawTable(held[key], HELD_PROTOTYPES[key]); + const imports = tables.imports as TsImportRegistry[]; + + const thawImportMap = (entries: [string, number][]): Map => + new Map(entries.map(([name, idx]) => [name, imports[idx] as TsImportRegistry])); + + const completeness = reply.completeness as FrozenCompleteness; + const deferred: DeferredVerdict[] = completeness.deferred.map(d => { + const base = { + shape: d.shape, + where: d.where, + calleeName: d.calleeName, + missingSoFar: d.missingSoFar, + }; + switch (d.hop) { + case 'DYNAMIC_IMPORT': + return { + ...base, + hop: d.hop, + importRow: d.importRowIndex === -1 ? undefined : imports[d.importRowIndex as number], + }; + case 'IMPORTED_NAME': + return { + ...base, + hop: d.hop, + imports: thawImportMap(d.importEntries ?? []), + localName: d.localName as string, + }; + case 'RECEIVER_TYPE': + return { + ...base, + hop: d.hop, + imports: thawImportMap(d.importEntries ?? []), + localTypeNames: new Set(d.localTypeNames ?? []), + typeName: d.typeName as string, + }; + } + }); + + return { + rendered: reply.rendered ?? {}, + modules: tables.modules as TsModuleRegistry[], + imports, + exports: tables.exports as TsExportRegistry[], + completeness: { gaps: completeness.gaps, deferred, report: completeness.report }, + filePath: reply.filePath as string, + }; +} + +/** + * Folds one file's completeness measurement into the shared accumulator, in + * file order, exactly as the serial loop's `accumulateFileCompleteness` call + * would have: counters add, per-shape counters add shape by shape, and the + * ordered lists concatenate. + */ +export function mergeCompleteness( + accumulator: CompletenessAccumulator, + delta: { gaps: IrGap[]; deferred: DeferredVerdict[]; report: Record } +): void { + const report = accumulator.report as unknown as Record; + for (const [key, value] of Object.entries(delta.report)) { + if (typeof value === 'number') { + report[key] = ((report[key] as number) ?? 0) + value; + } else if (key === 'byReceiverKind') { + const into = report[key] as Record>; + for (const [shape, counts] of Object.entries(value as Record>)) { + const bucket = into[shape] ?? (into[shape] = Object.fromEntries( + Object.keys(counts).map(k => [k, 0]) + ) as Record); + for (const [k, n] of Object.entries(counts)) bucket[k] = (bucket[k] ?? 0) + n; + } + } else if (Array.isArray(value)) { + (report[key] as unknown[]).push(...value); + } + } + accumulator.gaps.push(...delta.gaps); + accumulator.deferred.push(...delta.deferred); +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available; `consume` may await its writes. + * `false` means no compiled worker: the caller falls back to its serial loop. + */ +export async function parseTsFilesInPool( + workerData: Record, + dispatches: TsParseDispatch[], + jobs: number, + consume: (i: number, outcome: TsParseOutcome) => Promise +): Promise { + return runParsePool( + path.join(__dirname, 'ts-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.held + ? { file: thawTsReply(reply) } + : { readError: reply.readError, extractError: reply.extractError } + ), + workerData + ); +} diff --git a/parser/src/workflows/typescript/ts-parse-worker.ts b/parser/src/workflows/typescript/ts-parse-worker.ts new file mode 100644 index 00000000..d7c268f2 --- /dev/null +++ b/parser/src/workflows/typescript/ts-parse-worker.ts @@ -0,0 +1,78 @@ +import * as fsp from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; + +import { TS_SKIP_DIRECTORIES } from '@/constants/typescript-constants'; +import { extractTypeScriptFile, TsFileFacts } from '@/parsers/typescript/extractors/ts-fact-extractor'; +import { TsConfigResolver } from '@/parsers/typescript/tsconfig-resolver'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; +import { scriptTextOf } from '@/utils/vue-sfc'; +import { freezeTsReply, TsParseDispatch, TsParseReply } from '@/workflows/typescript/ts-parse-pool'; +import { toRelative, tsResolutionContext } from '@/workflows/typescript/ts-resolution-context'; + +/** + * One TypeScript parse worker: the same per-file extraction the serial loop + * runs, from the same shared inputs. The closures extraction needs are + * rebuilt here from the plain values in `workerData` through the SAME + * `tsResolutionContext` the analyzer uses, and the per-file tsconfig comes + * from this worker's own `TsConfigResolver`, which reads the same files from + * disk the analyzer's did. The two error cases mirror the serial loop's two + * catch blocks exactly. + */ +const init = workerData as { + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + excludes: string[]; + projectModuleHashes: Map; +}; + +const configResolver = new TsConfigResolver(); +const workspacePackages = WorkspacePackages.discover( + init.pathAnchor, + new Set(TS_SKIP_DIRECTORIES) +); +const { toProjectRelative, resolveWorkspaceModule } = tsResolutionContext( + init, + workspacePackages +); + +const port = parentPort; +if (!port) throw new Error('ts-parse-worker must run as a worker thread'); + +port.on('message', (job: TsParseDispatch) => { + void (async () => { + let sourceText: string; + try { + sourceText = job.sourceText ?? (await fsp.readFile(job.file, 'utf-8')); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies TsParseReply); + return; + } + try { + const governing = configResolver.resolve(job.file); + const script = scriptTextOf(job.file, sourceText); + const facts: TsFileFacts = extractTypeScriptFile({ + absoluteFilePath: job.file, + filePath: job.filePath, + baseMservPath: init.baseMservPath, + moduleQualifiedName: job.moduleQualifiedName, + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash: init.serviceVersionLinkHash, + tsConfigPath: governing.configPath === '' + ? '' + : toRelative(init.pathAnchor, governing.configPath), + moduleResolutionMode: governing.moduleResolutionMode, + decoratorSystem: governing.decoratorSystem, + compilerOptions: governing.options, + packageName: '', + projectModuleHashes: init.projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }); + port.postMessage(freezeTsReply(job.i, facts)); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies TsParseReply); + } + })(); +}); diff --git a/parser/src/workflows/typescript/ts-relation-writer.ts b/parser/src/workflows/typescript/ts-relation-writer.ts index f431759d..59c31fcf 100644 --- a/parser/src/workflows/typescript/ts-relation-writer.ts +++ b/parser/src/workflows/typescript/ts-relation-writer.ts @@ -92,6 +92,37 @@ export class TsRelationWriter { } } + /** + * Appends one file's rows ALREADY RENDERED — a parse worker runs `toCsv` + * beside the extraction so the main thread writes strings instead of + * re-walking rows. Every line passes the same `verifyRow` the object path + * runs, on the same string that is written; the header must be the one the + * rows' class renders, and the first appender's header wins exactly as the + * object path's first row does. + */ + async appendRendered(header: string, lines: readonly string[]): Promise { + if (this.closed) { + throw new Error(`${path.basename(this.outputPath)}: appended after the file was published`); + } + if (lines.length === 0) { + return; + } + if (this.handle === undefined) { + this.handle = await fsp.open(this.temporaryPath, 'w'); + this.header = header; + this.width = countTabs(this.header) + 1; + this.buffer.push(this.header + '\n'); + } + for (const line of lines) { + verifyRow(line, this.width, this.outputPath, this.rows + 2); + this.buffer.push(line + '\n'); + this.rows += 1; + } + if (this.buffer.length >= TS_CSV_CHUNK_SIZE) { + await this.flush(); + } + } + private async flush(): Promise { if (this.handle === undefined || this.buffer.length === 0) { return; diff --git a/parser/src/workflows/typescript/ts-resolution-context.ts b/parser/src/workflows/typescript/ts-resolution-context.ts new file mode 100644 index 00000000..37d66130 --- /dev/null +++ b/parser/src/workflows/typescript/ts-resolution-context.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extractor'; +import { stripTsExtension } from '@/parsers/typescript/ts-module-paths'; +import { WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; + +/** + * The shared inputs a TypeScript file's extraction closes over, built from + * PLAIN DATA so the analyzer's loop and a parse worker construct the same + * three functions from the same values and cannot drift. The parts are pure + * (`toProjectRelative`), derived from paths alone (`sourceModuleHashOf` — the + * design that lets a module augmentation key under a file that has not been + * parsed), or memoised pure lookups (`resolveWorkspaceModule`), which is what + * makes per-file extraction order-free and therefore poolable at all. + */ +export interface TsResolutionInputs { + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** Directory NAMES excluded from the walk, as the analyzer resolved them. */ + excludes: ReadonlySet | readonly string[]; + /** Every in-program file's module hash, keyed by normalized absolute path. */ + projectModuleHashes: Map; +} + +export interface TsResolutionContext { + toProjectRelative: (absolutePath: string) => string; + sourceModuleHashOf: (absolutePath: string) => string | undefined; + resolveWorkspaceModule: (specifier: string) => WorkspaceModule | undefined; +} + +export function toRelative(rootDir: string, file: string): string { + return path.relative(rootDir, file).split(path.sep).join('/') || path.basename(file); +} + +export function stripExtension(relativePath: string): string { + return stripTsExtension(relativePath); +} + +export function tsResolutionContext( + inputs: TsResolutionInputs, + workspacePackages: WorkspacePackages +): TsResolutionContext { + const { pathAnchor, baseMservPath, serviceVersionLinkHash, projectModuleHashes } = inputs; + const excludes = inputs.excludes instanceof Set ? inputs.excludes : new Set(inputs.excludes); + + const toProjectRelative = (absolutePath: string): string => + stripExtension(toRelative(pathAnchor, absolutePath)); + + // A sibling package imported by its name binds to the source its entry is + // built from. That source may belong to another program under the same + // anchor, whose module hash is the same pure function of its path; a source + // file outside the anchor or under a skipped directory is walked by no + // program and binds nothing. + const sourceModuleHashOf = (absolutePath: string): string | undefined => { + const inProgram = projectModuleHashes.get(absolutePath); + if (inProgram !== undefined) { + return inProgram; + } + const relative = path.relative(pathAnchor, absolutePath); + if (relative.startsWith('..') || path.isAbsolute(relative) || /\.d\.(m|c)?ts$/.test(relative) + || relative.split(path.sep).some((segment) => excludes.has(segment)) + || !fs.existsSync(absolutePath)) { + return undefined; + } + return moduleHashFor(toRelative(pathAnchor, absolutePath), baseMservPath, serviceVersionLinkHash); + }; + + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): WorkspaceModule | undefined => { + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, sourceModuleHashOf)); + } + return workspaceResolutions.get(specifier); + }; + + return { toProjectRelative, sourceModuleHashOf, resolveWorkspaceModule }; +} diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index 26287df5..f47f68d6 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -28,12 +28,18 @@ import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extract import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { extractTsPackageEntries } from '@/parsers/typescript/ts-package-entry-extractor'; import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; -import { WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; import { TsConfigResolver } from '@/parsers/typescript/tsconfig-resolver'; +import { + mergeCompleteness as mergeFileCompleteness, + parsePoolJobs, + parseTsFilesInPool, + STREAMED_KEYS, + STREAMED_TABLES, +} from '@/workflows/typescript/ts-parse-pool'; +import { tsResolutionContext } from '@/workflows/typescript/ts-resolution-context'; import { TsRelationWriter } from './ts-relation-writer'; import { EntityUtils } from '@/utils/entity-utils'; import { isGeneratedOutputDirectory } from '@/utils/generated-output'; -import { stripTsExtension } from '@/parsers/typescript/ts-module-paths'; import { isGitIgnoredDir } from '@/utils/git-ignored'; import { isVueFile, @@ -265,36 +271,21 @@ export class TypeScriptProjectAnalyzer { moduleHashFor(toRelative(pathAnchor, file), options.baseMservPath, serviceVersionLinkHash) ); } - const toProjectRelative = (absolutePath: string): string => - stripExtension(toRelative(pathAnchor, absolutePath)); - // A sibling package imported by its name binds to the source its entry is built - // from. That source may belong to another program under the same anchor, whose - // module hash is the same pure function of its path; a source file outside the - // anchor or under a skipped directory is walked by no program and binds nothing. + // The three closures extraction needs, built from plain values through + // ts-resolution-context.ts — the SAME constructor a parse worker uses, so + // the serial loop and the pool cannot drift. const workspacePackages = this.workspacePackagesAt(pathAnchor); - const sourceModuleHashOf = (absolutePath: string): string | undefined => { - const inProgram = projectModuleHashes.get(absolutePath); - if (inProgram !== undefined) { - return inProgram; - } - const relative = path.relative(pathAnchor, absolutePath); - if (relative.startsWith('..') || path.isAbsolute(relative) || /\.d\.(m|c)?ts$/.test(relative) - || relative.split(path.sep).some((segment) => excludes.has(segment)) - || !fs.existsSync(absolutePath)) { - return undefined; - } - return moduleHashFor(toRelative(pathAnchor, absolutePath), options.baseMservPath, serviceVersionLinkHash); - }; - const workspaceResolutions = new Map(); - const resolveWorkspaceModule = (specifier: string): WorkspaceModule | undefined => { - if (workspacePackages.size === 0) { - return undefined; - } - if (!workspaceResolutions.has(specifier)) { - workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, sourceModuleHashOf)); - } - return workspaceResolutions.get(specifier); + const resolutionInputs = { + pathAnchor, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + excludes, + projectModuleHashes, }; + const { toProjectRelative, resolveWorkspaceModule } = tsResolutionContext( + resolutionInputs, + workspacePackages + ); // Skips accumulate across the programs one analyzePrograms call drives; a // standalone analyze starts its own list. @@ -329,54 +320,29 @@ export class TypeScriptProjectAnalyzer { const completenessAccumulator = newCompletenessAccumulator(); let analysed = 0; - for (const file of files) { - let sourceText: string; - // the closure walk read this file already; take its text and release it - const prefetched = rootProgram?.texts.get(file); - if (prefetched !== undefined) { - rootProgram!.texts.delete(file); - } - try { - sourceText = prefetched ?? await fsp.readFile(file, 'utf-8'); - } catch (error) { + // One file's outcome on the SERIAL path (AXIOMCODE_PARSE_JOBS=1, or no + // compiled worker): the loop below calls it inline. The pooled path's + // consume mirrors this body over worker-rendered rows, in the same file + // order, so skip order, writer order and output bytes match. + const consumeOutcome = async ( + file: string, + outcome: { readError?: string; extractError?: string; facts?: TsFileFacts } + ): Promise => { + if (outcome.readError !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.READ_ERROR, String(error)); - continue; + SkippedFileReason.READ_ERROR, outcome.readError); + return; } - const governing = configResolver.resolve(file); - const script = scriptTextOf(file, sourceText); - let facts: TsFileFacts; - try { - facts = extractTypeScriptFile({ - absoluteFilePath: file, - filePath: toRelative(pathAnchor, file), - baseMservPath: options.baseMservPath, - moduleQualifiedName: toProjectRelative(file), - sourceText: script.text, - scriptKind: script.scriptKind, - serviceVersionLinkHash, - tsConfigPath: governing.configPath === '' - ? '' - : toRelative(pathAnchor, governing.configPath), - moduleResolutionMode: governing.moduleResolutionMode, - // Per file, from the config that actually claims it. `legacy/` in the - // fixture corpus compiles under experimentalDecorators while its - // siblings do not, and the source is identical either way. - decoratorSystem: governing.decoratorSystem, - compilerOptions: governing.options, - packageName: '', - projectModuleHashes, - toProjectRelative, - resolveWorkspaceModule, - }); - } catch (error) { + if (outcome.extractError !== undefined || !outcome.facts) { // An extraction error is a DEFECT, never a decision. Counted apart from // anything else so a parser that throws on every file cannot report a // clean run with empty relations. this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EXTRACTION_ERROR, String(error)); - continue; + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; } + const facts = outcome.facts; analysed += 1; // Measured HERE, before the row is let go. The measurement was already // per-file: the two indexes it called cross-file were keyed by a file's @@ -405,6 +371,119 @@ export class TypeScriptProjectAnalyzer { await writerFor(TYPESCRIPT_CSV_FILES.FIELD_POSITIONS).append(facts.fieldPositions); await writerFor(TYPESCRIPT_CSV_FILES.COMMENTS).append(facts.comments); await writerFor(TYPESCRIPT_CSV_FILES.PARSE_GAPS).append(facts.parseGaps); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files: + // extraction is per-file by design (cross-file call resolution is the + // engine's; the module link passes run after every file, below), so the + // pool only changes WHERE a file is parsed, never what order its rows are + // consumed in. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; + // the pool declining (no compiled worker beside this file) falls back to + // it too. + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + pooled = await parseTsFilesInPool( + { + pathAnchor, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + excludes: [...excludes], + projectModuleHashes, + }, + files.map((file, i) => { + // the closure walk read this file already; hand its text over + const prefetched = rootProgram?.texts.get(file); + if (prefetched !== undefined) { + rootProgram!.texts.delete(file); + } + return { + i, + file, + sourceText: prefetched, + filePath: toRelative(pathAnchor, file), + moduleQualifiedName: toProjectRelative(file), + }; + }), + jobs, + async (i, outcome) => { + const file = files[i] as string; + if (outcome.readError !== undefined) { + this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, + SkippedFileReason.READ_ERROR, outcome.readError); + return; + } + if (outcome.extractError !== undefined || !outcome.file) { + this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; + } + const pooledFile = outcome.file; + analysed += 1; + // The worker already measured this file; fold its counters, gaps and + // deferred verdicts in, in file order, as the serial call would have. + mergeFileCompleteness(completenessAccumulator, pooledFile.completeness); + moduleGraph.push({ + filePath: pooledFile.filePath, + modules: pooledFile.modules, + imports: pooledFile.imports, + exports: pooledFile.exports, + }); + for (const key of STREAMED_KEYS) { + const rendered = pooledFile.rendered[key]; + if (rendered) { + await writerFor(STREAMED_TABLES[key]).appendRendered(rendered.header, rendered.lines); + } + } + } + ); + } + if (!pooled) { + for (const file of files) { + // the closure walk read this file already; take its text and release it + const prefetched = rootProgram?.texts.get(file); + if (prefetched !== undefined) { + rootProgram!.texts.delete(file); + } + let outcome: { readError?: string; extractError?: string; facts?: TsFileFacts }; + try { + const sourceText = prefetched ?? await fsp.readFile(file, 'utf-8'); + try { + const governing = configResolver.resolve(file); + const script = scriptTextOf(file, sourceText); + outcome = { + facts: extractTypeScriptFile({ + absoluteFilePath: file, + filePath: toRelative(pathAnchor, file), + baseMservPath: options.baseMservPath, + moduleQualifiedName: toProjectRelative(file), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash, + tsConfigPath: governing.configPath === '' + ? '' + : toRelative(pathAnchor, governing.configPath), + moduleResolutionMode: governing.moduleResolutionMode, + // Per file, from the config that actually claims it. `legacy/` in the + // fixture corpus compiles under experimentalDecorators while its + // siblings do not, and the source is identical either way. + decoratorSystem: governing.decoratorSystem, + compilerOptions: governing.options, + packageName: '', + projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + } catch (error) { + outcome = { readError: String(error) }; + } + await consumeOutcome(file, outcome); + } } // The MODULE graph is the parser's, and it needs every file: an import of @@ -881,10 +960,6 @@ function pathAnchorFor(rootDir: string, baseMservPath: string): string { return contained ? base : root; } -function stripExtension(relativePath: string): string { - return stripTsExtension(relativePath); -} - /** Re-exported so a caller can create a source file the same way the extractor does. */ export const TYPESCRIPT_SCRIPT_TARGET = ts.ScriptTarget.Latest; From be91d28b78c7619214481a6ae685b107d433cfc0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:47 -0700 Subject: [PATCH 142/258] perf(javascript): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file work — read, SFC script split, ts.createSourceFile, the whole extraction spine — now runs on worker threads (js-parse-pool.ts, js-parse-worker.ts), the same pattern Python landed on parse-pool-core. The loop body became consumeOutcome, called in sorted file order by the pool and inline by the serial loop, so skip order, write order and the output bytes do not depend on which worker finishes first. The three skip cases cross as three distinct facts (read error, component with nothing to read, extractor throw), exactly as the serial loop records them. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path, and a run with no compiled worker falls back to it. Two things Python's half did not need. JavaScript extraction consumes a project-wide input, projectModuleHashes — minted from paths alone before any file is parsed, so it is fixed input, not cross-file state — and cloning it into every dispatch would copy the whole map once per file; it crosses once per run instead, through a temporary JSON file whose path rides on each dispatch. And the serial loop's resolver caches (path aliases, workspace packages, the workspace-resolution memo) are pure functions of the filesystem, so each worker rebuilds its own rather than being seeded from main-thread state — seeding would hide an order dependence instead of proving there is none. The governing package.json verdict does ride each dispatch, because the main thread already resolved it for the module-hash mint and the two answers must not be able to disagree. The writers are async where Python's accumulation was not, so the pool path chains outcomes on a tail promise: file order is preserved, the first error is rethrown before the relations publish, and nothing is buffered beyond the dispatch window. No leak this time: the byte gate held on the first run. The one module-level cache in the JavaScript extractor stack is a realpath memo, and it is order-independent, so unlike Python (whose gate caught a byte-range map never reset between files) no extractor state needed resetting and no rows changed: old serial, new serial and pooled IR are identical. On a 6,340-file subject the javascript stage runs 14.4 s serial, 11.1 s at the default 4 jobs, 10.8 s at 6 (medians of three interleaved reps); a 638-file subject runs 4.65 s to 2.96 s. The gain is smaller than Python's because the main thread still thaws and streams every row — the big subject's expressions relation alone is 190 MB — work the serial loop writes without a thread crossing. Pooled max RSS is 1.28 GB / 1.38 GB on the two subjects, inside the default heap with no NODE_OPTIONS. IR is byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool on both subjects at the default width and at 6 jobs, and across serial reruns. tests/run.py --lang javascript: 296 of 307, and the pristine base commit scores the same 296 with the identical 11 failures (four pre-existing cases about resolution semantics, none touching parse order), so the pool changes no case. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../javascript/javascript-project-analyzer.ts | 160 ++++++++++---- .../src/workflows/javascript/js-parse-pool.ts | 195 ++++++++++++++++++ .../workflows/javascript/js-parse-worker.ts | 133 ++++++++++++ 3 files changed, 445 insertions(+), 43 deletions(-) create mode 100644 parser/src/workflows/javascript/js-parse-pool.ts create mode 100644 parser/src/workflows/javascript/js-parse-worker.ts diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 47a90e4a..eba9bb35 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -12,7 +12,6 @@ import { import { SkippedFileReason } from '@/enums/SkippedFileReason'; import { extractJavaScriptFile, - JsFileFacts, } from '@/parsers/javascript/extractors/js-fact-extractor'; import { accumulateFileCompleteness, @@ -53,6 +52,11 @@ import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; import { isGitIgnoredDir } from '@/utils/git-ignored'; import { scriptTextOf } from '@/utils/vue-sfc'; +import { + JsParseOutcome, + parseFilesInPool, + parsePoolJobs, +} from '@/workflows/javascript/js-parse-pool'; /** * Each relation's header, from its registry, so an EMPTY relation still writes @@ -60,7 +64,7 @@ import { scriptTextOf } from '@/utils/vue-sfc'; * constant list — which is why the prototype can answer without a row. */ /** Source extensions a workspace package's entry is mapped back to, in the order they are looked for. */ -const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; +export const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; const HEADER_BY_FILE: Readonly> = { [JAVASCRIPT_CSV_FILES.MODULES]: JsModuleRegistry.prototype.getCsvHeader(), @@ -442,54 +446,36 @@ export class JavaScriptProjectAnalyzer { try { await writerFor(JAVASCRIPT_CSV_FILES.PACKAGE_ENTRIES).append(packageEntries); - for (const file of files) { - let sourceText: string; - try { - sourceText = await fsp.readFile(file, 'utf-8'); - } catch (error) { + + // One file's outcome, consumed the same way whichever thread produced it. + // The pool calls this in file order as results arrive (never after + // buffering them all — a project's rows fill the heap once, not twice), + // and the serial loop calls it inline, so skip order, write order and + // therefore output bytes are identical across the two paths. + const consumeOutcome = async (file: string, outcome: JsParseOutcome): Promise => { + if (outcome.readError !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.READ_ERROR, String(error)); - continue; + SkippedFileReason.READ_ERROR, outcome.readError); + return; } // A component is read once, by `scriptTextOf`: a `.vue` as its virtual // script, a `.svelte`/`.astro` as its JavaScript blocks. One with nothing // this analyzer can read (a lang="ts" Vue script is the TypeScript one's) is a recorded skip. - const script = scriptTextOf(file, sourceText); - if (script.unread !== undefined) { + if (outcome.unread !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EMPTY_CONTENT, script.unread); - continue; + SkippedFileReason.EMPTY_CONTENT, outcome.unread); + return; } - const governing = governingByFile.get(file)!; - let facts: JsFileFacts; - try { - facts = extractJavaScriptFile({ - absoluteFilePath: file, - filePath: toRelative(pathAnchor, file), - baseMservPath: baseMservPath, - moduleQualifiedName: toProjectRelative(file), - sourceText: script.text, - scriptKind: script.scriptKind, - serviceVersionLinkHash, - moduleSystem: governing.moduleSystem, - moduleSystemSource: governing.moduleSystemSource, - governingPackageJsonPath: governing.packageJsonPath === '' - ? '' - : toRelative(pathAnchor, governing.packageJsonPath), - packageName: governing.packageName, - compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), - projectModuleHashes, - toProjectRelative, - resolveWorkspaceModule, - }); - } catch (error) { + if (outcome.extractError !== undefined || outcome.facts === undefined) { // An extraction error is a DEFECT, never a decision. Counted apart // from anything else so a parser that throws on every file cannot // report a clean run with empty relations. this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EXTRACTION_ERROR, String(error)); - continue; + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; } + const facts = outcome.facts; analysed += 1; const module = facts.modules[0]!; if (module.sourceProvenance !== 'PROJECT') { @@ -523,6 +509,94 @@ export class JavaScriptProjectAnalyzer { // measure folds this file into running totals and the objects become // garbage on the next iteration. accumulateFileCompleteness(completeness, facts); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (js-parse-pool.ts): read, parse, extract and hash are independent + // between files — `projectModuleHashes` and every resolver cache the + // loop shares were derived from paths alone before any file was parsed, + // so no file's extraction reads another's results. The loop below still + // CONSUMES every outcome in sorted file order through the unchanged + // body. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; the + // pool declining (no compiled worker) falls back to it too. + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + // The pool's consume callback is synchronous; the body above awaits + // its writers. Outcomes are chained in the file order the pool + // guarantees, so writes land in exactly the serial order, and the + // chain is awaited (and its first error rethrown) before the + // relations publish. After a failure the remaining outcomes are + // dropped unconsumed, as the serial loop drops the files after a + // throw. + let consumeError: unknown; + let tail: Promise = Promise.resolve(); + pooled = await parseFilesInPool( + { + pathAnchor, + baseMservPath, + serviceVersionLinkHash, + excludeDirs: [...excludes], + moduleHashes: [...projectModuleHashes], + }, + files.map((filePath) => ({ filePath, governing: governingByFile.get(filePath)! })), + jobs, + (i, outcome) => { + tail = tail + .then(() => (consumeError === undefined + ? consumeOutcome(files[i]!, outcome) + : undefined)) + .catch((error) => { consumeError = consumeError ?? error; }); + } + ); + await tail; + if (consumeError !== undefined) { + throw consumeError; + } + } + if (!pooled) { + for (const file of files) { + let sourceText: string; + try { + sourceText = await fsp.readFile(file, 'utf-8'); + } catch (error) { + await consumeOutcome(file, { readError: String(error) }); + continue; + } + const script = scriptTextOf(file, sourceText); + if (script.unread !== undefined) { + await consumeOutcome(file, { unread: script.unread }); + continue; + } + const governing = governingByFile.get(file)!; + let outcome: JsParseOutcome; + try { + outcome = { + facts: extractJavaScriptFile({ + absoluteFilePath: file, + filePath: toRelative(pathAnchor, file), + baseMservPath: baseMservPath, + moduleQualifiedName: toProjectRelative(file), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash, + moduleSystem: governing.moduleSystem, + moduleSystemSource: governing.moduleSystemSource, + governingPackageJsonPath: governing.packageJsonPath === '' + ? '' + : toRelative(pathAnchor, governing.packageJsonPath), + packageName: governing.packageName, + compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), + projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + await consumeOutcome(file, outcome); + } } for (const filename of Object.values(JAVASCRIPT_CSV_FILES)) { @@ -631,7 +705,7 @@ export class JavaScriptProjectAnalyzer { * here. A parser running two resolvers and comparing them is doing resolution * work, which is exactly what `js_import.resolverAgreement` was deleted for. */ -function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.CompilerOptions { +export function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.CompilerOptions { return { allowJs: true, target: ts.ScriptTarget.ESNext, @@ -642,7 +716,7 @@ function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.Comp } /** The alias half of a `jsconfig.json` / `tsconfig.json`: nothing else of it is read. */ -type PathAliases = Partial>; +export type PathAliases = Partial>; /** * The `compilerOptions.paths` / `baseUrl` that govern a file, from the nearest @@ -659,7 +733,7 @@ type PathAliases = Partial(); private readonly bundlerByDirectory = new Map(); @@ -844,7 +918,7 @@ function pathAnchorFor(rootDir: string, baseMservPath: string): string { return rootIsInsideBase ? base : rootDir; } -function toRelative(anchor: string, absolutePath: string): string { +export function toRelative(anchor: string, absolutePath: string): string { return path.relative(anchor, absolutePath).split(path.sep).join('/'); } @@ -855,7 +929,7 @@ function toRelative(anchor: string, absolutePath: string): string { * name carried an extension. The directory part is preserved, which is why the * strip is applied to the basename and rejoined rather than to the whole path. */ -function stripExtension(relativePath: string): string { +export function stripExtension(relativePath: string): string { const slash = relativePath.lastIndexOf('/'); const directory = slash < 0 ? '' : relativePath.slice(0, slash + 1); return directory + stripJsExtension(relativePath.slice(slash + 1)); diff --git a/parser/src/workflows/javascript/js-parse-pool.ts b/parser/src/workflows/javascript/js-parse-pool.ts new file mode 100644 index 00000000..d7a99b70 --- /dev/null +++ b/parser/src/workflows/javascript/js-parse-pool.ts @@ -0,0 +1,195 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { JsBlockRegistry } from '@/analysis-types/javascript/JsBlockRegistry'; +import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry'; +import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry'; +import { JsExportRegistry } from '@/analysis-types/javascript/JsExportRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { JsFieldRegistry } from '@/analysis-types/javascript/JsFieldRegistry'; +import { JsImportRegistry } from '@/analysis-types/javascript/JsImportRegistry'; +import { JsMethodParameterRegistry } from '@/analysis-types/javascript/JsMethodParameterRegistry'; +import { JsMethodRegistry } from '@/analysis-types/javascript/JsMethodRegistry'; +import { JsModuleRegistry } from '@/analysis-types/javascript/JsModuleRegistry'; +import { JsParseGapRegistry } from '@/analysis-types/javascript/JsParseGapRegistry'; +import { JsScopeRegistry } from '@/analysis-types/javascript/JsScopeRegistry'; +import { JsTypeHeritageRegistry } from '@/analysis-types/javascript/JsTypeHeritageRegistry'; +import { JsTypeReferenceRegistry } from '@/analysis-types/javascript/JsTypeReferenceRegistry'; +import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; +import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; +import { JsFileFacts } from '@/parsers/javascript/extractors/js-fact-extractor'; +import { GoverningPackageJson } from '@/parsers/javascript/package-json-resolver'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The JavaScript half of the parallel parse stage: which prototype each + * table's rows get back, and the shape of a dispatch and a reply. Everything + * thread- and shape-related lives in parse-pool-core.ts. + * + * Rows must come back as REAL instances, not snapshots: the writers call + * `toCsv()` on every row, and the completeness measure calls `getHash()` on + * imports and `importLinkHashValue()` on call sites — all prototype methods. + * + * ## Two things Python's half does not have + * + * 1. **A project-wide input.** `extractJavaScriptFile` consumes + * `projectModuleHashes` — one entry per file in the whole analysis, minted + * from paths alone BEFORE any file is parsed, so it is fixed input to the + * pool, not cross-file state. Cloning it into every dispatch would copy the + * whole map once per FILE; instead it crosses once per RUN, through a + * temporary JSON file whose path rides on each dispatch and that each + * worker reads a single time (`JsParseSharedState`). + * 2. **Derived-from-disk inputs.** The alias configs, workspace packages and + * the governing `package.json` are all functions of the filesystem, which + * every worker shares — so each worker rebuilds its own resolver caches + * rather than shipping closures across the thread boundary. The per-file + * CONCLUSION of `PackageJsonResolver` does ride on the dispatch, because + * the main thread has already computed it for the module-hash mint and two + * computations of one answer is one more than needed. + */ +const TABLE_PROTOTYPES = { + modules: JsModuleRegistry.prototype, + scopes: JsScopeRegistry.prototype, + types: JsTypeRegistry.prototype, + heritages: JsTypeHeritageRegistry.prototype, + methods: JsMethodRegistry.prototype, + methodParameters: JsMethodParameterRegistry.prototype, + fields: JsFieldRegistry.prototype, + variables: JsVariableRegistry.prototype, + blocks: JsBlockRegistry.prototype, + expressions: JsExpressionRegistry.prototype, + callSites: JsCallSiteRegistry.prototype, + imports: JsImportRegistry.prototype, + exports: JsExportRegistry.prototype, + comments: JsCommentRegistry.prototype, + typeReferences: JsTypeReferenceRegistry.prototype, + parseGaps: JsParseGapRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** + * The run-wide input every file's extraction consumes, written ONCE to a + * temporary JSON file rather than cloned into every dispatch. Everything in + * it is either a scalar of the run or derived from paths alone before any + * file was parsed — nothing in it depends on another file's extraction, which + * is what lets the files parse in any order. + */ +export interface JsParseSharedState { + /** Canonical anchor every emitted path hangs off — see `pathAnchorFor`. */ + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** The walk's directory excludes, for the worker's workspace discovery. */ + excludeDirs: string[]; + /** Absolute path -> `js_module` hash, for every file in the analysis. */ + moduleHashes: [string, string][]; +} + +/** What the analyzer sends a worker for one file: strings and one small record. */ +export interface JsParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + filePath: string; + /** Where this run's `JsParseSharedState` sits; identical on every dispatch. */ + sharedPath: string; + /** + * The governing `package.json`'s verdict, as the main thread resolved it + * for the module-hash mint. Dispatched rather than re-resolved so the hash + * a worker emits and the hash the mint produced cannot disagree. + */ + governing: GoverningPackageJson; +} + +/** One file's outcome: the same four cases the serial loop distinguishes. */ +export interface JsParseOutcome { + readError?: string; + /** `scriptTextOf` found nothing this analyzer can read (a lang="ts" Vue script). */ + unread?: string; + extractError?: string; + facts?: JsFileFacts; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface JsParseReply { + i: number; + readError?: string; + unread?: string; + extractError?: string; + facts?: Record; +} + +/** + * Worker side: a fact set as columns structured clone can carry cheaply. + * + * Only the sixteen row tables cross. The linking fields (`declarations`, + * `binder`, `sourceFile`, …) are dropped deliberately: they hold extractor + * instances and `ts.SourceFile`s, which structured clone cannot carry, and + * `JsFileFacts` documents that nothing outside the extractor consumes them — + * the analyzer reads exactly the tables and the module row's own columns. + */ +export function freezeFactSet(facts: JsFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = freezeTable(facts[key] as unknown as object[]); + } + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFactSet(frozen: Record): JsFileFacts { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as JsFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseFilesInPool( + shared: JsParseSharedState, + files: { filePath: string; governing: GoverningPackageJson }[], + jobs: number, + consume: (i: number, outcome: JsParseOutcome) => void +): Promise { + const workerPath = path.join(__dirname, 'js-parse-worker.js'); + // Checked here as well as in the core, because the shared file should not + // be written for a run that is about to decline the pool. + if (!fs.existsSync(workerPath)) { + return false; + } + const sharedPath = path.join( + os.tmpdir(), + `axiomcode-js-parse-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e9)}.json` + ); + await fsp.writeFile(sharedPath, JSON.stringify(shared), 'utf-8'); + try { + return await runParsePool( + workerPath, + files.map((file, i) => ({ i, filePath: file.filePath, sharedPath, governing: file.governing })), + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, unread: reply.unread, extractError: reply.extractError } + ) + ); + } finally { + await fsp.rm(sharedPath, { force: true }); + } +} diff --git a/parser/src/workflows/javascript/js-parse-worker.ts b/parser/src/workflows/javascript/js-parse-worker.ts new file mode 100644 index 00000000..9b0f6981 --- /dev/null +++ b/parser/src/workflows/javascript/js-parse-worker.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { extractJavaScriptFile } from '@/parsers/javascript/extractors/js-fact-extractor'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; +import { scriptTextOf } from '@/utils/vue-sfc'; +import { + compilerOptionsFor, + JS_SOURCE_EXTENSIONS, + PathAliasResolver, + stripExtension, + toRelative, +} from '@/workflows/javascript/javascript-project-analyzer'; +import { + freezeFactSet, + JsParseDispatch, + JsParseReply, + JsParseSharedState, +} from '@/workflows/javascript/js-parse-pool'; + +/** + * One parse worker: reads a file, runs the SAME extraction the serial loop + * runs, and posts the fact set back as a prototype-less snapshot + * (`freezeFactSet`). The error cases mirror the serial loop's exactly — a + * read failure, a component with nothing to read, and an extractor throw are + * three different facts, and the analyzer records them under three reasons. + * + * ## The worker's caches are rebuilt from disk, not shipped from the thread + * + * The serial loop reuses one `PathAliasResolver`, one `WorkspacePackages` + * discovery and one memo of workspace resolutions across the whole project. + * Every one of those is a pure function of the filesystem and of + * `projectModuleHashes` — which the main thread minted from paths alone, + * before any file was parsed — so a per-worker rebuild answers identically + * whatever order files reach whichever worker. Seeding them from main-thread + * state instead would HIDE an order dependence rather than prove there is + * none; the byte-gate against the serial run is what proves it. + */ +interface RunContext { + sharedPath: string; + shared: JsParseSharedState; + projectModuleHashes: Map; + pathAliases: PathAliasResolver; + toProjectRelative: (absolutePath: string) => string; + resolveWorkspaceModule: (specifier: string) => string | undefined; +} + +let context: RunContext | undefined; + +/** The run-wide state, loaded once per run (the path is identical on every dispatch). */ +function contextFor(sharedPath: string): RunContext { + if (context !== undefined && context.sharedPath === sharedPath) { + return context; + } + const shared = JSON.parse(fs.readFileSync(sharedPath, 'utf-8')) as JsParseSharedState; + const projectModuleHashes = new Map(shared.moduleHashes); + const excludes = new Set(shared.excludeDirs); + const toProjectRelative = (absolutePath: string): string => + stripExtension(toRelative(shared.pathAnchor, absolutePath)); + // Discovered lazily, as the serial loop's is built once up front: the walk + // only happens at all when a file holds a bare specifier to resolve. + let workspacePackages: WorkspacePackages | undefined; + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): string | undefined => { + workspacePackages ??= WorkspacePackages.discover(shared.pathAnchor, excludes); + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, + (absolutePath) => projectModuleHashes.get(absolutePath), JS_SOURCE_EXTENSIONS)?.absolutePath); + } + return workspaceResolutions.get(specifier); + }; + context = { + sharedPath, + shared, + projectModuleHashes, + pathAliases: new PathAliasResolver(), + toProjectRelative, + resolveWorkspaceModule, + }; + return context; +} + +const port = parentPort; +if (!port) throw new Error('js-parse-worker must run as a worker thread'); + +port.on('message', (job: JsParseDispatch) => { + void (async () => { + const run = contextFor(job.sharedPath); + let sourceText: string; + try { + sourceText = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies JsParseReply); + return; + } + const script = scriptTextOf(job.filePath, sourceText); + if (script.unread !== undefined) { + port.postMessage({ i: job.i, unread: script.unread } satisfies JsParseReply); + return; + } + try { + const facts = extractJavaScriptFile({ + absoluteFilePath: job.filePath, + filePath: toRelative(run.shared.pathAnchor, job.filePath), + baseMservPath: run.shared.baseMservPath, + moduleQualifiedName: run.toProjectRelative(job.filePath), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash: run.shared.serviceVersionLinkHash, + moduleSystem: job.governing.moduleSystem, + moduleSystemSource: job.governing.moduleSystemSource, + governingPackageJsonPath: job.governing.packageJsonPath === '' + ? '' + : toRelative(run.shared.pathAnchor, job.governing.packageJsonPath), + packageName: job.governing.packageName, + compilerOptions: compilerOptionsFor( + job.governing.moduleSystem, + run.pathAliases.aliasesFor(job.filePath) + ), + projectModuleHashes: run.projectModuleHashes, + toProjectRelative: run.toProjectRelative, + resolveWorkspaceModule: run.resolveWorkspaceModule, + }); + port.postMessage({ i: job.i, facts: freezeFactSet(facts) } satisfies JsParseReply); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies JsParseReply); + } + })(); +}); From 93950055aa8eee45983c737e0dabf443f54eb00b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:58:59 -0700 Subject: [PATCH 143/258] python: an imported module-level instance types its receiver across the module boundary (#1140) Three pieces, one shape: `order_service = OrderService()` in one module, `from svc.order_service import order_service; order_service.cancel(oid)` in another, and the call fell to a name match while the identical call next to the assignment resolved. - parser, import step: when the imported member's name collides with the module's own last segment -- the ordinary singleton idiom, the value named after its module -- the member-vs-submodule fallback suffix-matched the TARGET MODULE ITSELF and resolved the import as MODULE with an empty hash, so the VARIABLE branch (#1143) never ran. A module-level variable now counts as a declared member ahead of the submodule fallback, and a "submodule" that is the target module itself is rejected as the suffix collision it is. - parser, call sites: the per-module local type index is now built for every module before any module resolves call sites, and a binding created by a VARIABLE import copies the exporting binding's inferred type. The ordinary NAME-receiver lookup then resolves calls through the imported instance. - engine: binding_value_type gains the mirror clause -- a name bound by a VARIABLE import takes the type of the binding it names, write-count trade-offs inherited from the exporting side. Suite: 43/43 green; new case 43-module-singleton-import pins both shapes (colliding and non-colliding names) plus a genuine-submodule control, all known_edge. Cases 21 and 42 re-blessed: +1 and +3 known_edge, the untyped_receiver:local_untyped reason disappears, nothing demoted. On three corpus projects the parse-level A/B gains 621 resolved call sites and loses zero; engine A/B on one subject replaces 18 placeholder rows with 24 known_edge rows, solve time unchanged (53s -> 42s). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 13 +++ .../43-module-singleton-import/src/api.py | 7 ++ .../src/order_service.py | 20 ++++ .../src/pkg/__init__.py | 0 .../src/pkg/tools.py | 5 + .../src/publisher.py | 5 + .../43-module-singleton-import/src/signals.py | 9 ++ .../43-module-singleton-import/src/use_pkg.py | 5 + .../expected/21-url-and-signal-dispatch.edges | 2 +- .../expected/21-url-and-signal-dispatch.tiers | 9 +- .../python/expected/42-signal-forms.edges | 6 +- .../python/expected/42-signal-forms.tiers | 9 +- .../expected/43-module-singleton-import.edges | 6 ++ .../43-module-singleton-import.entries | 1 + .../43-module-singleton-import.framework | 6 ++ .../expected/43-module-singleton-import.tiers | 23 +++++ .../extractors/python-resolution-linker.ts | 93 ++++++++++++++++++- 17 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 graph/test/python/cases/43-module-singleton-import/src/api.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/order_service.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/publisher.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/signals.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/use_pkg.py create mode 100644 graph/test/python/expected/43-module-singleton-import.edges create mode 100644 graph/test/python/expected/43-module-singleton-import.entries create mode 100644 graph/test/python/expected/43-module-singleton-import.framework create mode 100644 graph/test/python/expected/43-module-singleton-import.tiers diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index 8385f783..30df9f4d 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -430,6 +430,19 @@ binding_value_type(p, b, t) :- assign_pair(p, tgt, val), expr_type(p, val, t). +// ── AN IMPORTED MODULE-LEVEL VALUE IS THE BINDING IT NAMES (#1140) ─────────── +// `from order_service import order_service` resolves to the exporting module's +// own binding row (import_resolved_target kind VARIABLE), and that binding's +// value type is derived above from its assignment. Without this clause the +// type stopped at the module boundary: the same `order_service.cancel()` was a +// known edge next to the assignment and a name match one import away. The +// write-count trade-offs are inherited, not re-decided -- a multi-write export +// arrives as the union and the tier follows from the target count as usual. +binding_value_type(p, b, t) :- + import_binding(p, b, i), + import_resolved_target(p, "VARIABLE", b2, i), + binding_value_type(p, b2, t). + // ── A CONDITIONAL EXPRESSION IS EITHER BRANCH ──────────────────────────────── // `w = HtmlWriter() if flag else PlainWriter()`. CONDITIONAL_EXPRESSION was present in // the IR with BODY / CONDITION / ORELSE children and NO rule read it, so a ternary had diff --git a/graph/test/python/cases/43-module-singleton-import/src/api.py b/graph/test/python/cases/43-module-singleton-import/src/api.py new file mode 100644 index 00000000..a169f808 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/api.py @@ -0,0 +1,7 @@ +"""Imports the singleton whose name collides with its module's last segment.""" + +from order_service import order_service + + +def cancel_endpoint(oid): + return order_service.cancel(oid) diff --git a/graph/test/python/cases/43-module-singleton-import/src/order_service.py b/graph/test/python/cases/43-module-singleton-import/src/order_service.py new file mode 100644 index 00000000..a3ba1433 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/order_service.py @@ -0,0 +1,20 @@ +"""A module-level singleton named after its own module — the ordinary idiom. + +`from order_service import order_service` must bind the VALUE, not suffix-match +back to this module and report a module import (#1140). +""" + + +class OrderService: + def cancel(self, oid): + return oid + + def refund(self, oid): + return oid + + +order_service = OrderService() + + +def same_module_caller(oid): + return order_service.cancel(oid) diff --git a/graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py b/graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py b/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py new file mode 100644 index 00000000..a02ce3d5 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py @@ -0,0 +1,5 @@ +"""Control: a GENUINE submodule import must keep resolving as a module.""" + + +def helper(): + return 1 diff --git a/graph/test/python/cases/43-module-singleton-import/src/publisher.py b/graph/test/python/cases/43-module-singleton-import/src/publisher.py new file mode 100644 index 00000000..4466e43e --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/publisher.py @@ -0,0 +1,5 @@ +from signals import order_placed + + +def publish(sender): + return order_placed.send(sender) diff --git a/graph/test/python/cases/43-module-singleton-import/src/signals.py b/graph/test/python/cases/43-module-singleton-import/src/signals.py new file mode 100644 index 00000000..b5bdb0e8 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/signals.py @@ -0,0 +1,9 @@ +"""A module-level value whose name does NOT collide with the module name.""" + + +class Signal: + def send(self, sender): + return sender + + +order_placed = Signal() diff --git a/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py b/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py new file mode 100644 index 00000000..027d3e32 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py @@ -0,0 +1,5 @@ +from pkg import tools + + +def run(): + return tools.helper() diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.edges b/graph/test/python/expected/21-url-and-signal-dispatch.edges index 63aeda15..9d07c714 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.edges +++ b/graph/test/python/expected/21-url-and-signal-dispatch.edges @@ -1,13 +1,13 @@ ambiguous_unknown DECORATOR_APPLICATION handlers. -> - ambiguous_unknown DECORATOR_APPLICATION main. -> - ambiguous_unknown METHOD_CALL main.not_a_signal_send -> - -ambiguous_unknown METHOD_CALL ship.ship_order -> - boundary_lib SIMPLE_CALL main. -> builtin:object.__init__ boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ known_edge DECORATOR_CALL handlers. -> handlers.receiver known_edge DECORATOR_CALL main. -> main.receiver known_edge METHOD_CALL main. -> main.Signal.connect known_edge METHOD_CALL main.place_order -> main.Signal.send +known_edge METHOD_CALL ship.ship_order -> signals.Signal.send known_edge SIMPLE_CALL handlers.on_never_shipped -> handlers._record known_edge SIMPLE_CALL handlers.on_shipped -> handlers._record known_edge SIMPLE_CALL main. -> main.path diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.tiers b/graph/test/python/expected/21-url-and-signal-dispatch.tiers index dff4f56f..56e70052 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.tiers +++ b/graph/test/python/expected/21-url-and-signal-dispatch.tiers @@ -1,9 +1,9 @@ distinct call sites emitted: 32 --- by tier: edge ROWS, and the distinct SITES they cover --- - 6 rows 6 sites ambiguous_unknown + 5 rows 5 sites ambiguous_unknown 4 rows 4 sites boundary_lib - 22 rows 22 sites known_edge + 23 rows 23 sites known_edge --- edge rows by call kind --- 4 DECORATOR_APPLICATION @@ -13,14 +13,13 @@ distinct call sites emitted: 32 --- unresolved reasons --- 4 decorator_factory_result_untyped - 1 untyped_receiver:local_untyped 1 untyped_receiver:parameter --- the engine's own conservation ledger --- 32 _total_sites - 6 ambiguous_unknown + 5 ambiguous_unknown 4 boundary_lib - 22 known_edge + 23 known_edge --- reconciling rows against the conserved site count --- edge rows 32 diff --git a/graph/test/python/expected/42-signal-forms.edges b/graph/test/python/expected/42-signal-forms.edges index c42f783d..456f0118 100644 --- a/graph/test/python/expected/42-signal-forms.edges +++ b/graph/test/python/expected/42-signal-forms.edges @@ -1,11 +1,11 @@ ambiguous_unknown DECORATOR_APPLICATION handlers. -> - ambiguous_unknown METHOD_CALL handlers. -> - ambiguous_unknown METHOD_CALL orders.close -> - -ambiguous_unknown METHOD_CALL orders.pay -> - -ambiguous_unknown METHOD_CALL orders.place -> - ambiguous_unknown METHOD_CALL orders.ship -> - -ambiguous_unknown METHOD_CALL orders.void -> - boundary_lib DECORATOR_CALL handlers. -> external:receiver boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ known_edge DECORATOR_CALL handlers. -> handlers.remember known_edge METHOD_CALL orders.not_a_signal -> orders.Outbox.asend +known_edge METHOD_CALL orders.pay -> signals.Signal.asend +known_edge METHOD_CALL orders.place -> signals.Signal.send +known_edge METHOD_CALL orders.void -> signals.Signal.send diff --git a/graph/test/python/expected/42-signal-forms.tiers b/graph/test/python/expected/42-signal-forms.tiers index e28d1d9d..e780b18a 100644 --- a/graph/test/python/expected/42-signal-forms.tiers +++ b/graph/test/python/expected/42-signal-forms.tiers @@ -1,9 +1,9 @@ distinct call sites emitted: 22 --- by tier: edge ROWS, and the distinct SITES they cover --- - 11 rows 11 sites ambiguous_unknown + 8 rows 8 sites ambiguous_unknown 9 rows 9 sites boundary_lib - 2 rows 2 sites known_edge + 5 rows 5 sites known_edge --- edge rows by call kind --- 5 DECORATOR_APPLICATION @@ -14,13 +14,12 @@ distinct call sites emitted: 22 --- unresolved reasons --- 5 decorator_factory_result_untyped 3 untyped_receiver:attribute_object_untyped - 3 untyped_receiver:local_untyped --- the engine's own conservation ledger --- 22 _total_sites - 11 ambiguous_unknown + 8 ambiguous_unknown 9 boundary_lib - 2 known_edge + 5 known_edge --- reconciling rows against the conserved site count --- edge rows 22 diff --git a/graph/test/python/expected/43-module-singleton-import.edges b/graph/test/python/expected/43-module-singleton-import.edges new file mode 100644 index 00000000..c35a14c7 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.edges @@ -0,0 +1,6 @@ +boundary_lib SIMPLE_CALL order_service. -> builtin:object.__init__ +boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ +known_edge METHOD_CALL api.cancel_endpoint -> order_service.OrderService.cancel +known_edge METHOD_CALL order_service.same_module_caller -> order_service.OrderService.cancel +known_edge METHOD_CALL publisher.publish -> signals.Signal.send +known_edge METHOD_CALL use_pkg.run -> pkg.tools.helper diff --git a/graph/test/python/expected/43-module-singleton-import.entries b/graph/test/python/expected/43-module-singleton-import.entries new file mode 100644 index 00000000..276e39fa --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.entries @@ -0,0 +1 @@ +── entry_point (0) ── diff --git a/graph/test/python/expected/43-module-singleton-import.framework b/graph/test/python/expected/43-module-singleton-import.framework new file mode 100644 index 00000000..29577b75 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.framework @@ -0,0 +1,6 @@ +── framework_edge (0) ── +── framework_unjoined (1) ── + 1 signal_dispatch no_receiver +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/43-module-singleton-import.tiers b/graph/test/python/expected/43-module-singleton-import.tiers new file mode 100644 index 00000000..3f658c74 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.tiers @@ -0,0 +1,23 @@ +distinct call sites emitted: 6 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 2 rows 2 sites boundary_lib + 4 rows 4 sites known_edge + +--- edge rows by call kind --- + 4 METHOD_CALL + 2 SIMPLE_CALL + +--- unresolved reasons --- + (none — every site resolved) + +--- the engine's own conservation ledger --- + 6 _total_sites + 2 boundary_lib + 4 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 6 + minus extra rows from multi-target sites 0 + = tier/site pairs 6 + engine's conserved site total 6 diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 798ac85b..0d5c5485 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -283,12 +283,27 @@ export class PythonResolutionLinker { ? undefined : exportsByModule.get(targetModule.qualifiedName)?.get(member) ?? this.followReExport(member, targetModule, exportsByModule, moduleByQualifiedName); - if (declared === undefined || declared === null) { + // A module-level VARIABLE is a member too, and the interpreter's + // member-first order applies to it the same as to a def or a class. + // Without this check, `from svc.order_service import order_service` — + // the ordinary singleton idiom, where the value is named after its + // module — fell into the submodule fallback below, whose findModule + // matches by SUFFIX and so handed back svc.order_service ITSELF: the + // import resolved to MODULE with an empty hash and the VARIABLE + // branch further down never ran (#1140). + const variableMember = + targetModule === undefined + ? undefined + : moduleVariablesByModule.get(targetModule.qualifiedName)?.get(member); + if ((declared === undefined || declared === null) && variableMember === undefined) { const asModule = targetName === null || targetName === '' ? member : `${targetName}.${member}`; const memberModule = this.findModule(asModule, moduleByQualifiedName); - if (memberModule) { + // The module found by suffix must not be the target module itself: + // `from X import Y` never binds X, so a "submodule" that IS X is a + // suffix collision, not an answer. + if (memberModule && memberModule !== targetModule) { record.setResolution(memberModule.moduleHash, PythonImportTargetKind.MODULE, ''); stats.importsResolved += 1; continue; @@ -688,6 +703,20 @@ export class PythonResolutionLinker { } } + // Per-module resolution context, built for EVERY module before ANY module + // resolves its call sites. The split matters for one reason: an imported + // module-level value's type lives in the EXPORTING module's local type + // index, and module order is arbitrary, so typing and resolution cannot + // share one sweep. + const resolutionCtxByModuleHash = new Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + boundNames: Set; + importedModuleNames: Set; + typesByName: Map; + localTypeByBinding: Map; + }>(); for (const module of modules) { const entityByBinding = new Map(); for (const method of module.methods) { @@ -779,6 +808,66 @@ export class PythonResolutionLinker { mroCache, }); + resolutionCtxByModuleHash.set(module.moduleHash, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + typesByName, + localTypeByBinding, + }); + } + + // A from-import of a module-level VALUE carries the binding it names + // (#1140, PythonImportTargetKind.VARIABLE) — but the IMPORTING module's + // local type index knew nothing about that binding, so + // `order_service.cancel()` still fell to a name match whenever + // `order_service = OrderService()` lives in another module, while the same + // call in the exporting module resolved. The exporter's own index has + // already typed that binding on the same three grounds any local uses; + // copy the answer onto the import's binding so the ordinary NAME-receiver + // lookup finds it. One hop only, by construction: a re-exported value + // resolves to an import binding, which is never an assigned module-scope + // binding, so it was not given VARIABLE kind in the first place. + for (const module of modules) { + const own = resolutionCtxByModuleHash.get(module.moduleHash); + if (!own) { + continue; + } + for (const record of module.imports) { + if (record.getResolvedTargetKind() !== PythonImportTargetKind.VARIABLE) { + continue; + } + const importBinding = record.getBindingLinkHash(); + const exportedBinding = record.getResolvedTargetHash(); + // An entry that already exists wins: the name is also assigned in this + // module, and that assignment (or its refusal, null) is the local truth. + if (importBinding === '' || exportedBinding === '' || own.localTypeByBinding.has(importBinding)) { + continue; + } + const exporter = resolutionCtxByModuleHash.get(record.getResolvedModuleLinkHash()); + const type = exporter?.localTypeByBinding.get(exportedBinding); + if (type) { + own.localTypeByBinding.set(importBinding, type); + } + } + } + + for (const module of modules) { + const ctx = resolutionCtxByModuleHash.get(module.moduleHash); + if (!ctx) { + continue; + } + const { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + typesByName, + localTypeByBinding, + } = ctx; for (const callSite of module.callSites) { // Retry anything WITHOUT A HASH, not merely anything UNRESOLVED. The // single-file pass has no module graph, so it can only say IMPORTED for From 1c41ba25183fe9f7d9f97a13040419f834b878d4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:00:53 -0700 Subject: [PATCH 144/258] =?UTF-8?q?path:=20--json=20says=20what=20the=20pr?= =?UTF-8?q?ose=20says=20=E2=80=94=20verified=20and=20the=20unresolved=20co?= =?UTF-8?q?unts=20are=20typed=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two successful path --json answers carried verified: null and bound: null beside prose lines stating the chain was verified and counting its unresolved calls, so an automation had to parse prose. verified is now true/false once hops were checked (null stays 'nothing was checked'); unresolved_inside and unresolved_closure are counted numbers where 0 is a counted zero, distinguishable from missing; bound is the same text the prose prints. Checks compare both formats on one query, at zero and at a nonzero count. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-path | 18 ++++++++++++++---- .../case.json | 9 +++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 749516e6..cd576280 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1736,13 +1736,16 @@ def closure(g, sel, upstream, limit=40, depth=40): # figures on a large tree, which reads as "unknown" and is skipped. The reader can only check the rows # actually in front of them, so the actionable number is the unresolved calls inside THOSE; the closure-wide # figure stays, in parentheses, as the honest total. + RESULT['unresolved_closure'] = u # 0 is a counted zero, not a missing value if u: shown_ids = [m for m, _ in (np_ if upstream else sorted(rows, key=lambda x: (x[1], g.sym[x[0]]['display'], x[0])))[:limit]] un = g.q("SELECT count(*) n FROM unresolved_sites WHERE caller_id IN (%s)" % ','.join('?' * (len(shown_ids) + len(ids))), *shown_ids, *ids)[0]['n'] if shown_ids or ids else 0 - print(f" bound: {un} unresolved call(s) inside the {len(shown_ids)} method(s) printed above" - + (f" ({u} across the whole closure)" if u != un else '') - + f" — the set is a lower bound; `path {sel}` for the chain") + RESULT['unresolved_inside'] = un + RESULT['bound'] = (f"{un} unresolved call(s) inside the {len(shown_ids)} method(s) printed above" + + (f" ({u} across the whole closure)" if u != un else '') + + f" — the set is a lower bound; `path {sel}` for the chain") + print(f" bound: {RESULT['bound']}") print(f" (chain from any one of them: path {sel}" + (")" if upstream else " — or the reverse)")) return 0 @@ -1919,13 +1922,20 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if shown >= limit and not show_all: print(f" … +{len(hits) - shown} more targets (--all)"); break blind = {n for n, _ in (read_back(res['parent'], q, hits[0][0], srcs) or [])} u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(blind))})", *blind)[0]['n'] if blind else 0 + # the JSON says what the prose says: verified true/false once hops were checked (null stays "nothing was + # checked"), and the unresolved count as a number, where 0 is a counted zero. An automation was parsing the + # prose to learn both because the fields sat at null beside a prose line stating them. + RESULT['verified'] = not (vbad or vlen) + RESULT['unresolved_inside'] = u print(f" verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length" if not (vbad or vlen) else f" ✗ verification failed on {vbad} hop(s) / {vlen} length(s) — report this") if seen_tiers: print(" what the hops are:"); [print(l) for l in ax_edges.legend(seen_tiers)] print_boundary(g, [m for m, _ in hits[:limit]], "the target(s) call into libraries", "the target(s) also make") if every: every_route(g, res, q, srcs, dsts, max_paths, adj) else: print(f" (one shortest chain per reached target; --every for all the routes and every method on any of them)") - if u: print(f" bound: the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see") + if u: + RESULT['bound'] = f"the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see" + print(f" bound: {RESULT['bound']}") return 0 # nothing resolved either way: say so, then whether unresolved sites would connect them print(f"no chain of resolved calls connects {la} and {lb} in either direction (searched {len(g.edges())} edges, depth ≤ 40)") diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 3aef6c65..9d30644c 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -25,6 +25,15 @@ {"why": "CONTROL: a caller of a method whose by-name site names ANOTHER method is not behind the target's by-name callers", "run": ["path", "*", "Ledger.settle"], "avoid": ["rollback"]}, + {"why": "--json says what the prose says: verification and the unresolved count are typed fields, 0 a counted zero, never null beside a prose line stating them", + "run": ["path", "nightly", "Ledger.settle", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_inside\": 0"]}, + {"why": "a chain through a method with an unresolved call types the count and the bound the prose prints", + "run": ["path", "scheduler", "replay", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_inside\": 1", "\"bound\": \"the methods on the nearest chain contain 1 unresolved call(s)"]}, + {"why": "the closure answer types its verification, the closure-wide unresolved count and the by-name groups", + "run": ["path", "*", "Ledger.settle", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_closure\": 0", "\"by_name\"", "\"by_name_behind\""]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From a662d1aedcbf4f20f1813f48ac77227f65356cde Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:00:53 -0700 Subject: [PATCH 145/258] =?UTF-8?q?mcp:=20context=20is=20back=20on=20the?= =?UTF-8?q?=20surface=20=E2=80=94=20the=20narrative=20verb=20beside=20find?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface trim folded context into find(question), which answers as ranked places; the flow answer (how does X work, every step in the order the calls are written, source=True with each step's code) had no tool left. context(task, source) joins the surface: five tools, each still at most two parameters and no options. The roster docs, the argument checks and the tools/list assertions carry it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 2 +- plugins/axiomcode/AGENTS.md | 4 +++- plugins/axiomcode/mcp/server.py | 13 +++++++++++-- plugins/axiomcode/rules/axiomcode.mdc | 4 +++- plugins/axiomcode/skills/axiomcode/SKILL.md | 3 ++- tests/front_door.py | 4 ++-- tests/mcp.py | 15 ++++++++++----- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..6c5b6c94 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help -# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, context, impact, path and tests. # ───────────────────────────────────────────────────────────────────────────── # INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which # the dispatcher, the test suites and the agent manifests call. diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde..196e7e41 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -8,9 +8,11 @@ breaks, which tests an edit reaches — ask the repository's call graph FIRST, t impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + context(task) how something works, as a narrative: the call flow step by step; + context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, -`axiomcode path
    `, `axiomcode tests`. +`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 2f30e142..d88a1f25 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -313,8 +313,9 @@ def plain(text): return '\n'.join(out) -# THE SMALL SURFACE. Three questions, each answered as numbered places with the code of the function each sits in, so -# a place is understood without opening its file. No options: the repository is the one the session works in. +# THE SMALL SURFACE. Four questions answered as numbered places with the code of the function each sits in, so +# a place is understood without opening its file, plus context, the one narrative verb: a task in words answered +# as the verb's own flow. No options beyond context's source: the repository is the one the session works in. @srv.tool() def find(question: str) -> str: """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); @@ -322,6 +323,14 @@ def find(question: str) -> str: is listed with its call sites: that is code you have to write.""" return plain(run(['find', question, os.getcwd()])) +@srv.tool() +def context(task: str, source: bool = False) -> str: + """How something works, from a task in words: the files and callables the task touches, and for a "how does X + work" question the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a + call. source=True asks for the flow with each step's code, so it is read without opening files. find() answers + the same question as ranked places; this is the verb for the narrative.""" + return plain(run(['context', task] + (['--source'] if source else []) + [os.getcwd()])) + @srv.tool() def impact(name: str = '') -> str: """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d77c4ea5..d7226cba 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -13,9 +13,11 @@ breaks, which tests an edit reaches — ask the repository's call graph FIRST, t impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + context(task) how something works, as a narrative: the call flow step by step; + context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, -`axiomcode path `, `axiomcode tests`. +`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493..fb55a99f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode @@ -17,6 +17,7 @@ Four questions, asked of the repository's call graph. Use the MCP tools when the | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | +| how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "" --source` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d034..1855f435 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -10,7 +10,7 @@ a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): find, impact and path answer with numbered places and a fenced code block; after an edit, impact with no name starts with `your edits:`, and tests lists the test with its code and ends with a `run:` line. - b. the MCP server lists exactly find, impact, path and tests, each with at most two parameters, and a call to one + b. the MCP server lists exactly find, context, impact, path and tests, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -120,7 +120,7 @@ def main(): # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly find, context, impact, path and tests', set(tools) == {'find', 'context', 'impact', 'path', 'tests'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) diff --git a/tests/mcp.py b/tests/mcp.py index 91f08c17..367fc778 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -29,9 +29,10 @@ CLI = os.path.join(ROOT, 'bin', 'axiomcode') LAUNCHER = os.path.join(ROOT, 'bin', 'axiomcode.js') SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') -# THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped -# at ten places with the rest counted, so no tool is paged. -TOOLS = {'find': ['question'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +# THE SMALL SURFACE: five questions, each with at most two parameters and no options. The front-door answer is capped +# at ten places with the rest counted, so no tool is paged. context is the one narrative verb: a task in words, +# answered as the verb's own flow rather than as places. +TOOLS = {'find': ['question'], 'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} def exchange(cmd, cwd, env=None, workdir=None): @@ -117,7 +118,9 @@ def check_arguments(label, cmd, cwd, lax=False): # it had been narrowed (#1567): the repository is the session's, and there are no flags ('find', {'question': 'x', 'in_path': 'src'}, 'in_path: unexpected argument'), ('impact', {'name': 'A.f', 'repo': cwd}, 'repo: unexpected argument'), - ('tests', {'why': True}, 'why: unexpected argument')] + ('tests', {'why': True}, 'why: unexpected argument'), + ('context', {}, 'task'), + ('context', {'task': 'x', 'budget': 3}, 'budget: unexpected argument')] for name, args, field in wrong: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -126,7 +129,8 @@ def check_arguments(label, cmd, cwd, lax=False): bad.append(f"{label}: {name}({json.dumps(args)}) was not refused naming {field!r}: {res}") # the control: every parameter a tool declares still passes, including the ones the CLI's hints name right = [('find', {'question': 'x'}), ('impact', {'name': 'A.f'}), ('impact', {}), - ('path', {'start': 'a', 'end': 'b'}), ('tests', {})] + ('path', {'start': 'a', 'end': 'b'}), ('tests', {}), + ('context', {'task': 'x'}), ('context', {'task': 'x', 'source': True})] for name, args in right: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -182,6 +186,7 @@ def check_front_door(): try: bad = [] for call, want in ((lambda: server.find('how is a total computed'), ['find', 'how is a total computed', cwd]), + (lambda: server.context('how is a total computed'), ['context', 'how is a total computed', cwd]), (lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), (lambda: server.impact(''), ['impact', cwd]), (lambda: server.impact(), ['impact', cwd]), From 8986b777e6aeb0aabf2b3b09e4aab1e0c5ce4c1f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:10:27 -0700 Subject: [PATCH 146/258] context: a task in another language names the lexical limit, not a parsing fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task written in a non-Latin script yielded 'nothing to search for in that task description', which reads as a fault in the task rather than as the stated limit it is: the graph's vocabulary is the code's own identifiers, which are English words and names. The refusal now says only English task words are supported and names the working escape — keep the language and include one identifier as written in the code; a mixed task lands. An English task with no content words keeps the plain line. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-context | 11 ++++++++++- .../path-crosses-framework-and-byname/case.json | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index d7077e55..6b003d54 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1178,7 +1178,16 @@ def main(argv): print(f" {n} — {len(rs)} call site(s)") for f, l, c, code in rs: print(f" {f}:{l}: {code}" + (f" [in {c}]" if c else '')) terms = task_terms(body) - if not terms: die("nothing to search for in that task description") + if not terms: + # the graph's vocabulary is the code's own identifiers, which are English words and names: a task + # written in another script matches nothing, and "nothing to search for" read as a parsing fault + # rather than a stated limit. A mixed task works — one identifier is enough to land. + if any(ord(c) > 127 and c.isalpha() for c in body or ''): + die("this task is written in a language the index cannot search: only English task words are supported," + " because the graph's vocabulary is the code's own identifiers." + " Rephrase the task in English, or keep your language and include one identifier as written in the" + " code (a mixed task works: the identifier lands it).") + die("nothing to search for in that task description") # what the question names that no graph here holds is said FIRST (#1571), and a scope that exists on disk but holds # no indexed file is a text scope, not a typo (#1382). Only a scope the index does not know is checked on disk. def holds(x): diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 9d30644c..62f0e0e5 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -34,6 +34,18 @@ {"why": "the closure answer types its verification, the closure-wide unresolved count and the by-name groups", "run": ["path", "*", "Ledger.settle", "--json"], "stdout_json": true, "want": ["\"verified\": true", "\"unresolved_closure\": 0", "\"by_name\"", "\"by_name_behind\""]}, + {"why": "a task written in another language names the lexical limit, not a parsing fault", + "run": ["context", "钱包转账的金额是怎么结算的"], "expect_error": true, + "want": ["only English task words are supported", "a mixed task works"], + "avoid": ["nothing to search for"]}, + {"why": "CONTROL: one identifier lands a task kept in another language", + "run": ["context", "订单 Ledger 怎么结算"], + "want": ["task terms:"], + "avoid": ["only English task words"]}, + {"why": "CONTROL: an English task with no content words keeps the plain empty-terms line", + "run": ["context", "it is"], "expect_error": true, + "want": ["nothing to search for in that task description"], + "avoid": ["only English task words"]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From f030c39c2d65542157c2b1aa48ce59a04a02af23 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:01 -0700 Subject: [PATCH 147/258] index: literals carry numbers and booleans, and a constant answers with its value A spec's factual claims are disproportionately numbers -- a retry count, a page size, a timeout, a default -- and the graph could locate every one of them and supply none: the index kept literalType=STRING rows only, and even a string literal was stored as (value, file, line) with no link to the constant it initializes. The parsers were never the gap -- all five emit numeric and boolean literals, and TS/JS emit object keys and values (OBJECT_PROPERTY_KEY / PROPERTY_VALUE roles) -- the rows were dropped at indexing. - literals gains kind ('string' | 'number' | 'bool', normalized across the five per-language enums) and name: the constant a scalar initializes, linked when strictly one literal sits on the declaration line of strictly one const/variable/field/enum_member symbol, with no call on the line, the literal in the language's VALUE position (litLinkRoles -- a subscript's key and a call's argument never link), and no sign-hiding parent (JavaScript's UNARY around -5). Dict passes, not correlated subqueries: the UPDATE form ran 67s against 4.8s on a mid-sized project because the literals indexes do not exist yet at that point; the dict form indexes the same project in 3.8s. - impact: a const target's first line states its value -- `change: const MAX_ITEMS = 5` -- so a limit is read off the answer, not the file. - the consumers that join literals as KEYS (ax_registration, graph_sql's literal fact, context's route sweep) filter kind='string', so a numeric "1" never joins a retry count to a topic; each tolerates a pre-v8 index. - INDEX_VERSION 7 -> 8; freshness re-indexes existing graphs. Verified: new constant-value cases (python + typescript, 8/8, with a call-initializer control); suites python 298/298, java 333/333, csharp 208/208; TS/JS at exact parity with the integration baseline's pre-existing failures (one refresh-banner race reproduced on both sides, passes isolated). On a mid-sized corpus project: 22,155 -> 27,806 literal rows, 793 named, a 10/10 random sample audit against source, and one subscript-key mislink (d["timeout_no_item"] as a field's value) caught and excluded by the role whitelist. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 4 +- .../axiomcode/scripts/ax_registration.py | 28 ++++++--- .../axiomcode/scripts/axiomcode-context | 5 +- .../skills/axiomcode/scripts/axiomcode-impact | 24 +++++-- .../skills/axiomcode/scripts/axiomcode-index | 63 ++++++++++++++----- .../skills/axiomcode/scripts/graph_sql.py | 10 ++- tests/cases/python/constant-value/case.json | 16 +++++ .../cases/python/constant-value/src/limits.py | 13 ++++ .../cases/typescript/constant-value/case.json | 16 +++++ .../typescript/constant-value/src/limits.ts | 9 +++ 10 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/cases/python/constant-value/case.json create mode 100644 tests/cases/python/constant-value/src/limits.py create mode 100644 tests/cases/typescript/constant-value/case.json create mode 100644 tests/cases/typescript/constant-value/src/limits.ts diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 2516f40b..ff11bb7b 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -14,6 +14,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools |---|---|---| | where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what is the value of constant X, and who reads it? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | @@ -44,7 +45,8 @@ from an empty answer. With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: `impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and -how), then the same answer for all of them. +how), then the same answer for all of them. A constant answers with its value — `change: const MAX_ITEMS = 5` — +so a limit, a default or a threshold is read off the first line rather than from the file. ## path diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index db6b6a19..6eb56111 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -63,7 +63,7 @@ def registrations(q, site_file=None): sf = site_file or (lambda x: x) lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) # the declarations a name identifies uniquely: only those can be named as the registered declaration, because # a site names a VALUE by identifier and two callables of one name would each claim the other's registration @@ -107,7 +107,7 @@ def route_site_lines(q, site_file=None): return {} lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) return _route_links(q, site_file or (lambda x: x), lits, set())[2] @@ -628,6 +628,20 @@ def _has(q, t): return bool(q("SELECT 1 FROM sqlite_master WHERE name=?", t)) +def _span_string_literals(q, f, a, b): + """the STRING literals inside one file span, kind-filtered the same way _string_literals is""" + try: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ? AND kind = 'string'", f, a, b) + except Exception: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b) + + +def _string_literals(q): + """the literals rows that are STRINGS, as (value, file, line). A v8 index also carries numbers and + booleans, which are never registration keys and would join everything (`"1"` matches every retry + count); a pre-v8 or degraded index has no kind column, and there every row is a string.""" + try: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL AND kind = 'string'") + except Exception: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + + # ── the DECORATION path: the key is written at the `@`, and the owner is recorded ──────────────────────────── # `registrations()` above skips DECORATOR_CALL sites deliberately, because a decoration is not a call that hands a # value over. It is the other half of the same idea and it carries BETTER evidence: the index records which @@ -814,7 +828,7 @@ def literal_verbs(q, at, site_file=None): 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"): + for v, f, l in _string_literals(q): 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] @@ -857,7 +871,7 @@ def value_route_registrations(q, site_file=None): names_at.setdefault((f, l), set()).update(routed[(f, n)]) import re out = [] - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if not (isinstance(v, str) and 0 < len(v) < 160): continue # A RESOURCE IS NOT A ROUTE. Measured on the JVM parser: the pair fired on @@ -1118,7 +1132,7 @@ def written(text, token): if not _KEY_POS.match(text, mm.end()): return True return False if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if v not in keys or (f, l) in cpos: continue L = read(f) text = L[l - 1] if L and l <= len(L) else None @@ -1187,7 +1201,7 @@ def key_writes(q, table_keys=None): the table's own key position or the constant's declaration).""" if table_keys is None: table_keys = {r[4] for r in table_registrations(q)} - rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + rows = [(v, f, l) for v, f, l in _string_literals(q) if v not in table_keys] if _has(q, 'literals') else [] return rows + table_key_writes(q, table_keys) @@ -1313,6 +1327,6 @@ def _sends_request(q, m): ph = ','.join('?' * len(REQUEST_CALLS)) if q(f"SELECT 1 FROM call_sites WHERE caller_id = ? AND callee_name IN ({ph}) LIMIT 1", m, *sorted(REQUEST_CALLS)): return True if _has(q, 'literals'): - for (v,) in q("SELECT value FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b): + for v, _f, _l in _span_string_literals(q, f, a, b): if isinstance(v, str) and re.fullmatch(r'/[\w\-./{}:%]*', v): return True return False diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 9bd4561d..d75c967a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -411,7 +411,10 @@ def route_seeds(g, text): for r in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE '%/%' OR text LIKE '%\"%'"): for v in re.findall(r'"([^"]*)"', r['text'] or ''): lits.append((v, r['file'], r['line'], r['owner_id'])) if asked and g.has('literals'): - for r in g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40"): + # strings only: a route is never a number, and the v8 index carries numbers and booleans too + try: lit_rows = g.q("SELECT value, file, line FROM literals WHERE (value LIKE '%/%' OR length(value) < 40) AND kind = 'string'") + except Exception: lit_rows = g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40") + for r in lit_rows: lits.append(((r['value'] or '').strip('"'), r['file'], r['line'], None)) by_file = collections.defaultdict(set) for v, f, _l, _o in lits: by_file[f].add(tuple(_route_parts(v))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 2e030faa..7a8dc195 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -464,7 +464,7 @@ class Impact: if d and d[0] == 'field' and kind in (None, 'field'): row = self.fields.get(d[1]['rowid']) or d[1] self.WHY['field'] = {'step': 'file and line: a field declared there', 'won': f"the line declares {row['display']} and no callable of its own", 'ids': [], 'rows': [(row['display'], f"{row['file']}:{row['line']}")]} - return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] + return [('field', f"{row['kind']} {row['display']}{self.const_value(row)} (at {s})", [row])] if d and d[0] == 'type' and kind in (None, 'type'): self.WHY['type'] = {'step': 'file and line: a type declared there', 'won': f"the line is the header of type {d[1]['display']}", 'ids': [d[1]['id']], 'rows': None} return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])] @@ -532,7 +532,7 @@ class Impact: if hit: return [('method', verb + (f" ({len(hit)} declarations)" if len(hit) > 1 else ''), [x['id'] for x in hit])] # …and a private FIELD (`Vault.#secret`, `#secret`) the same way: rewritten, it asked for `Vault..secret` rows = self.field_rows(verb) if kind in (None, 'field') and re.search(r'(^|\.)#[A-Za-z_$]', verb) else [] - if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] + if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] base = re.sub(r'\(.*\)$', '', s).replace('#', '.').strip('.') if not (self.field_rows(base) or self.types(base, soft=True) or self.methods(base, soft=True)): base = base.replace('$', '.') # Outer$Inner — unless $ is part of the name ($Gson$Types) m0 = re.match(r'^(.+)\.$', base) @@ -552,7 +552,7 @@ class Impact: if kind in (None, 'field'): rows = self.field_rows(base) if rows: self.WHY['field'] = self.why_field(base, rows) - if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) + if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: self.WHY['type'] = self._why_t.get(base) @@ -692,6 +692,16 @@ class Impact: f['file'], r['line'], f['line'], f['line']): continue return True return False + def const_value(self, row): + """`` = 5`` — the scalar the v8 index linked to this declaration's line (literals.name), so the + answer to `impact MAX_ITEMS` states the value a spec would print, not only who reads it. Empty + on a pre-v8 index, a non-scalar initializer, or any line the linker refused as ambiguous.""" + try: + for v, k in self.g.q("SELECT value, kind FROM literals WHERE name = ? AND file = ? AND line = ? LIMIT 1", + row['display'], row['file'], row['line']): + return f" = '{v}'" if k == 'string' else f" = {v}" + except Exception: pass + return '' 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 @@ -3077,8 +3087,12 @@ def main(argv): if tests_only: sys.stdout = io.StringIO() for k, lab, pay in targets: if k in ('method', 'field', 'type'): - nm = lab.split()[-1].split('(')[0].split('.')[-1] - n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) if g.has('literals') else 0 + # a const's label carries its value (`const MAX_ITEMS = 5`): the name is the word before `=` + nm = lab.split(' = ')[0].split()[-1].split('(')[0].split('.')[-1] + if g.has('literals'): + try: n = len(g.q("SELECT 1 FROM literals WHERE value = ? AND kind = 'string'", nm)) + except Exception: n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) + else: n = 0 d = len(g.q("SELECT 1 FROM decorations WHERE text LIKE ?", f'%"{nm}"%')) if g.has('decorations') else 0 if n + d > 1: print(f" the name {nm} is also written as a string in {n + d} place(s) ({STRING_BINDS.get(target_ext(g, pay), STRING_BINDS[''])}): ask for it quoted, `impact '\"{nm}\"'`, to get those") break diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 2f08ca83..c3905612 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -7,7 +7,9 @@ Adds to /.axiomcode/out/graph.sqlite, idempotently: function | method | constructor | class | interface | enum | enum_member | type | namespace | const | variable | field | module refs every place an identifier or member is USED (the question grep answers) - literals string literals; comments comments and docstrings + literals string, number and boolean literals, with kind ('string'|'number'|'bool') and, when exactly + one scalar sits on a declaration's line, the NAME of the const/field it initializes — so + "what is MAX_ITEMS" is answerable; comments comments and docstrings nesting (type, outer type) recovered by line containment — Java's qualified_name drops the outer skipped files the parser skipped (so `find` can say "exists, not indexed" instead of nothing) index_meta what was indexed, from where, and whether the IR was available @@ -20,10 +22,12 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) -INDEX_VERSION = '7' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; +INDEX_VERSION = '8' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; # v6: TypeScript class-property arrows named after their field; - # v7: data keys of module-level const objects declared, Object.freeze seen through); the query + # v7: data keys of module-level const objects declared, Object.freeze seen through; + # v8: literals carry numbers and booleans with a kind column, and the name of the constant + # a scalar initializes — a spec's numbers were the one thing the graph could not answer); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -80,7 +84,7 @@ A = { modules=None, expr=dict(file='all-expressions.csv', kind='kind', name='literalValue', line='startLine', fileVia=('types', 'typeRegistryLinkHash'), refKinds={'IDENTIFIER_REFERENCE', 'FIELD_ACCESS', 'CLASS_LITERAL'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'LONG': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('types', 'typeRegistryLinkHash')), # @Annotation(args) on a method or type: the arguments live in a second file keyed by the annotation hash @@ -98,7 +102,7 @@ A = { # the key of an object literal (`{ eventId: … }`) is stored with the entity kind OBJECT_PROPERTY_KEY, not # UNKNOWN: it is never a property ACCESS, so an access the engine bound on the same line is not this name keyRole=dict(role='edgeRole', value='OBJECT_PROPERTY_KEY'), - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), # the variable's own hash: the engine's field_access names a module variable by it when an identifier the binder @@ -125,7 +129,7 @@ A = { modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), expr=dict(file='all-python-expressions.csv', kind='kind', name=('NAME_REFERENCE:literalValue', 'ATTRIBUTE_ACCESS:dottedPath'), line='startLine', fileVia=('modules', 'pyModuleLinkHash'), refKinds={'NAME_REFERENCE', 'ATTRIBUTE_ACCESS'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'FLOAT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ASSIGNMENT_VALUE'}), comments=dict(file='all-python-comments.csv', text='text', kind='kind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-python-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'pyModuleLinkHash')), decorations=dict(file='all-python-decorators.csv', name='decoratorName', owner='ownerHash', line='startLine', fileVia=('modules', 'pyModuleLinkHash'), text='fullText'), @@ -137,7 +141,7 @@ A = { modules=dict(file='all-javascript-modules.csv', id='jsModuleUniqueHash', filePath='filePath'), expr=dict(file='all-javascript-expressions.csv', kind='expressionKind', name='name', line='startLine', fileVia=('modules', 'ownerModuleLinkHash'), refKinds={'IDENTIFIER', 'PROPERTY_ACCESS'}, entityKind='referenceKind', - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='text'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='text', litLinkRoles={'OPERAND', 'ASSIGNMENT_VALUE'}, litLinkBlockKinds={'UNARY'}), comments=dict(file='all-javascript-comments.csv', text='text', kind='commentKind', line='startLine', fileVia=('modules', 'ownerModuleLinkHash')), # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and @@ -175,7 +179,7 @@ A = { # name elsewhere is a by-name reader whatever its qualifier says, so `_r.Limit` was listed under AuditOptions.Limit memberName=dict(role='edgeRole', value='MEMBER_NAME', parent='parentExpressionHash', id='csExpressionUniqueHash'), # the literal TYPE column is literalKind here, not literalType as in java/typescript/python - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'INTEGER': 'number', 'REAL': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-csharp-comments.csv', text='commentText', kind='commentKind', line='startLine', fileVia=('modules', 'csModuleLinkHash')), # type references carry no module link of their own. The owner is a type only for a base-list reference; for # `M()`, `new T()`, a parameter, a return or a field it is the expression, method, parameter or field, so @@ -247,7 +251,7 @@ DROP TABLE IF EXISTS nesting; DROP TABLE IF EXISTS type_refs; DROP TABLE IF EXIS DROP VIEW IF EXISTS callers; DROP VIEW IF EXISTS callees; DROP VIEW IF EXISTS source; DROP VIEW IF EXISTS sites; CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, qualified_name TEXT, signature TEXT, file TEXT, line INT, end_line INT, owner TEXT, is_test INT, method_id TEXT, type_id TEXT); CREATE TABLE refs(name TEXT, file TEXT, line INT, kind TEXT, entity_kind TEXT); -CREATE TABLE literals(value TEXT, file TEXT, line INT); +CREATE TABLE literals(value TEXT, file TEXT, line INT, kind TEXT, name TEXT); CREATE TABLE comments(text TEXT, file TEXT, line INT, kind TEXT); CREATE TABLE nesting(type_id TEXT, outer_type_id TEXT); CREATE TABLE type_refs(name TEXT, file TEXT, line INT, context TEXT, owner_kind TEXT); @@ -658,15 +662,40 @@ for r in rows(e['file']): if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek if kr and ek in ('', 'UNKNOWN') and r.get(kr['role']) == kr['value']: ek = kr['value'] refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) - elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: + elif k in e['litKinds'] and r.get(e['litType'][0]) in e['litType'][1]: + kind = e['litType'][1][r.get(e['litType'][0])] # 'string' | 'number' | 'bool' across every language v = (r.get(e['litValue']) or '') - # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived - # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched - if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') - if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" - if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes - if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0))) -c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?)", lits) + if kind == 'string': + # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived + # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched + if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') + if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" + if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes + if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0), kind, r.get('edgeRole', ''))) +# a scalar's NAME: `MAX_ITEMS = 5` — the one literal on a const/variable/field declaration's line IS its value, +# so the row gets the symbol's display and "what is MAX_ITEMS" has an answer with a source location. Strictly: +# one literal, one such symbol, no call on the line (`RETRY = max(3, env())` names no value), the literal in the +# language's VALUE position — litLinkRoles, which keeps a subscript's key (`d["timeout"]`, INDEX_ARGUMENT) and a +# call's argument out — and, where an initializer's sign hides in a parent node (JavaScript's UNARY around `-5`), +# no such node on the line either (litLinkBlockKinds). +# Dict passes, not correlated subqueries: the UPDATE form of this join ran minutes against seconds on a mid-sized +# repository, because at this point the literals indexes do not exist yet. +lit_count = collections.Counter((f, l) for _v, f, l, _k, _r in lits) +decl_at = {} # (file, line) -> the ONE declared name there, or None once a second appears +for d, f, l in c.execute("SELECT display, file, line FROM symbols WHERE kind IN ('const', 'variable', 'field', 'enum_member') AND line > 0"): + decl_at[(f, l)] = None if (f, l) in decl_at else d +call_at = {(rel(f), l) for f, l in c.execute("SELECT DISTINCT file_path, start_line FROM call_sites WHERE file_path IS NOT NULL")} +link_roles = e.get('litLinkRoles') or set() +block_kinds = e.get('litLinkBlockKinds') or set() +blocked_at = set() +if block_kinds: + for r in rows(e['file']): + if r.get(e['kind'], '') in block_kinds: blocked_at.add((file_of(r, e), int(r.get(e['line']) or 0))) +lits = [(v, f, l, k, + (decl_at.get((f, l)) if role in link_roles and l > 0 and lit_count[(f, l)] == 1 + and (f, l) not in call_at and (f, l) not in blocked_at else None)) + for v, f, l, k, role in lits] +c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?,?,?)", lits) cm = A['comments'] c.executemany("INSERT INTO comments VALUES (?,?,?,?)", ((r.get(cm['text'], '')[:400], file_of(r, cm), int(r.get(cm['line']) or 0), r.get(cm['kind'], '')) for r in rows(cm['file']) if r.get(cm['text']))) # where a TYPE is used in a declaration or expression — field type, parameter, return, generic argument, `new` — diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db0458a3..ecf87c75 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1990,7 +1990,10 @@ def direct_for_string(q, vals, at, rel): """ rows = [] if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64"): + # strings only: the v8 index also carries numbers and booleans, and `True` is identifier-shaped + try: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64 AND kind = 'string'") + except Exception: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64") + for v, f, l in lit_rows: if v not in vals or not re.fullmatch(r'[A-Za-z_]\w*', v): continue c = at(f, l) if c: rows.append((c, 'uses', 'names it in a string literal', 'text', rel(f) if f else '', l or 0)) @@ -2473,8 +2476,9 @@ def discriminants(q): 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 + type it builds without naming it. Read from the literals table — only rows whose value is one of the known + discriminant strings can match, so the numbers a v8 index adds never join (a literal + type `kind: 'X'` in an interface is still 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 diff --git a/tests/cases/python/constant-value/case.json b/tests/cases/python/constant-value/case.json new file mode 100644 index 00000000..be534a24 --- /dev/null +++ b/tests/cases/python/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = True"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/python/constant-value/src/limits.py b/tests/cases/python/constant-value/src/limits.py new file mode 100644 index 00000000..14202567 --- /dev/null +++ b/tests/cases/python/constant-value/src/limits.py @@ -0,0 +1,13 @@ +MAX_ITEMS = 5 +TIER_NAME = 'gold' +ENABLED = True + + +def pick(n): + return min(n, MAX_ITEMS) + + +RETRY_LIMIT = pick(3) + +WINDOW = ( + 60) diff --git a/tests/cases/typescript/constant-value/case.json b/tests/cases/typescript/constant-value/case.json new file mode 100644 index 00000000..3441f810 --- /dev/null +++ b/tests/cases/typescript/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = true"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/typescript/constant-value/src/limits.ts b/tests/cases/typescript/constant-value/src/limits.ts new file mode 100644 index 00000000..9abed5da --- /dev/null +++ b/tests/cases/typescript/constant-value/src/limits.ts @@ -0,0 +1,9 @@ +export const MAX_ITEMS = 5; +export const TIER_NAME = 'gold'; +export const ENABLED = true; + +export function pick(n: number): number { + return Math.min(n, MAX_ITEMS); +} + +export const RETRY_LIMIT = pick(3); From 95a86dc621b38115a794c78f1bf0d4cd7a419592 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:20:28 -0700 Subject: [PATCH 148/258] surfaces: context is the fifth public verb, with its one option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit pinned the trimmed surface (context internal, no MCP tool, no flag taught anywhere but index's). context moves to PUBLIC — dispatcher help, SKILL.md section in both copies, MCP tool — and --source joins the flags the surface may teach. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 8 ++++++++ skills/axiomcode/SKILL.md | 8 ++++++++ tests/surfaces.py | 8 ++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 9cb4c269..04ca8a04 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -57,6 +57,14 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## context + +How something works, from a task in your own words: the files and callables the task touches and, for a +how-does-X-work question, the call flow step by step. Example: `context(task="how is an invoice settled", +source=True)` — source carries each step's code, so the flow is read without opening files. Only English task +words land (the graph's vocabulary is the code's identifiers); any language works once the task includes one +identifier as written in the code. + ## index `axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ae477b92..38950cb1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -56,6 +56,14 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## context + +How something works, from a task in your own words: the files and callables the task touches and, for a +how-does-X-work question, the call flow step by step. Example: `context(task="how is an invoice settled", +source=True)` — source carries each step's code, so the flow is read without opening files. Only English task +words land (the graph's vocabulary is the code's identifiers); any language works once the task includes one +identifier as written in the code. + ## index `axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an diff --git a/tests/surfaces.py b/tests/surfaces.py index d7e5cb58..ea2e81f7 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -15,7 +15,7 @@ that is in neither list fails, so exposing one is a decision rather than an accident (#1034). The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, and the README's CLI section) name no old -MCP tool (`axiomcode_context` …) and no flag other than index's. +MCP tool (`axiomcode_context` …) and no flag other than index's setup flags and context's --source. python3 tests/surfaces.py """ @@ -29,17 +29,17 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'impact', 'path', 'tests'] +PUBLIC = ['index', 'impact', 'path', 'tests', 'context'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'search by task words; the orient hook and the suites call it, no caller-facing surface does', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', } OLD_TOOLS = re.compile(r'\baxiomcode_(context|impact|path|changed|test_impact|graph|index|diff)\b') -INDEX_FLAGS = {'--lang', '--src', '--library'} +# the flags the surface may teach: index's setup flags, and context's one option (the flow with each step's code) +INDEX_FLAGS = {'--lang', '--src', '--library', '--source'} def dispatched(): From a3ab44a170ae2e3c0ce60b1fd50289b310022cdd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:32:27 -0700 Subject: [PATCH 149/258] =?UTF-8?q?engine:=20the=20solve=20runs=20in=20par?= =?UTF-8?q?allel=20where=20it=20is=20safe=20=E2=80=94=20linux-x64=20by=20d?= =?UTF-8?q?efault,=202.5x=20on=20a=20large=20subject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soufflé emits parallel loops only when the program is GENERATED with -j; without it the C++ holds zero parallel sections, so every engine built or shipped before this was sequential by construction. Generation now always asks for the loops, and OpenMP at compile time decides the flavor: a platform without it compiles the same code and runs it serially, byte-for-byte as before. Measured on a 6,139-file Java subject (1.83M expression facts): 147s serial -> 54-59s at -j8, and the outputs are equal as sets — row order shifts between flavors, and the bundle loads rows into sqlite, which keeps no order. The parallel flavor is part of the cache name (-par) and of the engine id (+seqlock-fix-1), so a serial binary is never taken for a parallel one, and no pre-fix cache entry or package survives: every binary from here is built from a PATCHED OptimisticReadWriteLock. The patch (souffle_overlay in run-souffle.sh, the same sed in build-engines.yml): the lock entered its write phase with fetch_or(memory_order_acquire), which lets the write section's data stores become visible BEFORE the version turns odd on a weakly-ordered CPU — a reader then reads a half-mutated node and still passes validate() against the stale even version. On x86 stores never reorder, so the hole is invisible there; on arm64 it produced nondeterministic segfaults in a different rule each run (ThreadSanitizer pinned the races to the btree insert paths). seq_cst on the entry RMW pins the odd version first and costs nothing on x86, where a locked RMW is already a full barrier. What ships parallel: linux-x64 (prebuilt engines carry a .parallel marker; locally compiled ones probe -fopenmp; libgomp links statically so no gcc runtime is required). What stays serial by default: arm64 everywhere, darwin and windows — with the entry fix in, a quiet arm64 machine ran 5/5 clean where it crashed before, but a residual crash mode under memory pressure remains (2/5 under a concurrent compile), so weak-memory CPUs wait for that fix; AXIOM_SOLVE_PARALLEL=1 opts any machine in for experiments and =0 forces serial anywhere. -j is passed on every run (a serial binary ignores it; AXIOMCODE_SOLVE_THREADS caps it, default min(cores, 8)). Gates: the patched parallel binary, 10 runs on the Java subject, every run's output sort-equal to serial; the default (serial) path on darwin re-runs the pipeline with the new id and passes an engine case end to end; goldens compare normalized (sorted, deduplicated), so CI's linux legs validate the parallel flavor continuously. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/build-engines.yml | 21 +++++- graph/pipeline/run-souffle.sh | 103 ++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index bd8b39bf..18f72b14 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -73,7 +73,9 @@ jobs: id="$(bash graph/pipeline/run-souffle.sh --language "$lang" --print-engine-id)" echo "$lang: $id"; printf '%s' "$id" > "gen/$lang.id" bash graph/pipeline/run-souffle.sh --language "$lang" --emit-program "gen/$lang.dl" - souffle -I graph -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } + # -j makes souffle EMIT the parallel loops (without it the C++ has none); whether a + # platform's binary runs them in parallel is decided at compile time below. + souffle -I graph -j 8 -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "gen/$lang.gen.log" done # the query programs: self-contained (no #include), keyed by dl_program.py itself @@ -89,6 +91,13 @@ jobs: bash .github/scripts/query-smoke.sh expect gen/queries cp .github/scripts/query-smoke.sh gen/queries/smoke.sh cp -r /usr/include/souffle gen/souffle + # the seqlock fix (see souffle_overlay in run-souffle.sh): the write-entry RMW + # must be seq_cst or a weakly-ordered CPU lets data stores pass the version-odd + # store and readers validate garbage. Patched here so every platform's binary + # is built from the same fixed header; the engine id carries +seqlock-fix-1. + sed -i 's/version\.fetch_or(0x1, std::memory_order_acquire)/version.fetch_or(0x1, std::memory_order_seq_cst)/g' gen/souffle/souffle/utility/ParallelUtil.h + grep -q 'fetch_or(0x1, std::memory_order_seq_cst)' gen/souffle/souffle/utility/ParallelUtil.h + ! grep -q 'fetch_or(0x1, std::memory_order_acquire)' gen/souffle/souffle/utility/ParallelUtil.h # one key for the whole set; a partial match restores the previous set echo "key=$(cat gen/*.id gen/queries/*.id | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" # The compile flags live in THIS file and ENGINE_ID does not cover them, so its @@ -135,7 +144,15 @@ jobs: for lang in $LANGUAGES; do if cmp -s "gen/$lang.id" "engines/$lang/ENGINE_ID"; then echo "$lang: cached, rules unchanged"; continue; fi mkdir -p "engines/$lang" - c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + # OpenMP on linux-x64 only: x86-TSO is where the optimistic btree's design + # assumptions hold (a residual arm64 crash mode under memory pressure survives + # the seqlock entry fix — see souffle_overlay in run-souffle.sh), and libgomp + # links STATICALLY so the binary runs on machines with no gcc runtime. The + # .parallel marker beside the binary is what run-souffle.sh reads to pass a + # real -j at run time; arm64 compiles the same patched code sequentially. + OMP=""; case "$(uname -m)" in x86_64) OMP="-fopenmp -Wl,-Bstatic,-lgomp,-Bdynamic";; esac + c++ -std=c++17 -O3 -w $OMP -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + [ -n "$OMP" ] && touch "engines/$lang/axiomcode-engine-$lang.parallel" cp "gen/$lang.id" "engines/$lang/ENGINE_ID" done mkdir -p engines/queries diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index c26c8774..5bfb4461 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -318,14 +318,14 @@ engine_id_of(){ done < "$prog" hin="$(mktemp "${TMPDIR:-/tmp}/axiom-engine-id.XXXXXX")" && [ -f "$hin" ] \ || { echo "❌ engine id: mktemp failed" >&2; return 1; } - if ! printf 'souffle=%s\n' "$SOUFFLE_VERSION" > "$hin" || ! cat "$prog" ${incs[@]+"${incs[@]}"} >> "$hin"; then + if ! printf 'souffle=%s+seqlock-fix-1\n' "$SOUFFLE_VERSION" > "$hin" || ! cat "$prog" ${incs[@]+"${incs[@]}"} >> "$hin"; then echo "❌ engine id: writing the hash input failed" >&2; rm -f "$hin"; return 1 fi # The expected size comes from the SOURCE files (wc's last line is their total), not from a # second read through cat, so a cat that loses bytes cannot agree with itself. want="$(wc -c "$prog" ${incs[@]+"${incs[@]}"})"; have="$(wc -c < "$hin")" want="${want##*$'\n'}"; want="${want#"${want%%[![:space:]]*}"}"; want="${want%% *}" - case "$want" in ""|*[!0-9]*) want=-1;; *) want=$(( want + ${#SOUFFLE_VERSION} + 9 ));; esac # 9 = "souffle=" + "\n" + case "$want" in ""|*[!0-9]*) want=-1;; *) want=$(( want + ${#SOUFFLE_VERSION} + 23 ));; esac # 23 = "souffle=" + "+seqlock-fix-1" + "\n" if [ "${have//[[:space:]]/}" != "$want" ]; then echo "❌ engine id: the hash input is ${have//[[:space:]]/} bytes, expected $want (short write)" >&2; rm -f "$hin"; return 1 fi @@ -379,8 +379,87 @@ case "${AXIOM_ENGINE_MARCH:-native}" in portable) MARCH_FLAG=();; *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; esac +# ── PARALLEL SOLVE ──────────────────────────────────────────────────────────── +# Soufflé emits parallel loops only when the program is GENERATED with -j — without it +# the C++ holds zero parallel sections, which is why every engine before this was +# sequential by construction. Compiled WITHOUT OpenMP the same generated code runs +# serially (pfor degrades to for), so generation always asks for the parallel loops and +# OpenMP at COMPILE time decides the flavor. Measured on a 6,139-file Java subject: +# 147s serial -> 59s at -j8; the outputs are equal as sets (row order shifts between +# flavors; the bundle loads rows into sqlite, which keeps no order). +# GATED PER PLATFORM. On darwin-arm64 the parallel RUNTIME segfaults nondeterministically +# — a different rule each crash, g++/libgomp and apple-clang/libomp alike, Soufflé 2.5 +# and master f53dab8 — so Darwin stays serial until upstream fixes it. Linux enables +# OpenMP when its toolchain takes -fopenmp. AXIOM_SOLVE_PARALLEL=0 forces serial +# anywhere; =1 forces the attempt anywhere (still needs a toolchain with -fopenmp). +# The flavor is part of the CACHE NAME, never shared between flavors: the two binaries +# answer with different row orders, and a cache hit must reproduce the flavor that ran +# yesterday, not whichever compiled first. +# THE SEQLOCK FIX (overlay). Soufflé's OptimisticReadWriteLock enters its write phase +# with fetch_or(..., memory_order_acquire): the version-odd store may become visible +# AFTER the write section's data stores on a weakly-ordered CPU, so a reader can read a +# half-mutated node and still pass validate() against the stale even version. On x86's +# TSO stores never reorder, which is why this only ever fired on arm64 (nondeterministic +# segfaults in a different rule each run, any toolchain, Soufflé 2.5 and master alike). +# seq_cst on the entry RMW pins the odd version BEFORE any data store; on x86 a locked +# RMW is already a full barrier, so the change costs nothing there. The engine id carries +# "+seqlock-fix-1", so no unpatched cache entry or package is ever taken for a patched one. +souffle_overlay(){ + local inner="$1" overlay="$CACHE_ROOT/include-seqlock-fix-1" + local hdr="$overlay/souffle/utility/ParallelUtil.h" + if [ ! -f "$hdr" ]; then + rm -rf "$overlay.tmp.$$" + mkdir -p "$overlay.tmp.$$" + cp -R "$inner/." "$overlay.tmp.$$/" || return 1 + local h="$overlay.tmp.$$/souffle/utility/ParallelUtil.h" + [ -f "$h" ] || return 1 + # three write-entry RMWs: start_write (two), try_start_write, try_upgrade_to_write + sed -i.bak 's/version\.fetch_or(0x1, std::memory_order_acquire)/version.fetch_or(0x1, std::memory_order_seq_cst)/g' "$h" && rm -f "$h.bak" + grep -q 'fetch_or(0x1, std::memory_order_seq_cst)' "$h" || return 1 + grep -q 'fetch_or(0x1, std::memory_order_acquire)' "$h" && return 1 + mv "$overlay.tmp.$$" "$overlay" 2>/dev/null || true # a concurrent run may have won; theirs is identical + rm -rf "$overlay.tmp.$$" + fi + [ -f "$hdr" ] && printf '%s' "$overlay" +} +OMP_FLAG=(); PAR_SUFFIX="" +probe_openmp(){ + # the flags this platform needs for a working OpenMP compile, or nothing. + # Linux: -fopenmp everywhere. Darwin: Apple clang only lowers the pragmas with the + # frontend flag plus Homebrew's libomp (-Xpreprocessor defines _OPENMP without + # lowering anything — a silently sequential binary, which is how this stayed hidden). + case "$(uname -s)" in + Linux) + if printf 'int main(){return 0;}' | c++ -fopenmp -x c++ -o /dev/null - 2>/dev/null; then + printf '%s' "-fopenmp"; return 0 + fi ;; + Darwin) + local omp + for omp in /opt/homebrew/opt/libomp /usr/local/opt/libomp; do + [ -f "$omp/lib/libomp.dylib" ] || continue + if printf 'int main(){return 0;}' | c++ -Xclang -fopenmp -I "$omp/include" -L "$omp/lib" -lomp -x c++ -o /dev/null - 2>/dev/null; then + printf '%s' "-Xclang -fopenmp -I $omp/include -L $omp/lib -lomp"; return 0 + fi + done ;; + esac + return 1 +} +# Default: Linux x86_64 only. The seqlock entry fix above repairs the diagnosed +# ordering hole (quiet-machine runs went clean), but a residual crash mode remains on +# arm64 under memory pressure, so weakly-ordered CPUs stay serial by default until it +# is found; AXIOM_SOLVE_PARALLEL=1 opts any machine in for experiments. +case "${AXIOM_SOLVE_PARALLEL:-}" in + 0) ;; + 1) if _OMP="$(probe_openmp)"; then + OMP_FLAG=($_OMP); PAR_SUFFIX="-par" + fi ;; + "") if [ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "x86_64" ] && _OMP="$(probe_openmp)"; then + # shellcheck disable=SC2206 — the probe emits simple flags, split wanted + OMP_FLAG=($_OMP); PAR_SUFFIX="-par" + fi ;; +esac EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac -BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" +BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$PAR_SUFFIX$EXE" # The platform string, in npm's spelling (process.platform-process.arch), because that is # how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. @@ -456,14 +535,16 @@ elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then # atomically rename, so a concurrent/aborted run never leaves a half-written binary. - if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then + if ! souffle -I "$SRC" -j 8 -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then cat "$INT/.souffle-gen.log" >&2; exit 1 fi awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } CXX_PLATFORM="" case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + OVERLAY="$(souffle_overlay "$INNER" || true)" + [ -n "$OVERLAY" ] || { echo "❌ could not prepare the patched soufflé headers (seqlock fix)" >&2; exit 1; } + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} ${OMP_FLAG[@]+"${OMP_FLAG[@]}"} -w $CXX_PLATFORM -I "$OVERLAY" -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 @@ -771,7 +852,15 @@ while [ "$iter" -lt 50 ]; do # 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. - rc=0; "$BIN" -F "$FACTS" -D "$RAW" || rc=$? + # -j is passed ALWAYS (a serial binary ignores it silently — verified); more than one + # thread only for a binary of the parallel flavor: one this run compiled with OpenMP, + # or a packaged/cached one whose builder left a .parallel marker beside it. + SOLVE_J=1 + if [ -n "$PAR_SUFFIX" ] || [ -f "$BIN.parallel" ]; then + cores="$( (command -v nproc >/dev/null 2>&1 && nproc) || sysctl -n hw.ncpu 2>/dev/null || echo 4 )" + SOLVE_J="${AXIOMCODE_SOLVE_THREADS:-$(( cores < 8 ? cores : 8 ))}" + fi + rc=0; "$BIN" -j "$SOLVE_J" -F "$FACTS" -D "$RAW" || rc=$? if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 132 ] && [ -z "$PACKAGED" ]; then rm -f "$BIN" @@ -810,7 +899,7 @@ done # here (it's in the shared cache), and facts must stay per-run (never shared) so concurrent # analyses of different projects don't collide. Runs only on success (set -e bails earlier # on failure, leaving the facts for debugging). -rm -rf "$FACTS" "$INT/souffle-program.cpp" +[ "${AXIOM_KEEP_FACTS:-0}" = "1" ] || rm -rf "$FACTS" "$INT/souffle-program.cpp" SOLVE_EPOCH=$(date +%s) echo "Elapsed (solve): $((SOLVE_EPOCH-START_EPOCH))s" From 243a4b294db6fc017ddda78a3ec49f2927332938 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:57:30 -0700 Subject: [PATCH 150/258] java rules: a local-variable use resolves to its declaration once, below the typing fixpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five recursive rules carried the same four-atom prefix inline — the LOCAL_VARIABLE reference scan over java_expression, the enclosing method, the (name, method) join to java_local_variable, and the local's initializer. Each sat inside the typing fixpoint, so semi-naive evaluation re-ran that join every iteration: 163 iterations on a large subject, with the planner anchoring on the 1.8M-row expression scan rather than the per-iteration delta. The souffle profile put ~40% of the whole solve inside those five rules, recomputing an answer that is pure syntax and never changes. local_use(use, local) and local_use_init(use, init) now materialize that join once (local-flow.dl; both sit below the fixpoint, since expr_ultimate_method's own small SCC completes first), and the five rules — recv_decl_ref's two var-initializer clauses, generic-chain's expr_arg_binding propagation, external-types' effectively-final flow, and local-flow's own receiver propagation — start from their recursive delta and probe the table. No semantic change, and the gate proves it: every output relation of the old and new engines on a 6,139-file Java subject is BYTE-identical, not merely equal as sets. Measured on that subject, same machine, serial: solve 132.9s -> 94.5s (-29%); this is platform-independent, so it is the serial-platform (darwin, windows, linux-arm64) counterpart of the linux-x64 -j gain, and the two stack. End to end, full index: the subject 191s (parse 35s + solve 114s + bundle 40s + its second language); an 8,576-file multi-module subject 73s all in. tests/run.py --lang java: 333 of 333. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 10 ++-------- graph/java/engine/resolution/external-types.dl | 5 ++--- graph/java/engine/resolution/generic-chain.dl | 5 +---- graph/java/engine/resolution/local-flow.dl | 18 +++++++++++++++--- graph/java/souffle/decls_all.dl | 2 ++ 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/graph/java/engine/expression-resolution/expr-type.dl b/graph/java/engine/expression-resolution/expr-type.dl index 94366240..054c50df 100644 --- a/graph/java/engine/expression-resolution/expr-type.dl +++ b/graph/java/engine/expression-resolution/expr-type.dl @@ -649,16 +649,10 @@ recv_decl_ref(recv, retRef, recvType) :- applicable_candidate(recv, innerCallee) // declared-type clause above yields a reference carrying no arguments. The initializer's callee // return type carries them. The field analogue is unnecessary: Java has no `var` fields, so a // field always writes its type. -recv_decl_ref(recv, retRef, t) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), +recv_decl_ref(recv, retRef, t) :- local_use_init(recv, initExpr), applicable_candidate(initExpr, callee), method_return_type_resolves(_, callee, retRef, t). -recv_decl_ref(recv, retRef, t) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), +recv_decl_ref(recv, retRef, t) :- local_use_init(recv, initExpr), applicable_candidate(initExpr, callee), lib_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, callee, "METHOD", retRef), lib_type_ref_resolves(_, retRef, t). diff --git a/graph/java/engine/resolution/external-types.dl b/graph/java/engine/resolution/external-types.dl index 77697803..4b83b6a1 100644 --- a/graph/java/engine/resolution/external-types.dl +++ b/graph/java/engine/resolution/external-types.dl @@ -178,9 +178,8 @@ expr_type("external", e, x) :- java_expression(_, _, _, _, _, _, _, _, _, _, nam // with `Format source()` unstaged), the local is that external type exactly as `Format f` would // be. Without it the receiver has no external type at all and the call is a declared unknown. // java_local_variable (19): 11 isVarInferred 13 method 18 hash -expr_type("external", e, x) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, e), - expr_ultimate_method("client", e, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, "true", _, m, _, _, _, _, local), +expr_type("external", e, x) :- local_use(e, local), + java_local_variable(_, _, _, _, _, _, _, _, _, _, _, "true", _, _, _, _, _, _, local), local_init_expr(local, init), expr_type("external", init, x). expr_type("external", e, x) :- java_expression("CAST_EXPRESSION", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e), diff --git a/graph/java/engine/resolution/generic-chain.dl b/graph/java/engine/resolution/generic-chain.dl index 5cf2ba5f..a9e35fae 100644 --- a/graph/java/engine/resolution/generic-chain.dl +++ b/graph/java/engine/resolution/generic-chain.dl @@ -100,10 +100,7 @@ expr_arg_binding(call, nested, nestedParam, arg) :- call_site(call, _, recv), // the method it actually appears in — same guard the recv_decl_ref clauses in expr-type.dl use. // java_expression (25): 11 name 15 referenceKind 25 hash. java_local_variable (19): 1 name 14 method 19 hash. expr_arg_binding(recv, bt, param, arg) :- - java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), + local_use_init(recv, initExpr), expr_arg_binding(initExpr, bt, param, arg). // ── INHERITED generics with a CONCRETE super-arg (c9): `class DogBox extends Box`. diff --git a/graph/java/engine/resolution/local-flow.dl b/graph/java/engine/resolution/local-flow.dl index 0d935863..8680ccab 100644 --- a/graph/java/engine/resolution/local-flow.dl +++ b/graph/java/engine/resolution/local-flow.dl @@ -33,6 +33,20 @@ // java_local_variable (19): 0 name 13 method 18 hash // ============================================================================ +// A USE of a local: the LOCAL_VARIABLE reference joined to its declaration, ONCE, below the +// typing fixpoint. These same three atoms sat inline in five recursive rules, and semi-naive +// evaluation re-ran the full java_expression scan every iteration — 163 iterations on a large +// subject, ~40% of the whole solve between them. Hoisted, each of those rules starts from its +// recursive delta and probes this table. +local_use(use, local) :- + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, use), + expr_ultimate_method("client", use, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local). +// ...and the uses whose local is initialized by a call, with that initializer. +local_use_init(use, initExpr) :- + local_use(use, local), + java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr). + // The initializer expression of a local (LOCAL_VAR_INITIALIZER root, owned by the local). local_init_expr(local, e) :- java_expression(_, "ROOT", "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e). @@ -53,7 +67,5 @@ local_flow_type(local, t) :- local_assign_value(local, e), expr_type(_, e, t). // PROPAGATE: a LOCAL_VARIABLE receiver reference takes the flow-type(s) of its local // (POSITIVE — resolves the concrete method directly; the cap handles the residual fan). expr_type("client", recv, t) :- - java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + local_use(recv, local), local_flow_type(local, t). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 57f51bdd..605f86b2 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -475,6 +475,8 @@ .decl cha_wide_base(c0:symbol) .decl cha_override_low(c0:symbol,c1:symbol) .decl local_init_expr(c0:symbol,c1:symbol) +.decl local_use(c0:symbol,c1:symbol) +.decl local_use_init(c0:symbol,c1:symbol) .decl local_assign_value(c0:symbol,c1:symbol) .decl local_flow_type(c0:symbol,c1:symbol) .decl param_flow_type(c0:symbol,c1:symbol) From 054187edd13487eba682e215578e3150942e42b3 Mon Sep 17 00:00:00 2001 From: Haolei Zhang Date: Wed, 7 Oct 2026 12:51:54 -0700 Subject: [PATCH 151/258] parser: an HTML and CSS front end on tree-sitter, with its specification and torture suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One web front end reads `.html`/`.htm`/`.xhtml` and `.css` into seventeen relations (`all-html-*.csv`, `all-css-*.csv`), the way the configuration formats are read: every scan target, structurally, nothing rendered. tree-sitter-html 0.23.2 and tree-sitter-css 0.23.0 build the trees; what either grammar rejects but the language allows (an unquoted `url(../x)`, `[a="b" i]`, `@container name`, `& &`, `! important`, `10%, 20%`, `col || td`, `:nth-child(2n of S)`, uppercase end tags, Jinja tags inside a start tag, …) is re-read from the source text and marked recovered, not reported as a gap. Pages: documents with doctype and template dialects, elements as written with XPath-like paths, attributes by kind, class tokens, every URL a page names classified and resolved to a file (honouring ``, for the page's own ` + + + + +
    + + + + + diff --git a/parser/src/test-data/web/fixture/site/js/app.js b/parser/src/test-data/web/fixture/site/js/app.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/js/vendor.js b/parser/src/test-data/web/fixture/site/js/vendor.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/page.html b/parser/src/test-data/web/fixture/site/page.html new file mode 100644 index 00000000..9595250c --- /dev/null +++ b/parser/src/test-data/web/fixture/site/page.html @@ -0,0 +1,18 @@ + + +Second + + + + + + +

    Second

    + + +self +

    A page with an unclosed tag and a stray end tag.

    +
    +
    implied tbody
    + + diff --git a/parser/src/test-data/web/fixture/site/partials/frame.html b/parser/src/test-data/web/fixture/site/partials/frame.html new file mode 100644 index 00000000..323136fb --- /dev/null +++ b/parser/src/test-data/web/fixture/site/partials/frame.html @@ -0,0 +1,6 @@ +
    +

    {% block title %}Partial{% endblock %}

    + Home + bad style + +
    diff --git a/parser/src/test-data/web/fixture/site/static/css/app.css b/parser/src/test-data/web/fixture/site/static/css/app.css new file mode 100644 index 00000000..dd2d582c --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/app.css @@ -0,0 +1,20 @@ +@charset "utf-8"; +/* theme tokens */ +@import url("base.css") layer(base); +@import "missing.css"; +@layer base, components; +:root { --gap: 8px; --brand: #09f; } +.nav > li.item:not(.hidden) a[href^="http" i]:hover::after { color: var(--brand, red); gap: var(--gap) } +#main .card, .card--wide, ul li:nth-child(2n of .item) { background: url(../img/bg.png) no-repeat; animation: spin 1s linear infinite; font-family: "Inter", Segoe UI, sans-serif } +@media (min-width: 40em) { .card { padding: calc(var(--gap) * 2) !important } } +@keyframes spin { from { transform: rotate(0) } 50% { opacity: .5 } to { transform: rotate(1turn) } } +@font-face { font-family: "Inter"; src: url(/fonts/inter.woff2) format("woff2") } +@supports (display: grid) { .grid { display: grid } } +@container sidebar (min-width: 400px) { .card { gap: 1rem } } +.sidebar { container-name: sidebar; container-type: inline-size } +.a { color: blue; .b & { color: red } &:hover { color: green } } +.broken { color: ; } +.title::before, .title:before { content: "" } +* { box-sizing: border-box } +h1 + p ~ em { margin: 0 } +col || td { margin: 0 } diff --git a/parser/src/test-data/web/fixture/site/static/css/base.css b/parser/src/test-data/web/fixture/site/static/css/base.css new file mode 100644 index 00000000..7493ecf6 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/base.css @@ -0,0 +1 @@ +body { margin: 0; -webkit-font-smoothing: antialiased } diff --git a/parser/src/test-data/web/fixture/site/static/css/empty.css b/parser/src/test-data/web/fixture/site/static/css/empty.css new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/css/vendor.min.css b/parser/src/test-data/web/fixture/site/static/css/vendor.min.css new file mode 100644 index 00000000..d030db28 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/vendor.min.css @@ -0,0 +1 @@ +.a{color:red}.b{color:blue}.c{margin:0;padding:0} diff --git a/parser/src/test-data/web/fixture/site/static/img/hero.png b/parser/src/test-data/web/fixture/site/static/img/hero.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/img/logo.png b/parser/src/test-data/web/fixture/site/static/img/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/img/sprite.svg b/parser/src/test-data/web/fixture/site/static/img/sprite.svg new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/vendor/sassy.css b/parser/src/test-data/web/fixture/site/vendor/sassy.css new file mode 100644 index 00000000..14a04a99 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/vendor/sassy.css @@ -0,0 +1,3 @@ +$primary: #333; +@mixin flex { display: flex; } +.x { color: $primary; @include flex; } diff --git a/parser/src/test-data/web/torture/css/alt.css b/parser/src/test-data/web/torture/css/alt.css new file mode 100644 index 00000000..97121e94 --- /dev/null +++ b/parser/src/test-data/web/torture/css/alt.css @@ -0,0 +1 @@ +.alt-only { color: red } diff --git a/parser/src/test-data/web/torture/css/app.css b/parser/src/test-data/web/torture/css/app.css new file mode 100644 index 00000000..04e4d5d5 --- /dev/null +++ b/parser/src/test-data/web/torture/css/app.css @@ -0,0 +1,237 @@ +@charset "utf-8"; +/* C01 import chain: app -> tokens -> theme; layered, conditional, missing, absolute, in a layer and with media */ +@import url("tokens.css") layer(tokens); +@import "layers.css"; +@import url(nested.css) supports(selector(&)) screen and (min-width: 1px); +@import "missing.css"; +@import url("https://fonts.example.com/inter.css"); +@import "strings.css" layer(vendor.strings); +@import "fonts.css"; +@import 'selectors.css'; +@import url( "gaps.css" ); +@import "app.css"; /* self import: a cycle for the recursive join */ + +@layer tokens, base, components, utilities; +@namespace svg url(http://www.w3.org/2000/svg); +@namespace url(http://www.w3.org/1999/xhtml); + +/* C02 the class joins the HTML fixtures are built around */ +.card { color: var(--brand); } +.card--wide { max-width: var(--wide, 60rem); } +.is-active { outline: 1px solid var(--brand, var(--fallback-brand, red)); } +.nav > a[href^="#"] { color: var(--anchor); } +.upper { color: red } /* HTML has Upper: no join by case */ +.SHOUT { color: red } /* HTML has SHOUT via CLASS="SHOUT": joins */ +#loud { color: red } /* HTML has ID="LOUD": no join by case */ +#LOUD { color: red } +#padded-id { color: red } /* HTML id=" padded-id ": trimmed on the element row */ +.dup { color: red } +.a\&b { color: red } /* HTML class a&b decodes to a&b */ +.tpl-class, .hbs-class, .ko-class, .srcdoc-class { color: red } /* classes written only inside script/srcdoc text */ +.in-template, .in-template-style { color: red } +.noscript-class, .ie-only, .commented-out, .in-jinja-comment { color: red } +.js-ready, .open { color: red } /* classes only a script adds */ +.never-used-anywhere { color: red } /* dead CSS */ +.div-in-p, .li-one, .li-two, .cell, .after-self-closing-div, .unclosed-at-eof { color: red } +.custom, .customized-builtin, .hidden-attr, .dup-attr { color: red } +.static-class, .item, .wrapper, .ng-static, .vue-static, .alpine-static, .htmx-static { color: red } +.xhtml-class, .cdata-class, .after-xml-self-closing { color: red } +.in-foreign, .in-annotation, .math-class, .nested-rect, .svg-rect, .use-class, .symbol-class { color: red } +.popover, .light-card, .shadow-only, .closed-only, .slotted-item, .default-slotted, .inert-class, .deep { color: red } + +/* C03 structural selectors: the HTML writes no tbody, no html, no head, no body */ +table > tbody > tr > td.cell { color: red } +table tr td.cell { color: blue } +html > body > div.card { color: red } +body > .card { color: red } +ul > li.li-two { color: red } +p > div.div-in-p { color: red } +div > span.after-self-closing-div { color: red } +a a { color: red } + +/* C04 functional pseudo-classes: parts at depth > 0 */ +:is(.card, .nav) .item { color: red } +:where(.card) .item { color: red } +.card:not(.is-active, #section-one) { color: red } +.card:has(> .item:hover, + .nav) { color: red } +li:nth-child(2n + 1 of .li-one, .li-two) { color: red } +:nth-last-child(-n+2) { color: red } +:not(:is(.a, .b)) { color: red } +.card:is(:hover, :focus-visible):not([disabled]) { color: red } +a:not([href]) { color: red } +:host(.featured) { color: red } +:host-context(.dark) .card { color: red } +::slotted(.slotted-item) { color: red } +my-card::part(title) { color: red } +input::placeholder, ::selection, ::backdrop, ::marker, ::file-selector-button, ::highlight(h), ::view-transition-group(root), ::cue(v[voice="a"]) { color: red } +:lang(en), :dir(rtl), :state(checked), :focus-within, :target, :empty, :root, :scope, :defined, :popover-open, :modal, :user-invalid, :placeholder-shown, :indeterminate, :read-write, :autofill, :fullscreen, :picture-in-picture, :playing, :paused, :link, :any-link, :visited, :local-link, :target-within, :current, :past, :future, :active-view-transition { color: red } + +/* C05 attribute selectors: every matcher, flags, quoted/unquoted, namespaced */ +[data-page] { color: red } +[data-page="index"] { color: red } +[data-page=index] { color: red } +[class~="card"] { color: red } +[class*="card"] { color: red } +[class^=card] { color: red } +[class$="wide" i] { color: red } +[class$="wide" s] { color: red } +[id|="section"] { color: red } +[href$=".pdf" i] { color: red } +[xlink|href] { color: red } +[*|href] { color: red } +[|href] { color: red } +a[target][href][rel] { color: red } +[aria-labelledby~="section-one"] { color: red } +[style*="var("] { color: red } +input[type="IMAGE" i] { color: red } + +/* C06 combinators and universal/namespace selectors */ +* { box-sizing: border-box } +*|* { color: red } +svg|* { color: red } +|rect { color: red } +svg|rect.svg-rect { color: red } +h2 + p ~ em > b { color: red } +.a>.b+.c~.d { color: red } +col || td { color: red } +.a .b .c +.d { color: red } +.card/* comment in a selector */.is-active { color: red } +.card, /* comment between selectors */ .nav, { color: red } +.card,, .nav { color: red } +.card . broken { color: red } +.123 { color: red } +.-valid-dash { color: red } +.--double-dash { color: red } +._underscore { color: red } +#1digit { color: red } +.a.b.c.d.e.f.g.h.i.j.k { color: red } +.a#b.c#d { color: red } +div#b.c[x]:hover::before { color: red } +DIV.Card:HOVER::BEFORE { color: red } +.before:before, .after::after, ::before, :after, ::first-line, ::first-letter, :first-letter { color: red } + +/* C07 the cascade torture: same element, same property, many sources */ +#section-one { color: rgb(1, 1, 1) } /* (1,0,0) */ +.card.card--wide.is-active { color: rgb(2, 2, 2) } /* (0,3,0) */ +section.card { color: rgb(3, 3, 3) !important } /* important beats everything unlayered */ +@layer base { .card { color: rgb(4, 4, 4) } section#section-one { color: rgb(5, 5, 5) !important } } +@layer utilities { .card { color: rgb(6, 6, 6) } } +@layer { .card { color: rgb(7, 7, 7) } } /* anonymous layer */ +.card { color: rgb(8, 8, 8) } +.card { color: rgb(9, 9, 9) } /* later in source order wins over the one above */ +:where(.card.card--wide.is-active#section-one) { color: rgb(10, 10, 10) } /* (0,0,0) */ +:is(#section-one, .card) { color: rgb(11, 11, 11) } /* (1,0,0) via :is */ +.card:not(#nope) { color: rgb(12, 12, 12) } /* (1,1,0) via :not */ +[id="section-one"] { color: rgb(13, 13, 13) } /* (0,1,0) attribute, not id */ +@media screen { .card { color: rgb(14, 14, 14) } } +@media print { .card { color: rgb(15, 15, 15) } } +@supports (color: red) { .card { color: rgb(16, 16, 16) } } +@supports not (display: grid) { .card { color: rgb(17, 17, 17) } } +@container sidebar (min-width: 1px) { .card { color: rgb(18, 18, 18) } } +@scope (.card) to (.item) { :scope { color: rgb(19, 19, 19) } .is-active { color: rgb(20, 20, 20) } } +@media (400px <= width <= 700px) { .card { color: rgb(21, 21, 21) } } +@media only screen and (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { .card { color: rgb(22, 22, 22) } } +@media (prefers-color-scheme: dark) { :root { --brand: black } .card { color: var(--brand) } } +@starting-style { .card { opacity: 0 } } +@page :first { margin: 1in } +@page { @top-center { content: "x" } } +@font-feature-values Inter { @styleset { nice-style: 12 } } +@counter-style thumbs { system: cyclic; symbols: "👍"; suffix: " " } +@position-try --fallback { top: anchor(bottom) } +@view-transition { navigation: auto } +@-moz-document url-prefix() { .card { color: red } } +@-webkit-keyframes vendor-spin { from { opacity: 0 } to { opacity: 1 } } +@media screen { @supports (display: grid) { @layer components { .card .item { color: rgb(23, 23, 23) } } } } + +/* C08 properties: custom, vendor, hacks, important variants, duplicates, uppercase, empty */ +.props { + --Brand: red; /* custom properties are case-sensitive: --Brand is not --brand */ + --brand: blue; + --empty:; + --with-brace: { a: b }; + --json: [1, 2, 3]; + --url: url(img/bg.png); + --nested: var(--brand, var(--Brand, var(--missing, red))); + color: var(--Brand); + background: var(--url); + -webkit-transition: all 1s; + -moz-transition: all 1s; + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#80000000'); + *zoom: 1; + _height: 1px; + color: red\9; + width: expression(document.body.clientWidth > 800 ? "800px" : "auto"); + margin: 0 ! important; + padding: 0!important; + color: red !IMPORTANT; + color: red;; + color: ; + : red; + color red; + COLOR: RED; + Color: Red; + display: grid; display: -ms-grid; + grid-template-areas: "a b" "c d"; + content: "a; } b { c: d }"; /* string with declaration-ending characters */ + content: "var(--not-a-var) url(not-a-url.png)"; /* string that looks like functions */ + background: url( "img/bg.png" ); + background: url('img/bg.png?v=1#frag'); + background: url(img/bg.png?v=1#frag); + background: url( img/bg.png ); + background: url("img/space name.png"); + background: url(img/space\ name.png); + background: url(data:image/png;base64,iVBORw0KGgo=); + background: url(//cdn.example.com/x.png); + background: url(/img/bg.png); + background: url(../img/bg.png); + background: url(#inline-ref); + background: url(); + background: url(""), url(img/bg.png), url(img/missing.png); + background: image-set("img/hero.png" 1x, "img/hero@2x.png" 2x); + background: -webkit-image-set(url(img/hero.png) 1x); + background: src("img/bg.png"); + mask: url(img/sprite.svg#mask); + cursor: url(img/cursor.cur) 2 2, url(img/cursor.cur), pointer; + list-style: url(img/bg.png); + border-image: url(img/bg.png) 30 round; + background: URL(img/bg.png); + color: color-mix(in srgb, var(--brand) 50%, var(--Brand)); + width: calc(100% - var(--gap, 8px) * 2); + padding: env(safe-area-inset-top, var(--pad)); + font: italic bold 12px/30px Inter, "Segoe UI", serif; /* font SHORTHAND: family names at the end */ + font: 12px system-ui; + font: menu; + font-family: Inter; + font-family: "Inter", 'Roboto Mono', Segoe UI, ui-sans-serif, sans-serif; + font-family: inherit; + font-family: var(--font); + animation: spin 1s linear infinite, fade 2s; + animation: 1s ease-in-out infinite reverse spin; + animation: 2s steps(4, end) slide; + animation: "quoted-name" 1s; + animation: none; + animation: linear 1s; /* linear is a keyword, not a name */ + animation: var(--anim) 1s; + animation-name: spin, fade, missing-keyframes, vendor-spin; + animation-name: none; + animation-name: ease; /* a keyframes named like a keyword */ + container: sidebar / inline-size; + container: a b / size; + container-name: sidebar other; + container-name: none; + container-type: inline-size; + transition: color var(--duration) var(--easing, ease); + will-change: transform; + content: counter(item) ". "; + grid-area: header; + position-try-fallbacks: --fallback; + anchor-name: --anchor; + position-anchor: --anchor; + view-transition-name: hero; + offset-path: url(#motion-path); + clip-path: url(img/sprite.svg#clip); + shape-outside: url(img/bg.png); + src: url(fonts/inter.woff2); /* src outside @font-face */ +} diff --git a/parser/src/test-data/web/torture/css/base.css b/parser/src/test-data/web/torture/css/base.css new file mode 100644 index 00000000..9a0e91a3 --- /dev/null +++ b/parser/src/test-data/web/torture/css/base.css @@ -0,0 +1,2 @@ +.base-class { color: red } +.card { color: rgb(100,100,100) } diff --git a/parser/src/test-data/web/torture/css/big.css b/parser/src/test-data/web/torture/css/big.css new file mode 100644 index 00000000..217950d0 --- /dev/null +++ b/parser/src/test-data/web/torture/css/big.css @@ -0,0 +1,1500 @@ +.big-0 { color: var(--brand); background: url(img/bg.png) } +.big-1 { color: var(--brand); background: url(img/bg.png) } +.big-2 { color: var(--brand); background: url(img/bg.png) } +.big-3 { color: var(--brand); background: url(img/bg.png) } +.big-4 { color: var(--brand); background: url(img/bg.png) } +.big-5 { color: var(--brand); background: url(img/bg.png) } +.big-6 { color: var(--brand); background: url(img/bg.png) } +.big-7 { color: var(--brand); background: url(img/bg.png) } +.big-8 { color: var(--brand); background: url(img/bg.png) } +.big-9 { color: var(--brand); background: url(img/bg.png) } +.big-10 { color: var(--brand); background: url(img/bg.png) } +.big-11 { color: var(--brand); background: url(img/bg.png) } +.big-12 { color: var(--brand); background: url(img/bg.png) } +.big-13 { color: var(--brand); background: url(img/bg.png) } +.big-14 { color: var(--brand); background: url(img/bg.png) } +.big-15 { color: var(--brand); background: url(img/bg.png) } +.big-16 { color: var(--brand); background: url(img/bg.png) } +.big-17 { color: var(--brand); background: url(img/bg.png) } +.big-18 { color: var(--brand); background: url(img/bg.png) } +.big-19 { color: var(--brand); background: url(img/bg.png) } +.big-20 { color: var(--brand); background: url(img/bg.png) } +.big-21 { color: var(--brand); background: url(img/bg.png) } +.big-22 { color: var(--brand); background: url(img/bg.png) } +.big-23 { color: var(--brand); background: url(img/bg.png) } +.big-24 { color: var(--brand); background: url(img/bg.png) } +.big-25 { color: var(--brand); background: url(img/bg.png) } +.big-26 { color: var(--brand); background: url(img/bg.png) } +.big-27 { color: var(--brand); background: url(img/bg.png) } +.big-28 { color: var(--brand); background: url(img/bg.png) } +.big-29 { color: var(--brand); background: url(img/bg.png) } +.big-30 { color: var(--brand); background: url(img/bg.png) } +.big-31 { color: var(--brand); background: url(img/bg.png) } +.big-32 { color: var(--brand); background: url(img/bg.png) } +.big-33 { color: var(--brand); background: url(img/bg.png) } +.big-34 { color: var(--brand); background: url(img/bg.png) } +.big-35 { color: var(--brand); background: url(img/bg.png) } +.big-36 { color: var(--brand); background: url(img/bg.png) } +.big-37 { color: var(--brand); background: url(img/bg.png) } +.big-38 { color: var(--brand); background: url(img/bg.png) } +.big-39 { color: var(--brand); background: url(img/bg.png) } +.big-40 { color: var(--brand); background: url(img/bg.png) } +.big-41 { color: var(--brand); background: url(img/bg.png) } +.big-42 { color: var(--brand); background: url(img/bg.png) } +.big-43 { color: var(--brand); background: url(img/bg.png) } +.big-44 { color: var(--brand); background: url(img/bg.png) } +.big-45 { color: var(--brand); background: url(img/bg.png) } +.big-46 { color: var(--brand); background: url(img/bg.png) } +.big-47 { color: var(--brand); background: url(img/bg.png) } +.big-48 { color: var(--brand); background: url(img/bg.png) } +.big-49 { color: var(--brand); background: url(img/bg.png) } +.big-50 { color: var(--brand); background: url(img/bg.png) } +.big-51 { color: var(--brand); background: url(img/bg.png) } +.big-52 { color: var(--brand); background: url(img/bg.png) } +.big-53 { color: var(--brand); background: url(img/bg.png) } +.big-54 { color: var(--brand); background: url(img/bg.png) } +.big-55 { color: var(--brand); background: url(img/bg.png) } +.big-56 { color: var(--brand); background: url(img/bg.png) } +.big-57 { color: var(--brand); background: url(img/bg.png) } +.big-58 { color: var(--brand); background: url(img/bg.png) } +.big-59 { color: var(--brand); background: url(img/bg.png) } +.big-60 { color: var(--brand); background: url(img/bg.png) } +.big-61 { color: var(--brand); background: url(img/bg.png) } +.big-62 { color: var(--brand); background: url(img/bg.png) } +.big-63 { color: var(--brand); background: url(img/bg.png) } +.big-64 { color: var(--brand); background: url(img/bg.png) } +.big-65 { color: var(--brand); background: url(img/bg.png) } +.big-66 { color: var(--brand); background: url(img/bg.png) } +.big-67 { color: var(--brand); background: url(img/bg.png) } +.big-68 { color: var(--brand); background: url(img/bg.png) } +.big-69 { color: var(--brand); background: url(img/bg.png) } +.big-70 { color: var(--brand); background: url(img/bg.png) } +.big-71 { color: var(--brand); background: url(img/bg.png) } +.big-72 { color: var(--brand); background: url(img/bg.png) } +.big-73 { color: var(--brand); background: url(img/bg.png) } +.big-74 { color: var(--brand); background: url(img/bg.png) } +.big-75 { color: var(--brand); background: url(img/bg.png) } +.big-76 { color: var(--brand); background: url(img/bg.png) } +.big-77 { color: var(--brand); background: url(img/bg.png) } +.big-78 { color: var(--brand); background: url(img/bg.png) } +.big-79 { color: var(--brand); background: url(img/bg.png) } +.big-80 { color: var(--brand); background: url(img/bg.png) } +.big-81 { color: var(--brand); background: url(img/bg.png) } +.big-82 { color: var(--brand); background: url(img/bg.png) } +.big-83 { color: var(--brand); background: url(img/bg.png) } +.big-84 { color: var(--brand); background: url(img/bg.png) } +.big-85 { color: var(--brand); background: url(img/bg.png) } +.big-86 { color: var(--brand); background: url(img/bg.png) } +.big-87 { color: var(--brand); background: url(img/bg.png) } +.big-88 { color: var(--brand); background: url(img/bg.png) } +.big-89 { color: var(--brand); background: url(img/bg.png) } +.big-90 { color: var(--brand); background: url(img/bg.png) } +.big-91 { color: var(--brand); background: url(img/bg.png) } +.big-92 { color: var(--brand); background: url(img/bg.png) } +.big-93 { color: var(--brand); background: url(img/bg.png) } +.big-94 { color: var(--brand); background: url(img/bg.png) } +.big-95 { color: var(--brand); background: url(img/bg.png) } +.big-96 { color: var(--brand); background: url(img/bg.png) } +.big-97 { color: var(--brand); background: url(img/bg.png) } +.big-98 { color: var(--brand); background: url(img/bg.png) } +.big-99 { color: var(--brand); background: url(img/bg.png) } +.big-100 { color: var(--brand); background: url(img/bg.png) } +.big-101 { color: var(--brand); background: url(img/bg.png) } +.big-102 { color: var(--brand); background: url(img/bg.png) } +.big-103 { color: var(--brand); background: url(img/bg.png) } +.big-104 { color: var(--brand); background: url(img/bg.png) } +.big-105 { color: var(--brand); background: url(img/bg.png) } +.big-106 { color: var(--brand); background: url(img/bg.png) } +.big-107 { color: var(--brand); background: url(img/bg.png) } +.big-108 { color: var(--brand); background: url(img/bg.png) } +.big-109 { color: var(--brand); background: url(img/bg.png) } +.big-110 { color: var(--brand); background: url(img/bg.png) } +.big-111 { color: var(--brand); background: url(img/bg.png) } +.big-112 { color: var(--brand); background: url(img/bg.png) } +.big-113 { color: var(--brand); background: url(img/bg.png) } +.big-114 { color: var(--brand); background: url(img/bg.png) } +.big-115 { color: var(--brand); background: url(img/bg.png) } +.big-116 { color: var(--brand); background: url(img/bg.png) } +.big-117 { color: var(--brand); background: url(img/bg.png) } +.big-118 { color: var(--brand); background: url(img/bg.png) } +.big-119 { color: var(--brand); background: url(img/bg.png) } +.big-120 { color: var(--brand); background: url(img/bg.png) } +.big-121 { color: var(--brand); background: url(img/bg.png) } +.big-122 { color: var(--brand); background: url(img/bg.png) } +.big-123 { color: var(--brand); background: url(img/bg.png) } +.big-124 { color: var(--brand); background: url(img/bg.png) } +.big-125 { color: var(--brand); background: url(img/bg.png) } +.big-126 { color: var(--brand); background: url(img/bg.png) } +.big-127 { color: var(--brand); background: url(img/bg.png) } +.big-128 { color: var(--brand); background: url(img/bg.png) } +.big-129 { color: var(--brand); background: url(img/bg.png) } +.big-130 { color: var(--brand); background: url(img/bg.png) } +.big-131 { color: var(--brand); background: url(img/bg.png) } +.big-132 { color: var(--brand); background: url(img/bg.png) } +.big-133 { color: var(--brand); background: url(img/bg.png) } +.big-134 { color: var(--brand); background: url(img/bg.png) } +.big-135 { color: var(--brand); background: url(img/bg.png) } +.big-136 { color: var(--brand); background: url(img/bg.png) } +.big-137 { color: var(--brand); background: url(img/bg.png) } +.big-138 { color: var(--brand); background: url(img/bg.png) } +.big-139 { color: var(--brand); background: url(img/bg.png) } +.big-140 { color: var(--brand); background: url(img/bg.png) } +.big-141 { color: var(--brand); background: url(img/bg.png) } +.big-142 { color: var(--brand); background: url(img/bg.png) } +.big-143 { color: var(--brand); background: url(img/bg.png) } +.big-144 { color: var(--brand); background: url(img/bg.png) } +.big-145 { color: var(--brand); background: url(img/bg.png) } +.big-146 { color: var(--brand); background: url(img/bg.png) } +.big-147 { color: var(--brand); background: url(img/bg.png) } +.big-148 { color: var(--brand); background: url(img/bg.png) } +.big-149 { color: var(--brand); background: url(img/bg.png) } +.big-150 { color: var(--brand); background: url(img/bg.png) } +.big-151 { color: var(--brand); background: url(img/bg.png) } +.big-152 { color: var(--brand); background: url(img/bg.png) } +.big-153 { color: var(--brand); background: url(img/bg.png) } +.big-154 { color: var(--brand); background: url(img/bg.png) } +.big-155 { color: var(--brand); background: url(img/bg.png) } +.big-156 { color: var(--brand); background: url(img/bg.png) } +.big-157 { color: var(--brand); background: url(img/bg.png) } +.big-158 { color: var(--brand); background: url(img/bg.png) } +.big-159 { color: var(--brand); background: url(img/bg.png) } +.big-160 { color: var(--brand); background: url(img/bg.png) } +.big-161 { color: var(--brand); background: url(img/bg.png) } +.big-162 { color: var(--brand); background: url(img/bg.png) } +.big-163 { color: var(--brand); background: url(img/bg.png) } +.big-164 { color: var(--brand); background: url(img/bg.png) } +.big-165 { color: var(--brand); background: url(img/bg.png) } +.big-166 { color: var(--brand); background: url(img/bg.png) } +.big-167 { color: var(--brand); background: url(img/bg.png) } +.big-168 { color: var(--brand); background: url(img/bg.png) } +.big-169 { color: var(--brand); background: url(img/bg.png) } +.big-170 { color: var(--brand); background: url(img/bg.png) } +.big-171 { color: var(--brand); background: url(img/bg.png) } +.big-172 { color: var(--brand); background: url(img/bg.png) } +.big-173 { color: var(--brand); background: url(img/bg.png) } +.big-174 { color: var(--brand); background: url(img/bg.png) } +.big-175 { color: var(--brand); background: url(img/bg.png) } +.big-176 { color: var(--brand); background: url(img/bg.png) } +.big-177 { color: var(--brand); background: url(img/bg.png) } +.big-178 { color: var(--brand); background: url(img/bg.png) } +.big-179 { color: var(--brand); background: url(img/bg.png) } +.big-180 { color: var(--brand); background: url(img/bg.png) } +.big-181 { color: var(--brand); background: url(img/bg.png) } +.big-182 { color: var(--brand); background: url(img/bg.png) } +.big-183 { color: var(--brand); background: url(img/bg.png) } +.big-184 { color: var(--brand); background: url(img/bg.png) } +.big-185 { color: var(--brand); background: url(img/bg.png) } +.big-186 { color: var(--brand); background: url(img/bg.png) } +.big-187 { color: var(--brand); background: url(img/bg.png) } +.big-188 { color: var(--brand); background: url(img/bg.png) } +.big-189 { color: var(--brand); background: url(img/bg.png) } +.big-190 { color: var(--brand); background: url(img/bg.png) } +.big-191 { color: var(--brand); background: url(img/bg.png) } +.big-192 { color: var(--brand); background: url(img/bg.png) } +.big-193 { color: var(--brand); background: url(img/bg.png) } +.big-194 { color: var(--brand); background: url(img/bg.png) } +.big-195 { color: var(--brand); background: url(img/bg.png) } +.big-196 { color: var(--brand); background: url(img/bg.png) } +.big-197 { color: var(--brand); background: url(img/bg.png) } +.big-198 { color: var(--brand); background: url(img/bg.png) } +.big-199 { color: var(--brand); background: url(img/bg.png) } +.big-200 { color: var(--brand); background: url(img/bg.png) } +.big-201 { color: var(--brand); background: url(img/bg.png) } +.big-202 { color: var(--brand); background: url(img/bg.png) } +.big-203 { color: var(--brand); background: url(img/bg.png) } +.big-204 { color: var(--brand); background: url(img/bg.png) } +.big-205 { color: var(--brand); background: url(img/bg.png) } +.big-206 { color: var(--brand); background: url(img/bg.png) } +.big-207 { color: var(--brand); background: url(img/bg.png) } +.big-208 { color: var(--brand); background: url(img/bg.png) } +.big-209 { color: var(--brand); background: url(img/bg.png) } +.big-210 { color: var(--brand); background: url(img/bg.png) } +.big-211 { color: var(--brand); background: url(img/bg.png) } +.big-212 { color: var(--brand); background: url(img/bg.png) } +.big-213 { color: var(--brand); background: url(img/bg.png) } +.big-214 { color: var(--brand); background: url(img/bg.png) } +.big-215 { color: var(--brand); background: url(img/bg.png) } +.big-216 { color: var(--brand); background: url(img/bg.png) } +.big-217 { color: var(--brand); background: url(img/bg.png) } +.big-218 { color: var(--brand); background: url(img/bg.png) } +.big-219 { color: var(--brand); background: url(img/bg.png) } +.big-220 { color: var(--brand); background: url(img/bg.png) } +.big-221 { color: var(--brand); background: url(img/bg.png) } +.big-222 { color: var(--brand); background: url(img/bg.png) } +.big-223 { color: var(--brand); background: url(img/bg.png) } +.big-224 { color: var(--brand); background: url(img/bg.png) } +.big-225 { color: var(--brand); background: url(img/bg.png) } +.big-226 { color: var(--brand); background: url(img/bg.png) } +.big-227 { color: var(--brand); background: url(img/bg.png) } +.big-228 { color: var(--brand); background: url(img/bg.png) } +.big-229 { color: var(--brand); background: url(img/bg.png) } +.big-230 { color: var(--brand); background: url(img/bg.png) } +.big-231 { color: var(--brand); background: url(img/bg.png) } +.big-232 { color: var(--brand); background: url(img/bg.png) } +.big-233 { color: var(--brand); background: url(img/bg.png) } +.big-234 { color: var(--brand); background: url(img/bg.png) } +.big-235 { color: var(--brand); background: url(img/bg.png) } +.big-236 { color: var(--brand); background: url(img/bg.png) } +.big-237 { color: var(--brand); background: url(img/bg.png) } +.big-238 { color: var(--brand); background: url(img/bg.png) } +.big-239 { color: var(--brand); background: url(img/bg.png) } +.big-240 { color: var(--brand); background: url(img/bg.png) } +.big-241 { color: var(--brand); background: url(img/bg.png) } +.big-242 { color: var(--brand); background: url(img/bg.png) } +.big-243 { color: var(--brand); background: url(img/bg.png) } +.big-244 { color: var(--brand); background: url(img/bg.png) } +.big-245 { color: var(--brand); background: url(img/bg.png) } +.big-246 { color: var(--brand); background: url(img/bg.png) } +.big-247 { color: var(--brand); background: url(img/bg.png) } +.big-248 { color: var(--brand); background: url(img/bg.png) } +.big-249 { color: var(--brand); background: url(img/bg.png) } +.big-250 { color: var(--brand); background: url(img/bg.png) } +.big-251 { color: var(--brand); background: url(img/bg.png) } +.big-252 { color: var(--brand); background: url(img/bg.png) } +.big-253 { color: var(--brand); background: url(img/bg.png) } +.big-254 { color: var(--brand); background: url(img/bg.png) } +.big-255 { color: var(--brand); background: url(img/bg.png) } +.big-256 { color: var(--brand); background: url(img/bg.png) } +.big-257 { color: var(--brand); background: url(img/bg.png) } +.big-258 { color: var(--brand); background: url(img/bg.png) } +.big-259 { color: var(--brand); background: url(img/bg.png) } +.big-260 { color: var(--brand); background: url(img/bg.png) } +.big-261 { color: var(--brand); background: url(img/bg.png) } +.big-262 { color: var(--brand); background: url(img/bg.png) } +.big-263 { color: var(--brand); background: url(img/bg.png) } +.big-264 { color: var(--brand); background: url(img/bg.png) } +.big-265 { color: var(--brand); background: url(img/bg.png) } +.big-266 { color: var(--brand); background: url(img/bg.png) } +.big-267 { color: var(--brand); background: url(img/bg.png) } +.big-268 { color: var(--brand); background: url(img/bg.png) } +.big-269 { color: var(--brand); background: url(img/bg.png) } +.big-270 { color: var(--brand); background: url(img/bg.png) } +.big-271 { color: var(--brand); background: url(img/bg.png) } +.big-272 { color: var(--brand); background: url(img/bg.png) } +.big-273 { color: var(--brand); background: url(img/bg.png) } +.big-274 { color: var(--brand); background: url(img/bg.png) } +.big-275 { color: var(--brand); background: url(img/bg.png) } +.big-276 { color: var(--brand); background: url(img/bg.png) } +.big-277 { color: var(--brand); background: url(img/bg.png) } +.big-278 { color: var(--brand); background: url(img/bg.png) } +.big-279 { color: var(--brand); background: url(img/bg.png) } +.big-280 { color: var(--brand); background: url(img/bg.png) } +.big-281 { color: var(--brand); background: url(img/bg.png) } +.big-282 { color: var(--brand); background: url(img/bg.png) } +.big-283 { color: var(--brand); background: url(img/bg.png) } +.big-284 { color: var(--brand); background: url(img/bg.png) } +.big-285 { color: var(--brand); background: url(img/bg.png) } +.big-286 { color: var(--brand); background: url(img/bg.png) } +.big-287 { color: var(--brand); background: url(img/bg.png) } +.big-288 { color: var(--brand); background: url(img/bg.png) } +.big-289 { color: var(--brand); background: url(img/bg.png) } +.big-290 { color: var(--brand); background: url(img/bg.png) } +.big-291 { color: var(--brand); background: url(img/bg.png) } +.big-292 { color: var(--brand); background: url(img/bg.png) } +.big-293 { color: var(--brand); background: url(img/bg.png) } +.big-294 { color: var(--brand); background: url(img/bg.png) } +.big-295 { color: var(--brand); background: url(img/bg.png) } +.big-296 { color: var(--brand); background: url(img/bg.png) } +.big-297 { color: var(--brand); background: url(img/bg.png) } +.big-298 { color: var(--brand); background: url(img/bg.png) } +.big-299 { color: var(--brand); background: url(img/bg.png) } +.big-300 { color: var(--brand); background: url(img/bg.png) } +.big-301 { color: var(--brand); background: url(img/bg.png) } +.big-302 { color: var(--brand); background: url(img/bg.png) } +.big-303 { color: var(--brand); background: url(img/bg.png) } +.big-304 { color: var(--brand); background: url(img/bg.png) } +.big-305 { color: var(--brand); background: url(img/bg.png) } +.big-306 { color: var(--brand); background: url(img/bg.png) } +.big-307 { color: var(--brand); background: url(img/bg.png) } +.big-308 { color: var(--brand); background: url(img/bg.png) } +.big-309 { color: var(--brand); background: url(img/bg.png) } +.big-310 { color: var(--brand); background: url(img/bg.png) } +.big-311 { color: var(--brand); background: url(img/bg.png) } +.big-312 { color: var(--brand); background: url(img/bg.png) } +.big-313 { color: var(--brand); background: url(img/bg.png) } +.big-314 { color: var(--brand); background: url(img/bg.png) } +.big-315 { color: var(--brand); background: url(img/bg.png) } +.big-316 { color: var(--brand); background: url(img/bg.png) } +.big-317 { color: var(--brand); background: url(img/bg.png) } +.big-318 { color: var(--brand); background: url(img/bg.png) } +.big-319 { color: var(--brand); background: url(img/bg.png) } +.big-320 { color: var(--brand); background: url(img/bg.png) } +.big-321 { color: var(--brand); background: url(img/bg.png) } +.big-322 { color: var(--brand); background: url(img/bg.png) } +.big-323 { color: var(--brand); background: url(img/bg.png) } +.big-324 { color: var(--brand); background: url(img/bg.png) } +.big-325 { color: var(--brand); background: url(img/bg.png) } +.big-326 { color: var(--brand); background: url(img/bg.png) } +.big-327 { color: var(--brand); background: url(img/bg.png) } +.big-328 { color: var(--brand); background: url(img/bg.png) } +.big-329 { color: var(--brand); background: url(img/bg.png) } +.big-330 { color: var(--brand); background: url(img/bg.png) } +.big-331 { color: var(--brand); background: url(img/bg.png) } +.big-332 { color: var(--brand); background: url(img/bg.png) } +.big-333 { color: var(--brand); background: url(img/bg.png) } +.big-334 { color: var(--brand); background: url(img/bg.png) } +.big-335 { color: var(--brand); background: url(img/bg.png) } +.big-336 { color: var(--brand); background: url(img/bg.png) } +.big-337 { color: var(--brand); background: url(img/bg.png) } +.big-338 { color: var(--brand); background: url(img/bg.png) } +.big-339 { color: var(--brand); background: url(img/bg.png) } +.big-340 { color: var(--brand); background: url(img/bg.png) } +.big-341 { color: var(--brand); background: url(img/bg.png) } +.big-342 { color: var(--brand); background: url(img/bg.png) } +.big-343 { color: var(--brand); background: url(img/bg.png) } +.big-344 { color: var(--brand); background: url(img/bg.png) } +.big-345 { color: var(--brand); background: url(img/bg.png) } +.big-346 { color: var(--brand); background: url(img/bg.png) } +.big-347 { color: var(--brand); background: url(img/bg.png) } +.big-348 { color: var(--brand); background: url(img/bg.png) } +.big-349 { color: var(--brand); background: url(img/bg.png) } +.big-350 { color: var(--brand); background: url(img/bg.png) } +.big-351 { color: var(--brand); background: url(img/bg.png) } +.big-352 { color: var(--brand); background: url(img/bg.png) } +.big-353 { color: var(--brand); background: url(img/bg.png) } +.big-354 { color: var(--brand); background: url(img/bg.png) } +.big-355 { color: var(--brand); background: url(img/bg.png) } +.big-356 { color: var(--brand); background: url(img/bg.png) } +.big-357 { color: var(--brand); background: url(img/bg.png) } +.big-358 { color: var(--brand); background: url(img/bg.png) } +.big-359 { color: var(--brand); background: url(img/bg.png) } +.big-360 { color: var(--brand); background: url(img/bg.png) } +.big-361 { color: var(--brand); background: url(img/bg.png) } +.big-362 { color: var(--brand); background: url(img/bg.png) } +.big-363 { color: var(--brand); background: url(img/bg.png) } +.big-364 { color: var(--brand); background: url(img/bg.png) } +.big-365 { color: var(--brand); background: url(img/bg.png) } +.big-366 { color: var(--brand); background: url(img/bg.png) } +.big-367 { color: var(--brand); background: url(img/bg.png) } +.big-368 { color: var(--brand); background: url(img/bg.png) } +.big-369 { color: var(--brand); background: url(img/bg.png) } +.big-370 { color: var(--brand); background: url(img/bg.png) } +.big-371 { color: var(--brand); background: url(img/bg.png) } +.big-372 { color: var(--brand); background: url(img/bg.png) } +.big-373 { color: var(--brand); background: url(img/bg.png) } +.big-374 { color: var(--brand); background: url(img/bg.png) } +.big-375 { color: var(--brand); background: url(img/bg.png) } +.big-376 { color: var(--brand); background: url(img/bg.png) } +.big-377 { color: var(--brand); background: url(img/bg.png) } +.big-378 { color: var(--brand); background: url(img/bg.png) } +.big-379 { color: var(--brand); background: url(img/bg.png) } +.big-380 { color: var(--brand); background: url(img/bg.png) } +.big-381 { color: var(--brand); background: url(img/bg.png) } +.big-382 { color: var(--brand); background: url(img/bg.png) } +.big-383 { color: var(--brand); background: url(img/bg.png) } +.big-384 { color: var(--brand); background: url(img/bg.png) } +.big-385 { color: var(--brand); background: url(img/bg.png) } +.big-386 { color: var(--brand); background: url(img/bg.png) } +.big-387 { color: var(--brand); background: url(img/bg.png) } +.big-388 { color: var(--brand); background: url(img/bg.png) } +.big-389 { color: var(--brand); background: url(img/bg.png) } +.big-390 { color: var(--brand); background: url(img/bg.png) } +.big-391 { color: var(--brand); background: url(img/bg.png) } +.big-392 { color: var(--brand); background: url(img/bg.png) } +.big-393 { color: var(--brand); background: url(img/bg.png) } +.big-394 { color: var(--brand); background: url(img/bg.png) } +.big-395 { color: var(--brand); background: url(img/bg.png) } +.big-396 { color: var(--brand); background: url(img/bg.png) } +.big-397 { color: var(--brand); background: url(img/bg.png) } +.big-398 { color: var(--brand); background: url(img/bg.png) } +.big-399 { color: var(--brand); background: url(img/bg.png) } +.big-400 { color: var(--brand); background: url(img/bg.png) } +.big-401 { color: var(--brand); background: url(img/bg.png) } +.big-402 { color: var(--brand); background: url(img/bg.png) } +.big-403 { color: var(--brand); background: url(img/bg.png) } +.big-404 { color: var(--brand); background: url(img/bg.png) } +.big-405 { color: var(--brand); background: url(img/bg.png) } +.big-406 { color: var(--brand); background: url(img/bg.png) } +.big-407 { color: var(--brand); background: url(img/bg.png) } +.big-408 { color: var(--brand); background: url(img/bg.png) } +.big-409 { color: var(--brand); background: url(img/bg.png) } +.big-410 { color: var(--brand); background: url(img/bg.png) } +.big-411 { color: var(--brand); background: url(img/bg.png) } +.big-412 { color: var(--brand); background: url(img/bg.png) } +.big-413 { color: var(--brand); background: url(img/bg.png) } +.big-414 { color: var(--brand); background: url(img/bg.png) } +.big-415 { color: var(--brand); background: url(img/bg.png) } +.big-416 { color: var(--brand); background: url(img/bg.png) } +.big-417 { color: var(--brand); background: url(img/bg.png) } +.big-418 { color: var(--brand); background: url(img/bg.png) } +.big-419 { color: var(--brand); background: url(img/bg.png) } +.big-420 { color: var(--brand); background: url(img/bg.png) } +.big-421 { color: var(--brand); background: url(img/bg.png) } +.big-422 { color: var(--brand); background: url(img/bg.png) } +.big-423 { color: var(--brand); background: url(img/bg.png) } +.big-424 { color: var(--brand); background: url(img/bg.png) } +.big-425 { color: var(--brand); background: url(img/bg.png) } +.big-426 { color: var(--brand); background: url(img/bg.png) } +.big-427 { color: var(--brand); background: url(img/bg.png) } +.big-428 { color: var(--brand); background: url(img/bg.png) } +.big-429 { color: var(--brand); background: url(img/bg.png) } +.big-430 { color: var(--brand); background: url(img/bg.png) } +.big-431 { color: var(--brand); background: url(img/bg.png) } +.big-432 { color: var(--brand); background: url(img/bg.png) } +.big-433 { color: var(--brand); background: url(img/bg.png) } +.big-434 { color: var(--brand); background: url(img/bg.png) } +.big-435 { color: var(--brand); background: url(img/bg.png) } +.big-436 { color: var(--brand); background: url(img/bg.png) } +.big-437 { color: var(--brand); background: url(img/bg.png) } +.big-438 { color: var(--brand); background: url(img/bg.png) } +.big-439 { color: var(--brand); background: url(img/bg.png) } +.big-440 { color: var(--brand); background: url(img/bg.png) } +.big-441 { color: var(--brand); background: url(img/bg.png) } +.big-442 { color: var(--brand); background: url(img/bg.png) } +.big-443 { color: var(--brand); background: url(img/bg.png) } +.big-444 { color: var(--brand); background: url(img/bg.png) } +.big-445 { color: var(--brand); background: url(img/bg.png) } +.big-446 { color: var(--brand); background: url(img/bg.png) } +.big-447 { color: var(--brand); background: url(img/bg.png) } +.big-448 { color: var(--brand); background: url(img/bg.png) } +.big-449 { color: var(--brand); background: url(img/bg.png) } +.big-450 { color: var(--brand); background: url(img/bg.png) } +.big-451 { color: var(--brand); background: url(img/bg.png) } +.big-452 { color: var(--brand); background: url(img/bg.png) } +.big-453 { color: var(--brand); background: url(img/bg.png) } +.big-454 { color: var(--brand); background: url(img/bg.png) } +.big-455 { color: var(--brand); background: url(img/bg.png) } +.big-456 { color: var(--brand); background: url(img/bg.png) } +.big-457 { color: var(--brand); background: url(img/bg.png) } +.big-458 { color: var(--brand); background: url(img/bg.png) } +.big-459 { color: var(--brand); background: url(img/bg.png) } +.big-460 { color: var(--brand); background: url(img/bg.png) } +.big-461 { color: var(--brand); background: url(img/bg.png) } +.big-462 { color: var(--brand); background: url(img/bg.png) } +.big-463 { color: var(--brand); background: url(img/bg.png) } +.big-464 { color: var(--brand); background: url(img/bg.png) } +.big-465 { color: var(--brand); background: url(img/bg.png) } +.big-466 { color: var(--brand); background: url(img/bg.png) } +.big-467 { color: var(--brand); background: url(img/bg.png) } +.big-468 { color: var(--brand); background: url(img/bg.png) } +.big-469 { color: var(--brand); background: url(img/bg.png) } +.big-470 { color: var(--brand); background: url(img/bg.png) } +.big-471 { color: var(--brand); background: url(img/bg.png) } +.big-472 { color: var(--brand); background: url(img/bg.png) } +.big-473 { color: var(--brand); background: url(img/bg.png) } +.big-474 { color: var(--brand); background: url(img/bg.png) } +.big-475 { color: var(--brand); background: url(img/bg.png) } +.big-476 { color: var(--brand); background: url(img/bg.png) } +.big-477 { color: var(--brand); background: url(img/bg.png) } +.big-478 { color: var(--brand); background: url(img/bg.png) } +.big-479 { color: var(--brand); background: url(img/bg.png) } +.big-480 { color: var(--brand); background: url(img/bg.png) } +.big-481 { color: var(--brand); background: url(img/bg.png) } +.big-482 { color: var(--brand); background: url(img/bg.png) } +.big-483 { color: var(--brand); background: url(img/bg.png) } +.big-484 { color: var(--brand); background: url(img/bg.png) } +.big-485 { color: var(--brand); background: url(img/bg.png) } +.big-486 { color: var(--brand); background: url(img/bg.png) } +.big-487 { color: var(--brand); background: url(img/bg.png) } +.big-488 { color: var(--brand); background: url(img/bg.png) } +.big-489 { color: var(--brand); background: url(img/bg.png) } +.big-490 { color: var(--brand); background: url(img/bg.png) } +.big-491 { color: var(--brand); background: url(img/bg.png) } +.big-492 { color: var(--brand); background: url(img/bg.png) } +.big-493 { color: var(--brand); background: url(img/bg.png) } +.big-494 { color: var(--brand); background: url(img/bg.png) } +.big-495 { color: var(--brand); background: url(img/bg.png) } +.big-496 { color: var(--brand); background: url(img/bg.png) } +.big-497 { color: var(--brand); background: url(img/bg.png) } +.big-498 { color: var(--brand); background: url(img/bg.png) } +.big-499 { color: var(--brand); background: url(img/bg.png) } +.big-500 { color: var(--brand); background: url(img/bg.png) } +.big-501 { color: var(--brand); background: url(img/bg.png) } +.big-502 { color: var(--brand); background: url(img/bg.png) } +.big-503 { color: var(--brand); background: url(img/bg.png) } +.big-504 { color: var(--brand); background: url(img/bg.png) } +.big-505 { color: var(--brand); background: url(img/bg.png) } +.big-506 { color: var(--brand); background: url(img/bg.png) } +.big-507 { color: var(--brand); background: url(img/bg.png) } +.big-508 { color: var(--brand); background: url(img/bg.png) } +.big-509 { color: var(--brand); background: url(img/bg.png) } +.big-510 { color: var(--brand); background: url(img/bg.png) } +.big-511 { color: var(--brand); background: url(img/bg.png) } +.big-512 { color: var(--brand); background: url(img/bg.png) } +.big-513 { color: var(--brand); background: url(img/bg.png) } +.big-514 { color: var(--brand); background: url(img/bg.png) } +.big-515 { color: var(--brand); background: url(img/bg.png) } +.big-516 { color: var(--brand); background: url(img/bg.png) } +.big-517 { color: var(--brand); background: url(img/bg.png) } +.big-518 { color: var(--brand); background: url(img/bg.png) } +.big-519 { color: var(--brand); background: url(img/bg.png) } +.big-520 { color: var(--brand); background: url(img/bg.png) } +.big-521 { color: var(--brand); background: url(img/bg.png) } +.big-522 { color: var(--brand); background: url(img/bg.png) } +.big-523 { color: var(--brand); background: url(img/bg.png) } +.big-524 { color: var(--brand); background: url(img/bg.png) } +.big-525 { color: var(--brand); background: url(img/bg.png) } +.big-526 { color: var(--brand); background: url(img/bg.png) } +.big-527 { color: var(--brand); background: url(img/bg.png) } +.big-528 { color: var(--brand); background: url(img/bg.png) } +.big-529 { color: var(--brand); background: url(img/bg.png) } +.big-530 { color: var(--brand); background: url(img/bg.png) } +.big-531 { color: var(--brand); background: url(img/bg.png) } +.big-532 { color: var(--brand); background: url(img/bg.png) } +.big-533 { color: var(--brand); background: url(img/bg.png) } +.big-534 { color: var(--brand); background: url(img/bg.png) } +.big-535 { color: var(--brand); background: url(img/bg.png) } +.big-536 { color: var(--brand); background: url(img/bg.png) } +.big-537 { color: var(--brand); background: url(img/bg.png) } +.big-538 { color: var(--brand); background: url(img/bg.png) } +.big-539 { color: var(--brand); background: url(img/bg.png) } +.big-540 { color: var(--brand); background: url(img/bg.png) } +.big-541 { color: var(--brand); background: url(img/bg.png) } +.big-542 { color: var(--brand); background: url(img/bg.png) } +.big-543 { color: var(--brand); background: url(img/bg.png) } +.big-544 { color: var(--brand); background: url(img/bg.png) } +.big-545 { color: var(--brand); background: url(img/bg.png) } +.big-546 { color: var(--brand); background: url(img/bg.png) } +.big-547 { color: var(--brand); background: url(img/bg.png) } +.big-548 { color: var(--brand); background: url(img/bg.png) } +.big-549 { color: var(--brand); background: url(img/bg.png) } +.big-550 { color: var(--brand); background: url(img/bg.png) } +.big-551 { color: var(--brand); background: url(img/bg.png) } +.big-552 { color: var(--brand); background: url(img/bg.png) } +.big-553 { color: var(--brand); background: url(img/bg.png) } +.big-554 { color: var(--brand); background: url(img/bg.png) } +.big-555 { color: var(--brand); background: url(img/bg.png) } +.big-556 { color: var(--brand); background: url(img/bg.png) } +.big-557 { color: var(--brand); background: url(img/bg.png) } +.big-558 { color: var(--brand); background: url(img/bg.png) } +.big-559 { color: var(--brand); background: url(img/bg.png) } +.big-560 { color: var(--brand); background: url(img/bg.png) } +.big-561 { color: var(--brand); background: url(img/bg.png) } +.big-562 { color: var(--brand); background: url(img/bg.png) } +.big-563 { color: var(--brand); background: url(img/bg.png) } +.big-564 { color: var(--brand); background: url(img/bg.png) } +.big-565 { color: var(--brand); background: url(img/bg.png) } +.big-566 { color: var(--brand); background: url(img/bg.png) } +.big-567 { color: var(--brand); background: url(img/bg.png) } +.big-568 { color: var(--brand); background: url(img/bg.png) } +.big-569 { color: var(--brand); background: url(img/bg.png) } +.big-570 { color: var(--brand); background: url(img/bg.png) } +.big-571 { color: var(--brand); background: url(img/bg.png) } +.big-572 { color: var(--brand); background: url(img/bg.png) } +.big-573 { color: var(--brand); background: url(img/bg.png) } +.big-574 { color: var(--brand); background: url(img/bg.png) } +.big-575 { color: var(--brand); background: url(img/bg.png) } +.big-576 { color: var(--brand); background: url(img/bg.png) } +.big-577 { color: var(--brand); background: url(img/bg.png) } +.big-578 { color: var(--brand); background: url(img/bg.png) } +.big-579 { color: var(--brand); background: url(img/bg.png) } +.big-580 { color: var(--brand); background: url(img/bg.png) } +.big-581 { color: var(--brand); background: url(img/bg.png) } +.big-582 { color: var(--brand); background: url(img/bg.png) } +.big-583 { color: var(--brand); background: url(img/bg.png) } +.big-584 { color: var(--brand); background: url(img/bg.png) } +.big-585 { color: var(--brand); background: url(img/bg.png) } +.big-586 { color: var(--brand); background: url(img/bg.png) } +.big-587 { color: var(--brand); background: url(img/bg.png) } +.big-588 { color: var(--brand); background: url(img/bg.png) } +.big-589 { color: var(--brand); background: url(img/bg.png) } +.big-590 { color: var(--brand); background: url(img/bg.png) } +.big-591 { color: var(--brand); background: url(img/bg.png) } +.big-592 { color: var(--brand); background: url(img/bg.png) } +.big-593 { color: var(--brand); background: url(img/bg.png) } +.big-594 { color: var(--brand); background: url(img/bg.png) } +.big-595 { color: var(--brand); background: url(img/bg.png) } +.big-596 { color: var(--brand); background: url(img/bg.png) } +.big-597 { color: var(--brand); background: url(img/bg.png) } +.big-598 { color: var(--brand); background: url(img/bg.png) } +.big-599 { color: var(--brand); background: url(img/bg.png) } +.big-600 { color: var(--brand); background: url(img/bg.png) } +.big-601 { color: var(--brand); background: url(img/bg.png) } +.big-602 { color: var(--brand); background: url(img/bg.png) } +.big-603 { color: var(--brand); background: url(img/bg.png) } +.big-604 { color: var(--brand); background: url(img/bg.png) } +.big-605 { color: var(--brand); background: url(img/bg.png) } +.big-606 { color: var(--brand); background: url(img/bg.png) } +.big-607 { color: var(--brand); background: url(img/bg.png) } +.big-608 { color: var(--brand); background: url(img/bg.png) } +.big-609 { color: var(--brand); background: url(img/bg.png) } +.big-610 { color: var(--brand); background: url(img/bg.png) } +.big-611 { color: var(--brand); background: url(img/bg.png) } +.big-612 { color: var(--brand); background: url(img/bg.png) } +.big-613 { color: var(--brand); background: url(img/bg.png) } +.big-614 { color: var(--brand); background: url(img/bg.png) } +.big-615 { color: var(--brand); background: url(img/bg.png) } +.big-616 { color: var(--brand); background: url(img/bg.png) } +.big-617 { color: var(--brand); background: url(img/bg.png) } +.big-618 { color: var(--brand); background: url(img/bg.png) } +.big-619 { color: var(--brand); background: url(img/bg.png) } +.big-620 { color: var(--brand); background: url(img/bg.png) } +.big-621 { color: var(--brand); background: url(img/bg.png) } +.big-622 { color: var(--brand); background: url(img/bg.png) } +.big-623 { color: var(--brand); background: url(img/bg.png) } +.big-624 { color: var(--brand); background: url(img/bg.png) } +.big-625 { color: var(--brand); background: url(img/bg.png) } +.big-626 { color: var(--brand); background: url(img/bg.png) } +.big-627 { color: var(--brand); background: url(img/bg.png) } +.big-628 { color: var(--brand); background: url(img/bg.png) } +.big-629 { color: var(--brand); background: url(img/bg.png) } +.big-630 { color: var(--brand); background: url(img/bg.png) } +.big-631 { color: var(--brand); background: url(img/bg.png) } +.big-632 { color: var(--brand); background: url(img/bg.png) } +.big-633 { color: var(--brand); background: url(img/bg.png) } +.big-634 { color: var(--brand); background: url(img/bg.png) } +.big-635 { color: var(--brand); background: url(img/bg.png) } +.big-636 { color: var(--brand); background: url(img/bg.png) } +.big-637 { color: var(--brand); background: url(img/bg.png) } +.big-638 { color: var(--brand); background: url(img/bg.png) } +.big-639 { color: var(--brand); background: url(img/bg.png) } +.big-640 { color: var(--brand); background: url(img/bg.png) } +.big-641 { color: var(--brand); background: url(img/bg.png) } +.big-642 { color: var(--brand); background: url(img/bg.png) } +.big-643 { color: var(--brand); background: url(img/bg.png) } +.big-644 { color: var(--brand); background: url(img/bg.png) } +.big-645 { color: var(--brand); background: url(img/bg.png) } +.big-646 { color: var(--brand); background: url(img/bg.png) } +.big-647 { color: var(--brand); background: url(img/bg.png) } +.big-648 { color: var(--brand); background: url(img/bg.png) } +.big-649 { color: var(--brand); background: url(img/bg.png) } +.big-650 { color: var(--brand); background: url(img/bg.png) } +.big-651 { color: var(--brand); background: url(img/bg.png) } +.big-652 { color: var(--brand); background: url(img/bg.png) } +.big-653 { color: var(--brand); background: url(img/bg.png) } +.big-654 { color: var(--brand); background: url(img/bg.png) } +.big-655 { color: var(--brand); background: url(img/bg.png) } +.big-656 { color: var(--brand); background: url(img/bg.png) } +.big-657 { color: var(--brand); background: url(img/bg.png) } +.big-658 { color: var(--brand); background: url(img/bg.png) } +.big-659 { color: var(--brand); background: url(img/bg.png) } +.big-660 { color: var(--brand); background: url(img/bg.png) } +.big-661 { color: var(--brand); background: url(img/bg.png) } +.big-662 { color: var(--brand); background: url(img/bg.png) } +.big-663 { color: var(--brand); background: url(img/bg.png) } +.big-664 { color: var(--brand); background: url(img/bg.png) } +.big-665 { color: var(--brand); background: url(img/bg.png) } +.big-666 { color: var(--brand); background: url(img/bg.png) } +.big-667 { color: var(--brand); background: url(img/bg.png) } +.big-668 { color: var(--brand); background: url(img/bg.png) } +.big-669 { color: var(--brand); background: url(img/bg.png) } +.big-670 { color: var(--brand); background: url(img/bg.png) } +.big-671 { color: var(--brand); background: url(img/bg.png) } +.big-672 { color: var(--brand); background: url(img/bg.png) } +.big-673 { color: var(--brand); background: url(img/bg.png) } +.big-674 { color: var(--brand); background: url(img/bg.png) } +.big-675 { color: var(--brand); background: url(img/bg.png) } +.big-676 { color: var(--brand); background: url(img/bg.png) } +.big-677 { color: var(--brand); background: url(img/bg.png) } +.big-678 { color: var(--brand); background: url(img/bg.png) } +.big-679 { color: var(--brand); background: url(img/bg.png) } +.big-680 { color: var(--brand); background: url(img/bg.png) } +.big-681 { color: var(--brand); background: url(img/bg.png) } +.big-682 { color: var(--brand); background: url(img/bg.png) } +.big-683 { color: var(--brand); background: url(img/bg.png) } +.big-684 { color: var(--brand); background: url(img/bg.png) } +.big-685 { color: var(--brand); background: url(img/bg.png) } +.big-686 { color: var(--brand); background: url(img/bg.png) } +.big-687 { color: var(--brand); background: url(img/bg.png) } +.big-688 { color: var(--brand); background: url(img/bg.png) } +.big-689 { color: var(--brand); background: url(img/bg.png) } +.big-690 { color: var(--brand); background: url(img/bg.png) } +.big-691 { color: var(--brand); background: url(img/bg.png) } +.big-692 { color: var(--brand); background: url(img/bg.png) } +.big-693 { color: var(--brand); background: url(img/bg.png) } +.big-694 { color: var(--brand); background: url(img/bg.png) } +.big-695 { color: var(--brand); background: url(img/bg.png) } +.big-696 { color: var(--brand); background: url(img/bg.png) } +.big-697 { color: var(--brand); background: url(img/bg.png) } +.big-698 { color: var(--brand); background: url(img/bg.png) } +.big-699 { color: var(--brand); background: url(img/bg.png) } +.big-700 { color: var(--brand); background: url(img/bg.png) } +.big-701 { color: var(--brand); background: url(img/bg.png) } +.big-702 { color: var(--brand); background: url(img/bg.png) } +.big-703 { color: var(--brand); background: url(img/bg.png) } +.big-704 { color: var(--brand); background: url(img/bg.png) } +.big-705 { color: var(--brand); background: url(img/bg.png) } +.big-706 { color: var(--brand); background: url(img/bg.png) } +.big-707 { color: var(--brand); background: url(img/bg.png) } +.big-708 { color: var(--brand); background: url(img/bg.png) } +.big-709 { color: var(--brand); background: url(img/bg.png) } +.big-710 { color: var(--brand); background: url(img/bg.png) } +.big-711 { color: var(--brand); background: url(img/bg.png) } +.big-712 { color: var(--brand); background: url(img/bg.png) } +.big-713 { color: var(--brand); background: url(img/bg.png) } +.big-714 { color: var(--brand); background: url(img/bg.png) } +.big-715 { color: var(--brand); background: url(img/bg.png) } +.big-716 { color: var(--brand); background: url(img/bg.png) } +.big-717 { color: var(--brand); background: url(img/bg.png) } +.big-718 { color: var(--brand); background: url(img/bg.png) } +.big-719 { color: var(--brand); background: url(img/bg.png) } +.big-720 { color: var(--brand); background: url(img/bg.png) } +.big-721 { color: var(--brand); background: url(img/bg.png) } +.big-722 { color: var(--brand); background: url(img/bg.png) } +.big-723 { color: var(--brand); background: url(img/bg.png) } +.big-724 { color: var(--brand); background: url(img/bg.png) } +.big-725 { color: var(--brand); background: url(img/bg.png) } +.big-726 { color: var(--brand); background: url(img/bg.png) } +.big-727 { color: var(--brand); background: url(img/bg.png) } +.big-728 { color: var(--brand); background: url(img/bg.png) } +.big-729 { color: var(--brand); background: url(img/bg.png) } +.big-730 { color: var(--brand); background: url(img/bg.png) } +.big-731 { color: var(--brand); background: url(img/bg.png) } +.big-732 { color: var(--brand); background: url(img/bg.png) } +.big-733 { color: var(--brand); background: url(img/bg.png) } +.big-734 { color: var(--brand); background: url(img/bg.png) } +.big-735 { color: var(--brand); background: url(img/bg.png) } +.big-736 { color: var(--brand); background: url(img/bg.png) } +.big-737 { color: var(--brand); background: url(img/bg.png) } +.big-738 { color: var(--brand); background: url(img/bg.png) } +.big-739 { color: var(--brand); background: url(img/bg.png) } +.big-740 { color: var(--brand); background: url(img/bg.png) } +.big-741 { color: var(--brand); background: url(img/bg.png) } +.big-742 { color: var(--brand); background: url(img/bg.png) } +.big-743 { color: var(--brand); background: url(img/bg.png) } +.big-744 { color: var(--brand); background: url(img/bg.png) } +.big-745 { color: var(--brand); background: url(img/bg.png) } +.big-746 { color: var(--brand); background: url(img/bg.png) } +.big-747 { color: var(--brand); background: url(img/bg.png) } +.big-748 { color: var(--brand); background: url(img/bg.png) } +.big-749 { color: var(--brand); background: url(img/bg.png) } +.big-750 { color: var(--brand); background: url(img/bg.png) } +.big-751 { color: var(--brand); background: url(img/bg.png) } +.big-752 { color: var(--brand); background: url(img/bg.png) } +.big-753 { color: var(--brand); background: url(img/bg.png) } +.big-754 { color: var(--brand); background: url(img/bg.png) } +.big-755 { color: var(--brand); background: url(img/bg.png) } +.big-756 { color: var(--brand); background: url(img/bg.png) } +.big-757 { color: var(--brand); background: url(img/bg.png) } +.big-758 { color: var(--brand); background: url(img/bg.png) } +.big-759 { color: var(--brand); background: url(img/bg.png) } +.big-760 { color: var(--brand); background: url(img/bg.png) } +.big-761 { color: var(--brand); background: url(img/bg.png) } +.big-762 { color: var(--brand); background: url(img/bg.png) } +.big-763 { color: var(--brand); background: url(img/bg.png) } +.big-764 { color: var(--brand); background: url(img/bg.png) } +.big-765 { color: var(--brand); background: url(img/bg.png) } +.big-766 { color: var(--brand); background: url(img/bg.png) } +.big-767 { color: var(--brand); background: url(img/bg.png) } +.big-768 { color: var(--brand); background: url(img/bg.png) } +.big-769 { color: var(--brand); background: url(img/bg.png) } +.big-770 { color: var(--brand); background: url(img/bg.png) } +.big-771 { color: var(--brand); background: url(img/bg.png) } +.big-772 { color: var(--brand); background: url(img/bg.png) } +.big-773 { color: var(--brand); background: url(img/bg.png) } +.big-774 { color: var(--brand); background: url(img/bg.png) } +.big-775 { color: var(--brand); background: url(img/bg.png) } +.big-776 { color: var(--brand); background: url(img/bg.png) } +.big-777 { color: var(--brand); background: url(img/bg.png) } +.big-778 { color: var(--brand); background: url(img/bg.png) } +.big-779 { color: var(--brand); background: url(img/bg.png) } +.big-780 { color: var(--brand); background: url(img/bg.png) } +.big-781 { color: var(--brand); background: url(img/bg.png) } +.big-782 { color: var(--brand); background: url(img/bg.png) } +.big-783 { color: var(--brand); background: url(img/bg.png) } +.big-784 { color: var(--brand); background: url(img/bg.png) } +.big-785 { color: var(--brand); background: url(img/bg.png) } +.big-786 { color: var(--brand); background: url(img/bg.png) } +.big-787 { color: var(--brand); background: url(img/bg.png) } +.big-788 { color: var(--brand); background: url(img/bg.png) } +.big-789 { color: var(--brand); background: url(img/bg.png) } +.big-790 { color: var(--brand); background: url(img/bg.png) } +.big-791 { color: var(--brand); background: url(img/bg.png) } +.big-792 { color: var(--brand); background: url(img/bg.png) } +.big-793 { color: var(--brand); background: url(img/bg.png) } +.big-794 { color: var(--brand); background: url(img/bg.png) } +.big-795 { color: var(--brand); background: url(img/bg.png) } +.big-796 { color: var(--brand); background: url(img/bg.png) } +.big-797 { color: var(--brand); background: url(img/bg.png) } +.big-798 { color: var(--brand); background: url(img/bg.png) } +.big-799 { color: var(--brand); background: url(img/bg.png) } +.big-800 { color: var(--brand); background: url(img/bg.png) } +.big-801 { color: var(--brand); background: url(img/bg.png) } +.big-802 { color: var(--brand); background: url(img/bg.png) } +.big-803 { color: var(--brand); background: url(img/bg.png) } +.big-804 { color: var(--brand); background: url(img/bg.png) } +.big-805 { color: var(--brand); background: url(img/bg.png) } +.big-806 { color: var(--brand); background: url(img/bg.png) } +.big-807 { color: var(--brand); background: url(img/bg.png) } +.big-808 { color: var(--brand); background: url(img/bg.png) } +.big-809 { color: var(--brand); background: url(img/bg.png) } +.big-810 { color: var(--brand); background: url(img/bg.png) } +.big-811 { color: var(--brand); background: url(img/bg.png) } +.big-812 { color: var(--brand); background: url(img/bg.png) } +.big-813 { color: var(--brand); background: url(img/bg.png) } +.big-814 { color: var(--brand); background: url(img/bg.png) } +.big-815 { color: var(--brand); background: url(img/bg.png) } +.big-816 { color: var(--brand); background: url(img/bg.png) } +.big-817 { color: var(--brand); background: url(img/bg.png) } +.big-818 { color: var(--brand); background: url(img/bg.png) } +.big-819 { color: var(--brand); background: url(img/bg.png) } +.big-820 { color: var(--brand); background: url(img/bg.png) } +.big-821 { color: var(--brand); background: url(img/bg.png) } +.big-822 { color: var(--brand); background: url(img/bg.png) } +.big-823 { color: var(--brand); background: url(img/bg.png) } +.big-824 { color: var(--brand); background: url(img/bg.png) } +.big-825 { color: var(--brand); background: url(img/bg.png) } +.big-826 { color: var(--brand); background: url(img/bg.png) } +.big-827 { color: var(--brand); background: url(img/bg.png) } +.big-828 { color: var(--brand); background: url(img/bg.png) } +.big-829 { color: var(--brand); background: url(img/bg.png) } +.big-830 { color: var(--brand); background: url(img/bg.png) } +.big-831 { color: var(--brand); background: url(img/bg.png) } +.big-832 { color: var(--brand); background: url(img/bg.png) } +.big-833 { color: var(--brand); background: url(img/bg.png) } +.big-834 { color: var(--brand); background: url(img/bg.png) } +.big-835 { color: var(--brand); background: url(img/bg.png) } +.big-836 { color: var(--brand); background: url(img/bg.png) } +.big-837 { color: var(--brand); background: url(img/bg.png) } +.big-838 { color: var(--brand); background: url(img/bg.png) } +.big-839 { color: var(--brand); background: url(img/bg.png) } +.big-840 { color: var(--brand); background: url(img/bg.png) } +.big-841 { color: var(--brand); background: url(img/bg.png) } +.big-842 { color: var(--brand); background: url(img/bg.png) } +.big-843 { color: var(--brand); background: url(img/bg.png) } +.big-844 { color: var(--brand); background: url(img/bg.png) } +.big-845 { color: var(--brand); background: url(img/bg.png) } +.big-846 { color: var(--brand); background: url(img/bg.png) } +.big-847 { color: var(--brand); background: url(img/bg.png) } +.big-848 { color: var(--brand); background: url(img/bg.png) } +.big-849 { color: var(--brand); background: url(img/bg.png) } +.big-850 { color: var(--brand); background: url(img/bg.png) } +.big-851 { color: var(--brand); background: url(img/bg.png) } +.big-852 { color: var(--brand); background: url(img/bg.png) } +.big-853 { color: var(--brand); background: url(img/bg.png) } +.big-854 { color: var(--brand); background: url(img/bg.png) } +.big-855 { color: var(--brand); background: url(img/bg.png) } +.big-856 { color: var(--brand); background: url(img/bg.png) } +.big-857 { color: var(--brand); background: url(img/bg.png) } +.big-858 { color: var(--brand); background: url(img/bg.png) } +.big-859 { color: var(--brand); background: url(img/bg.png) } +.big-860 { color: var(--brand); background: url(img/bg.png) } +.big-861 { color: var(--brand); background: url(img/bg.png) } +.big-862 { color: var(--brand); background: url(img/bg.png) } +.big-863 { color: var(--brand); background: url(img/bg.png) } +.big-864 { color: var(--brand); background: url(img/bg.png) } +.big-865 { color: var(--brand); background: url(img/bg.png) } +.big-866 { color: var(--brand); background: url(img/bg.png) } +.big-867 { color: var(--brand); background: url(img/bg.png) } +.big-868 { color: var(--brand); background: url(img/bg.png) } +.big-869 { color: var(--brand); background: url(img/bg.png) } +.big-870 { color: var(--brand); background: url(img/bg.png) } +.big-871 { color: var(--brand); background: url(img/bg.png) } +.big-872 { color: var(--brand); background: url(img/bg.png) } +.big-873 { color: var(--brand); background: url(img/bg.png) } +.big-874 { color: var(--brand); background: url(img/bg.png) } +.big-875 { color: var(--brand); background: url(img/bg.png) } +.big-876 { color: var(--brand); background: url(img/bg.png) } +.big-877 { color: var(--brand); background: url(img/bg.png) } +.big-878 { color: var(--brand); background: url(img/bg.png) } +.big-879 { color: var(--brand); background: url(img/bg.png) } +.big-880 { color: var(--brand); background: url(img/bg.png) } +.big-881 { color: var(--brand); background: url(img/bg.png) } +.big-882 { color: var(--brand); background: url(img/bg.png) } +.big-883 { color: var(--brand); background: url(img/bg.png) } +.big-884 { color: var(--brand); background: url(img/bg.png) } +.big-885 { color: var(--brand); background: url(img/bg.png) } +.big-886 { color: var(--brand); background: url(img/bg.png) } +.big-887 { color: var(--brand); background: url(img/bg.png) } +.big-888 { color: var(--brand); background: url(img/bg.png) } +.big-889 { color: var(--brand); background: url(img/bg.png) } +.big-890 { color: var(--brand); background: url(img/bg.png) } +.big-891 { color: var(--brand); background: url(img/bg.png) } +.big-892 { color: var(--brand); background: url(img/bg.png) } +.big-893 { color: var(--brand); background: url(img/bg.png) } +.big-894 { color: var(--brand); background: url(img/bg.png) } +.big-895 { color: var(--brand); background: url(img/bg.png) } +.big-896 { color: var(--brand); background: url(img/bg.png) } +.big-897 { color: var(--brand); background: url(img/bg.png) } +.big-898 { color: var(--brand); background: url(img/bg.png) } +.big-899 { color: var(--brand); background: url(img/bg.png) } +.big-900 { color: var(--brand); background: url(img/bg.png) } +.big-901 { color: var(--brand); background: url(img/bg.png) } +.big-902 { color: var(--brand); background: url(img/bg.png) } +.big-903 { color: var(--brand); background: url(img/bg.png) } +.big-904 { color: var(--brand); background: url(img/bg.png) } +.big-905 { color: var(--brand); background: url(img/bg.png) } +.big-906 { color: var(--brand); background: url(img/bg.png) } +.big-907 { color: var(--brand); background: url(img/bg.png) } +.big-908 { color: var(--brand); background: url(img/bg.png) } +.big-909 { color: var(--brand); background: url(img/bg.png) } +.big-910 { color: var(--brand); background: url(img/bg.png) } +.big-911 { color: var(--brand); background: url(img/bg.png) } +.big-912 { color: var(--brand); background: url(img/bg.png) } +.big-913 { color: var(--brand); background: url(img/bg.png) } +.big-914 { color: var(--brand); background: url(img/bg.png) } +.big-915 { color: var(--brand); background: url(img/bg.png) } +.big-916 { color: var(--brand); background: url(img/bg.png) } +.big-917 { color: var(--brand); background: url(img/bg.png) } +.big-918 { color: var(--brand); background: url(img/bg.png) } +.big-919 { color: var(--brand); background: url(img/bg.png) } +.big-920 { color: var(--brand); background: url(img/bg.png) } +.big-921 { color: var(--brand); background: url(img/bg.png) } +.big-922 { color: var(--brand); background: url(img/bg.png) } +.big-923 { color: var(--brand); background: url(img/bg.png) } +.big-924 { color: var(--brand); background: url(img/bg.png) } +.big-925 { color: var(--brand); background: url(img/bg.png) } +.big-926 { color: var(--brand); background: url(img/bg.png) } +.big-927 { color: var(--brand); background: url(img/bg.png) } +.big-928 { color: var(--brand); background: url(img/bg.png) } +.big-929 { color: var(--brand); background: url(img/bg.png) } +.big-930 { color: var(--brand); background: url(img/bg.png) } +.big-931 { color: var(--brand); background: url(img/bg.png) } +.big-932 { color: var(--brand); background: url(img/bg.png) } +.big-933 { color: var(--brand); background: url(img/bg.png) } +.big-934 { color: var(--brand); background: url(img/bg.png) } +.big-935 { color: var(--brand); background: url(img/bg.png) } +.big-936 { color: var(--brand); background: url(img/bg.png) } +.big-937 { color: var(--brand); background: url(img/bg.png) } +.big-938 { color: var(--brand); background: url(img/bg.png) } +.big-939 { color: var(--brand); background: url(img/bg.png) } +.big-940 { color: var(--brand); background: url(img/bg.png) } +.big-941 { color: var(--brand); background: url(img/bg.png) } +.big-942 { color: var(--brand); background: url(img/bg.png) } +.big-943 { color: var(--brand); background: url(img/bg.png) } +.big-944 { color: var(--brand); background: url(img/bg.png) } +.big-945 { color: var(--brand); background: url(img/bg.png) } +.big-946 { color: var(--brand); background: url(img/bg.png) } +.big-947 { color: var(--brand); background: url(img/bg.png) } +.big-948 { color: var(--brand); background: url(img/bg.png) } +.big-949 { color: var(--brand); background: url(img/bg.png) } +.big-950 { color: var(--brand); background: url(img/bg.png) } +.big-951 { color: var(--brand); background: url(img/bg.png) } +.big-952 { color: var(--brand); background: url(img/bg.png) } +.big-953 { color: var(--brand); background: url(img/bg.png) } +.big-954 { color: var(--brand); background: url(img/bg.png) } +.big-955 { color: var(--brand); background: url(img/bg.png) } +.big-956 { color: var(--brand); background: url(img/bg.png) } +.big-957 { color: var(--brand); background: url(img/bg.png) } +.big-958 { color: var(--brand); background: url(img/bg.png) } +.big-959 { color: var(--brand); background: url(img/bg.png) } +.big-960 { color: var(--brand); background: url(img/bg.png) } +.big-961 { color: var(--brand); background: url(img/bg.png) } +.big-962 { color: var(--brand); background: url(img/bg.png) } +.big-963 { color: var(--brand); background: url(img/bg.png) } +.big-964 { color: var(--brand); background: url(img/bg.png) } +.big-965 { color: var(--brand); background: url(img/bg.png) } +.big-966 { color: var(--brand); background: url(img/bg.png) } +.big-967 { color: var(--brand); background: url(img/bg.png) } +.big-968 { color: var(--brand); background: url(img/bg.png) } +.big-969 { color: var(--brand); background: url(img/bg.png) } +.big-970 { color: var(--brand); background: url(img/bg.png) } +.big-971 { color: var(--brand); background: url(img/bg.png) } +.big-972 { color: var(--brand); background: url(img/bg.png) } +.big-973 { color: var(--brand); background: url(img/bg.png) } +.big-974 { color: var(--brand); background: url(img/bg.png) } +.big-975 { color: var(--brand); background: url(img/bg.png) } +.big-976 { color: var(--brand); background: url(img/bg.png) } +.big-977 { color: var(--brand); background: url(img/bg.png) } +.big-978 { color: var(--brand); background: url(img/bg.png) } +.big-979 { color: var(--brand); background: url(img/bg.png) } +.big-980 { color: var(--brand); background: url(img/bg.png) } +.big-981 { color: var(--brand); background: url(img/bg.png) } +.big-982 { color: var(--brand); background: url(img/bg.png) } +.big-983 { color: var(--brand); background: url(img/bg.png) } +.big-984 { color: var(--brand); background: url(img/bg.png) } +.big-985 { color: var(--brand); background: url(img/bg.png) } +.big-986 { color: var(--brand); background: url(img/bg.png) } +.big-987 { color: var(--brand); background: url(img/bg.png) } +.big-988 { color: var(--brand); background: url(img/bg.png) } +.big-989 { color: var(--brand); background: url(img/bg.png) } +.big-990 { color: var(--brand); background: url(img/bg.png) } +.big-991 { color: var(--brand); background: url(img/bg.png) } +.big-992 { color: var(--brand); background: url(img/bg.png) } +.big-993 { color: var(--brand); background: url(img/bg.png) } +.big-994 { color: var(--brand); background: url(img/bg.png) } +.big-995 { color: var(--brand); background: url(img/bg.png) } +.big-996 { color: var(--brand); background: url(img/bg.png) } +.big-997 { color: var(--brand); background: url(img/bg.png) } +.big-998 { color: var(--brand); background: url(img/bg.png) } +.big-999 { color: var(--brand); background: url(img/bg.png) } +.big-1000 { color: var(--brand); background: url(img/bg.png) } +.big-1001 { color: var(--brand); background: url(img/bg.png) } +.big-1002 { color: var(--brand); background: url(img/bg.png) } +.big-1003 { color: var(--brand); background: url(img/bg.png) } +.big-1004 { color: var(--brand); background: url(img/bg.png) } +.big-1005 { color: var(--brand); background: url(img/bg.png) } +.big-1006 { color: var(--brand); background: url(img/bg.png) } +.big-1007 { color: var(--brand); background: url(img/bg.png) } +.big-1008 { color: var(--brand); background: url(img/bg.png) } +.big-1009 { color: var(--brand); background: url(img/bg.png) } +.big-1010 { color: var(--brand); background: url(img/bg.png) } +.big-1011 { color: var(--brand); background: url(img/bg.png) } +.big-1012 { color: var(--brand); background: url(img/bg.png) } +.big-1013 { color: var(--brand); background: url(img/bg.png) } +.big-1014 { color: var(--brand); background: url(img/bg.png) } +.big-1015 { color: var(--brand); background: url(img/bg.png) } +.big-1016 { color: var(--brand); background: url(img/bg.png) } +.big-1017 { color: var(--brand); background: url(img/bg.png) } +.big-1018 { color: var(--brand); background: url(img/bg.png) } +.big-1019 { color: var(--brand); background: url(img/bg.png) } +.big-1020 { color: var(--brand); background: url(img/bg.png) } +.big-1021 { color: var(--brand); background: url(img/bg.png) } +.big-1022 { color: var(--brand); background: url(img/bg.png) } +.big-1023 { color: var(--brand); background: url(img/bg.png) } +.big-1024 { color: var(--brand); background: url(img/bg.png) } +.big-1025 { color: var(--brand); background: url(img/bg.png) } +.big-1026 { color: var(--brand); background: url(img/bg.png) } +.big-1027 { color: var(--brand); background: url(img/bg.png) } +.big-1028 { color: var(--brand); background: url(img/bg.png) } +.big-1029 { color: var(--brand); background: url(img/bg.png) } +.big-1030 { color: var(--brand); background: url(img/bg.png) } +.big-1031 { color: var(--brand); background: url(img/bg.png) } +.big-1032 { color: var(--brand); background: url(img/bg.png) } +.big-1033 { color: var(--brand); background: url(img/bg.png) } +.big-1034 { color: var(--brand); background: url(img/bg.png) } +.big-1035 { color: var(--brand); background: url(img/bg.png) } +.big-1036 { color: var(--brand); background: url(img/bg.png) } +.big-1037 { color: var(--brand); background: url(img/bg.png) } +.big-1038 { color: var(--brand); background: url(img/bg.png) } +.big-1039 { color: var(--brand); background: url(img/bg.png) } +.big-1040 { color: var(--brand); background: url(img/bg.png) } +.big-1041 { color: var(--brand); background: url(img/bg.png) } +.big-1042 { color: var(--brand); background: url(img/bg.png) } +.big-1043 { color: var(--brand); background: url(img/bg.png) } +.big-1044 { color: var(--brand); background: url(img/bg.png) } +.big-1045 { color: var(--brand); background: url(img/bg.png) } +.big-1046 { color: var(--brand); background: url(img/bg.png) } +.big-1047 { color: var(--brand); background: url(img/bg.png) } +.big-1048 { color: var(--brand); background: url(img/bg.png) } +.big-1049 { color: var(--brand); background: url(img/bg.png) } +.big-1050 { color: var(--brand); background: url(img/bg.png) } +.big-1051 { color: var(--brand); background: url(img/bg.png) } +.big-1052 { color: var(--brand); background: url(img/bg.png) } +.big-1053 { color: var(--brand); background: url(img/bg.png) } +.big-1054 { color: var(--brand); background: url(img/bg.png) } +.big-1055 { color: var(--brand); background: url(img/bg.png) } +.big-1056 { color: var(--brand); background: url(img/bg.png) } +.big-1057 { color: var(--brand); background: url(img/bg.png) } +.big-1058 { color: var(--brand); background: url(img/bg.png) } +.big-1059 { color: var(--brand); background: url(img/bg.png) } +.big-1060 { color: var(--brand); background: url(img/bg.png) } +.big-1061 { color: var(--brand); background: url(img/bg.png) } +.big-1062 { color: var(--brand); background: url(img/bg.png) } +.big-1063 { color: var(--brand); background: url(img/bg.png) } +.big-1064 { color: var(--brand); background: url(img/bg.png) } +.big-1065 { color: var(--brand); background: url(img/bg.png) } +.big-1066 { color: var(--brand); background: url(img/bg.png) } +.big-1067 { color: var(--brand); background: url(img/bg.png) } +.big-1068 { color: var(--brand); background: url(img/bg.png) } +.big-1069 { color: var(--brand); background: url(img/bg.png) } +.big-1070 { color: var(--brand); background: url(img/bg.png) } +.big-1071 { color: var(--brand); background: url(img/bg.png) } +.big-1072 { color: var(--brand); background: url(img/bg.png) } +.big-1073 { color: var(--brand); background: url(img/bg.png) } +.big-1074 { color: var(--brand); background: url(img/bg.png) } +.big-1075 { color: var(--brand); background: url(img/bg.png) } +.big-1076 { color: var(--brand); background: url(img/bg.png) } +.big-1077 { color: var(--brand); background: url(img/bg.png) } +.big-1078 { color: var(--brand); background: url(img/bg.png) } +.big-1079 { color: var(--brand); background: url(img/bg.png) } +.big-1080 { color: var(--brand); background: url(img/bg.png) } +.big-1081 { color: var(--brand); background: url(img/bg.png) } +.big-1082 { color: var(--brand); background: url(img/bg.png) } +.big-1083 { color: var(--brand); background: url(img/bg.png) } +.big-1084 { color: var(--brand); background: url(img/bg.png) } +.big-1085 { color: var(--brand); background: url(img/bg.png) } +.big-1086 { color: var(--brand); background: url(img/bg.png) } +.big-1087 { color: var(--brand); background: url(img/bg.png) } +.big-1088 { color: var(--brand); background: url(img/bg.png) } +.big-1089 { color: var(--brand); background: url(img/bg.png) } +.big-1090 { color: var(--brand); background: url(img/bg.png) } +.big-1091 { color: var(--brand); background: url(img/bg.png) } +.big-1092 { color: var(--brand); background: url(img/bg.png) } +.big-1093 { color: var(--brand); background: url(img/bg.png) } +.big-1094 { color: var(--brand); background: url(img/bg.png) } +.big-1095 { color: var(--brand); background: url(img/bg.png) } +.big-1096 { color: var(--brand); background: url(img/bg.png) } +.big-1097 { color: var(--brand); background: url(img/bg.png) } +.big-1098 { color: var(--brand); background: url(img/bg.png) } +.big-1099 { color: var(--brand); background: url(img/bg.png) } +.big-1100 { color: var(--brand); background: url(img/bg.png) } +.big-1101 { color: var(--brand); background: url(img/bg.png) } +.big-1102 { color: var(--brand); background: url(img/bg.png) } +.big-1103 { color: var(--brand); background: url(img/bg.png) } +.big-1104 { color: var(--brand); background: url(img/bg.png) } +.big-1105 { color: var(--brand); background: url(img/bg.png) } +.big-1106 { color: var(--brand); background: url(img/bg.png) } +.big-1107 { color: var(--brand); background: url(img/bg.png) } +.big-1108 { color: var(--brand); background: url(img/bg.png) } +.big-1109 { color: var(--brand); background: url(img/bg.png) } +.big-1110 { color: var(--brand); background: url(img/bg.png) } +.big-1111 { color: var(--brand); background: url(img/bg.png) } +.big-1112 { color: var(--brand); background: url(img/bg.png) } +.big-1113 { color: var(--brand); background: url(img/bg.png) } +.big-1114 { color: var(--brand); background: url(img/bg.png) } +.big-1115 { color: var(--brand); background: url(img/bg.png) } +.big-1116 { color: var(--brand); background: url(img/bg.png) } +.big-1117 { color: var(--brand); background: url(img/bg.png) } +.big-1118 { color: var(--brand); background: url(img/bg.png) } +.big-1119 { color: var(--brand); background: url(img/bg.png) } +.big-1120 { color: var(--brand); background: url(img/bg.png) } +.big-1121 { color: var(--brand); background: url(img/bg.png) } +.big-1122 { color: var(--brand); background: url(img/bg.png) } +.big-1123 { color: var(--brand); background: url(img/bg.png) } +.big-1124 { color: var(--brand); background: url(img/bg.png) } +.big-1125 { color: var(--brand); background: url(img/bg.png) } +.big-1126 { color: var(--brand); background: url(img/bg.png) } +.big-1127 { color: var(--brand); background: url(img/bg.png) } +.big-1128 { color: var(--brand); background: url(img/bg.png) } +.big-1129 { color: var(--brand); background: url(img/bg.png) } +.big-1130 { color: var(--brand); background: url(img/bg.png) } +.big-1131 { color: var(--brand); background: url(img/bg.png) } +.big-1132 { color: var(--brand); background: url(img/bg.png) } +.big-1133 { color: var(--brand); background: url(img/bg.png) } +.big-1134 { color: var(--brand); background: url(img/bg.png) } +.big-1135 { color: var(--brand); background: url(img/bg.png) } +.big-1136 { color: var(--brand); background: url(img/bg.png) } +.big-1137 { color: var(--brand); background: url(img/bg.png) } +.big-1138 { color: var(--brand); background: url(img/bg.png) } +.big-1139 { color: var(--brand); background: url(img/bg.png) } +.big-1140 { color: var(--brand); background: url(img/bg.png) } +.big-1141 { color: var(--brand); background: url(img/bg.png) } +.big-1142 { color: var(--brand); background: url(img/bg.png) } +.big-1143 { color: var(--brand); background: url(img/bg.png) } +.big-1144 { color: var(--brand); background: url(img/bg.png) } +.big-1145 { color: var(--brand); background: url(img/bg.png) } +.big-1146 { color: var(--brand); background: url(img/bg.png) } +.big-1147 { color: var(--brand); background: url(img/bg.png) } +.big-1148 { color: var(--brand); background: url(img/bg.png) } +.big-1149 { color: var(--brand); background: url(img/bg.png) } +.big-1150 { color: var(--brand); background: url(img/bg.png) } +.big-1151 { color: var(--brand); background: url(img/bg.png) } +.big-1152 { color: var(--brand); background: url(img/bg.png) } +.big-1153 { color: var(--brand); background: url(img/bg.png) } +.big-1154 { color: var(--brand); background: url(img/bg.png) } +.big-1155 { color: var(--brand); background: url(img/bg.png) } +.big-1156 { color: var(--brand); background: url(img/bg.png) } +.big-1157 { color: var(--brand); background: url(img/bg.png) } +.big-1158 { color: var(--brand); background: url(img/bg.png) } +.big-1159 { color: var(--brand); background: url(img/bg.png) } +.big-1160 { color: var(--brand); background: url(img/bg.png) } +.big-1161 { color: var(--brand); background: url(img/bg.png) } +.big-1162 { color: var(--brand); background: url(img/bg.png) } +.big-1163 { color: var(--brand); background: url(img/bg.png) } +.big-1164 { color: var(--brand); background: url(img/bg.png) } +.big-1165 { color: var(--brand); background: url(img/bg.png) } +.big-1166 { color: var(--brand); background: url(img/bg.png) } +.big-1167 { color: var(--brand); background: url(img/bg.png) } +.big-1168 { color: var(--brand); background: url(img/bg.png) } +.big-1169 { color: var(--brand); background: url(img/bg.png) } +.big-1170 { color: var(--brand); background: url(img/bg.png) } +.big-1171 { color: var(--brand); background: url(img/bg.png) } +.big-1172 { color: var(--brand); background: url(img/bg.png) } +.big-1173 { color: var(--brand); background: url(img/bg.png) } +.big-1174 { color: var(--brand); background: url(img/bg.png) } +.big-1175 { color: var(--brand); background: url(img/bg.png) } +.big-1176 { color: var(--brand); background: url(img/bg.png) } +.big-1177 { color: var(--brand); background: url(img/bg.png) } +.big-1178 { color: var(--brand); background: url(img/bg.png) } +.big-1179 { color: var(--brand); background: url(img/bg.png) } +.big-1180 { color: var(--brand); background: url(img/bg.png) } +.big-1181 { color: var(--brand); background: url(img/bg.png) } +.big-1182 { color: var(--brand); background: url(img/bg.png) } +.big-1183 { color: var(--brand); background: url(img/bg.png) } +.big-1184 { color: var(--brand); background: url(img/bg.png) } +.big-1185 { color: var(--brand); background: url(img/bg.png) } +.big-1186 { color: var(--brand); background: url(img/bg.png) } +.big-1187 { color: var(--brand); background: url(img/bg.png) } +.big-1188 { color: var(--brand); background: url(img/bg.png) } +.big-1189 { color: var(--brand); background: url(img/bg.png) } +.big-1190 { color: var(--brand); background: url(img/bg.png) } +.big-1191 { color: var(--brand); background: url(img/bg.png) } +.big-1192 { color: var(--brand); background: url(img/bg.png) } +.big-1193 { color: var(--brand); background: url(img/bg.png) } +.big-1194 { color: var(--brand); background: url(img/bg.png) } +.big-1195 { color: var(--brand); background: url(img/bg.png) } +.big-1196 { color: var(--brand); background: url(img/bg.png) } +.big-1197 { color: var(--brand); background: url(img/bg.png) } +.big-1198 { color: var(--brand); background: url(img/bg.png) } +.big-1199 { color: var(--brand); background: url(img/bg.png) } +.big-1200 { color: var(--brand); background: url(img/bg.png) } +.big-1201 { color: var(--brand); background: url(img/bg.png) } +.big-1202 { color: var(--brand); background: url(img/bg.png) } +.big-1203 { color: var(--brand); background: url(img/bg.png) } +.big-1204 { color: var(--brand); background: url(img/bg.png) } +.big-1205 { color: var(--brand); background: url(img/bg.png) } +.big-1206 { color: var(--brand); background: url(img/bg.png) } +.big-1207 { color: var(--brand); background: url(img/bg.png) } +.big-1208 { color: var(--brand); background: url(img/bg.png) } +.big-1209 { color: var(--brand); background: url(img/bg.png) } +.big-1210 { color: var(--brand); background: url(img/bg.png) } +.big-1211 { color: var(--brand); background: url(img/bg.png) } +.big-1212 { color: var(--brand); background: url(img/bg.png) } +.big-1213 { color: var(--brand); background: url(img/bg.png) } +.big-1214 { color: var(--brand); background: url(img/bg.png) } +.big-1215 { color: var(--brand); background: url(img/bg.png) } +.big-1216 { color: var(--brand); background: url(img/bg.png) } +.big-1217 { color: var(--brand); background: url(img/bg.png) } +.big-1218 { color: var(--brand); background: url(img/bg.png) } +.big-1219 { color: var(--brand); background: url(img/bg.png) } +.big-1220 { color: var(--brand); background: url(img/bg.png) } +.big-1221 { color: var(--brand); background: url(img/bg.png) } +.big-1222 { color: var(--brand); background: url(img/bg.png) } +.big-1223 { color: var(--brand); background: url(img/bg.png) } +.big-1224 { color: var(--brand); background: url(img/bg.png) } +.big-1225 { color: var(--brand); background: url(img/bg.png) } +.big-1226 { color: var(--brand); background: url(img/bg.png) } +.big-1227 { color: var(--brand); background: url(img/bg.png) } +.big-1228 { color: var(--brand); background: url(img/bg.png) } +.big-1229 { color: var(--brand); background: url(img/bg.png) } +.big-1230 { color: var(--brand); background: url(img/bg.png) } +.big-1231 { color: var(--brand); background: url(img/bg.png) } +.big-1232 { color: var(--brand); background: url(img/bg.png) } +.big-1233 { color: var(--brand); background: url(img/bg.png) } +.big-1234 { color: var(--brand); background: url(img/bg.png) } +.big-1235 { color: var(--brand); background: url(img/bg.png) } +.big-1236 { color: var(--brand); background: url(img/bg.png) } +.big-1237 { color: var(--brand); background: url(img/bg.png) } +.big-1238 { color: var(--brand); background: url(img/bg.png) } +.big-1239 { color: var(--brand); background: url(img/bg.png) } +.big-1240 { color: var(--brand); background: url(img/bg.png) } +.big-1241 { color: var(--brand); background: url(img/bg.png) } +.big-1242 { color: var(--brand); background: url(img/bg.png) } +.big-1243 { color: var(--brand); background: url(img/bg.png) } +.big-1244 { color: var(--brand); background: url(img/bg.png) } +.big-1245 { color: var(--brand); background: url(img/bg.png) } +.big-1246 { color: var(--brand); background: url(img/bg.png) } +.big-1247 { color: var(--brand); background: url(img/bg.png) } +.big-1248 { color: var(--brand); background: url(img/bg.png) } +.big-1249 { color: var(--brand); background: url(img/bg.png) } +.big-1250 { color: var(--brand); background: url(img/bg.png) } +.big-1251 { color: var(--brand); background: url(img/bg.png) } +.big-1252 { color: var(--brand); background: url(img/bg.png) } +.big-1253 { color: var(--brand); background: url(img/bg.png) } +.big-1254 { color: var(--brand); background: url(img/bg.png) } +.big-1255 { color: var(--brand); background: url(img/bg.png) } +.big-1256 { color: var(--brand); background: url(img/bg.png) } +.big-1257 { color: var(--brand); background: url(img/bg.png) } +.big-1258 { color: var(--brand); background: url(img/bg.png) } +.big-1259 { color: var(--brand); background: url(img/bg.png) } +.big-1260 { color: var(--brand); background: url(img/bg.png) } +.big-1261 { color: var(--brand); background: url(img/bg.png) } +.big-1262 { color: var(--brand); background: url(img/bg.png) } +.big-1263 { color: var(--brand); background: url(img/bg.png) } +.big-1264 { color: var(--brand); background: url(img/bg.png) } +.big-1265 { color: var(--brand); background: url(img/bg.png) } +.big-1266 { color: var(--brand); background: url(img/bg.png) } +.big-1267 { color: var(--brand); background: url(img/bg.png) } +.big-1268 { color: var(--brand); background: url(img/bg.png) } +.big-1269 { color: var(--brand); background: url(img/bg.png) } +.big-1270 { color: var(--brand); background: url(img/bg.png) } +.big-1271 { color: var(--brand); background: url(img/bg.png) } +.big-1272 { color: var(--brand); background: url(img/bg.png) } +.big-1273 { color: var(--brand); background: url(img/bg.png) } +.big-1274 { color: var(--brand); background: url(img/bg.png) } +.big-1275 { color: var(--brand); background: url(img/bg.png) } +.big-1276 { color: var(--brand); background: url(img/bg.png) } +.big-1277 { color: var(--brand); background: url(img/bg.png) } +.big-1278 { color: var(--brand); background: url(img/bg.png) } +.big-1279 { color: var(--brand); background: url(img/bg.png) } +.big-1280 { color: var(--brand); background: url(img/bg.png) } +.big-1281 { color: var(--brand); background: url(img/bg.png) } +.big-1282 { color: var(--brand); background: url(img/bg.png) } +.big-1283 { color: var(--brand); background: url(img/bg.png) } +.big-1284 { color: var(--brand); background: url(img/bg.png) } +.big-1285 { color: var(--brand); background: url(img/bg.png) } +.big-1286 { color: var(--brand); background: url(img/bg.png) } +.big-1287 { color: var(--brand); background: url(img/bg.png) } +.big-1288 { color: var(--brand); background: url(img/bg.png) } +.big-1289 { color: var(--brand); background: url(img/bg.png) } +.big-1290 { color: var(--brand); background: url(img/bg.png) } +.big-1291 { color: var(--brand); background: url(img/bg.png) } +.big-1292 { color: var(--brand); background: url(img/bg.png) } +.big-1293 { color: var(--brand); background: url(img/bg.png) } +.big-1294 { color: var(--brand); background: url(img/bg.png) } +.big-1295 { color: var(--brand); background: url(img/bg.png) } +.big-1296 { color: var(--brand); background: url(img/bg.png) } +.big-1297 { color: var(--brand); background: url(img/bg.png) } +.big-1298 { color: var(--brand); background: url(img/bg.png) } +.big-1299 { color: var(--brand); background: url(img/bg.png) } +.big-1300 { color: var(--brand); background: url(img/bg.png) } +.big-1301 { color: var(--brand); background: url(img/bg.png) } +.big-1302 { color: var(--brand); background: url(img/bg.png) } +.big-1303 { color: var(--brand); background: url(img/bg.png) } +.big-1304 { color: var(--brand); background: url(img/bg.png) } +.big-1305 { color: var(--brand); background: url(img/bg.png) } +.big-1306 { color: var(--brand); background: url(img/bg.png) } +.big-1307 { color: var(--brand); background: url(img/bg.png) } +.big-1308 { color: var(--brand); background: url(img/bg.png) } +.big-1309 { color: var(--brand); background: url(img/bg.png) } +.big-1310 { color: var(--brand); background: url(img/bg.png) } +.big-1311 { color: var(--brand); background: url(img/bg.png) } +.big-1312 { color: var(--brand); background: url(img/bg.png) } +.big-1313 { color: var(--brand); background: url(img/bg.png) } +.big-1314 { color: var(--brand); background: url(img/bg.png) } +.big-1315 { color: var(--brand); background: url(img/bg.png) } +.big-1316 { color: var(--brand); background: url(img/bg.png) } +.big-1317 { color: var(--brand); background: url(img/bg.png) } +.big-1318 { color: var(--brand); background: url(img/bg.png) } +.big-1319 { color: var(--brand); background: url(img/bg.png) } +.big-1320 { color: var(--brand); background: url(img/bg.png) } +.big-1321 { color: var(--brand); background: url(img/bg.png) } +.big-1322 { color: var(--brand); background: url(img/bg.png) } +.big-1323 { color: var(--brand); background: url(img/bg.png) } +.big-1324 { color: var(--brand); background: url(img/bg.png) } +.big-1325 { color: var(--brand); background: url(img/bg.png) } +.big-1326 { color: var(--brand); background: url(img/bg.png) } +.big-1327 { color: var(--brand); background: url(img/bg.png) } +.big-1328 { color: var(--brand); background: url(img/bg.png) } +.big-1329 { color: var(--brand); background: url(img/bg.png) } +.big-1330 { color: var(--brand); background: url(img/bg.png) } +.big-1331 { color: var(--brand); background: url(img/bg.png) } +.big-1332 { color: var(--brand); background: url(img/bg.png) } +.big-1333 { color: var(--brand); background: url(img/bg.png) } +.big-1334 { color: var(--brand); background: url(img/bg.png) } +.big-1335 { color: var(--brand); background: url(img/bg.png) } +.big-1336 { color: var(--brand); background: url(img/bg.png) } +.big-1337 { color: var(--brand); background: url(img/bg.png) } +.big-1338 { color: var(--brand); background: url(img/bg.png) } +.big-1339 { color: var(--brand); background: url(img/bg.png) } +.big-1340 { color: var(--brand); background: url(img/bg.png) } +.big-1341 { color: var(--brand); background: url(img/bg.png) } +.big-1342 { color: var(--brand); background: url(img/bg.png) } +.big-1343 { color: var(--brand); background: url(img/bg.png) } +.big-1344 { color: var(--brand); background: url(img/bg.png) } +.big-1345 { color: var(--brand); background: url(img/bg.png) } +.big-1346 { color: var(--brand); background: url(img/bg.png) } +.big-1347 { color: var(--brand); background: url(img/bg.png) } +.big-1348 { color: var(--brand); background: url(img/bg.png) } +.big-1349 { color: var(--brand); background: url(img/bg.png) } +.big-1350 { color: var(--brand); background: url(img/bg.png) } +.big-1351 { color: var(--brand); background: url(img/bg.png) } +.big-1352 { color: var(--brand); background: url(img/bg.png) } +.big-1353 { color: var(--brand); background: url(img/bg.png) } +.big-1354 { color: var(--brand); background: url(img/bg.png) } +.big-1355 { color: var(--brand); background: url(img/bg.png) } +.big-1356 { color: var(--brand); background: url(img/bg.png) } +.big-1357 { color: var(--brand); background: url(img/bg.png) } +.big-1358 { color: var(--brand); background: url(img/bg.png) } +.big-1359 { color: var(--brand); background: url(img/bg.png) } +.big-1360 { color: var(--brand); background: url(img/bg.png) } +.big-1361 { color: var(--brand); background: url(img/bg.png) } +.big-1362 { color: var(--brand); background: url(img/bg.png) } +.big-1363 { color: var(--brand); background: url(img/bg.png) } +.big-1364 { color: var(--brand); background: url(img/bg.png) } +.big-1365 { color: var(--brand); background: url(img/bg.png) } +.big-1366 { color: var(--brand); background: url(img/bg.png) } +.big-1367 { color: var(--brand); background: url(img/bg.png) } +.big-1368 { color: var(--brand); background: url(img/bg.png) } +.big-1369 { color: var(--brand); background: url(img/bg.png) } +.big-1370 { color: var(--brand); background: url(img/bg.png) } +.big-1371 { color: var(--brand); background: url(img/bg.png) } +.big-1372 { color: var(--brand); background: url(img/bg.png) } +.big-1373 { color: var(--brand); background: url(img/bg.png) } +.big-1374 { color: var(--brand); background: url(img/bg.png) } +.big-1375 { color: var(--brand); background: url(img/bg.png) } +.big-1376 { color: var(--brand); background: url(img/bg.png) } +.big-1377 { color: var(--brand); background: url(img/bg.png) } +.big-1378 { color: var(--brand); background: url(img/bg.png) } +.big-1379 { color: var(--brand); background: url(img/bg.png) } +.big-1380 { color: var(--brand); background: url(img/bg.png) } +.big-1381 { color: var(--brand); background: url(img/bg.png) } +.big-1382 { color: var(--brand); background: url(img/bg.png) } +.big-1383 { color: var(--brand); background: url(img/bg.png) } +.big-1384 { color: var(--brand); background: url(img/bg.png) } +.big-1385 { color: var(--brand); background: url(img/bg.png) } +.big-1386 { color: var(--brand); background: url(img/bg.png) } +.big-1387 { color: var(--brand); background: url(img/bg.png) } +.big-1388 { color: var(--brand); background: url(img/bg.png) } +.big-1389 { color: var(--brand); background: url(img/bg.png) } +.big-1390 { color: var(--brand); background: url(img/bg.png) } +.big-1391 { color: var(--brand); background: url(img/bg.png) } +.big-1392 { color: var(--brand); background: url(img/bg.png) } +.big-1393 { color: var(--brand); background: url(img/bg.png) } +.big-1394 { color: var(--brand); background: url(img/bg.png) } +.big-1395 { color: var(--brand); background: url(img/bg.png) } +.big-1396 { color: var(--brand); background: url(img/bg.png) } +.big-1397 { color: var(--brand); background: url(img/bg.png) } +.big-1398 { color: var(--brand); background: url(img/bg.png) } +.big-1399 { color: var(--brand); background: url(img/bg.png) } +.big-1400 { color: var(--brand); background: url(img/bg.png) } +.big-1401 { color: var(--brand); background: url(img/bg.png) } +.big-1402 { color: var(--brand); background: url(img/bg.png) } +.big-1403 { color: var(--brand); background: url(img/bg.png) } +.big-1404 { color: var(--brand); background: url(img/bg.png) } +.big-1405 { color: var(--brand); background: url(img/bg.png) } +.big-1406 { color: var(--brand); background: url(img/bg.png) } +.big-1407 { color: var(--brand); background: url(img/bg.png) } +.big-1408 { color: var(--brand); background: url(img/bg.png) } +.big-1409 { color: var(--brand); background: url(img/bg.png) } +.big-1410 { color: var(--brand); background: url(img/bg.png) } +.big-1411 { color: var(--brand); background: url(img/bg.png) } +.big-1412 { color: var(--brand); background: url(img/bg.png) } +.big-1413 { color: var(--brand); background: url(img/bg.png) } +.big-1414 { color: var(--brand); background: url(img/bg.png) } +.big-1415 { color: var(--brand); background: url(img/bg.png) } +.big-1416 { color: var(--brand); background: url(img/bg.png) } +.big-1417 { color: var(--brand); background: url(img/bg.png) } +.big-1418 { color: var(--brand); background: url(img/bg.png) } +.big-1419 { color: var(--brand); background: url(img/bg.png) } +.big-1420 { color: var(--brand); background: url(img/bg.png) } +.big-1421 { color: var(--brand); background: url(img/bg.png) } +.big-1422 { color: var(--brand); background: url(img/bg.png) } +.big-1423 { color: var(--brand); background: url(img/bg.png) } +.big-1424 { color: var(--brand); background: url(img/bg.png) } +.big-1425 { color: var(--brand); background: url(img/bg.png) } +.big-1426 { color: var(--brand); background: url(img/bg.png) } +.big-1427 { color: var(--brand); background: url(img/bg.png) } +.big-1428 { color: var(--brand); background: url(img/bg.png) } +.big-1429 { color: var(--brand); background: url(img/bg.png) } +.big-1430 { color: var(--brand); background: url(img/bg.png) } +.big-1431 { color: var(--brand); background: url(img/bg.png) } +.big-1432 { color: var(--brand); background: url(img/bg.png) } +.big-1433 { color: var(--brand); background: url(img/bg.png) } +.big-1434 { color: var(--brand); background: url(img/bg.png) } +.big-1435 { color: var(--brand); background: url(img/bg.png) } +.big-1436 { color: var(--brand); background: url(img/bg.png) } +.big-1437 { color: var(--brand); background: url(img/bg.png) } +.big-1438 { color: var(--brand); background: url(img/bg.png) } +.big-1439 { color: var(--brand); background: url(img/bg.png) } +.big-1440 { color: var(--brand); background: url(img/bg.png) } +.big-1441 { color: var(--brand); background: url(img/bg.png) } +.big-1442 { color: var(--brand); background: url(img/bg.png) } +.big-1443 { color: var(--brand); background: url(img/bg.png) } +.big-1444 { color: var(--brand); background: url(img/bg.png) } +.big-1445 { color: var(--brand); background: url(img/bg.png) } +.big-1446 { color: var(--brand); background: url(img/bg.png) } +.big-1447 { color: var(--brand); background: url(img/bg.png) } +.big-1448 { color: var(--brand); background: url(img/bg.png) } +.big-1449 { color: var(--brand); background: url(img/bg.png) } +.big-1450 { color: var(--brand); background: url(img/bg.png) } +.big-1451 { color: var(--brand); background: url(img/bg.png) } +.big-1452 { color: var(--brand); background: url(img/bg.png) } +.big-1453 { color: var(--brand); background: url(img/bg.png) } +.big-1454 { color: var(--brand); background: url(img/bg.png) } +.big-1455 { color: var(--brand); background: url(img/bg.png) } +.big-1456 { color: var(--brand); background: url(img/bg.png) } +.big-1457 { color: var(--brand); background: url(img/bg.png) } +.big-1458 { color: var(--brand); background: url(img/bg.png) } +.big-1459 { color: var(--brand); background: url(img/bg.png) } +.big-1460 { color: var(--brand); background: url(img/bg.png) } +.big-1461 { color: var(--brand); background: url(img/bg.png) } +.big-1462 { color: var(--brand); background: url(img/bg.png) } +.big-1463 { color: var(--brand); background: url(img/bg.png) } +.big-1464 { color: var(--brand); background: url(img/bg.png) } +.big-1465 { color: var(--brand); background: url(img/bg.png) } +.big-1466 { color: var(--brand); background: url(img/bg.png) } +.big-1467 { color: var(--brand); background: url(img/bg.png) } +.big-1468 { color: var(--brand); background: url(img/bg.png) } +.big-1469 { color: var(--brand); background: url(img/bg.png) } +.big-1470 { color: var(--brand); background: url(img/bg.png) } +.big-1471 { color: var(--brand); background: url(img/bg.png) } +.big-1472 { color: var(--brand); background: url(img/bg.png) } +.big-1473 { color: var(--brand); background: url(img/bg.png) } +.big-1474 { color: var(--brand); background: url(img/bg.png) } +.big-1475 { color: var(--brand); background: url(img/bg.png) } +.big-1476 { color: var(--brand); background: url(img/bg.png) } +.big-1477 { color: var(--brand); background: url(img/bg.png) } +.big-1478 { color: var(--brand); background: url(img/bg.png) } +.big-1479 { color: var(--brand); background: url(img/bg.png) } +.big-1480 { color: var(--brand); background: url(img/bg.png) } +.big-1481 { color: var(--brand); background: url(img/bg.png) } +.big-1482 { color: var(--brand); background: url(img/bg.png) } +.big-1483 { color: var(--brand); background: url(img/bg.png) } +.big-1484 { color: var(--brand); background: url(img/bg.png) } +.big-1485 { color: var(--brand); background: url(img/bg.png) } +.big-1486 { color: var(--brand); background: url(img/bg.png) } +.big-1487 { color: var(--brand); background: url(img/bg.png) } +.big-1488 { color: var(--brand); background: url(img/bg.png) } +.big-1489 { color: var(--brand); background: url(img/bg.png) } +.big-1490 { color: var(--brand); background: url(img/bg.png) } +.big-1491 { color: var(--brand); background: url(img/bg.png) } +.big-1492 { color: var(--brand); background: url(img/bg.png) } +.big-1493 { color: var(--brand); background: url(img/bg.png) } +.big-1494 { color: var(--brand); background: url(img/bg.png) } +.big-1495 { color: var(--brand); background: url(img/bg.png) } +.big-1496 { color: var(--brand); background: url(img/bg.png) } +.big-1497 { color: var(--brand); background: url(img/bg.png) } +.big-1498 { color: var(--brand); background: url(img/bg.png) } +.big-1499 { color: var(--brand); background: url(img/bg.png) } diff --git a/parser/src/test-data/web/torture/css/bom.css b/parser/src/test-data/web/torture/css/bom.css new file mode 100644 index 00000000..53fd637b --- /dev/null +++ b/parser/src/test-data/web/torture/css/bom.css @@ -0,0 +1 @@ +.bom-class { color: red } diff --git a/parser/src/test-data/web/torture/css/disabled.css b/parser/src/test-data/web/torture/css/disabled.css new file mode 100644 index 00000000..bc3d0dc6 --- /dev/null +++ b/parser/src/test-data/web/torture/css/disabled.css @@ -0,0 +1 @@ +.disabled-only { color: red } diff --git a/parser/src/test-data/web/torture/css/fonts.css b/parser/src/test-data/web/torture/css/fonts.css new file mode 100644 index 00000000..d4d5fefe --- /dev/null +++ b/parser/src/test-data/web/torture/css/fonts.css @@ -0,0 +1,38 @@ +/* F01 font faces: multiple src, local(), format(), tech(), unicode-range; and the families that use them */ +@font-face { + font-family: "Inter"; + src: local("Inter"), local(Inter-Regular), + url(../fonts/inter.woff2) format("woff2") tech(variations), + url("../fonts/inter.woff") format("woff"), + url(../fonts/missing.woff) format("woff"), + url(https://fonts.example.com/inter.woff2); + font-weight: 100 900; + unicode-range: U+0000-00FF, U+0131; + font-display: swap; +} +@font-face { font-family: Inter; src: url(../fonts/inter.woff2); font-weight: bold } +@font-face { font-family: 'Roboto Mono'; src: url(../fonts/roboto.woff2) } +@font-face { font-family: "Unused Face"; src: url(../fonts/unused.woff2) } +@font-face { src: url(../fonts/nameless.woff2) } +@font-face { font-family: var(--font); src: url(../fonts/x.woff2) } +@font-face { font-family: "Segoe UI"; src: local("Segoe UI") } +.f1 { font-family: Inter } +.f2 { font-family: "Inter" } +.f3 { font-family: 'Inter' } +.f4 { font-family: inter } /* case-insensitive match in browsers */ +.f5 { font-family: Roboto Mono, monospace } +.f6 { font-family: "Roboto Mono", monospace } +.f7 { font-family: Segoe UI, Inter, Arial, sans-serif } +.f8 { font: 12px Inter } +.f9 { font: bold 12px/1 "Roboto Mono", monospace } +.f10 { font: italic small-caps bold condensed 16px/2 cursive } +.f11 { font: caption } +.f12 { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto } +.f13 { font-family: "Nonexistent Face" } +.f14 { font-family: Inter !important } +.f15 { font-family: var(--font, Inter) } +.f16 { font-family: "Inter", } +.f17 { font-family: , Inter } +.f18 { font-family: "Inter"Inter } +.f19 { font-family: Inter Inter } +.f20 { font-family: "Inter" "Inter" } diff --git a/parser/src/test-data/web/torture/css/gaps-many.css b/parser/src/test-data/web/torture/css/gaps-many.css new file mode 100644 index 00000000..c9582e34 --- /dev/null +++ b/parser/src/test-data/web/torture/css/gaps-many.css @@ -0,0 +1,300 @@ +.k0 { color: rgb(1, } +.k1 { color: rgb(1, } +.k2 { color: rgb(1, } +.k3 { color: rgb(1, } +.k4 { color: rgb(1, } +.k5 { color: rgb(1, } +.k6 { color: rgb(1, } +.k7 { color: rgb(1, } +.k8 { color: rgb(1, } +.k9 { color: rgb(1, } +.k10 { color: rgb(1, } +.k11 { color: rgb(1, } +.k12 { color: rgb(1, } +.k13 { color: rgb(1, } +.k14 { color: rgb(1, } +.k15 { color: rgb(1, } +.k16 { color: rgb(1, } +.k17 { color: rgb(1, } +.k18 { color: rgb(1, } +.k19 { color: rgb(1, } +.k20 { color: rgb(1, } +.k21 { color: rgb(1, } +.k22 { color: rgb(1, } +.k23 { color: rgb(1, } +.k24 { color: rgb(1, } +.k25 { color: rgb(1, } +.k26 { color: rgb(1, } +.k27 { color: rgb(1, } +.k28 { color: rgb(1, } +.k29 { color: rgb(1, } +.k30 { color: rgb(1, } +.k31 { color: rgb(1, } +.k32 { color: rgb(1, } +.k33 { color: rgb(1, } +.k34 { color: rgb(1, } +.k35 { color: rgb(1, } +.k36 { color: rgb(1, } +.k37 { color: rgb(1, } +.k38 { color: rgb(1, } +.k39 { color: rgb(1, } +.k40 { color: rgb(1, } +.k41 { color: rgb(1, } +.k42 { color: rgb(1, } +.k43 { color: rgb(1, } +.k44 { color: rgb(1, } +.k45 { color: rgb(1, } +.k46 { color: rgb(1, } +.k47 { color: rgb(1, } +.k48 { color: rgb(1, } +.k49 { color: rgb(1, } +.k50 { color: rgb(1, } +.k51 { color: rgb(1, } +.k52 { color: rgb(1, } +.k53 { color: rgb(1, } +.k54 { color: rgb(1, } +.k55 { color: rgb(1, } +.k56 { color: rgb(1, } +.k57 { color: rgb(1, } +.k58 { color: rgb(1, } +.k59 { color: rgb(1, } +.k60 { color: rgb(1, } +.k61 { color: rgb(1, } +.k62 { color: rgb(1, } +.k63 { color: rgb(1, } +.k64 { color: rgb(1, } +.k65 { color: rgb(1, } +.k66 { color: rgb(1, } +.k67 { color: rgb(1, } +.k68 { color: rgb(1, } +.k69 { color: rgb(1, } +.k70 { color: rgb(1, } +.k71 { color: rgb(1, } +.k72 { color: rgb(1, } +.k73 { color: rgb(1, } +.k74 { color: rgb(1, } +.k75 { color: rgb(1, } +.k76 { color: rgb(1, } +.k77 { color: rgb(1, } +.k78 { color: rgb(1, } +.k79 { color: rgb(1, } +.k80 { color: rgb(1, } +.k81 { color: rgb(1, } +.k82 { color: rgb(1, } +.k83 { color: rgb(1, } +.k84 { color: rgb(1, } +.k85 { color: rgb(1, } +.k86 { color: rgb(1, } +.k87 { color: rgb(1, } +.k88 { color: rgb(1, } +.k89 { color: rgb(1, } +.k90 { color: rgb(1, } +.k91 { color: rgb(1, } +.k92 { color: rgb(1, } +.k93 { color: rgb(1, } +.k94 { color: rgb(1, } +.k95 { color: rgb(1, } +.k96 { color: rgb(1, } +.k97 { color: rgb(1, } +.k98 { color: rgb(1, } +.k99 { color: rgb(1, } +.k100 { color: rgb(1, } +.k101 { color: rgb(1, } +.k102 { color: rgb(1, } +.k103 { color: rgb(1, } +.k104 { color: rgb(1, } +.k105 { color: rgb(1, } +.k106 { color: rgb(1, } +.k107 { color: rgb(1, } +.k108 { color: rgb(1, } +.k109 { color: rgb(1, } +.k110 { color: rgb(1, } +.k111 { color: rgb(1, } +.k112 { color: rgb(1, } +.k113 { color: rgb(1, } +.k114 { color: rgb(1, } +.k115 { color: rgb(1, } +.k116 { color: rgb(1, } +.k117 { color: rgb(1, } +.k118 { color: rgb(1, } +.k119 { color: rgb(1, } +.k120 { color: rgb(1, } +.k121 { color: rgb(1, } +.k122 { color: rgb(1, } +.k123 { color: rgb(1, } +.k124 { color: rgb(1, } +.k125 { color: rgb(1, } +.k126 { color: rgb(1, } +.k127 { color: rgb(1, } +.k128 { color: rgb(1, } +.k129 { color: rgb(1, } +.k130 { color: rgb(1, } +.k131 { color: rgb(1, } +.k132 { color: rgb(1, } +.k133 { color: rgb(1, } +.k134 { color: rgb(1, } +.k135 { color: rgb(1, } +.k136 { color: rgb(1, } +.k137 { color: rgb(1, } +.k138 { color: rgb(1, } +.k139 { color: rgb(1, } +.k140 { color: rgb(1, } +.k141 { color: rgb(1, } +.k142 { color: rgb(1, } +.k143 { color: rgb(1, } +.k144 { color: rgb(1, } +.k145 { color: rgb(1, } +.k146 { color: rgb(1, } +.k147 { color: rgb(1, } +.k148 { color: rgb(1, } +.k149 { color: rgb(1, } +.k150 { color: rgb(1, } +.k151 { color: rgb(1, } +.k152 { color: rgb(1, } +.k153 { color: rgb(1, } +.k154 { color: rgb(1, } +.k155 { color: rgb(1, } +.k156 { color: rgb(1, } +.k157 { color: rgb(1, } +.k158 { color: rgb(1, } +.k159 { color: rgb(1, } +.k160 { color: rgb(1, } +.k161 { color: rgb(1, } +.k162 { color: rgb(1, } +.k163 { color: rgb(1, } +.k164 { color: rgb(1, } +.k165 { color: rgb(1, } +.k166 { color: rgb(1, } +.k167 { color: rgb(1, } +.k168 { color: rgb(1, } +.k169 { color: rgb(1, } +.k170 { color: rgb(1, } +.k171 { color: rgb(1, } +.k172 { color: rgb(1, } +.k173 { color: rgb(1, } +.k174 { color: rgb(1, } +.k175 { color: rgb(1, } +.k176 { color: rgb(1, } +.k177 { color: rgb(1, } +.k178 { color: rgb(1, } +.k179 { color: rgb(1, } +.k180 { color: rgb(1, } +.k181 { color: rgb(1, } +.k182 { color: rgb(1, } +.k183 { color: rgb(1, } +.k184 { color: rgb(1, } +.k185 { color: rgb(1, } +.k186 { color: rgb(1, } +.k187 { color: rgb(1, } +.k188 { color: rgb(1, } +.k189 { color: rgb(1, } +.k190 { color: rgb(1, } +.k191 { color: rgb(1, } +.k192 { color: rgb(1, } +.k193 { color: rgb(1, } +.k194 { color: rgb(1, } +.k195 { color: rgb(1, } +.k196 { color: rgb(1, } +.k197 { color: rgb(1, } +.k198 { color: rgb(1, } +.k199 { color: rgb(1, } +.k200 { color: rgb(1, } +.k201 { color: rgb(1, } +.k202 { color: rgb(1, } +.k203 { color: rgb(1, } +.k204 { color: rgb(1, } +.k205 { color: rgb(1, } +.k206 { color: rgb(1, } +.k207 { color: rgb(1, } +.k208 { color: rgb(1, } +.k209 { color: rgb(1, } +.k210 { color: rgb(1, } +.k211 { color: rgb(1, } +.k212 { color: rgb(1, } +.k213 { color: rgb(1, } +.k214 { color: rgb(1, } +.k215 { color: rgb(1, } +.k216 { color: rgb(1, } +.k217 { color: rgb(1, } +.k218 { color: rgb(1, } +.k219 { color: rgb(1, } +.k220 { color: rgb(1, } +.k221 { color: rgb(1, } +.k222 { color: rgb(1, } +.k223 { color: rgb(1, } +.k224 { color: rgb(1, } +.k225 { color: rgb(1, } +.k226 { color: rgb(1, } +.k227 { color: rgb(1, } +.k228 { color: rgb(1, } +.k229 { color: rgb(1, } +.k230 { color: rgb(1, } +.k231 { color: rgb(1, } +.k232 { color: rgb(1, } +.k233 { color: rgb(1, } +.k234 { color: rgb(1, } +.k235 { color: rgb(1, } +.k236 { color: rgb(1, } +.k237 { color: rgb(1, } +.k238 { color: rgb(1, } +.k239 { color: rgb(1, } +.k240 { color: rgb(1, } +.k241 { color: rgb(1, } +.k242 { color: rgb(1, } +.k243 { color: rgb(1, } +.k244 { color: rgb(1, } +.k245 { color: rgb(1, } +.k246 { color: rgb(1, } +.k247 { color: rgb(1, } +.k248 { color: rgb(1, } +.k249 { color: rgb(1, } +.k250 { color: rgb(1, } +.k251 { color: rgb(1, } +.k252 { color: rgb(1, } +.k253 { color: rgb(1, } +.k254 { color: rgb(1, } +.k255 { color: rgb(1, } +.k256 { color: rgb(1, } +.k257 { color: rgb(1, } +.k258 { color: rgb(1, } +.k259 { color: rgb(1, } +.k260 { color: rgb(1, } +.k261 { color: rgb(1, } +.k262 { color: rgb(1, } +.k263 { color: rgb(1, } +.k264 { color: rgb(1, } +.k265 { color: rgb(1, } +.k266 { color: rgb(1, } +.k267 { color: rgb(1, } +.k268 { color: rgb(1, } +.k269 { color: rgb(1, } +.k270 { color: rgb(1, } +.k271 { color: rgb(1, } +.k272 { color: rgb(1, } +.k273 { color: rgb(1, } +.k274 { color: rgb(1, } +.k275 { color: rgb(1, } +.k276 { color: rgb(1, } +.k277 { color: rgb(1, } +.k278 { color: rgb(1, } +.k279 { color: rgb(1, } +.k280 { color: rgb(1, } +.k281 { color: rgb(1, } +.k282 { color: rgb(1, } +.k283 { color: rgb(1, } +.k284 { color: rgb(1, } +.k285 { color: rgb(1, } +.k286 { color: rgb(1, } +.k287 { color: rgb(1, } +.k288 { color: rgb(1, } +.k289 { color: rgb(1, } +.k290 { color: rgb(1, } +.k291 { color: rgb(1, } +.k292 { color: rgb(1, } +.k293 { color: rgb(1, } +.k294 { color: rgb(1, } +.k295 { color: rgb(1, } +.k296 { color: rgb(1, } +.k297 { color: rgb(1, } +.k298 { color: rgb(1, } +.k299 { color: rgb(1, } diff --git a/parser/src/test-data/web/torture/css/gaps.css b/parser/src/test-data/web/torture/css/gaps.css new file mode 100644 index 00000000..5f8559ef --- /dev/null +++ b/parser/src/test-data/web/torture/css/gaps.css @@ -0,0 +1,34 @@ +/* G01 syntax the grammar must survive, with rows still emitted for the readable rest */ +.before-gaps { color: red } +.g1 { color: red +.g2 { color: blue } +.g3 { color: red }} +} +.g4 { ; color: red } +.g5 { color: red; ; ; } +.g6 { color } +.g7 { : } +.g8 { @media (x) { color: red } } +.g9 { color: red } .g10 +.g11 { color: red } } +@media { .g12 { color: red } } +@media screen { .g13 { color: red } +.g14 { color: red } +@unknown-rule foo { .g15 { color: red } } +@unknown-statement foo bar; +@import "late.css"; /* @import after rules is ignored by browsers */ +@charset "utf-8"; /* @charset not first is ignored by browsers */ +.g16 { color: red } /* unclosed comment +.g17 { color: red } +*/ +.g18 { color: red } + +.g20 { color: red } +.g21 { background: url(img/bg.png } +.g22 { color: rgb(1, 2 } +.g23 { width: calc(1px + } +.g24 { content: "x } +.g25 { color: red } +.g26 [ { color: red } +.g27 ( { color: red } +.g28 { color: red diff --git a/parser/src/test-data/web/torture/css/imported-by-style.css b/parser/src/test-data/web/torture/css/imported-by-style.css new file mode 100644 index 00000000..09a3be8f --- /dev/null +++ b/parser/src/test-data/web/torture/css/imported-by-style.css @@ -0,0 +1,2 @@ +.imported-by-style { color: red } +@import "tokens.css"; diff --git a/parser/src/test-data/web/torture/css/layers.css b/parser/src/test-data/web/torture/css/layers.css new file mode 100644 index 00000000..91288b03 --- /dev/null +++ b/parser/src/test-data/web/torture/css/layers.css @@ -0,0 +1,17 @@ +/* L01 cascade layers: statement, block, nested, dotted, anonymous, imported-into, media-wrapped */ +@layer reset, base, components.buttons, components.cards, utilities; +@layer reset { * { margin: 0 } } +@layer base { .card { color: red } @layer typography { h1 { font-size: 2rem } } } +@layer components { @layer buttons { .btn { color: red } } @layer cards { .card { color: blue } } } +@layer components.cards { .card .item { color: green } } +@layer base.typography { h2 { font-size: 1rem } } +@layer { .anonymous { color: red } } +@media screen { @layer utilities { .u-hide { display: none } } } +@layer utilities { @media print { .u-hide { display: block } } } +@import "print.css" layer(print-layer); +@import "alt.css" layer; +@layer sub.sub.sub { .deep-layer { color: red } } +@layer a, b, a; +@layer reset; +@layer spaced , names ; +.unlayered { color: red } diff --git a/parser/src/test-data/web/torture/css/long-line.css b/parser/src/test-data/web/torture/css/long-line.css new file mode 100644 index 00000000..af1eb44d --- /dev/null +++ b/parser/src/test-data/web/torture/css/long-line.css @@ -0,0 +1 @@ +.m1{color:red}.m2{color:red}.m3{color:red}.m4{color:red}.m5{color:red}.m6{color:red}.m7{color:red}.m8{color:red}.m9{color:red}.m10{color:red}.m11{color:red}.m12{color:red}.m13{color:red}.m14{color:red}.m15{color:red}.m16{color:red}.m17{color:red}.m18{color:red}.m19{color:red}.m20{color:red}.m21{color:red}.m22{color:red}.m23{color:red}.m24{color:red}.m25{color:red}.m26{color:red}.m27{color:red}.m28{color:red}.m29{color:red}.m30{color:red}.m31{color:red}.m32{color:red}.m33{color:red}.m34{color:red}.m35{color:red}.m36{color:red}.m37{color:red}.m38{color:red}.m39{color:red}.m40{color:red}.m41{color:red}.m42{color:red}.m43{color:red}.m44{color:red}.m45{color:red}.m46{color:red}.m47{color:red}.m48{color:red}.m49{color:red}.m50{color:red}.m51{color:red}.m52{color:red}.m53{color:red}.m54{color:red}.m55{color:red}.m56{color:red}.m57{color:red}.m58{color:red}.m59{color:red}.m60{color:red}.m61{color:red}.m62{color:red}.m63{color:red}.m64{color:red}.m65{color:red}.m66{color:red}.m67{color:red}.m68{color:red}.m69{color:red}.m70{color:red}.m71{color:red}.m72{color:red}.m73{color:red}.m74{color:red}.m75{color:red}.m76{color:red}.m77{color:red}.m78{color:red}.m79{color:red}.m80{color:red}.m81{color:red}.m82{color:red}.m83{color:red}.m84{color:red}.m85{color:red}.m86{color:red}.m87{color:red}.m88{color:red}.m89{color:red}.m90{color:red}.m91{color:red}.m92{color:red}.m93{color:red}.m94{color:red}.m95{color:red}.m96{color:red}.m97{color:red}.m98{color:red}.m99{color:red}.m100{color:red}.m101{color:red}.m102{color:red}.m103{color:red}.m104{color:red}.m105{color:red}.m106{color:red}.m107{color:red}.m108{color:red}.m109{color:red}.m110{color:red}.m111{color:red}.m112{color:red}.m113{color:red}.m114{color:red}.m115{color:red}.m116{color:red}.m117{color:red}.m118{color:red}.m119{color:red}.m120{color:red}.m121{color:red}.m122{color:red}.m123{color:red}.m124{color:red}.m125{color:red}.m126{color:red}.m127{color:red}.m128{color:red}.m129{color:red}.m130{color:red}.m131{color:red}.m132{color:red}.m133{color:red}.m134{color:red}.m135{color:red}.m136{color:red}.m137{color:red}.m138{color:red}.m139{color:red}.m140{color:red}.m141{color:red}.m142{color:red}.m143{color:red}.m144{color:red}.m145{color:red}.m146{color:red}.m147{color:red}.m148{color:red}.m149{color:red}.m150{color:red}.m151{color:red}.m152{color:red}.m153{color:red}.m154{color:red}.m155{color:red}.m156{color:red}.m157{color:red}.m158{color:red}.m159{color:red}.m160{color:red}.m161{color:red}.m162{color:red}.m163{color:red}.m164{color:red}.m165{color:red}.m166{color:red}.m167{color:red}.m168{color:red}.m169{color:red}.m170{color:red}.m171{color:red}.m172{color:red}.m173{color:red}.m174{color:red}.m175{color:red}.m176{color:red}.m177{color:red}.m178{color:red}.m179{color:red}.m180{color:red}.m181{color:red}.m182{color:red}.m183{color:red}.m184{color:red}.m185{color:red}.m186{color:red}.m187{color:red}.m188{color:red}.m189{color:red}.m190{color:red}.m191{color:red}.m192{color:red}.m193{color:red}.m194{color:red}.m195{color:red}.m196{color:red}.m197{color:red}.m198{color:red}.m199{color:red}.m200{color:red}.m201{color:red}.m202{color:red}.m203{color:red}.m204{color:red}.m205{color:red}.m206{color:red}.m207{color:red}.m208{color:red}.m209{color:red}.m210{color:red}.m211{color:red}.m212{color:red}.m213{color:red}.m214{color:red}.m215{color:red}.m216{color:red}.m217{color:red}.m218{color:red}.m219{color:red}.m220{color:red}.m221{color:red}.m222{color:red}.m223{color:red}.m224{color:red}.m225{color:red}.m226{color:red}.m227{color:red}.m228{color:red}.m229{color:red}.m230{color:red}.m231{color:red}.m232{color:red}.m233{color:red}.m234{color:red}.m235{color:red}.m236{color:red}.m237{color:red}.m238{color:red}.m239{color:red}.m240{color:red}.m241{color:red}.m242{color:red}.m243{color:red}.m244{color:red}.m245{color:red}.m246{color:red}.m247{color:red}.m248{color:red}.m249{color:red}.m250{color:red}.m251{color:red}.m252{color:red}.m253{color:red}.m254{color:red}.m255{color:red}.m256{color:red}.m257{color:red}.m258{color:red}.m259{color:red}.m260{color:red}.m261{color:red}.m262{color:red}.m263{color:red}.m264{color:red}.m265{color:red}.m266{color:red}.m267{color:red}.m268{color:red}.m269{color:red}.m270{color:red}.m271{color:red}.m272{color:red}.m273{color:red}.m274{color:red}.m275{color:red}.m276{color:red}.m277{color:red}.m278{color:red}.m279{color:red}.m280{color:red}.m281{color:red}.m282{color:red}.m283{color:red}.m284{color:red}.m285{color:red}.m286{color:red}.m287{color:red}.m288{color:red}.m289{color:red}.m290{color:red}.m291{color:red}.m292{color:red}.m293{color:red}.m294{color:red}.m295{color:red}.m296{color:red}.m297{color:red}.m298{color:red}.m299{color:red}.m300{color:red}.m301{color:red}.m302{color:red}.m303{color:red}.m304{color:red}.m305{color:red}.m306{color:red}.m307{color:red}.m308{color:red}.m309{color:red}.m310{color:red}.m311{color:red}.m312{color:red}.m313{color:red}.m314{color:red}.m315{color:red}.m316{color:red}.m317{color:red}.m318{color:red}.m319{color:red}.m320{color:red}.m321{color:red}.m322{color:red}.m323{color:red}.m324{color:red}.m325{color:red}.m326{color:red}.m327{color:red}.m328{color:red}.m329{color:red}.m330{color:red}.m331{color:red}.m332{color:red}.m333{color:red}.m334{color:red}.m335{color:red}.m336{color:red}.m337{color:red}.m338{color:red}.m339{color:red}.m340{color:red}.m341{color:red}.m342{color:red}.m343{color:red}.m344{color:red}.m345{color:red}.m346{color:red}.m347{color:red}.m348{color:red}.m349{color:red}.m350{color:red}.m351{color:red}.m352{color:red}.m353{color:red}.m354{color:red}.m355{color:red}.m356{color:red}.m357{color:red}.m358{color:red}.m359{color:red}.m360{color:red}.m361{color:red}.m362{color:red}.m363{color:red}.m364{color:red}.m365{color:red}.m366{color:red}.m367{color:red}.m368{color:red}.m369{color:red}.m370{color:red}.m371{color:red}.m372{color:red}.m373{color:red}.m374{color:red}.m375{color:red}.m376{color:red}.m377{color:red}.m378{color:red}.m379{color:red}.m380{color:red}.m381{color:red}.m382{color:red}.m383{color:red}.m384{color:red}.m385{color:red}.m386{color:red}.m387{color:red}.m388{color:red}.m389{color:red}.m390{color:red}.m391{color:red}.m392{color:red}.m393{color:red}.m394{color:red}.m395{color:red}.m396{color:red}.m397{color:red}.m398{color:red}.m399{color:red}.m400{color:red} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/css/nested.css b/parser/src/test-data/web/torture/css/nested.css new file mode 100644 index 00000000..8832cf59 --- /dev/null +++ b/parser/src/test-data/web/torture/css/nested.css @@ -0,0 +1,45 @@ +/* N01 CSS nesting: every & position, implicit nesting, nested at-rules with bare declarations */ +.card { + color: red; + &:hover { color: blue } + & .item { color: green } + .item & { color: yellow } + & + & { margin: 0 } + &.is-active { outline: 1px } + &#section-one { outline: 2px } + & > .nav, & ~ .nav { color: red } + .item, .nav { color: red } /* implicit & descendant, two selectors */ + > .direct { color: red } /* leading combinator */ + + .sib { color: red } + ~ .gen { color: red } + :hover { color: red } /* implicit: &:hover? no, & :hover descendant */ + .a &, .b & { color: red } + &, &.x { color: red } + :is(&, .alias) .item { color: red } + &:not(.is-active) .item { color: red } + .parent & .child { color: red } + && { color: red } + & & { color: red } + @media (min-width: 1px) { color: red; & .item { color: blue } .nested-in-media { color: green } } + @supports (display: grid) { display: grid } + @container sidebar (min-width: 1px) { color: red } + @layer nested-layer { color: red } + @scope (.card) { .item { color: red } } + @starting-style { opacity: 0 } + --nested-var: 1; + .deep { .deeper { .deepest { color: var(--nested-var) } } } + @media (a) { @media (b) { @supports (c) { .x { color: red } } } } + & .item { & .leaf { color: red } } + .item { .leaf { color: red } } + @nest .parent & { color: red } /* pre-standard @nest */ + color: blue; /* declaration after nested rules */ +} +.card.is-active { & .item { color: red } } +.a, .b { & .c { color: red } .d & { color: red } } +@media (x) { .outer { .inner { color: red } } } +@layer l { .outer { & .inner { color: red } } } +.p { &__elem { color: red } &--mod { color: red } } /* BEM-style: invalid in CSS nesting, valid in Sass */ +.q { &-suffix { color: red } } +.r { &:is(.s) { color: red } } +.t { .u & .v & { color: red } } +.w { &.x.y > &.z { color: red } } diff --git a/parser/src/test-data/web/torture/css/norel.css b/parser/src/test-data/web/torture/css/norel.css new file mode 100644 index 00000000..867918fd --- /dev/null +++ b/parser/src/test-data/web/torture/css/norel.css @@ -0,0 +1 @@ +.norel-only { color: red } diff --git a/parser/src/test-data/web/torture/css/print.css b/parser/src/test-data/web/torture/css/print.css new file mode 100644 index 00000000..8c1f9cba --- /dev/null +++ b/parser/src/test-data/web/torture/css/print.css @@ -0,0 +1,2 @@ +@media print { .print-rule { color: red } } +.print-only { color: red } diff --git a/parser/src/test-data/web/torture/css/sassy.css b/parser/src/test-data/web/torture/css/sassy.css new file mode 100644 index 00000000..8bb5165e --- /dev/null +++ b/parser/src/test-data/web/torture/css/sassy.css @@ -0,0 +1,5 @@ +$primary: red; +@mixin m { color: $primary } +.sassy { @include m; &__elem { color: red } } +// line comment +.x { .y { color: red } } diff --git a/parser/src/test-data/web/torture/css/selectors.css b/parser/src/test-data/web/torture/css/selectors.css new file mode 100644 index 00000000..d1bf6329 --- /dev/null +++ b/parser/src/test-data/web/torture/css/selectors.css @@ -0,0 +1,34 @@ +/* E01 escaped identifiers: every form the CSS syntax admits */ +.md\:flex { display: flex } +.md\3A flex { display: flex } +.md\00003Aflex { display: flex } +.w-1\/2 { width: 50% } +.\31 23 { order: 1 } +.\31 0 { order: 10 } +.\-mt-2 { margin: 0 } +.a\ b { color: red } +.\@media { color: red } +.\#hash { color: red } +.\.dot { color: red } +.\\backslash { color: red } +.caf\e9 { color: red } +.café { color: red } +.\1F680 { color: red } +.🚀 { color: red } +#\31 23 { color: red } +#id\:colon { color: red } +[data-x\:y] { color: red } +[data-x="a\"b"] { color: red } +svg|rect\:x { color: red } +.a\,b { color: red } +.a\>b { color: red } +.a\&b { color: red } +.\[\&_svg\]\:size-4 svg { color: red } +.\!font-bold { color: red } +.\32 xl\:grid-cols-\[repeat\(auto-fill\2c minmax\(200px\2c 1fr\)\)\] { color: red } +.group:hover .group-hover\:visible { color: red } +.peer:checked ~ .peer-checked\:block { color: red } +.data-\[state\=open\]\:bg-red[data-state="open"] { color: red } +.aria-\[checked\]\:bg-red[aria-checked] { color: red } +.supports-\[display\:grid\]\:grid { color: red } +.has-\[\>img\]\:p-0:has(> img) { color: red } diff --git a/parser/src/test-data/web/torture/css/strings.css b/parser/src/test-data/web/torture/css/strings.css new file mode 100644 index 00000000..a87c6197 --- /dev/null +++ b/parser/src/test-data/web/torture/css/strings.css @@ -0,0 +1,36 @@ +/* S01 strings and comments that contain CSS syntax */ +.s1 { content: "}" } +.s2 { content: "{" } +.s3 { content: ";" } +.s4 { content: "a; color: red" } +.s5 { content: "/* not a comment */" } +.s6 { content: "url(not.png)" } +.s7 { content: 'var(--not)' } +.s8 { content: "\"" } +.s9 { content: '\'' } +.s10 { content: "\201C \201D" } +.s11 { content: "line\ +continued" } +.s12 { content: "unterminated } +.s13 { color: red } +.s14 { content: "\"; } .injected { color: red }" } +.s15 { background: url("a)b.png") } +.s16 { background: url('a"b.png') } +.s17 { background: url(a\)b.png) } +.s18 { font-family: "Font; Name" } +.s19 { font-family: "Font } Name" } +.s20 { quotes: "«" "»" } +.s21 { content: attr(data-page) } +.s22 { content: counter(a) " / " counter(b) } +.s23 { grid-template-areas: "header header" "sidebar main" } +.s24 { content: "{{ not_a_template }}" } +.s25 { content: "<%= not_erb %>" } +.s26 { content: "${not_dollar}" } +.s27[data-x="}"] { color: red } +.s28[data-x=";"] { color: red } +.s29[data-x="url(x)"] { color: red } +.s30:not([data-x="{"]) { color: red } +/* .s31 { color: red } */ +/* a comment with url(img/bg.png) and var(--x) and .commented-class */ +/* unterminated comment at the end of the file +.s32 { color: red } diff --git a/parser/src/test-data/web/torture/css/theme.css b/parser/src/test-data/web/torture/css/theme.css new file mode 100644 index 00000000..f6f83199 --- /dev/null +++ b/parser/src/test-data/web/torture/css/theme.css @@ -0,0 +1,14 @@ +/* K02 the end of the import chain: app -> tokens -> theme; imports tokens back (cycle) */ +@import "tokens.css"; +.theme-light { --brand: #0af } +.theme-dark { --brand: #a0f } +@keyframes fade { from { opacity: 0 } to { opacity: 1 } } +@keyframes spin { from { transform: rotate(0) } 50% { opacity: .5 } to { transform: rotate(1turn) } } +@keyframes spin { to { transform: rotate(2turn) } } /* redefinition: the last one wins */ +@keyframes "quoted-name" { to { opacity: 0 } } +@keyframes ease { to { opacity: 0 } } /* a keyframes named like a keyword */ +@keyframes none { to { opacity: 0 } } /* invalid name, written anyway */ +@keyframes slide { 0%, 50% { left: 0 } 100% { left: 1px } 33.3% { left: 2px } from, to { left: 3px } } +@keyframes var-inside { to { color: var(--brand); background: url(img/bg.png) } } +@-webkit-keyframes spin { to { transform: rotate(0) } } +@-moz-keyframes spin { to { transform: rotate(0) } } diff --git a/parser/src/test-data/web/torture/css/tokens.css b/parser/src/test-data/web/torture/css/tokens.css new file mode 100644 index 00000000..7be1084f --- /dev/null +++ b/parser/src/test-data/web/torture/css/tokens.css @@ -0,0 +1,35 @@ +/* K01 custom properties: defined at :root, overridden per theme, registered, used in fallbacks and shorthands */ +@import "theme.css"; +:root { + --brand: #09f; + --gap: 8px; + --anchor: var(--brand); + --fallback-brand: pink; + --font: Inter, sans-serif; + --anim: spin; + --duration: 1s; + --easing: ease-in; + --pad: 1px; + --wide: 70rem; +} +:root.dark, .dark, [data-theme="dark"], html[data-theme=dark] body { --brand: #fff; } +@media (prefers-color-scheme: dark) { :root { --brand: #000 } } +@property --brand { syntax: ''; inherits: true; initial-value: red } +@property --registered-never-used { syntax: '*'; inherits: false } +.scoped { --gap: 4px; } +.scoped .child { gap: var(--gap); } +.uses-undefined { color: var(--undefined-anywhere); } +.uses-undefined-with-fallback { color: var(--undefined-anywhere, red); } +.defined-in-style-attr { padding: var(--local); } /* --local is defined only in a style="" attribute of index.html */ +.var-in-selector-arg:is(.x) { --in-is: 1 } +.var-name-cases { color: var(--BRAND); color: var( --brand ); color: var(--brand,); color: var(--brand , 1px 2px); color: var(--brand, url(img/bg.png)); color: var(--brand, var(--gap)) } +.var-not-a-var { color: var(brand); color: var(); color: var(--); } +.var-in-shorthand { margin: var(--gap) calc(var(--gap) * 2) 0 var(--gap, var(--pad)); } +.var-in-important { color: var(--brand) !important; } +.var-in-url { background: url(var(--url)); } /* not valid CSS, but written */ +.var-in-string { content: "var(--brand)"; } +.var-in-comment { /* color: var(--commented) */ color: red; } +.var-in-custom { --alias: var(--brand); --chain: var(--alias); color: var(--chain); } +.var-in-animation { animation: var(--anim) var(--duration); } +.var-in-font { font-family: var(--font); font: 12px var(--font); } +.var-in-container { container-name: var(--container-name); } diff --git a/parser/src/test-data/web/torture/css/upper.css b/parser/src/test-data/web/torture/css/upper.css new file mode 100644 index 00000000..f31c0364 --- /dev/null +++ b/parser/src/test-data/web/torture/css/upper.css @@ -0,0 +1 @@ +.upper-only { color: red } diff --git a/parser/src/test-data/web/torture/css/vendor.min.css b/parser/src/test-data/web/torture/css/vendor.min.css new file mode 100644 index 00000000..154c5ba5 --- /dev/null +++ b/parser/src/test-data/web/torture/css/vendor.min.css @@ -0,0 +1 @@ +.vendor{color:red}.card{color:rgb(200,200,200)} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/fonts/inter.woff b/parser/src/test-data/web/torture/fonts/inter.woff new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/fonts/inter.woff2 b/parser/src/test-data/web/torture/fonts/inter.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/forms.html b/parser/src/test-data/web/torture/forms.html new file mode 100644 index 00000000..b95f4594 --- /dev/null +++ b/parser/src/test-data/web/torture/forms.html @@ -0,0 +1,51 @@ + + + + Torture: id references + + + + + + + + +
    + + + + + + + + +
    nameage
    x
    +
    + + +
    pop
    + + + + legacy named anchor + to a name, not an id + to an id + case-different fragment + bare hash + implicit top + to the other page + to an uppercase id on the other page + cross-page fragment, dangling + cross-page fragment, page missing + + targets frame-b + targets a keyword +
    +
    +
    +
    +
    + + duplicate id with the first input + + diff --git a/parser/src/test-data/web/torture/img/bg.png b/parser/src/test-data/web/torture/img/bg.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/cursor.cur b/parser/src/test-data/web/torture/img/cursor.cur new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/favicon.ico b/parser/src/test-data/web/torture/img/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/hero.png b/parser/src/test-data/web/torture/img/hero.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/hero.webp b/parser/src/test-data/web/torture/img/hero.webp new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/logo.png b/parser/src/test-data/web/torture/img/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/logo@2x.png b/parser/src/test-data/web/torture/img/logo@2x.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/space name.png b/parser/src/test-data/web/torture/img/space name.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/sprite.svg b/parser/src/test-data/web/torture/img/sprite.svg new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/index.html b/parser/src/test-data/web/torture/index.html new file mode 100644 index 00000000..6dd32a5a --- /dev/null +++ b/parser/src/test-data/web/torture/index.html @@ -0,0 +1,208 @@ + + + + + Torture: the main page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    whitespace, tab entity, newline, duplicate token, padded id
    +
    valueless class attribute
    +
    empty class attribute
    +
    entity-bearing class tokens
    +
    case variants: CSS .upper must NOT match Upper
    +
    uppercase tag and attribute names
    +
    duplicate class attribute: first wins, second is a gap
    + + + + +
    +

    duplicate id on the page

    + +

    var in style attribute

    +

    custom property defined in a style attribute

    +

    urls in style attribute, relative to the page

    +

    root-relative url in style attribute

    +

    important and font shorthand

    +

    template in style attribute

    +

    broken declaration list, second declaration must survive

    +

    a rule where a declaration list belongs

    +

    empty style

    +

    keyframes, font, container refs in a style attribute

    +

    brace in a style attribute

    +

    uppercase property names

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "; trailingText(); + + + + comma inside a srcset url + + + + + + + + + + + + +
    cite
    + + + + + + +
    + a browser makes me a CHILD of the div above +

    a

    div inside a p closes the p
    trailing

    +
    • one
    • two
    + +
    no tbody written
    +
    c
    h
    body row after thead
    + outer nested anchor +
    t
    d
    t2
    +
    + + + +
    + + +

    entities:   & <tag> ' ' &unknown; ©

    +

    aria idrefs

    + +
    styled only by base.css, which this page does not load (its link is under the base)
    +
    + + + diff --git a/parser/src/test-data/web/torture/js/app.js b/parser/src/test-data/web/torture/js/app.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/lib.js b/parser/src/test-data/web/torture/js/lib.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/module.js b/parser/src/test-data/web/torture/js/module.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/vendor.js b/parser/src/test-data/web/torture/js/vendor.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/media/clip.mp4 b/parser/src/test-data/web/torture/media/clip.mp4 new file mode 100644 index 00000000..93db0574 --- /dev/null +++ b/parser/src/test-data/web/torture/media/clip.mp4 @@ -0,0 +1 @@ +a{color:red} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/media/clip.vtt b/parser/src/test-data/web/torture/media/clip.vtt new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/media/poster.png b/parser/src/test-data/web/torture/media/poster.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/public/css/dup.css b/parser/src/test-data/web/torture/public/css/dup.css new file mode 100644 index 00000000..9a7bd6df --- /dev/null +++ b/parser/src/test-data/web/torture/public/css/dup.css @@ -0,0 +1 @@ +body{margin:0} diff --git a/parser/src/test-data/web/torture/quirks.html b/parser/src/test-data/web/torture/quirks.html new file mode 100644 index 00000000..8b505279 --- /dev/null +++ b/parser/src/test-data/web/torture/quirks.html @@ -0,0 +1,265 @@ + + +Quirks + + + +
    +

    crlf

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    +

    +

    + + \ No newline at end of file diff --git a/parser/src/test-data/web/torture/shadow/shadow.html b/parser/src/test-data/web/torture/shadow/shadow.html new file mode 100644 index 00000000..72f1a5d9 --- /dev/null +++ b/parser/src/test-data/web/torture/shadow/shadow.html @@ -0,0 +1,70 @@ + + + + Torture: scoping boundaries + + + + +
    outside any shadow root
    + + + + + slotted from the light DOM + default slot + + + + + + + + + + + + + +
    zero levels in: no rule reaches me
    + + +
    + + + diff --git a/parser/src/test-data/web/torture/static/css/dup.css b/parser/src/test-data/web/torture/static/css/dup.css new file mode 100644 index 00000000..fb939513 --- /dev/null +++ b/parser/src/test-data/web/torture/static/css/dup.css @@ -0,0 +1 @@ +body{margin:1px} diff --git a/parser/src/test-data/web/torture/svg.html b/parser/src/test-data/web/torture/svg.html new file mode 100644 index 00000000..5cec2740 --- /dev/null +++ b/parser/src/test-data/web/torture/svg.html @@ -0,0 +1,43 @@ + + + + Torture: foreign content + + + + + + + + + + + + + + + svg anchor + + +
    html again: tag and attribute names lowercase here
    + +
    + svg title must not become the document title + + + +
    + x
    + + + diff --git a/parser/src/test-data/web/torture/templates/jinja.html b/parser/src/test-data/web/torture/templates/jinja.html new file mode 100644 index 00000000..08b80ea0 --- /dev/null +++ b/parser/src/test-data/web/torture/templates/jinja.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block head %} + + + +{% endblock %} +{% block body %} +
    + {% for item in items %} + {{ item.name }} + {% endfor %} + conditional class + +

    style with template value

    +

    custom property from the template

    + {# a jinja comment with
    #} + +
    + +{% endblock %} diff --git a/parser/src/test-data/web/torture/templates/mixed.html b/parser/src/test-data/web/torture/templates/mixed.html new file mode 100644 index 00000000..0d7eb298 --- /dev/null +++ b/parser/src/test-data/web/torture/templates/mixed.html @@ -0,0 +1,19 @@ + + + + + + +
    x
    +
    thymeleaf inline
    +
    angular
    +
    vue
    +
    alpine
    +
    htmx
    +
    livewire, liveview, hyperscript, knockout
    +
    <%= body %>
    +
    +
      @foreach (var i in Model.Items) {
    • @i.Name
    • }
    +

    ${name} and #{expr} and @{link} and {#block}

    + + diff --git a/parser/src/test-data/web/torture/templates/tailwind.html b/parser/src/test-data/web/torture/templates/tailwind.html new file mode 100644 index 00000000..6fe4638b --- /dev/null +++ b/parser/src/test-data/web/torture/templates/tailwind.html @@ -0,0 +1,13 @@ +
    + utility classes whose CSS selectors are escaped + stacked variants +
    + diff --git a/parser/src/test-data/web/torture/xhtml/page.xhtml b/parser/src/test-data/web/torture/xhtml/page.xhtml new file mode 100644 index 00000000..37a76c3d --- /dev/null +++ b/parser/src/test-data/web/torture/xhtml/page.xhtml @@ -0,0 +1,21 @@ + + + + + Torture: XHTML + + " } + //]]> + + + + +
    +
    +
    + a sibling in XML, a child in HTML + + + diff --git a/parser/src/test/web-tests.ts b/parser/src/test/web-tests.ts new file mode 100644 index 00000000..316994ff --- /dev/null +++ b/parser/src/test/web-tests.ts @@ -0,0 +1,1099 @@ +/** + * WEB (HTML + CSS) TESTS — one file, mirroring services-tests.ts. + * + * npx tsx src/test/web-tests.ts # everything + * npx tsx src/test/web-tests.ts --list # what runs, and what it proves + * npx tsx src/test/web-tests.ts --bless # rewrite the goldens + * + * NO BROWSER, NO NETWORK. Every check runs the parser and compares the result + * with expectations in this file or checked into src/test-data/web. + * + * ## What this suite can and cannot do + * + * The golden is drift detection: it was produced by this parser and agrees with + * whatever the parser currently does, mistakes included. + * + * The format checks are the part that can find a defect. Each is written from + * the HTML or CSS specification (the parsing algorithm, the Selectors specificity + * rules, the URL syntax) rather than from parser output, and each names the + * plausible wrong implementation it rules out — a reader that splits `class` on + * spaces only, a specificity that counts `:where()`, a resolver that follows + * `../..` out of the project. A check no plausible implementation fails proves + * nothing, so the `rules out` line is part of the check. + * + * The structural checks — primary-key uniqueness, foreign-key integrity across + * the sixteen relations, arity against the frozen schema, enum domains, and + * determinism across two runs — are the ones that catch what a fixture cannot + * foresee. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { CssStylesheet } from '@/analysis-types/css/CssStylesheet'; +import { WEB_CSV_FILES, WEB_PARSE_GAP_LIMIT } from '@/constants/web-constants'; +import { CssSourceProvenance, CssStylesheetSource } from '@/enums/css/CssStylesheetSource'; +import { CssParseGapKind } from '@/enums/css/CssParseGapKind'; +import { CssSelectorPartKind, CssCombinator } from '@/enums/css/CssSelectorPartKind'; +import { CssValueReferenceKind } from '@/enums/css/CssValueReferenceKind'; +import { HtmlAttributeKind } from '@/enums/html/HtmlAttributeKind'; +import { HtmlDocumentKind } from '@/enums/html/HtmlDocumentKind'; +import { HtmlHandlerSource } from '@/enums/html/HtmlHandlerSource'; +import { HtmlNamespace } from '@/enums/html/HtmlNamespace'; +import { HtmlParseGapKind } from '@/enums/html/HtmlParseGapKind'; +import { HtmlScriptKind, HtmlScriptType } from '@/enums/html/HtmlScriptKind'; +import { HtmlTemplateDialect } from '@/enums/html/HtmlTemplateDialect'; +import { WebUrlKind } from '@/enums/web/WebUrlKind'; +import * as CssEnums from '@/enums/css'; +import * as HtmlEnums from '@/enums/html'; +import * as WebEnums from '@/enums/web'; +import { CssExtraction, CssParser } from '@/parsers/css/css-parser'; +import { HtmlExtraction, HtmlParser } from '@/parsers/html/html-parser'; +import { WebProjectAnalyzer } from '@/workflows/web/web-project-analyzer'; + +const DATA = 'src/test-data/web'; +const GOLDEN = path.join(DATA, '_golden'); +const SCHEMA = JSON.parse(fs.readFileSync('src/schema/web/schema.json', 'utf-8')) as { + relations: Record; commaSets?: string[] }>; +}; + +/** The values a cell holds: one, or every member of a comma-set column. */ +function cellValues(relation: string, column: string, cell: string): string[] { + if (cell === '') return []; + return (SCHEMA.relations[relation]?.commaSets ?? []).includes(column) ? cell.split(',') : [cell]; +} +const BLESS = process.argv.includes('--bless'); + +/** Columns that cannot be compared across machines: absolute paths. */ +const VOLATILE = /^(baseMservPath|filePath|resolvedFilePath)$/; +const HASH_COLUMN = /(Hash|hash)$/; + +type Row = Record; + +const fail = (m: string): number => { console.log(' ✗ ' + m); return 1; }; + +// --------------------------------------------------------------------------- +// format checks — from the specifications, in memory, no fixture tree +// --------------------------------------------------------------------------- + +/** A throwaway directory for checks that need real files to resolve against. */ +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'axiom-web-')); +function file(rel: string, content = ''): string { + const p = path.join(TMP, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + return p; +} + +function parseHtml(content: string, rel = 'site/page.html'): HtmlExtraction { + const p = file(rel, content); + return new HtmlParser().parse(content, p, path.join(TMP, 'site'), 'WEB_FIXTURE_VERSION'); +} + +function parseCss(content: string, rel = 'site/css/x.css', origin = { line: 1, column: 1 }): CssExtraction & { sheet: CssStylesheet } { + const p = file(rel, content); + const sheet = new CssStylesheet({ + name: 'x', fileName: path.basename(p), filePath: p, baseMservPath: path.join(TMP, 'site'), relativePath: rel, + sourceKind: CssStylesheetSource.FILE, sourceProvenance: CssSourceProvenance.PROJECT, ownerHtmlElementLinkHash: '', + htmlDocumentLinkHash: '', startLine: 1, startColumn: 1, endLine: 1, serviceVersionLinkHash: 'WEB_FIXTURE_VERSION', + }); + const x = new CssParser().parseStylesheet(content, { + stylesheet: sheet, line: origin.line, column: origin.column, filePath: p, projectRoot: path.join(TMP, 'site'), + serviceVersionLinkHash: 'WEB_FIXTURE_VERSION', + }); + return { ...x, sheet }; +} + +/** (a,b,c) of the first selector of the first rule. */ +function specificity(selector: string): string { + const s = parseCss(`${selector} { color: red }`).selectors[0]!; + return `${s.specificityA},${s.specificityB},${s.specificityC}`; +} + +function col(text: string, needle: string, occurrence = 1): { line: number; column: number } { + let from = 0; + let at = -1; + for (let i = 0; i < occurrence; i += 1) { + at = text.indexOf(needle, from); + from = at + 1; + } + const before = text.slice(0, at); + const line = before.split('\n').length; + const column = at - (before.lastIndexOf('\n') + 1) + 1; + return { line, column }; +} + +interface FormatCheck { + name: string; + /** The clause of the specification this check comes from. */ + spec: string; + /** The plausible wrong implementation this check fails. */ + rulesOut: string; + run: () => number; +} + +const formatChecks: FormatCheck[] = [ + // ── HTML: the tree ────────────────────────────────────────────────────── + { + name: 'a-fragment-is-the-elements-written-and-nothing-implied', + spec: 'the grammar builds the elements written; the html, head and body a browser implies are not in the source', + rulesOut: 'a reader that invents wrapper rows, or that calls a snippet a whole page', + run: () => { + const x = parseHtml('

    only a paragraph

    '); + let bad = 0; + const tags = x.elements.map((e) => e.tagName).join(' '); + if (tags !== 'p') bad += fail(`elements=${tags}, expected p alone`); + if (x.document.documentKind !== HtmlDocumentKind.FRAGMENT) bad += fail(`documentKind=${x.document.documentKind}`); + const p = x.elements[0]!; + if (p.parentElementLinkHash !== '' || p.path !== '/p[1]' || p.depth !== 0) bad += fail(`a top-level element reads as ${p.path} at depth ${p.depth} with parent ${JSON.stringify(p.parentElementLinkHash)}`); + const whole = parseHtml('

    x

    '); + if (whole.document.documentKind !== HtmlDocumentKind.DOCUMENT || whole.document.doctype !== 'html') { + bad += fail(`a full document reads as ${whole.document.documentKind} with doctype ${JSON.stringify(whole.document.doctype)}`); + } + if (whole.elements.map((e) => e.path).join(' ') !== '/html[1] /html[1]/body[1] /html[1]/body[1]/p[1]') bad += fail(`paths=${whole.elements.map((e) => e.path).join(' ')}`); + const noDoctype = parseHtml(''); + if (noDoctype.document.documentKind !== HtmlDocumentKind.DOCUMENT) bad += fail('a page with and no doctype reads as a fragment'); + return bad; + }, + }, + { + name: 'a-paragraph-is-closed-by-the-next-one', + spec: 'a p element\'s end tag may be omitted if it is immediately followed by another p', + rulesOut: 'a reader that nests the second p inside the first', + run: () => { + const text = '
    \n

    one\n

    two\n

    '; + const x = parseHtml(text); + const ps = x.elements.filter((e) => e.tagName === 'p'); + let bad = 0; + if (ps.length !== 2) return fail(`${ps.length} p elements, expected 2`); + if (ps[0]!.path !== '/div[1]/p[1]' || ps[1]!.path !== '/div[1]/p[2]') { + bad += fail(`paths ${ps.map((p) => p.path).join(' ')}: the second p is a sibling, not a child`); + } + if (ps[0]!.endLine !== 3) bad += fail(`first p ends on line ${ps[0]!.endLine}, expected 3 where the second opens`); + if (ps[0]!.textContent !== 'one' || ps[1]!.textContent !== 'two') bad += fail('text content crossed the implicit close'); + if (ps[0]!.position !== 0 || ps[1]!.position !== 1) bad += fail('positions are not the sibling ordinals'); + return bad; + }, + }, + { + name: 'template-content-is-walked', + spec: 'a template element\'s contents are a DocumentFragment, not children of the element', + rulesOut: 'a walker that reads childNodes and finds the template empty', + run: () => { + const x = parseHtml(''); + const span = x.elements.find((e) => e.tagName === 'span'); + if (span === undefined) return fail('no span inside the template'); + let bad = 0; + if (span.path !== '/template[1]/span[1]') bad += fail(`path=${span.path}`); + if (!x.classReferences.some((c) => c.className === 'tpl')) bad += fail('the class inside the template was not read'); + return bad; + }, + }, + { + name: 'a-void-element-has-no-end-tag-and-no-children', + spec: 'void elements have no end tag and never have contents', + rulesOut: 'a reader that swallows the following text into the img', + run: () => { + const x = parseHtml('

    after

    '); + const img = x.elements.find((e) => e.tagName === 'img')!; + const p = x.elements.find((e) => e.tagName === 'p')!; + let bad = 0; + if (!img.isVoid) bad += fail('img is not void'); + const expectedEnd = '

    '.length + 1; + if (img.endColumn !== expectedEnd) bad += fail(`img ends at column ${img.endColumn}, expected ${expectedEnd} (just past its start tag)`); + if (p.textContent !== 'after') bad += fail(`p text=${JSON.stringify(p.textContent)}`); + return bad; + }, + }, + { + name: 'foreign-content-keeps-its-namespace-and-prefixed-attributes', + spec: 'svg and MathML elements are in their own namespaces; xlink:href is a prefixed attribute', + rulesOut: 'a reader that lowercases viewBox or loses the xlink prefix', + run: () => { + const x = parseHtml(''); + const svg = x.elements.find((e) => e.tagName === 'svg')!; + let bad = 0; + if (svg.namespace !== HtmlNamespace.SVG) bad += fail(`svg namespace=${svg.namespace}`); + if (!x.attributes.some((a) => a.name === 'viewBox')) bad += fail('viewBox lost its case'); + const xlink = x.attributes.find((a) => a.prefix === 'xlink'); + if (xlink === undefined || xlink.name !== 'href' || xlink.attributeKind !== HtmlAttributeKind.URL) { + bad += fail('xlink:href is not a prefixed URL attribute'); + } + const ref = x.references.find((r) => r.attributeName === 'xlink:href'); + if (ref === undefined || ref.urlKind !== WebUrlKind.FRAGMENT || ref.fragment !== 'a') bad += fail('xlink:href="#a" is not a FRAGMENT reference to a'); + return bad; + }, + }, + // ── HTML: attributes ──────────────────────────────────────────────────── + { + name: 'a-value-less-attribute-is-distinguished-from-an-empty-one', + spec: 'an attribute without a value has the empty string as its value', + rulesOut: 'a reader that records both as value="" and cannot tell disabled from disabled=""', + run: () => { + const x = parseHtml(''); + const by = Object.fromEntries(x.attributes.map((a) => [a.name, a])); + let bad = 0; + if (by['disabled']?.hasValue !== false) bad += fail('disabled reports a value'); + if (by['required']?.hasValue !== true) bad += fail('required="" reports no value'); + if (by['value']?.hasValue !== true) bad += fail("value='' reports no value"); + if (by['data-x']?.value !== 'y' || by['data-x']?.attributeKind !== HtmlAttributeKind.DATA) bad += fail('data-x = "y" with spaces around = was misread'); + return bad; + }, + }, + { + name: 'a-duplicate-attribute-keeps-the-first-and-records-a-gap', + spec: 'the first attribute with a name wins; a repeat is a parse error', + rulesOut: 'a reader that emits two rows for one attribute name and collides their keys (the grammar reports no duplicate)', + run: () => { + const x = parseHtml('

    '); + const ids = x.attributes.filter((a) => a.name === 'id'); + let bad = 0; + if (ids.length !== 1 || ids[0]!.value !== 'a') bad += fail(`id rows=${ids.map((a) => a.value).join(',')}, expected the first, a`); + if (!x.parseGaps.some((g) => g.gapKind === HtmlParseGapKind.PARSE_ERROR && g.detail === 'duplicate-attribute')) { + bad += fail('no duplicate-attribute gap'); + } + if (x.elements.find((e) => e.tagName === 'div')!.id !== 'a') bad += fail('element id is not the first'); + return bad; + }, + }, + { + name: 'class-tokens-split-on-ascii-whitespace-and-keep-repeats', + spec: 'the class attribute is a set of space-separated tokens; the tokenizer splits on space, tab, LF, FF, CR', + rulesOut: 'a reader that splits on a single space or deduplicates', + run: () => { + const x = parseHtml('
    '); + const got = x.classReferences.map((c) => `${c.position}:${c.className}`).join(' '); + let bad = 0; + if (got !== '0:a 1:b 2:c 3:a') bad += fail(`tokens=${got}`); + if (new Set(x.classReferences.map((c) => c.getHash())).size !== 4) bad += fail('two occurrences of one class share a key'); + const div = x.elements.find((e) => e.tagName === 'div')!; + if (div.toCsv().split('\t')[5] !== 'a,b,c,a') bad += fail(`classNames column=${div.toCsv().split('\t')[5]}`); + return bad; + }, + }, + { + name: 'attribute-kinds-follow-the-name', + spec: 'on* attributes are event handler content attributes; data-* and aria-* are reserved prefixes', + rulesOut: 'a classifier that calls a Vue @click or an Alpine x-on an event handler', + run: () => { + const x = parseHtml(''); + const by = Object.fromEntries(x.attributes.map((a) => [a.name, a.attributeKind])); + const want: Record = { + onclick: HtmlAttributeKind.EVENT_HANDLER, '@click': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'v-if': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + 'x-data': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'hx-get': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'th:text': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + 'data-q': HtmlAttributeKind.DATA, 'aria-hidden': HtmlAttributeKind.ARIA, role: HtmlAttributeKind.ARIA, for: HtmlAttributeKind.FOR, + form: HtmlAttributeKind.ID_REFERENCE, rel: HtmlAttributeKind.REL, type: HtmlAttributeKind.TYPE, name: HtmlAttributeKind.NAME, + style: HtmlAttributeKind.STYLE, 'ng-if': HtmlAttributeKind.TEMPLATE_DIRECTIVE, '[prop]': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + '(ev)': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + }; + let bad = 0; + for (const [name, kind] of Object.entries(want)) { + if (by[name] !== kind) bad += fail(`${name}: ${by[name]}, expected ${kind}`); + } + // `@click="b"` is a template event: Vue calls the handler it names, and the row says so by its source. + const b = x.handlerCalls.find((h) => h.calleeName === 'b'); + if (b === undefined || b.handlerSource !== HtmlHandlerSource.TEMPLATE_EVENT || b.argumentCount !== 0) bad += fail('a template event directive is not a TEMPLATE_EVENT handler call'); + if (x.handlerCalls.some((h) => h.handlerSource === HtmlHandlerSource.EVENT_ATTRIBUTE && h.calleeName !== 'a')) bad += fail('a template directive was read as an on* handler'); + const dialects = [...x.document.templateDialects].sort().join(','); + if (!dialects.includes('ALPINE') || !dialects.includes('HTMX') || !dialects.includes('THYMELEAF') || !dialects.includes('VUE') || !dialects.includes('ANGULAR')) { + bad += fail(`dialects=${dialects}`); + } + return bad; + }, + }, + // ── HTML: URLs ────────────────────────────────────────────────────────── + { + name: 'url-kinds-are-decided-from-the-text', + spec: 'URL parsing: scheme, host-relative, path-absolute, path-relative and fragment-only forms', + rulesOut: 'a classifier that treats mailto: as a relative path or #x as a file', + run: () => { + const cases: Array<[string, WebUrlKind, string?]> = [ + ['a/b.css', WebUrlKind.RELATIVE], ['./a.js', WebUrlKind.RELATIVE], ['../x', WebUrlKind.RELATIVE], + ['/static/a.js', WebUrlKind.ROOT_RELATIVE], ['https://h/x.js', WebUrlKind.ABSOLUTE], ['HTTP://h/x', WebUrlKind.ABSOLUTE], + ['//cdn/x.js', WebUrlKind.PROTOCOL_RELATIVE], ['#team', WebUrlKind.FRAGMENT, 'team'], ['data:image/png;base64,AAA', WebUrlKind.DATA_URI], + ['javascript:go()', WebUrlKind.JAVASCRIPT_URI], ['mailto:a@b', WebUrlKind.OTHER_SCHEME], ['tel:123', WebUrlKind.OTHER_SCHEME], + ['{{ url_for("x") }}', WebUrlKind.TEMPLATE_EXPRESSION], ['/static/{{ name }}.js', WebUrlKind.TEMPLATE_EXPRESSION], + ['<%= path %>', WebUrlKind.TEMPLATE_EXPRESSION], ['${ctx}/x', WebUrlKind.TEMPLATE_EXPRESSION], ['', WebUrlKind.EMPTY], + [' ', WebUrlKind.EMPTY], + ]; + let bad = 0; + for (const [url, kind, fragment] of cases) { + const x = parseHtml(`x`); + const r = x.references[0]; + if (r === undefined) { bad += fail(`${JSON.stringify(url)}: no reference`); continue; } + if (r.urlKind !== kind) bad += fail(`${JSON.stringify(url)}: ${r.urlKind}, expected ${kind}`); + if (fragment !== undefined && r.fragment !== fragment) bad += fail(`${JSON.stringify(url)}: fragment=${r.fragment}`); + } + const split = parseHtml('x').references[0]!; + if (split.path !== 'page.html' || split.query !== 'x=1&y=2' || split.fragment !== 'top') { + bad += fail(`split: path=${split.path} query=${split.query} fragment=${split.fragment}`); + } + return bad; + }, + }, + { + name: 'a-relative-url-resolves-against-the-file-and-never-outside-the-root', + spec: 'a relative reference resolves against the base URL, which defaults to the document\'s', + rulesOut: 'a resolver that follows ../.. out of the project, or resolves to a file that is not there', + run: () => { + file('site/css/present.css', 'a{}'); + file('outside.css', 'b{}'); + const x = parseHtml('', 'site/page.html'); + const by = Object.fromEntries(x.references.map((r) => [r.urlAsWritten, r])); + let bad = 0; + if (by['css/present.css']?.resolvedFilePath !== path.join(TMP, 'site/css/present.css') || !by['css/present.css']?.isResolved) { + bad += fail('a present file was not resolved'); + } + if (by['css/absent.css']?.isResolved !== false) bad += fail('an absent file reads as resolved'); + if (by['../outside.css']?.isResolved !== false) bad += fail('a file outside the project root was resolved'); + if (x.document.toCsv().split('\t')[14] !== '3') bad += fail(`stylesheetReferenceCount=${x.document.toCsv().split('\t')[14]}, expected 3`); + return bad; + }, + }, + { + name: 'a-root-relative-url-resolves-only-when-one-served-root-holds-it', + spec: 'a path-absolute URL resolves against the origin, which the repository does not record', + rulesOut: 'a resolver that guesses between two candidate roots, or never resolves /static/x', + run: () => { + file('app/public/static/one.js', ''); + file('app/pages/deep/page.html', ''); + file('app/public/static/two.js', ''); + file('app/pages/static/two.js', ''); + const content = ''; + const p = file('app/pages/deep/page.html', content); + const x = new HtmlParser().parse(content, p, path.join(TMP, 'app'), 'V'); + const by = Object.fromEntries(x.references.map((r) => [r.urlAsWritten, r])); + let bad = 0; + if (by['/static/one.js']?.resolvedFilePath !== path.join(TMP, 'app/public/static/one.js')) { + bad += fail(`one candidate: resolved to ${JSON.stringify(by['/static/one.js']?.resolvedFilePath)}`); + } + if (by['/static/two.js']?.isResolved !== false) bad += fail('two candidates were not refused'); + if (by['/static/none.js']?.isResolved !== false) bad += fail('no candidate reads as resolved'); + const script = x.scripts.find((s) => s.src === '/static/one.js')!; + if (script.resolvedFilePath !== by['/static/one.js']!.resolvedFilePath || script.referenceLinkHash !== by['/static/one.js']!.getHash()) { + bad += fail('the script row does not carry its reference\'s resolution'); + } + return bad; + }, + }, + { + name: 'reference-kinds-follow-the-element', + spec: 'link rel=stylesheet is a stylesheet, source in picture is an image, elsewhere media', + rulesOut: 'a classifier that keys on the attribute name alone', + run: () => { + const x = parseHtml( + '' + + '' + + '
    ' + + '' + + '' + ); + const got = x.references.map((r) => `${r.referenceKind}:${r.urlAsWritten}`).sort().join(' '); + const want = [ + 'STYLESHEET:a.css', 'LINK_RESOURCE:i.png', 'STYLESHEET:b.css', 'IMAGE:p.webp', 'IMAGE:p.png', 'MEDIA:v.mp4', 'MEDIA:v.png', + 'MEDIA:v.webm', 'IMAGE:btn.png', 'OTHER:x', 'FORM_ACTION:/f', 'FORM_ACTION:/g', 'FRAME:f.html', 'FRAME:o.swf', 'BASE:/', + 'ANCHOR:l', 'ANCHOR:m', 'META_REFRESH:next.html', + ].sort().join(' '); + return got === want ? 0 : fail(`got ${got}\n want ${want}`); + }, + }, + { + name: 'srcset-candidates-are-separate-references', + spec: 'a srcset is a comma-separated list of candidates, each a URL and an optional descriptor', + rulesOut: 'a reader that records the whole attribute as one URL', + run: () => { + const x = parseHtml(''); + const got = x.references.map((r) => `${r.position}:${r.urlAsWritten}`).join(' '); + return got === '0:a.png 1:b.png 2:c.png 0:d.png' ? 0 : fail(`got ${got}`); + }, + }, + // ── HTML: handlers and scripts ────────────────────────────────────────── + { + name: 'every-call-in-a-handler-is-a-row-at-its-column', + spec: 'an event handler content attribute\'s value is a FunctionBody', + rulesOut: 'a reader that keeps the first call only, or cites the attribute instead of the call', + run: () => { + const text = '

    \n x'; + const x = parseHtml(text); + const got = x.handlerCalls.map((h) => `${h.handlerSource === 'JAVASCRIPT_URL' ? 'url' : h.eventName}:${h.calleeText}/${h.calleeName}/${h.receiverText}/${h.argumentCount}${h.isNew ? '/new' : ''}`).join(' '); + const want = 'url:toggle/toggle//0 click:track/track//2 click:confirm/confirm//1 click:app.ui.remove/remove/app.ui/1 click:Audio/Audio//1/new'; + let bad = 0; + if (got !== want) bad += fail(`got ${got}\n want ${want}`); + const remove = x.handlerCalls.find((h) => h.calleeName === 'remove')!; + const at = col(text, 'app.ui.remove'); + if (remove.startLine !== at.line || remove.startColumn !== at.column) { + bad += fail(`remove cited at ${remove.startLine}:${remove.startColumn}, written at ${at.line}:${at.column}`); + } + const toggle = x.handlerCalls.find((h) => h.calleeName === 'toggle')!; + const tat = col(text, 'toggle()'); + if (toggle.startLine !== tat.line || toggle.startColumn !== tat.column) bad += fail(`toggle cited at ${toggle.startLine}:${toggle.startColumn}, written at ${tat.line}:${tat.column}`); + if (x.parseGaps.length !== 0) bad += fail(`gaps on well-formed handlers: ${x.parseGaps.map((g) => g.detail).join('; ')}`); + return bad; + }, + }, + { + name: 'a-handler-that-is-not-javascript-is-a-gap-not-a-call', + spec: 'a template placeholder in a handler is not a FunctionBody until rendered', + rulesOut: 'a reader that silently drops the attribute, or invents a call named handler', + run: () => { + const x = parseHtml(''); + let bad = 0; + const gaps = x.parseGaps.filter((g) => g.gapKind === HtmlParseGapKind.HANDLER_SYNTAX); + if (gaps.length !== 2) bad += fail(`${gaps.length} handler gaps, expected 2: ${x.parseGaps.map((g) => g.detail).join('; ')}`); + if (x.handlerCalls.some((h) => h.calleeName === 'handler')) bad += fail('a placeholder became a call'); + if (!x.handlerCalls.some((h) => h.calleeName === 'save')) bad += fail('the call the recovered tree still holds was dropped'); + return bad; + }, + }, + { + name: 'script-type-decides-what-the-browser-does-with-the-body', + spec: 'the type attribute: absent or a JavaScript MIME type runs; module; importmap; anything else is a data block', + rulesOut: 'a reader that runs every script or none', + run: () => { + const types: Array<[string, HtmlScriptType]> = [ + ['', HtmlScriptType.CLASSIC], ['text/javascript', HtmlScriptType.CLASSIC], ['application/javascript', HtmlScriptType.CLASSIC], + ['module', HtmlScriptType.MODULE], ['importmap', HtmlScriptType.IMPORTMAP], ['speculationrules', HtmlScriptType.SPECULATION_RULES], + ['application/json', HtmlScriptType.JSON], ['application/ld+json', HtmlScriptType.JSON], ['text/x-template', HtmlScriptType.TEMPLATE], + ['text/x-handlebars-template', HtmlScriptType.TEMPLATE], ['text/babel', HtmlScriptType.TRANSPILED], ['text/x-mathjax-config', HtmlScriptType.DATA_BLOCK], + ]; + let bad = 0; + for (const [type, want] of types) { + const x = parseHtml(`1`); + if (x.scripts[0]?.scriptType !== want) bad += fail(`type=${JSON.stringify(type)}: ${x.scripts[0]?.scriptType}, expected ${want}`); + } + return bad; + }, + }, + { + name: 'an-inline-script-body-is-located-exactly', + spec: 'script content is raw text between the start tag and the end tag', + rulesOut: 'a reader that trims the body or counts from the start tag', + run: () => { + const text = '\n\n \n'; + const x = parseHtml(text); + const [inline, external] = x.scripts; + let bad = 0; + if (inline === undefined || external === undefined) return fail(`${x.scripts.length} scripts`); + if (inline.scriptKind !== HtmlScriptKind.INLINE || !inline.isDefer) bad += fail('inline/defer misread'); + const bodyStart = text.indexOf(''); + const s = col(text, text.slice(bodyStart, bodyEnd)); + if (inline.bodyStartLine !== s.line || inline.bodyStartColumn !== s.column) bad += fail(`body starts ${inline.bodyStartLine}:${inline.bodyStartColumn}, expected ${s.line}:${s.column}`); + if (inline.bodyLength !== bodyEnd - bodyStart) bad += fail(`bodyLength=${inline.bodyLength}, expected ${bodyEnd - bodyStart}`); + if (inline.bodyEndLine !== 5 || inline.bodyEndColumn !== 3) bad += fail(`body ends ${inline.bodyEndLine}:${inline.bodyEndColumn}, expected 5:3`); + if (external.scriptKind !== HtmlScriptKind.EXTERNAL || external.bodyLength !== 0) bad += fail('an external script\'s body was read'); + if (x.document.toCsv().split('\t')[12] !== '2' || x.document.toCsv().split('\t')[13] !== '1') bad += fail('script counts are wrong'); + return bad; + }, + }, + // ── HTML: inline CSS ──────────────────────────────────────────────────── + { + name: 'a-style-element-is-a-stylesheet-at-the-page-s-lines', + spec: 'a style element\'s content is a CSS stylesheet', + rulesOut: 'a reader that cites line 1 of the CSS for a rule on line 4 of the page', + run: () => { + const text = '\n \n'; + const x = parseHtml(text); + let bad = 0; + if (x.stylesheets.length !== 1) return fail(`${x.stylesheets.length} stylesheets`); + const sheet = x.stylesheets[0]!; + const style = x.elements.find((e) => e.tagName === 'style')!; + if (sheet.sourceKind !== CssStylesheetSource.HTML_STYLE_ELEMENT || sheet.ownerHtmlElementLinkHash !== style.getHash()) bad += fail('the sheet does not chain to its element'); + if (sheet.startLine !== 2 || sheet.startColumn !== 10 || sheet.endLine !== 5) bad += fail(`sheet spans ${sheet.startLine}:${sheet.startColumn}-${sheet.endLine}`); + const b = x.css.rules.find((r) => r.preludeText === '.b')!; + if (b.startLine !== 4 || b.startColumn !== 5) bad += fail(`.b at ${b.startLine}:${b.startColumn}, expected 4:5`); + const gap = x.css.declarations.find((d) => d.property === 'gap')!; + if (gap.startLine !== 4 || gap.startColumn !== 10 || gap.stylesheetLinkHash !== sheet.getHash()) bad += fail(`gap at ${gap.startLine}:${gap.startColumn}`); + if (!x.css.valueReferences.some((v) => v.name === '--g' && v.startLine === 4)) bad += fail('var(--g) not read from the inline sheet'); + if (sheet.getRuleCount() !== 2 || sheet.getDeclarationCount() !== 2) bad += fail('counts not back-patched'); + const other = parseHtml(''); + if (other.stylesheets.length !== 0) bad += fail('a non-CSS style type was parsed as CSS'); + return bad; + }, + }, + { + name: 'a-style-attribute-is-a-declaration-list-owned-by-the-attribute', + spec: 'the style attribute\'s value is a ', + rulesOut: 'a reader that needs a rule to own a declaration, or drops the good half of a bad list', + run: () => { + const text = '

    x
    '; + const x = parseHtml(text); + const div = x.attributes.find((a) => a.name === 'style' && a.value.startsWith('color: red'))!; + const mine = x.css.declarations.filter((d) => d.htmlAttributeLinkHash === div.getHash()); + let bad = 0; + if (mine.map((d) => `${d.property}=${d.valueText}`).join(' ') !== 'color=red margin=0') bad += fail(`declarations=${mine.map((d) => d.property).join(',')}`); + if (mine.some((d) => d.ruleLinkHash !== '' || d.stylesheetLinkHash !== '')) bad += fail('a style attribute declaration claims a rule or a sheet'); + const at = col(text, 'margin'); + if (mine[1]!.startLine !== at.line || mine[1]!.startColumn !== at.column) bad += fail(`margin at ${mine[1]!.startLine}:${mine[1]!.startColumn}, written at ${at.line}:${at.column}`); + if (!x.css.declarations.some((d) => d.property === 'top')) bad += fail('the readable declaration after a bad one was dropped'); + if (!x.parseGaps.some((g) => g.gapKind === HtmlParseGapKind.STYLE_ATTRIBUTE_SYNTAX)) bad += fail('no STYLE_ATTRIBUTE_SYNTAX gap'); + return bad; + }, + }, + { + name: 'template-dialects-are-read-from-their-markers', + spec: 'the file is a template of the family whose delimiters it uses', + rulesOut: 'a detector keyed on the file extension, which is .html for all of them', + run: () => { + const cases: Array<[string, HtmlTemplateDialect[]]> = [ + ['

    {{ a }}

    ', [HtmlTemplateDialect.MUSTACHE]], + ['{% if a %}

    {{ a }}

    {% endif %}', [HtmlTemplateDialect.JINJA, HtmlTemplateDialect.MUSTACHE]], + ['{{#each items}}
  • {{this}}
  • {{/each}}', [HtmlTemplateDialect.HANDLEBARS, HtmlTemplateDialect.MUSTACHE]], + ['<% if (u) { %>

    <%= u %>

    <% } %>', [HtmlTemplateDialect.ERB]], + ['', [HtmlTemplateDialect.PHP]], + ['@model Foo\n

    @Model.Name

    ', [HtmlTemplateDialect.RAZOR]], + ['

    ${name}

    ', [HtmlTemplateDialect.DOLLAR_BRACE]], + ['

    x

    ', [HtmlTemplateDialect.DOLLAR_BRACE, HtmlTemplateDialect.THYMELEAF]], + ['

    plain

    ', []], + ]; + let bad = 0; + for (const [text, want] of cases) { + const got = [...parseHtml(text).document.templateDialects].sort().join(','); + if (got !== [...want].sort().join(',')) bad += fail(`${JSON.stringify(text)}: ${got || '(none)'}, expected ${want.join(',') || '(none)'}`); + } + return bad; + }, + }, + { + name: 'gaps-are-capped-with-a-count-of-the-rest', + spec: 'a parse error per attribute on a file with thousands of them is a count, not a table', + rulesOut: 'an extractor that emits ten thousand gap rows, or drops them with no trace', + run: () => { + const attrs = Array.from({ length: WEB_PARSE_GAP_LIMIT + 50 }, (_, i) => `a${i}="1" a${i}="2"`).join(' '); + const x = parseHtml(`
    `); + const limit = x.parseGaps.find((g) => g.gapKind === HtmlParseGapKind.GAP_LIMIT_REACHED); + let bad = 0; + if (x.parseGaps.length !== WEB_PARSE_GAP_LIMIT + 1) bad += fail(`${x.parseGaps.length} gaps, expected ${WEB_PARSE_GAP_LIMIT} + 1`); + if (limit === undefined || !limit.detail.startsWith('50 ')) bad += fail(`limit row=${limit?.detail}`); + if (x.document.getParseGapCount() !== x.parseGaps.length) bad += fail('document gap count disagrees'); + return bad; + }, + }, + // ── CSS: selectors ────────────────────────────────────────────────────── + { + name: 'specificity-follows-selectors-level-4', + spec: 'count ids (a); classes, attributes and pseudo-classes (b); types and pseudo-elements (c); :where() adds nothing; :is()/:not()/:has() add their most specific argument', + rulesOut: 'a counter that counts :where(), ignores :not()\'s argument, or counts *', + run: () => { + const cases: Array<[string, string]> = [ + ['#a .b c::before', '1,1,2'], ['*', '0,0,0'], ['ul li', '0,0,2'], [':root', '0,1,0'], + [':is(#x, .y) p', '1,0,1'], [':where(.x) p', '0,0,1'], ['div:not(.a .b)', '0,2,1'], [':has(> img)', '0,0,1'], + ['li:nth-child(2n of .x)', '0,2,1'], ['a:hover', '0,1,1'], ['a:before', '0,0,2'], ['input[type="text"]', '0,1,1'], + [':host(.a)', '0,2,0'], ['.a.b.c', '0,3,0'], ['svg|rect', '0,0,1'], ['&:hover', '0,1,0'], + ]; + let bad = 0; + for (const [selector, want] of cases) { + const got = specificity(selector); + if (got !== want) bad += fail(`${selector}: ${got}, expected ${want}`); + } + return bad; + }, + }, + { + name: 'selector-parts-keep-compounds-combinators-and-nesting', + spec: 'a complex selector is compounds separated by combinators; a functional pseudo-class takes selectors as arguments', + rulesOut: 'a flattener that loses which compound a class is in, or whether it was negated', + run: () => { + const x = parseCss('.nav > li.item:not(.hidden) + a[href^="http" i] ~ b::before, #x { color: red }'); + const got = x.selectorParts.map((p) => `${p.position}:${p.partKind}:${p.name}${p.value ? '=' + p.value : ''}${p.attributeMatcher}${p.attributeFlags}@${p.compoundIndex}${p.combinatorBefore === CssCombinator.NONE ? '' : '<' + p.combinatorBefore}${p.depth ? '^' + p.depth : ''}`).join(' '); + const want = '0:CLASS:nav@0 1:TYPE:li@1