From 02045fba9b23cbd1a7070bd5e0d7765e6fbb3926 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:23:45 -0700 Subject: [PATCH 01/19] csharp: impact on an interface method finds its callers and implementations (#1362) A call through an interface- or abstract-typed receiver fanned to the implementations only, so the declaration it binds to had no caller. And nothing joined an implementation back to that declaration: type_ancestors was keyed on the partial-type group, not on types.id, and the override pairs were never exported. - call_chain_edge names the bodiless declaration a fanned call binds to, with the site's own tier; the resolved set the oracle scores and the tier count are unchanged - type_ancestor_type exports ancestors keyed on the declaration - override_pair exports overrides and interface implementations, to the overrides table and as the nominal basis of dispatch_candidates Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 5 ++-- graph/bundle/languages.ts | 5 +++- graph/bundle/schema.ts | 5 ++-- .../engine/call-edge-generation/call_chain.dl | 25 +++++++++++++++++ .../engine/resolution/type-hierarchy.dl | 9 ++++++ .../engine/resolution/virtual-dispatch.dl | 11 ++++++++ graph/csharp/souffle/decls_all.dl | 4 +++ graph/csharp/souffle/export_manifest.tsv | 2 ++ .../csharp/tools/overload-by-argument-test.sh | 4 +-- .../interface-and-abstract-members/case.json | 14 ++++++++++ .../src/AreaService.cs | 14 ++++++++++ .../src/Direct.cs | 7 +++++ .../src/Shapes.cs | 28 +++++++++++++++++++ .../src/Unrelated.cs | 7 +++++ 14 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 tests/cases/csharp/interface-and-abstract-members/case.json create mode 100644 tests/cases/csharp/interface-and-abstract-members/src/AreaService.cs create mode 100644 tests/cases/csharp/interface-and-abstract-members/src/Direct.cs create mode 100644 tests/cases/csharp/interface-and-abstract-members/src/Shapes.cs create mode 100644 tests/cases/csharp/interface-and-abstract-members/src/Unrelated.cs diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 8b9b8c290..525b62110 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -598,7 +598,7 @@ THE DISPATCH ENVELOPE: (base method, method that may run instead) for every call | value | languages | meaning | |---|---|---| -| `nominal` | java, typescript | A written extends/implements reaches the candidate's owner from the base's owner. The strongest evidence there is: the author declared the relationship. | +| `nominal` | java, typescript, csharp | A written extends/implements reaches the candidate's owner from the base's owner. The strongest evidence there is: the author declared the relationship. | | `structural` | typescript | No declaration; the candidate's owner satisfies the base's owner by SHAPE. Emitted only for supertypes with no nominal implementor at all, so it never competes with a declared answer — but it is a heuristic, and a consumer that wants declarations only filters it out. | | `value` | java, typescript, csharp, python | A function stored in a field, variable or parameter whose type is the base's callable type; a call through that holder may run it. In TypeScript also a function written as an implementation of an interface's member signature: an object-literal member of a literal typed by the interface (#1208), or a function assigned to the member through a property chain, `inst.i.run = (x) => …` (#1283). In C# also what one delegate member is assigned from another (`a.Run = a.Parse`). In Python, a function assigned onto an instance's member from outside its class, which shadows the class's method of that name. The base is the signature the call resolves to (bodiless), so the pair is how a walk over callers reaches the function that runs. Flow-derived rather than declared: a consumer that wants declarations only filters it out. | | `mro` | python | The subtype's C3 linearisation picks the candidate for that attribute name. Not merely "the subtype declares this name" — a name a sibling base wins is attributed to that sibling. | @@ -609,7 +609,7 @@ THE DISPATCH ENVELOPE: (base method, method that may run instead) for every call ### `overrides` -Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java only, and kept for compatibility — it is exactly `dispatch_candidates` filtered to `basis = nominal`. Prefer `dispatch_candidates`, which is populated in every language. +Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java and C# only, and kept for compatibility — it is exactly `dispatch_candidates` filtered to `basis = nominal`. Prefer `dispatch_candidates`, which is populated in every language. | # | column | type | key | null | idx | meaning | |---|---|---|---|---|---|---| @@ -620,7 +620,6 @@ Virtual-dispatch pairs: (base method, overriding method) wherever a call to the - **typescript** — EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`. - **python** — 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. -- **csharp** — EMPTY — C# records the dispatch envelope in dispatch_candidates, not as override rows. ### `entry_points` diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index 0aad38268..cfb657d2b 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -353,7 +353,10 @@ const CSHARP: LanguageAdapter = { prefixes: { method: 'CS_METHOD_', type: 'CS_TYPE_', expression: 'CS_EXPRESSION_', module: 'CS_MODULE_' }, raw: { callEdges: CALL_EDGES, - typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [1, 2] }, + // keyed on the declaration, not the partial-type group type_ancestor itself uses, + // so it joins types.id + typeAncestors: { file: 'resolution-type-ancestor-type.csv', columns: [1, 2] }, + overrides: { file: 'resolution-virtual-override.csv', columns: [0, 1] }, entryPoints: { file: 'entry-point.csv', columns: [0, 1] }, entryReachable: { file: 'entry-reachable.csv', columns: [0] }, dispatchCandidates: { file: 'dispatch-candidates.csv', columns: [0, 1, 2] }, diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index b2aaec37b..dad9c147e 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -143,7 +143,7 @@ export const CORE_TABLES: readonly TableSpec[] = [ }, { name: 'overrides', - description: 'Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java only, and kept for compatibility — it is exactly `dispatch_candidates` filtered to `basis = nominal`. Prefer `dispatch_candidates`, which is populated in every language.', + description: 'Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java and C# only, and kept for compatibility — it is exactly `dispatch_candidates` filtered to `basis = nominal`. Prefer `dispatch_candidates`, which is populated in every language.', columns: [ { name: 'method_id', type: 'TEXT', key: true, indexed: true, description: 'FK → methods.id — the base (declared) method.' }, { name: 'overriding_method_id', type: 'TEXT', key: true, indexed: true, description: 'FK → methods.id — the override in a subtype.' }, @@ -691,7 +691,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'orm_hook', languages: P, meaning: 'A lifecycle or validation hook registered by decoration. The data layer calls it; nothing in the client does.' }, // dispatch_candidates.basis - { table: 'dispatch_candidates', column: 'basis', value: 'nominal', languages: ['java', 'typescript'], meaning: 'A written extends/implements reaches the candidate\'s owner from the base\'s owner. The strongest evidence there is: the author declared the relationship.' }, + { table: 'dispatch_candidates', column: 'basis', value: 'nominal', languages: ['java', 'typescript', 'csharp'], meaning: 'A written extends/implements reaches the candidate\'s owner from the base\'s owner. The strongest evidence there is: the author declared the relationship.' }, { table: 'dispatch_candidates', column: 'basis', value: 'structural', languages: T, meaning: 'No declaration; the candidate\'s owner satisfies the base\'s owner by SHAPE. Emitted only for supertypes with no nominal implementor at all, so it never competes with a declared answer — but it is a heuristic, and a consumer that wants declarations only filters it out.' }, { table: 'dispatch_candidates', column: 'basis', value: 'value', languages: ['java', 'typescript', 'csharp', 'python'], meaning: 'A function stored in a field, variable or parameter whose type is the base\'s callable type; a call through that holder may run it. In TypeScript also a function written as an implementation of an interface\'s member signature: an object-literal member of a literal typed by the interface (#1208), or a function assigned to the member through a property chain, `inst.i.run = (x) => …` (#1283). In C# also what one delegate member is assigned from another (`a.Run = a.Parse`). In Python, a function assigned onto an instance\'s member from outside its class, which shadows the class\'s method of that name. The base is the signature the call resolves to (bodiless), so the pair is how a walk over callers reaches the function that runs. Flow-derived rather than declared: a consumer that wants declarations only filters it out.' }, { table: 'dispatch_candidates', column: 'basis', value: 'mro', languages: P, meaning: 'The subtype\'s C3 linearisation picks the candidate for that attribute name. Not merely "the subtype declares this name" — a name a sibling base wins is attributed to that sibling.' }, @@ -764,7 +764,6 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'csharp', table: 'skipped', note: 'EMPTY — the C# front end writes no skipped-files report. It takes the opposite line: a construct its grammar does not cover fails the run rather than skipping the file, so there is no per-file decision to record. An empty table here is not evidence that every file was read.' }, { language: 'csharp', table: 'type_instantiated', note: 'EMPTY — the C# engine derives the instantiation set but does not export it. Dispatch in C# is not narrowed by it either (see the README of the C# engine).' }, { language: 'csharp', table: 'type_use', note: 'EMPTY — the C# front end does not export type uses yet.' }, - { language: 'csharp', table: 'overrides', note: 'EMPTY — C# records the dispatch envelope in dispatch_candidates, not as override rows.' }, { language: 'javascript', table: 'dispatch_candidates', note: 'EMPTY — JavaScript has no declared dispatch to widen from: a multi_inferred set is already the values the receiver may hold (see call_edges), so there is no envelope to record.' }, { language: 'javascript', table: 'skipped', note: 'A DIRECTORY_EXCLUDED row\'s file_path is a PRUNED DIRECTORY, not a file, and `detail` carries how many files are behind it; those files have no rows of their own. So `SELECT count(*) FROM skipped` is not the number of files missing, and a join on file_path will not match them. Filter the reason out when you want per-file rows.' }, { language: 'python', table: 'skipped', note: 'The only front end that positions a skip: a PY2_CONSTRUCT_DETECTED row carries the construct and its line and column, so the file can be triaged without re-running the parser.' }, diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index 6e74254c7..fce505f12 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -178,6 +178,31 @@ call_chain_edge(e, caller, "-", callee, "lib", "boundary_lib", kind) :- call_from("client", e, caller), invocation_site("client", e, kind). +// client -> THE BODILESS DECLARATION A FANNED CALL BINDS TO. `s.Area()` on an +// IShape resolves to every implementation, and the declaration it is written +// against -- the interface member, or an abstract method -- is in no resolved set, +// because no body of it can run. Without this edge the declaration has no caller: +// asking who depends on IShape.Area finds nothing while every caller of it is in the +// graph. It is what the compiler binds the call to, and it takes the site's own class, +// so the site keeps the tier its runtime targets gave it. Emitted here and not in +// expr_resolves_to_method, which is the fan the oracle scores and the count the tier +// is read from. +call_chain_edge(e, caller, "-", declared, "client", cls, kind) :- + call_names_bodiless(e, declared), + call_from("client", e, caller), + call_class("client", e, cls), + invocation_site("client", e, kind). +call_names_bodiless(e, declared) :- + expr_call_candidate("client", e, declared), + method_decl("client", _, _, _, declared), + method_is_bodiless("client", declared), + !dispatch_is_exact("client", e), + !ovl_drop("client", e, declared), + expr_resolves_to_method("client", e, _). +method_is_bodiless(prov, m) :- method_is_abstract(prov, m). +method_is_bodiless(prov, m) :- + interface_member(prov, _, _, "method", m), !interface_has_default_impl(prov, m). + // client -> EXTERNAL. The target is outside every staged IR, so ToMethod is a LABEL // (`external:.`) rather than a method hash, and the bundle routes it to // callee_label by the "external" provenance -- the same convention Java and Python diff --git a/graph/csharp/engine/resolution/type-hierarchy.dl b/graph/csharp/engine/resolution/type-hierarchy.dl index 2d5f70aab..a88dfd533 100644 --- a/graph/csharp/engine/resolution/type-hierarchy.dl +++ b/graph/csharp/engine/resolution/type-hierarchy.dl @@ -223,6 +223,15 @@ type_ancestor("client", gk, agk) :- type_self_or_ancestor(prov, gk, gk) :- type_group(prov, _, gk). type_self_or_ancestor(prov, gk, agk) :- type_ancestor(prov, gk, agk). +// type_ancestor_type(Prov, TypeHash, AncestorTypeHash) -- type_ancestor keyed on the +// DECLARATION rather than the merged group, which is what the exported graph keys a +// type on: `types.id`, `methods.owner_type_id`. A partial type has one row per part, +// so every part is a subtype of every ancestor of the whole. The ancestor keeps its +// own provenance, so a client type deriving from a staged library type joins to the +// library's declaration. +type_ancestor_type(prov, t, at) :- + type_ancestor(prov, gk, agk), type_group(prov, t, gk), type_group(_, at, agk). + // type_subtype(Prov, Sub, Super) -- the reverse direction, which is what CHA fan-out // needs: given a declared receiver type, every type that could be there at runtime. type_subtype(prov, sub, super) :- type_ancestor(prov, sub, super). diff --git a/graph/csharp/engine/resolution/virtual-dispatch.dl b/graph/csharp/engine/resolution/virtual-dispatch.dl index 746fc3f4e..a429165f0 100644 --- a/graph/csharp/engine/resolution/virtual-dispatch.dl +++ b/graph/csharp/engine/resolution/virtual-dispatch.dl @@ -145,6 +145,17 @@ dispatch_target("client", ifaceM, impl, "interface_impl") :- implements_member(" dispatch_target(prov, ifaceM, ifaceM, "default_interface_impl") :- interface_has_default_impl(prov, ifaceM). +// THE DECLARED HALF OF THE ENVELOPE. override_pair(Base, Candidate) -- an override +// or an interface implementation in client code, which is what a call bound to Base +// may run instead. Without it a consumer asking what must change with an interface +// method found nothing: the call edges name the implementations, and nothing named +// the interface member they implement. Exported as the override table and as the +// `nominal` basis of the envelope, the shape Java gives the same pair. +override_pair(m, d) :- + dispatch_target(_, m, d, r), d != m, (r = "override" ; r = "interface_impl"), + method_decl("client", _, _, _, d). +method_dispatch_candidate(m, d, "nominal") :- override_pair(m, d). + // An override is also a target through everything IT overrides, transitively, which // the closure above gives. What it must NOT pick up is a HIDING method. dispatch_excluded_hiding(prov, m, h) :- diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 27a5ce721..4bd35c734 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -95,6 +95,7 @@ .decl call_kind(c0:symbol,c1:symbol,c2:symbol) .decl call_member_dispatches(c0:symbol,c1: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) .decl call_needs_member_lookup(c0:symbol,c1:symbol) .decl call_needs_scope_lookup(c0:symbol,c1:symbol) @@ -449,6 +450,7 @@ .decl method_in_type(c0:symbol,c1:symbol,c2:symbol) .decl method_is_abstract(c0:symbol,c1:symbol) .decl method_is_accessor(c0:symbol,c1:symbol) +.decl method_is_bodiless(c0:symbol,c1:symbol) .decl method_is_ctor(c0:symbol,c1:symbol) .decl method_is_dispatchable(c0:symbol,c1:symbol) .decl method_is_extension(c0:symbol,c1:symbol) @@ -536,6 +538,7 @@ .decl ns_prefix(c0:symbol,c1:symbol,c2:symbol) .decl override_cand(c0:symbol,c1:symbol,c2:symbol,c3:number) .decl override_depth(c0:symbol,c1:symbol,c2:symbol,c3:number) +.decl override_pair(c0:symbol,c1:symbol) .decl overrides(c0:symbol,c1:symbol,c2:symbol) .decl overrides_transitive(c0:symbol,c1:symbol,c2:symbol) .decl ovl_arg_at(c0:symbol,c1:symbol,c2:number,c3:symbol) @@ -689,6 +692,7 @@ .decl tok_at_a(c0:symbol,c1:number) .decl type_access(c0:symbol,c1:symbol,c2:symbol) .decl type_ancestor(c0:symbol,c1:symbol,c2:symbol) +.decl type_ancestor_type(c0:symbol,c1:symbol,c2:symbol) .decl type_arg_fixed(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl type_arg_for(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_assembly(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/csharp/souffle/export_manifest.tsv b/graph/csharp/souffle/export_manifest.tsv index d5c935d9c..1b4583e2c 100644 --- a/graph/csharp/souffle/export_manifest.tsv +++ b/graph/csharp/souffle/export_manifest.tsv @@ -36,3 +36,5 @@ remote_unsent remote-unsent.csv remote_undetermined remote-undetermined.csv entry_point entry-point.csv entry_reachable entry-reachable.csv +type_ancestor_type resolution-type-ancestor-type.csv +override_pair resolution-virtual-override.csv diff --git a/graph/test/csharp/tools/overload-by-argument-test.sh b/graph/test/csharp/tools/overload-by-argument-test.sh index 875ca8f77..a486731ec 100755 --- a/graph/test/csharp/tools/overload-by-argument-test.sh +++ b/graph/test/csharp/tools/overload-by-argument-test.sh @@ -68,8 +68,8 @@ Pruned G known_edge MathUtil.G(int) Pruned G known_edge MathUtil.G(T) Dispatched Area multi_inferred Circle.Area(int) Shape.Area(int) Dispatched Area multi_inferred Circle.Area(string) Shape.Area(string) -ThroughInterface K multi_inferred Explicit.K(int) Implicit.K(int) -ThroughInterface K multi_inferred Explicit.K(string) Implicit.K(string) +ThroughInterface K multi_inferred Explicit.K(int) IKeyed.K(int) Implicit.K(int) +ThroughInterface K multi_inferred Explicit.K(string) IKeyed.K(string) Implicit.K(string) Arity Pad known_edge MathUtil.Pad(int) Arity Pad known_edge MathUtil.Pad(int,int) Unknown Scale multi_inferred MathUtil.Scale(double) MathUtil.Scale(int) MathUtil.Scale(string) diff --git a/tests/cases/csharp/interface-and-abstract-members/case.json b/tests/cases/csharp/interface-and-abstract-members/case.json new file mode 100644 index 000000000..a25802d24 --- /dev/null +++ b/tests/cases/csharp/interface-and-abstract-members/case.json @@ -0,0 +1,14 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a call through an interface-typed receiver is a caller of the interface member, and every implementation must change with it; a call on a freshly constructed receiver binds to the class and is not a caller of the interface, and a same-named method on an unrelated class is not an implementation", + "run": ["impact", "IShape.Area"], + "want": ["must change with it", "Circle.Area", "Sq.Area", "overrides it", "AreaService.Total src/AreaService.cs:8 — calls it"], + "avoid": ["Plot.Area", "Direct.One src/Direct.cs:5 — calls it", "no override table"]}, + {"why": "an implementation is bound to the interface member it implements", + "run": ["impact", "Circle.Area"], + "want": ["IShape.Area", "it overrides this"], + "avoid": ["no override table"]}, + {"why": "a call through an abstract-class receiver is a caller of the abstract method, and its override must change with it", + "run": ["impact", "Report.Render"], + "want": ["TextReport.Render", "overrides it", "AreaService.Print src/AreaService.cs:12 — calls it"], + "avoid": ["no override table"]}]} diff --git a/tests/cases/csharp/interface-and-abstract-members/src/AreaService.cs b/tests/cases/csharp/interface-and-abstract-members/src/AreaService.cs new file mode 100644 index 000000000..a2631280e --- /dev/null +++ b/tests/cases/csharp/interface-and-abstract-members/src/AreaService.cs @@ -0,0 +1,14 @@ +namespace Shapes +{ + public class AreaService + { + public double Total(IShape[] shapes) + { + double t = 0; + foreach (IShape s in shapes) { t += s.Area(); } + return t; + } + + public string Print(Report r) { return r.Render(); } + } +} diff --git a/tests/cases/csharp/interface-and-abstract-members/src/Direct.cs b/tests/cases/csharp/interface-and-abstract-members/src/Direct.cs new file mode 100644 index 000000000..d45811cd7 --- /dev/null +++ b/tests/cases/csharp/interface-and-abstract-members/src/Direct.cs @@ -0,0 +1,7 @@ +namespace Shapes +{ + public class Direct + { + public double One() { return new Circle(1).Area(); } + } +} diff --git a/tests/cases/csharp/interface-and-abstract-members/src/Shapes.cs b/tests/cases/csharp/interface-and-abstract-members/src/Shapes.cs new file mode 100644 index 000000000..674136a03 --- /dev/null +++ b/tests/cases/csharp/interface-and-abstract-members/src/Shapes.cs @@ -0,0 +1,28 @@ +namespace Shapes +{ + public interface IShape { double Area(); } + + public class Circle : IShape + { + private readonly double r; + public Circle(double r) { this.r = r; } + public double Area() { return 3.14 * r * r; } + } + + public class Sq : IShape + { + private readonly double s; + public Sq(double s) { this.s = s; } + public double Area() { return s * s; } + } + + public abstract class Report + { + public abstract string Render(); + } + + public class TextReport : Report + { + public override string Render() { return "text"; } + } +} diff --git a/tests/cases/csharp/interface-and-abstract-members/src/Unrelated.cs b/tests/cases/csharp/interface-and-abstract-members/src/Unrelated.cs new file mode 100644 index 000000000..45f82fd6b --- /dev/null +++ b/tests/cases/csharp/interface-and-abstract-members/src/Unrelated.cs @@ -0,0 +1,7 @@ +namespace Shapes +{ + public class Plot + { + public double Area() { return 0; } + } +} From 34fcc80f667a0aa0aa27a2d3e41c8ec9a14ef408 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:34:14 -0700 Subject: [PATCH 02/19] python: a line inserted after a method's docstring is a change to that method (#1358) The docstring line counted as noise, so an insertion anchored on it was charged to the enclosing class and `changed` named no declaration; `test-impact` then selected nothing. An insertion inside a body that is already reported as a body change is also no longer listed a second time as unattributed new lines inside the class. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 8 +++- .../python/insert-after-docstring/case.json | 48 +++++++++++++++++++ .../python/insert-after-docstring/lib.py | 11 +++++ .../insert-after-docstring/new-later.txt | 12 +++++ .../python/insert-after-docstring/new.txt | 12 +++++ .../python/insert-after-docstring/old.txt | 11 +++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/insert-after-docstring/case.json create mode 100644 tests/cases/python/insert-after-docstring/lib.py create mode 100644 tests/cases/python/insert-after-docstring/new-later.txt create mode 100644 tests/cases/python/insert-after-docstring/new.txt create mode 100644 tests/cases/python/insert-after-docstring/old.txt diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 0055d86c0..c44725c89 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -343,7 +343,11 @@ class Changed: # and no method, and `test-impact` had no changed declaration to seed from (#1102). The blank line this # guard was written for is still rejected, and so is a closing brace, which can sit outside the body. anchored = after <= len(OL) and (OL[after - 1].strip() == '{' or not noise(OL[after - 1])) - if is_add and owner and owner[0] <= after < owner[1] and anchored: # strictly inside a callable's body: that callable changed + # a Python docstring is the first statement of the body, so an insertion right after it is inside the + # callable just as the brace above is; rejecting it charged the edit to the class (#1358) + if after <= len(OL) and owner and owner[3] != 'module' and owner[0] < after and OL[after - 1].strip().startswith(TRIPLE): 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): out.append(dict(kind='body', symbol=owner[4], id=owner[2], file=rel, line=owner[0], end=owner[1], old_lines=[after], target=owner[4], target_kind='method')) # and the inserted lines may still declare something at the type's level (a method extracted from this body) @@ -418,7 +422,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)) - if not names and is_add: adds.append((rel, f"{j2 - j1 + 1} new line(s) at {rel}:{j1}" + (f" inside {t[4]}" if t else ''), None)) + 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 # `async def` — fails that test and the same declaration was reported twice: once as `signature`, and once as diff --git a/tests/cases/python/insert-after-docstring/case.json b/tests/cases/python/insert-after-docstring/case.json new file mode 100644 index 000000000..5b3939dd0 --- /dev/null +++ b/tests/cases/python/insert-after-docstring/case.json @@ -0,0 +1,48 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a line inserted directly after a method's docstring is a change to THAT METHOD: the docstring is the first line of its body, not a blank line between declarations, so the edit used to be charged to the class and name no declaration", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new.txt", + "--file", + "lib.py" + ], + "want": [ + "body", + "Scaler.scale" + ], + "avoid": [ + "no changed declaration", + "inside Scaler" + ] + }, + { + "why": "a line inserted one statement further into the body names the method and only the method: the insertion is already accounted for as a body change, so it is not also listed as unattributed new lines inside the class", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.txt", + "--new", + "{repo}/new-later.txt", + "--file", + "lib.py" + ], + "want": [ + "body", + "Scaler.scale" + ], + "avoid": [ + "no changed declaration", + "inside Scaler" + ] + } + ] +} diff --git a/tests/cases/python/insert-after-docstring/lib.py b/tests/cases/python/insert-after-docstring/lib.py new file mode 100644 index 000000000..c8eeb2055 --- /dev/null +++ b/tests/cases/python/insert-after-docstring/lib.py @@ -0,0 +1,11 @@ +class Scaler: + def __init__(self, factor): + self.factor = factor + + def scale(self, value): + """Multiply value by the factor.""" + value = value * self.factor + return value + + def other(self): + return 1 diff --git a/tests/cases/python/insert-after-docstring/new-later.txt b/tests/cases/python/insert-after-docstring/new-later.txt new file mode 100644 index 000000000..aaf90945f --- /dev/null +++ b/tests/cases/python/insert-after-docstring/new-later.txt @@ -0,0 +1,12 @@ +class Scaler: + def __init__(self, factor): + self.factor = factor + + def scale(self, value): + """Multiply value by the factor.""" + value = value * self.factor + value = value + 0 + return value + + def other(self): + return 1 diff --git a/tests/cases/python/insert-after-docstring/new.txt b/tests/cases/python/insert-after-docstring/new.txt new file mode 100644 index 000000000..9806d7871 --- /dev/null +++ b/tests/cases/python/insert-after-docstring/new.txt @@ -0,0 +1,12 @@ +class Scaler: + def __init__(self, factor): + self.factor = factor + + def scale(self, value): + """Multiply value by the factor.""" + x = 0 + value = value * self.factor + return value + + def other(self): + return 1 diff --git a/tests/cases/python/insert-after-docstring/old.txt b/tests/cases/python/insert-after-docstring/old.txt new file mode 100644 index 000000000..c8eeb2055 --- /dev/null +++ b/tests/cases/python/insert-after-docstring/old.txt @@ -0,0 +1,11 @@ +class Scaler: + def __init__(self, factor): + self.factor = factor + + def scale(self, value): + """Multiply value by the factor.""" + value = value * self.factor + return value + + def other(self): + return 1 From e8120662d94a04806c5ca56c5d9d3c37fe66c529 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:07:46 -0700 Subject: [PATCH 03/19] changed: a removed or new declaration is listed once, and a class by its own name (#1377) A method's header line and its body lines are two keys, and both were turned into a "removed" row, so the method was listed twice; with `--range` on a graph built at the newer commit, that is every new method. The class header was charged to a callable the front end synthesises on that line (Java's implicit constructor, TypeScript's constructor, Python's class body), so a removed or added class was named by that member and never by its own name. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/axiomcode-changed | 9 ++++++++- tests/cases/java/removed-class-listed-once/case.json | 6 ++++++ tests/cases/java/removed-class-listed-once/new.java | 0 tests/cases/java/removed-class-listed-once/old.java | 6 ++++++ .../cases/java/removed-class-listed-once/src/Meter.java | 6 ++++++ tests/cases/python/removed-class-listed-once/case.json | 6 ++++++ tests/cases/python/removed-class-listed-once/lib.py | 4 ++++ tests/cases/python/removed-class-listed-once/new.txt | 0 tests/cases/python/removed-class-listed-once/old.txt | 4 ++++ 9 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/cases/java/removed-class-listed-once/case.json create mode 100644 tests/cases/java/removed-class-listed-once/new.java create mode 100644 tests/cases/java/removed-class-listed-once/old.java create mode 100644 tests/cases/java/removed-class-listed-once/src/Meter.java create mode 100644 tests/cases/python/removed-class-listed-once/case.json create mode 100644 tests/cases/python/removed-class-listed-once/lib.py create mode 100644 tests/cases/python/removed-class-listed-once/new.txt create mode 100644 tests/cases/python/removed-class-listed-once/old.txt diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 0055d86c0..7648ee21e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -241,6 +241,10 @@ class Changed: m = narrowest(ln, {'method', 'function', 'constructor', 'module'}) t = narrowest(ln, {'class', 'interface', 'enum', 'type', 'namespace'}) key = None + # a callable that starts on the type's own header line and ends within the type is one the front end + # synthesised for it (an implicit constructor, a Python class body): the header is the TYPE's, or a new class + # 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) elif m: key = ('body', m) @@ -271,7 +275,10 @@ class Changed: 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)) - if gone: entry['kind'] = 'removed'; entry['target'] = self.target(kind, d, k, n); 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) + continue if kind == 'signature': # compared with comments removed: `f( /* note */ a)` is `f(a)`. Strings stay, so an edited route in a # decoration is still a header change diff --git a/tests/cases/java/removed-class-listed-once/case.json b/tests/cases/java/removed-class-listed-once/case.json new file mode 100644 index 000000000..aa96f6822 --- /dev/null +++ b/tests/cases/java/removed-class-listed-once/case.json @@ -0,0 +1,6 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a removed class and its one method are two declarations, each listed once: the method's header line and its body lines used to give it two rows, and the class was named by its implicit constructor instead of by its own name", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/Meter.java"], + "want": ["changed declarations (2)", "removed Meter ", "Meter.read"], + "avoid": ["Meter.Meter"]}]} diff --git a/tests/cases/java/removed-class-listed-once/new.java b/tests/cases/java/removed-class-listed-once/new.java new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/java/removed-class-listed-once/old.java b/tests/cases/java/removed-class-listed-once/old.java new file mode 100644 index 000000000..7fcc4f668 --- /dev/null +++ b/tests/cases/java/removed-class-listed-once/old.java @@ -0,0 +1,6 @@ +class Meter { + int read(int raw) { + int value = raw * 2; + return value; + } +} diff --git a/tests/cases/java/removed-class-listed-once/src/Meter.java b/tests/cases/java/removed-class-listed-once/src/Meter.java new file mode 100644 index 000000000..7fcc4f668 --- /dev/null +++ b/tests/cases/java/removed-class-listed-once/src/Meter.java @@ -0,0 +1,6 @@ +class Meter { + int read(int raw) { + int value = raw * 2; + return value; + } +} diff --git a/tests/cases/python/removed-class-listed-once/case.json b/tests/cases/python/removed-class-listed-once/case.json new file mode 100644 index 000000000..d7841ce48 --- /dev/null +++ b/tests/cases/python/removed-class-listed-once/case.json @@ -0,0 +1,6 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a removed class and its one method are two declarations, each listed once: the method's header line and its body lines used to give it two rows, and the class was named by its class-body initializer instead of by its own name", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new.txt", "--file", "lib.py"], + "want": ["changed declarations (2)", "removed Meter ", "Meter.read"], + "avoid": [""]}]} diff --git a/tests/cases/python/removed-class-listed-once/lib.py b/tests/cases/python/removed-class-listed-once/lib.py new file mode 100644 index 000000000..e77e26305 --- /dev/null +++ b/tests/cases/python/removed-class-listed-once/lib.py @@ -0,0 +1,4 @@ +class Meter: + def read(self, raw): + value = raw * 2 + return value diff --git a/tests/cases/python/removed-class-listed-once/new.txt b/tests/cases/python/removed-class-listed-once/new.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/removed-class-listed-once/old.txt b/tests/cases/python/removed-class-listed-once/old.txt new file mode 100644 index 000000000..e77e26305 --- /dev/null +++ b/tests/cases/python/removed-class-listed-once/old.txt @@ -0,0 +1,4 @@ +class Meter: + def read(self, raw): + value = raw * 2 + return value From be090ec4c1931138658b1f55ecbabc6b3c2c8b50 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:26:27 -0700 Subject: [PATCH 04/19] python: a script with a python shebang and no .py extension is indexed (#1376) The parser collected only .py and .pyi files, so an executable script named without an extension contributed no symbols and no edges: an edit to it named no declaration, and calls from it into the project's modules were missing from their callers. A file with no extension whose first line is a python shebang is now analysed as a module. The plugin watches and counts such scripts, reads them as Python in changed and impact, and no longer lists them among the files the index does not read as source. Parser version 0.1.1. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/package.json | 2 +- .../python/python-project-analyzer.ts | 26 +++++++++++++++++-- .../skills/axiomcode/scripts/ax_fresh.py | 23 ++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-build | 2 +- .../axiomcode/scripts/axiomcode-changed | 5 ++-- .../skills/axiomcode/scripts/axiomcode-impact | 7 ++++- tests/cases/python/shebang-script/case.json | 14 ++++++++++ tests/cases/python/shebang-script/deploy | 2 ++ tests/cases/python/shebang-script/make-report | 10 +++++++ tests/cases/python/shebang-script/new.txt | 10 +++++++ tests/cases/python/shebang-script/old.txt | 10 +++++++ tests/cases/python/shebang-script/pricing.py | 2 ++ 12 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 tests/cases/python/shebang-script/case.json create mode 100755 tests/cases/python/shebang-script/deploy create mode 100755 tests/cases/python/shebang-script/make-report create mode 100644 tests/cases/python/shebang-script/new.txt create mode 100755 tests/cases/python/shebang-script/old.txt create mode 100644 tests/cases/python/shebang-script/pricing.py diff --git a/parser/package.json b/parser/package.json index fe7527c54..6c9395caa 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.0", + "version": "0.1.1", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 1669761b5..2925a86b9 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -154,6 +154,28 @@ const CHUNK_SIZE = 50_000; * deferred set for the second freeze, so the rejection detail lives in the * skipped-files CSV until it is unfrozen. */ + +/** + * A `.py`/`.pyi` file, or an executable script with no extension whose first + * line is a python shebang (`#!/usr/bin/env python3`): the interpreter runs it + * as a module, so it is analysed as one (#1376). + */ +const PYTHON_SHEBANG = /^#![^\n]*\bpython[0-9.]*\b/; +export function isPythonSourceFile(full: string, name: string): boolean { + if (name.endsWith('.py') || name.endsWith('.pyi')) return true; + if (name.includes('.')) return false; + let fd: number | undefined; + try { + fd = fs.openSync(full, 'r'); + const head = Buffer.alloc(128); + const n = fs.readSync(fd, head, 0, head.length, 0); + return PYTHON_SHEBANG.test(head.subarray(0, n).toString('utf-8')); + } catch { + return false; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} export class PythonProjectAnalyzer { private extractor: PythonFactExtractor; private resolutionLinker: PythonResolutionLinker; @@ -568,7 +590,7 @@ export class PythonProjectAnalyzer { found.push(...(await this.collectPythonFiles(full, excludes, false))); continue; } - if (entry.name.endsWith('.py') || entry.name.endsWith('.pyi')) { + if (entry.isFile() && isPythonSourceFile(full, entry.name)) { found.push(full); } } @@ -603,7 +625,7 @@ export class PythonProjectAnalyzer { found.push(...(await this.collectPythonFiles(full, excludes, false))); continue; } - if (entry.name.endsWith('.py') || entry.name.endsWith('.pyi')) { + if (entry.isFile() && isPythonSourceFile(full, entry.name)) { found.push(full); } } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 57636c7c5..294bf75fc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -26,7 +26,7 @@ Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 10) is how long a query verb waits.""" -import errno, hashlib, json, os, subprocess, sys, time +import re, errno, hashlib, json, os, subprocess, sys, time H = os.path.dirname(os.path.abspath(__file__)) @@ -60,12 +60,24 @@ def out_dir(repo): return os.path.join(repo, '.axiomcode', 'out') def table_path(repo): return os.path.join(out_dir(repo), 'files.json') def state_path(repo): return os.path.join(repo, '.axiomcode', 'refresh.json') +PY_SHEBANG = re.compile(r'#![^\n]*\bpython[0-9.]*\b') + +def python_script(p, name=None): + """an executable Python script with no extension: its first line is a python shebang. The parser analyses it as a + module (#1376), so it is watched and counted like a .py file""" + if '.' in (name or os.path.basename(p)): return False + try: + with open(p, 'rb') as fh: head = fh.read(128) + except OSError: return False + return bool(PY_SHEBANG.match(head.decode('utf-8', 'replace'))) + def watched(root, lang): exts, names, prune = EXT.get(lang, ()), NAMES.get(lang, ()), PRUNE.get(lang, PRUNE_ALL) for d, subdirs, files in os.walk(root): subdirs[:] = [s for s in subdirs if s not in prune] for f in files: - if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d): + if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \ + or (lang == 'python' and python_script(os.path.join(d, f), f)): yield os.path.join(d, f) def digest(p): @@ -337,6 +349,13 @@ def main(argv): lib = os.environ.get('AXIOMCODE_LIBRARY', '') json.dump(dict(lang=lang, src=src_arg.strip('/'), src_arg=src_arg, library=lib, built=time.time(), files=snapshot(repo, lang, os.path.join(repo, src_arg))), sys.stdout); return 0 + if cmd == 'pyscripts': + # how many extensionless python scripts are under repo: added to the build's .py count when it picks a language + n = 0 + for d, subdirs, files in os.walk(repo): + subdirs[:] = [x for x in subdirs if x not in PRUNE_ALL] + n += sum(1 for f in files if python_script(os.path.join(d, f), f)) + print(n); return 0 if cmd == 'uptodate': # exit 0 when the recorded table was built with this language / --src / --library and no file differs lang, src_arg, lib = argv[3], argv[4] if len(argv) > 4 else '', argv[5] if len(argv) > 5 else '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 4724ce140..bb7fe597a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -49,7 +49,7 @@ ENGINE="$AXIOMCODE_ENGINE" exit 1; } # no `head` in this pipeline: under pipefail a closed pipe kills find and, with set -e, the script — silently count(){ find "$SRC" \( -path '*/node_modules' -o -path '*/.axiomcode' -o -path '*/.git' -o -path '*/.venv' -o -path '*/venv' -o -path '*/site-packages' -o -path '*/dist' -o -path '*/build' \) -prune -o -type f \( "$@" \) -print 2>/dev/null | wc -l | tr -d ' '; } -py=$(count -name '*.py'); ja=$(count -name '*.java'); ts=$(count -name '*.ts' -o -name '*.tsx'); js=$(count -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.jsx'); cs=$(count -name '*.cs') +py=$(( $(count -name '*.py') + $(python3 "$H/ax_fresh.py" pyscripts "$SRC" 2>/dev/null || echo 0) )); ja=$(count -name '*.java'); ts=$(count -name '*.ts' -o -name '*.tsx'); js=$(count -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.jsx'); cs=$(count -name '*.cs') LANG_ARG="${AXIOMCODE_LANG:-}" if [ -z "$LANG_ARG" ]; then LANG_ARG=java; best=$ja diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 0055d86c0..423e16150 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -196,6 +196,7 @@ class Changed: # ── 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 = [] # a blank line, a comment line, a brace on its own: not a change to a declaration @@ -275,7 +276,7 @@ class Changed: if kind == 'signature': # compared with comments removed: `f( /* note */ a)` is `f(a)`. Strings stay, so an edited route in a # decoration is still a header change - uncomment = lambda lines: re.sub(r'\s+', ' ', strip_code('\n'.join(lines), strings=False, hash_comments=rel.endswith('.py'))).strip() + uncomment = lambda lines: re.sub(r'\s+', ' ', strip_code('\n'.join(lines), strings=False, hash_comments=is_py)).strip() 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 = '' @@ -352,7 +353,7 @@ class Changed: names = []; hdrs = {} # the type's body level in the NEW text: brace depth just after its header (indentation for Python); a declaration # the insertion introduces sits exactly there — a local inside a new method's body sits deeper - py = rel.endswith('.py'); tline = new_of.get(t[0]) if t else None + py = is_py; tline = new_of.get(t[0]) if t else None if t and not tline: # the type's header line moved: find it in the new text by name 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8f5c9f22e..4c6def0a0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -198,7 +198,7 @@ class Impact: """the source with comments and string literals blanked (same length, same line breaks): a name in a javadoc, a docstring or a string is not a use. Block /* */, line // and # (Python), '…' "…" `…` and triple-quoted strings.""" if ('code', file) in self.src: return self.src[('code', file)] - t = '\n'.join(self.lines(file)); out = []; i = 0; n = len(t); py = file.endswith('.py') + t = '\n'.join(self.lines(file)); out = []; i = 0; n = len(t); py = file.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', t)) triple = py or file.endswith(('.java', '.kt', '.scala', '.groovy')) # a Java text block is """ too js = file.endswith(('.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx')) prev = '' # the last code character seen @@ -593,6 +593,9 @@ class Impact: out = set() for f in files: ext = os.path.splitext(f)[1].lower() + if not ext: # a python shebang script (#1376) + try: ext = '.py' if re.match(r'#![^\n]*\bpython[0-9.]*\b', (self.lines(f) or [''])[0]) else '' + except Exception: ext = '' rx = self.IMPORT_RE.get(ext) if not rx: continue try: text = '\n'.join(self.lines(f)) @@ -625,12 +628,14 @@ class Impact: a services file, a properties file, a build script, a spec) breaks at run time and the compiler is silent.""" if hasattr(self, '_nsf'): return self._nsf out = [] + indexed = {sy['file'] for sy in self.g.sym.values() if sy.get('file')} # a shebang script is source too (#1376) for root, dirs, files in os.walk(self.g.repo): dirs[:] = [d for d in dirs if d not in self.SKIP_DIR and not d.startswith('.axiomcode')] for fn in files: ext = os.path.splitext(fn)[1].lower() if ext in self.SOURCE_EXT or ext in self.PROSE_EXT: continue fp = os.path.join(root, fn) + if os.path.relpath(fp, self.g.repo).replace(os.sep, '/') in indexed: continue try: if os.path.getsize(fp) > 512 * 1024: continue with open(fp, 'rb') as fh: head = fh.read(2048) diff --git a/tests/cases/python/shebang-script/case.json b/tests/cases/python/shebang-script/case.json new file mode 100644 index 000000000..885b16155 --- /dev/null +++ b/tests/cases/python/shebang-script/case.json @@ -0,0 +1,14 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a script with a python shebang and no .py extension is a module: the function it calls has it as a caller, and it is not listed among the files the index does not read as source", + "run": ["impact", "price"], + "want": ["report"], + "avoid": ["[text] make-report"]}, + {"why": "a shell script that only runs python3 is not read as Python: its mention of the function stays a text match, not a caller", + "run": ["impact", "price"], + "want": ["[text] deploy:2"], + "avoid": ["deploy."]}, + {"why": "an edit inside a shebang script names the function it changed, so test-impact has a declaration to start from", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new.txt", "--file", "make-report"], + "want": ["body", "report"], + "avoid": ["no declarations known here", "no changed declaration"]}]} diff --git a/tests/cases/python/shebang-script/deploy b/tests/cases/python/shebang-script/deploy new file mode 100755 index 000000000..da5df3553 --- /dev/null +++ b/tests/cases/python/shebang-script/deploy @@ -0,0 +1,2 @@ +#!/bin/sh +python3 -c 'from pricing import price; print(price(1, 1))' diff --git a/tests/cases/python/shebang-script/make-report b/tests/cases/python/shebang-script/make-report new file mode 100755 index 000000000..c95b99a70 --- /dev/null +++ b/tests/cases/python/shebang-script/make-report @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(3, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/new.txt b/tests/cases/python/shebang-script/new.txt new file mode 100644 index 000000000..7a4550003 --- /dev/null +++ b/tests/cases/python/shebang-script/new.txt @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(4, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/old.txt b/tests/cases/python/shebang-script/old.txt new file mode 100755 index 000000000..c95b99a70 --- /dev/null +++ b/tests/cases/python/shebang-script/old.txt @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(3, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/pricing.py b/tests/cases/python/shebang-script/pricing.py new file mode 100644 index 000000000..53f2f8440 --- /dev/null +++ b/tests/cases/python/shebang-script/pricing.py @@ -0,0 +1,2 @@ +def price(qty, unit): + return qty * unit From 31609445e72d2a7550936a266f7858aa72f7b4c8 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:31:56 -0700 Subject: [PATCH 05/19] release 0.1.3: every package, plugin manifest and the parser at 0.1.3 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- gemini-extension.json | 2 +- package.json | 12 ++++++------ parser/package.json | 2 +- plugins/axiomcode/.claude-plugin/plugin.json | 2 +- plugins/axiomcode/.codex-plugin/plugin.json | 2 +- plugins/axiomcode/.cursor-plugin/plugin.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/gemini-extension.json b/gemini-extension.json index defcedc65..5d36ac9bd 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.2", + "version": "0.1.3", "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 b38548c41..9d61673fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.2", + "version": "0.1.3", "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.2", - "@axiomcode/engine-darwin-x64": "0.1.2", - "@axiomcode/engine-linux-x64": "0.1.2", - "@axiomcode/engine-linux-arm64": "0.1.2", - "@axiomcode/engine-win32-x64": "0.1.2" + "@axiomcode/engine-darwin-arm64": "0.1.3", + "@axiomcode/engine-darwin-x64": "0.1.3", + "@axiomcode/engine-linux-x64": "0.1.3", + "@axiomcode/engine-linux-arm64": "0.1.3", + "@axiomcode/engine-win32-x64": "0.1.3" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/package.json b/parser/package.json index 6c9395caa..cfbc39fb1 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.1", + "version": "0.1.3", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 28f13b63e..38e818dc4 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.2", + "version": "0.1.3", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index f4d18b779..c16a6f50b 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.2", + "version": "0.1.3", "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 a8f886ec1..743e1acf0 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.2", + "version": "0.1.3", "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 c7d2e3d96cd9109b56cd04f12408faec711a5ee0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:44:23 -0700 Subject: [PATCH 06/19] help: --lang and --language list csharp The index verb, the engine's --language and the MCP index tool all accept csharp, but their help named only java, typescript, python and javascript. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 4 ++-- plugins/axiomcode/mcp/server.py | 2 +- plugins/axiomcode/skills/axiomcode/scripts/axiomcode | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/axiomcode b/bin/axiomcode index 649bdad65..f4f866b84 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -17,8 +17,8 @@ # (run.source_version). Defaults to the source's git commit, or # v1.0.0 outside a checkout. # --exclude-tests Exclude test code. Included by default. -# --language Restrict the run to one language: java, typescript, python or -# javascript. +# --language Restrict the run to one language: java, typescript, python, +# javascript or csharp. # --debug Also write csv/*.csv and keep raw/ beside each graph. # # Engine options: diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 9308681f1..ca7a1af92 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -53,7 +53,7 @@ def run(args, cwd=None, timeout=900): @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 when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" + """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.""" a = ['index', repo] + (['--lang', lang] if lang else []) + (['--src', src] if src else []) + (['--library', library] if library else []) return run(a) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index ab1dd8233..3dafbbc7f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -1,7 +1,7 @@ #!/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 index [] [--lang java|typescript|python|javascript] [--src ] [--library [,…]] +# 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] # THE FRONT DOOR: the files and callables a task touches, from the problem statement — when there is no name From 2e6214f5c82a6b1b96bb9c5836e9bc328907d059 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:42:36 -0700 Subject: [PATCH 07/19] index: every language in a repository gets a graph, and queries ask each A repository written in several languages was indexed as the language with the most files, and every other language was dropped without a word: no symbol, no edge, and `changed` on an edit in a dropped language said it touched nothing the graph knows. The file table watched that one language too, so an edit elsewhere never made the graph stale. Every language with files is now built, in one engine run (`--language` takes a comma list). The main language's graph stays at .axiomcode/out/graph.sqlite, as before; every other language's is .axiomcode/lang//out/graph.sqlite, with its baseline beside the main one's in .axiomcode/base/lang/. Graphs are not merged and no call is followed across languages. The dispatcher asks every graph for context, path, impact, changed and test-impact (ax_langs.py) and puts the answers together: one answering graph is printed as it would be alone; several are headed by language, main first; --json keeps the answering graph's object and adds `other_languages` only when more than one answers. `changed` reports each edited file once, from the graph that owns it. A repository in one language takes none of these paths. The file table watches every language, a refresh detects the languages again unless --lang was given, and a table from before this change counts as stale in a repository with other languages, so the first refresh indexes them. A language that fails to build is named; only the main language failing fails the build. Single-language repositories are unchanged: every graph table and every query answer is identical before and after. tests/multi_language.py covers the build, queries, the one-graph output shape, changed, refresh, --lang, the upgrade and a single-language control. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 18 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 3 +- .../skills/axiomcode/scripts/ax_fresh.py | 47 ++++- .../skills/axiomcode/scripts/ax_langs.py | 121 ++++++++++++ .../skills/axiomcode/scripts/axiomcode | 19 +- .../skills/axiomcode/scripts/axiomcode-build | 105 +++++++++-- .../axiomcode/scripts/axiomcode-changed | 19 +- .../axiomcode/scripts/axiomcode-test-impact | 9 +- skills/axiomcode/SKILL.md | 3 +- tests/README.md | 3 + tests/multi_language.py | 177 ++++++++++++++++++ 11 files changed, 487 insertions(+), 37 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py create mode 100644 tests/multi_language.py diff --git a/bin/axiomcode b/bin/axiomcode index f4f866b84..0c3265c78 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -17,7 +17,7 @@ # (run.source_version). Defaults to the source's git commit, or # v1.0.0 outside a checkout. # --exclude-tests Exclude test code. Included by default. -# --language Restrict the run to one language: java, typescript, python, +# --language [,…] Restrict the run to these languages: java, typescript, python, # javascript or csharp. # --debug Also write csv/*.csv and keep raw/ beside each graph. # @@ -177,7 +177,11 @@ case "$cmd" in [ -n "$src" ] || src="${pos[0]:-}"; [ -n "$out" ] || out="${pos[1]:-}" [ -n "$src" ] && [ -n "$out" ] || die "usage: axiomcode [--library [,…]] [options] (try --help)" [ -d "$src" ] || die "not a directory: $src" - [ -z "$lang" ] || [ -d "$ROOT/graph/$lang" ] || die "no engine for --language=$lang (have: $(ls -d "$ROOT"/graph/*/engine 2>/dev/null | sed 's#.*/graph/##; s#/engine##' | tr '\n' ' '))" + # --language takes one language or a comma list of them (a repository written in several) + IFS=',' read -ra want <<< "$lang" + for l in ${want[@]+"${want[@]}"}; do + [ -d "$ROOT/graph/$l" ] || die "no engine for --language=$l (have: $(ls -d "$ROOT"/graph/*/engine 2>/dev/null | sed 's#.*/graph/##; s#/engine##' | tr '\n' ' '))" + done [ -n "$version" ] || version="$(git -C "$src" rev-parse HEAD 2>/dev/null || echo v1.0.0)" popts+=(--version "$version") mkdir -p "$out"; out="$(cd "$out" && pwd)"; int="$out/.intermediate"; ir="$int/ir" @@ -233,8 +237,16 @@ case "$cmd" in found=(); for d in "$ir"/*/; do l="$(basename "$d")"; [ -d "$ROOT/graph/$l/engine" ] && found+=("$l"); done [ ${#found[@]} -gt 0 ] || { echo "❌ no language with an engine in the IR (folders: $(ls "$ir" | tr '\n' ' '))" >&2; exit 1; } langs=("${found[@]}") - if [ -n "$lang" ]; then + if [ ${#want[@]} -eq 1 ]; then case " ${found[*]} " in *" $lang "*) langs=("$lang");; *) echo "❌ --language=$lang, but the IR holds only: ${found[*]}" >&2; exit 1;; esac + elif [ ${#want[@]} -gt 1 ]; then + # A LANGUAGE ASKED FOR AND NOT FOUND IS NAMED, NEVER DROPPED IN SILENCE. The caller counted files by + # extension; the parser finds PROJECTS, and a file no project claims is not in the IR. The others are + # still solved, and the caller is told which one is missing. + langs=(); missing=() + for l in "${want[@]}"; do case " ${found[*]} " in *" $l "*) langs+=("$l");; *) missing+=("$l");; esac; done + [ ${#missing[@]} -gt 0 ] && echo "⚠️ asked for ${missing[*]}, but the IR holds no such language (it holds: ${found[*]})" >&2 + [ ${#langs[@]} -gt 0 ] || { echo "❌ --language=$lang, but the IR holds only: ${found[*]}" >&2; exit 1; } fi echo "▶ languages: ${langs[*]}" # ONE LANGUAGE'S FAILURE MUST NOT COST THE OTHERS THEIR GRAPHS. Under `set -e` this loop diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 7271f7757..793aebb83 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -35,7 +35,8 @@ Rules that decide whether an answer means anything: the background after edits, with the flags it was built with; a query waits a few seconds for it. An answer that still predates an edit says so on a `graph refresh:` line naming the files: read those for their current text. A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. -- When building: `--lang typescript --src src` on a mixed repo; `--library ` so calls into dependencies +- 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 294bf75fc..6c5f78aa9 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -40,6 +40,8 @@ 'python': ('.py', '.pyi'), 'csharp': ('.cs', '.csproj', '.props', '.targets', '.sln'), } +# the SOURCE files of each language: what makes a repository "have" that language (axiomcode-build counts the same) +SOURCE = {'java': ('.java',), 'typescript': ('.ts', '.tsx'), 'python': ('.py',), 'javascript': ('.js', '.mjs', '.cjs', '.jsx'), 'csharp': ('.cs',)} NAMES = { 'java': ('lombok.config',), 'typescript': ('package.json',), @@ -72,6 +74,14 @@ def python_script(p, name=None): return bool(PY_SHEBANG.match(head.decode('utf-8', 'replace'))) def watched(root, lang): + """every file the parser of `lang` reads under root; `lang` may be a comma list (a repository in several + languages, each with its own graph), and a file two of them read is yielded once""" + if ',' in lang: + seen = set() + for l in lang.split(','): + for p in watched(root, l): + if p not in seen: seen.add(p); yield p + return exts, names, prune = EXT.get(lang, ()), NAMES.get(lang, ()), PRUNE.get(lang, PRUNE_ALL) for d, subdirs, files in os.walk(root): subdirs[:] = [s for s in subdirs if s not in prune] @@ -117,6 +127,16 @@ def changes(repo, table=None): try: if digest(p) != o[2]: changed.append(rel) except OSError: changed.append(rel) + if 'lang_auto' not in t: + # A TABLE FROM BEFORE EVERY LANGUAGE WAS INDEXED watches its one language, so a repository whose other languages + # were dropped looked fresh until that language's files changed. Another language's source is new to it: the + # refresh that follows detects the languages again (worker), once, and records a table that knows them all. + mine = set(t.get('lang', '').split(',')) + for l in SOURCE: + if l in mine: continue + for p in watched(src, l): + rel = os.path.relpath(p, repo) + if p.endswith(SOURCE[l]) and rel not in old: added.append(rel); break return sorted(changed), sorted(added), sorted(set(old) - seen) def read_state(repo): @@ -132,15 +152,28 @@ def write_state(repo, **kv): def has_graph(repo): return os.path.exists(os.path.join(out_dir(repo), 'graph.sqlite')) +def graph_lang(): + """the language whose graph a verb reads when it is not the main one (AXIOMCODE_GRAPH_LANG, set by the dispatcher + as it asks each language's graph in turn), else ''""" + return os.environ.get('AXIOMCODE_GRAPH_LANG', '') + +def graph_dir(repo): + """the directory holding the current graph (/out/graph.sqlite) of the language being asked: .axiomcode for + the main language, .axiomcode/lang/ for any other""" + l = graph_lang() + return os.path.join(repo, '.axiomcode', 'lang', l) if l else os.path.join(repo, '.axiomcode') + def baseline_graph(repo): """the graph directory that describes the BASELINE `changed` measures edits against, when it is not the current graph: after a background refresh the current graph describes the edited tree, and the one it replaced is kept - in .axiomcode/base (axiomcode-build, keep_base_graph). None when the current graph is the baseline's.""" - b = os.path.join(repo, '.axiomcode', 'base') + in .axiomcode/base (axiomcode-build, keep_base_graph); another language's beside it, in .axiomcode/base/lang/. + None when the current graph is the baseline's.""" + root = os.path.join(repo, '.axiomcode', 'base') + b = os.path.join(root, 'lang', graph_lang()) if graph_lang() else root try: base = open(os.path.join(out_dir(repo), 'base-tree')).read().strip() indexed = open(os.path.join(out_dir(repo), 'indexed-tree')).read().strip() - if base and base != indexed and open(os.path.join(b, 'tree')).read().strip() == base and os.path.exists(os.path.join(b, 'out', 'graph.sqlite')): + if base and base != indexed and open(os.path.join(root, 'tree')).read().strip() == base and os.path.exists(os.path.join(b, 'out', 'graph.sqlite')): return b except OSError: pass return None @@ -258,9 +291,13 @@ def worker(repo): if st.get('failed_table') == fp: return 0 n = sum(len(x) for x in c) why = (f"{n} file(s) changed" if n else f"HEAD moved to {(head(repo) or '')[:10]}") + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" - env = dict(os.environ, AXIOMCODE_LANG=t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), + # languages DETECTED are detected again (a language the repository gained since gets its graph); only a + # --lang the user gave is kept. A table from before this was recorded counts as detected: it was built with + # one language even where the repository had several, and keeping that would keep the others out for good + env = dict(os.environ, AXIOMCODE_LANG='' if t.get('lang_auto', True) else t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) env.pop('AXIOMCODE_LIBRARY', None) + env.pop('AXIOMCODE_LANG_AUTO', None); env.pop('AXIOMCODE_GRAPH_LANG', None) if t.get('library'): env['AXIOMCODE_LIBRARY'] = t['library'] 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) @@ -347,7 +384,7 @@ def main(argv): if cmd == 'snapshot': lang, src_arg = argv[3], (argv[4] if len(argv) > 4 else '') lib = os.environ.get('AXIOMCODE_LIBRARY', '') - json.dump(dict(lang=lang, src=src_arg.strip('/'), src_arg=src_arg, library=lib, built=time.time(), + json.dump(dict(lang=lang, lang_auto=bool(os.environ.get('AXIOMCODE_LANG_AUTO')), src=src_arg.strip('/'), src_arg=src_arg, library=lib, built=time.time(), files=snapshot(repo, lang, os.path.join(repo, src_arg))), sys.stdout); return 0 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/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py new file mode 100644 index 000000000..cac5818a5 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""ax_langs.py [args…] — ask every language's graph, for a repository written in several. + +axiomcode-build gives each language its own graph: the main one (most files) at .axiomcode/out/graph.sqlite, as it +always was, and every other at .axiomcode/lang//out/graph.sqlite. A graph holds one language and no call is +followed from one to another; what a question needs is that NONE of them is left out. So the verb runs once per graph +(AXIOMCODE_GRAPH / AXIOMCODE_GRAPH_LANG name it), all at once, and the answers are put together: + + one graph answers its answer, as it would be alone (another language's is headed with its name) + several answer each, headed with its language, the main one first + none answers each graph's refusal, headed, and the main graph's exit status + --json the answering graph's object, as it would be alone; when several answer, the main one's + (or the first) with `other_languages`: {: } added. A reader that + knows one language reads the object it always read. + +An answer is exit status 0; a refusal (nothing by that name in this graph) is not. Status 3 is a graph with nothing to +say: `changed` and `test-impact` asked about an edit none of whose files is that graph's language (owner() below). It is +left out of the answer, and when every graph says so the main graph's "no change" is the answer. +""" +import concurrent.futures, glob, json, os, subprocess, sys + +H = os.path.dirname(os.path.abspath(__file__)) + + +def graphs(repo): + """[(language, graph dir)], the main graph first as ('', None): its dir is the default the verbs already use""" + out = [('', None)] + for d in sorted(glob.glob(os.path.join(repo, '.axiomcode', 'lang', '*'))): + if os.path.isfile(os.path.join(d, 'out', 'graph.sqlite')): out.append((os.path.basename(d), d)) + return out + + +# the language whose graph a NEW file belongs to, by extension, in order of preference: a .js file is the JavaScript +# graph's when there is one (the parser gives it to that front end), else TypeScript's, which reads JavaScript too +BY_EXT = {'.py': ('python',), '.pyi': ('python',), '.java': ('java',), '.cs': ('csharp',), + '.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',), + '.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'), '.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')} + + +def owners(repo, files): + """{file: the language whose graph answers for it ('' = the main graph)}. A file a graph holds is that graph's; a file + none holds (new, or skipped) goes by its extension to a language that has a graph, else to the main graph. Every + file has exactly one owner, so an edit is reported once, by the graph that can read it.""" + import sqlite3 + gs = graphs(repo); main = main_language(repo); held = {} + for lang, d in gs: + db = os.path.join(d or os.path.join(repo, '.axiomcode'), 'out', 'graph.sqlite') + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + for (f,) in con.execute("SELECT DISTINCT rel FROM paths"): held.setdefault(f, lang) + con.close() + except Exception: pass + have = {l for l, _ in gs if l} | {main} + out = {} + for f in files: + if f in held: out[f] = held[f]; continue + pick = next((l for l in BY_EXT.get(os.path.splitext(f)[1], ()) if l in have), '') + out[f] = '' if pick == main else pick + return out + + +def main_language(repo): + import sqlite3 + try: + con = sqlite3.connect(f"file:{os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')}?mode=ro", uri=True) + v = con.execute("SELECT value FROM run WHERE key = 'language'").fetchone(); con.close() + return v[0] if v else 'main' + except Exception: return 'main' + + +def ask(script, args, lang, gdir): + env = dict(os.environ) + env['AXIOMCODE_FANOUT'] = '1' + if gdir: env.update(AXIOMCODE_GRAPH=gdir, AXIOMCODE_GRAPH_LANG=lang) + r = subprocess.run([sys.executable, os.path.join(H, script)] + args, env=env, capture_output=True, text=True) + return r.returncode, r.stdout, r.stderr + + +def main(argv): + repo, script, args = os.path.realpath(argv[0]), argv[1], argv[2:] + gs = graphs(repo) + if len(gs) == 1: # one language: the verb itself, nothing added + os.execv(sys.executable, [sys.executable, os.path.join(H, script)] + args) + first = main_language(repo) + with concurrent.futures.ThreadPoolExecutor(len(gs)) as ex: + res = list(ex.map(lambda g: ask(script, args, *g), gs)) + named = [(g[0] or first, g[0] == '', *r) for g, r in zip(gs, res)] + if all(n[2] == 3 for n in named): # no graph has anything to say: the main one says so + sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]); return 0 + named = [n for n in named if n[2] != 3] + answered = [n for n in named if n[2] == 0] + + if '--json' in args and answered: + objs = [] + for lang, is_main, rc, out, err in answered: + try: objs.append((lang, json.loads(out))) + except ValueError: objs.append((lang, out)) + base_lang, base = objs[0] + if len(objs) > 1: + if not isinstance(base, dict): base = {'answer': base} + base = dict(base, language=base_lang, other_languages={l: o for l, o in objs[1:]}) + print(json.dumps(base, indent=1)) + else: + print(json.dumps(base, indent=1) if not isinstance(base, str) else base, end='' if isinstance(base, str) else '\n') + sys.stderr.write(''.join(n[4] for n in answered)) + return 0 + + show = answered or named + if not answered and len({(n[3], n[4]) for n in named}) == 1: # the same refusal from every graph: once + print(f"══ {', '.join(n[0] for n in named)} graphs ══"); sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]) + return named[0][2] + for i, (lang, is_main, rc, out, err) in enumerate(show): + if len(show) > 1 or not is_main: + print(('' if i == 0 else '\n') + f"══ {lang} graph ══" + ('' if is_main else f" (.axiomcode/lang/{lang})"), flush=True) + sys.stdout.write(out); sys.stdout.flush(); sys.stderr.write(err); sys.stderr.flush() + return 0 if answered else named[0][2] + + +if __name__ == '__main__': + if len(sys.argv) < 3: 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 3dafbbc7f..25e875ff4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -43,8 +43,9 @@ # write the preference into the repository's CLAUDE.md as one marked block: for a question about how code connects, # prefer this over grep/Glob. States a preference, blocks nothing; re-running replaces, --remove takes it out. # -# --lang picks the language when the repo mixes several (auto-detection counts files, so a TypeScript project with many -# .js test scripts is taken for JavaScript); --src limits the analysed tree (e.g. src); --library names dependency roots. +# A repo in several languages is indexed in all of them, one graph each, and the query verbs ask every graph (the main +# 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. H="$(cd "$(dirname "$0")" && pwd)" # PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not @@ -117,13 +118,17 @@ if [ -f "$FR/.axiomcode/out/graph.sqlite" ]; then changed|test-impact|tests) python3 "$H/ax_fresh.py" baseline "$FR" "${AXIOMCODE_BASELINE_WAIT:-30}" || true ;; esac fi +# 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. +Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_langs.py" case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; - context) exec python3 "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; - path) exec python3 "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; - impact) exec python3 "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; - changed) exec python3 "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; - test-impact|tests) exec python3 "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + context) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + path) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; + impact) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; + test-impact|tests) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; graph) exec python3 "$H/axiomcode-graph" build ${ARGS[@]+"${ARGS[@]}"} ;; install) exec python3 "$H/axiomcode-install" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # what this dispatcher answers to; bin/axiomcode asks, so the two cannot drift diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index bb7fe597a..c1b468c84 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -4,9 +4,13 @@ # symbols, refs, literals, comments, nesting, views). Prints how much of the graph is resolved vs unknown. # AXIOMCODE_ENGINE checkout of axiomcodegraph (default: the one this skill lives in) # AXIOMCODE_SRC subtree to analyse (e.g. one module of a monorepo); the graph still lives at /.axiomcode -# AXIOMCODE_LANG restrict to java | typescript | python | javascript | csharp (default: the language with the most files) +# AXIOMCODE_LANG restrict to java | typescript | python | javascript | csharp, or a comma list of them, the first being +# the main one (default: every language with files; the main one is the language with the most) # AXIOMCODE_LIBRARY comma-separated library roots (source trees or IR) passed as --library; without them, calls into # dependencies are declared unknown rather than guessed +# A repository in several languages gets one graph per language. The main language's is the one it always had, +# .axiomcode/out/graph.sqlite; every other language's is .axiomcode/lang//out/graph.sqlite, and the query verbs +# (the `axiomcode` dispatcher) ask each of them. Each graph is one language: no call is followed from one to another. set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "${1:-.}" && pwd)"; SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" @@ -51,13 +55,25 @@ ENGINE="$AXIOMCODE_ENGINE" count(){ find "$SRC" \( -path '*/node_modules' -o -path '*/.axiomcode' -o -path '*/.git' -o -path '*/.venv' -o -path '*/venv' -o -path '*/site-packages' -o -path '*/dist' -o -path '*/build' \) -prune -o -type f \( "$@" \) -print 2>/dev/null | wc -l | tr -d ' '; } py=$(( $(count -name '*.py') + $(python3 "$H/ax_fresh.py" pyscripts "$SRC" 2>/dev/null || echo 0) )); ja=$(count -name '*.java'); ts=$(count -name '*.ts' -o -name '*.tsx'); js=$(count -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.jsx'); cs=$(count -name '*.cs') LANG_ARG="${AXIOMCODE_LANG:-}" +# EVERY LANGUAGE IN THE REPOSITORY IS INDEXED. Only the one with the most files used to be, and the rest were dropped +# without a word: a Python edit in a TypeScript repository touched "no declaration the graph knows". LANG_ARG is the +# main language (its graph is the one every older reader knows); LANGS is all of them, main first. if [ -z "$LANG_ARG" ]; then LANG_ARG=java; best=$ja [ "$ts" -gt "$best" ] && { LANG_ARG=typescript; best=$ts; } [ "$py" -gt "$best" ] && { LANG_ARG=python; best=$py; } [ "$js" -gt "$best" ] && { LANG_ARG=javascript; best=$js; } [ "$cs" -gt "$best" ] && { LANG_ARG=csharp; best=$cs; } + LANGS="$LANG_ARG" + for l in java:$ja typescript:$ts python:$py javascript:$js csharp:$cs; do + [ "${l%%:*}" != "$LANG_ARG" ] && [ "${l#*:}" -gt 0 ] && LANGS="$LANGS,${l%%:*}" + done + export AXIOMCODE_LANG_AUTO=1 # recorded in the file table: a refresh detects the languages again +else + LANGS="$LANG_ARG"; LANG_ARG="${LANGS%%,*}"; unset AXIOMCODE_LANG_AUTO fi +unset AXIOMCODE_GRAPH_LANG # a build writes every language's graph, never one chosen for a query +OTHER_LANGS=""; [ "$LANGS" != "$LANG_ARG" ] && OTHER_LANGS="${LANGS#*,}" OUT="$REPO/.axiomcode/out"; mkdir -p "$OUT" # ONE BUILD AT A TIME PER REPOSITORY. The background refresher (ax_fresh.py) and an explicit `axiomcode index` both # write $OUT/$LANG_ARG; two at once clobber each other's graph. The lock is on fd 9, which this shell holds for its @@ -69,7 +85,7 @@ if ! python3 "$H/ax_fresh.py" lock 9 --try; then fi GD="$(cd "$REPO" && git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" [ -n "$GD" ] && mkdir -p "$GD/info" && { grep -qx '.axiomcode' "$GD/info/exclude" 2>/dev/null || echo '.axiomcode' >> "$GD/info/exclude"; } || true -STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANG_ARG-${AXIOMCODE_SRC:-.}-${AXIOMCODE_LIBRARY:-nolib}" +STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANGS-${AXIOMCODE_SRC:-.}-${AXIOMCODE_LIBRARY:-nolib}" # ASKING FOR LESS IS NOT A CACHE MISS. The stamp includes the library roots, so a bare `index` on a repo whose # graph was built WITH roots does not match, and the rebuild that follows spends the full build cost to arrive # at a client-only graph: every call into a dependency ambiguous_unknown, the resolution rate understated, @@ -79,7 +95,7 @@ STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANG_ARG-$ # The library field is a list of PATHS, and paths contain hyphens (`other-lib`), so the stamp cannot be split # on its last hyphen -- that cuts inside the path and no two stamps ever compare equal. Split on the known # prefix instead: everything before the library field is fixed-format and built right here. -PREFIX="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANG_ARG-${AXIOMCODE_SRC:-.}-" +PREFIX="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANGS-${AXIOMCODE_SRC:-.}-" if [ -z "${AXIOMCODE_LIBRARY:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ] && [ -f "$OUT/stamp" ] && [ -f "$OUT/graph.sqlite" ]; then PREV="$(cat "$OUT/stamp")" if [ "${PREV#"$PREFIX"}" != "$PREV" ] && [ "${PREV#"$PREFIX"}" != nolib ]; then @@ -122,8 +138,14 @@ keep_base_graph(){ if [ -z "$BT" ] || [ "$BT" = "$INDEXED_TREE" ]; then rm -rf "$B"; return 0; fi if [ "$BT" = "$OLD_INDEXED" ] && [ -n "$PREV" ] && [ -f "$PREV/graph.sqlite" ] && [ ! -L "$PREV/graph.sqlite" ]; then rm -rf "$B"; mkdir -p "$B/out"; mv "$PREV/graph.sqlite" "$B/out/graph.sqlite"; echo "$BT" > "$B/tree"; BASE_SAVED=1 + # every other language's graph still describes the same tree: it is replaced only after this, so a hard link keeps + # it for the baseline while the live one stays in place for the queries that arrive meanwhile + local l; for l in ${OTHER_LANGS//,/ }; do + [ -f "$LANGDIR/$l/out/graph.sqlite" ] && mkdir -p "$B/lang/$l/out" && ln -f "$LANGDIR/$l/out/graph.sqlite" "$B/lang/$l/out/graph.sqlite" + done elif [ "$(cat "$B/tree" 2>/dev/null)" != "$BT" ]; then build_head_graph "$BT" fi + return 0 } # the baseline is HEAD and the working tree is not: no graph describes HEAD's text, so one is built from it, exported # with `git archive` into a scratch directory (the user's checkout and index are never touched). node_modules is not in @@ -140,6 +162,10 @@ build_head_graph(){ D="$(readlink "$T/.axiomcode/out/graph.sqlite" 2>/dev/null || true)" if [ -n "$D" ] && [ -f "$D" ]; then rm -rf "$REPO/.axiomcode/base"; mkdir -p "$REPO/.axiomcode/base/out"; mv "$D" "$REPO/.axiomcode/base/out/graph.sqlite" + for D in "$T"/.axiomcode/lang/*/out/graph.sqlite; do # and every other language's, beside it + [ -f "$D" ] || continue; local L; L="$(basename "$(dirname "$(dirname "$D")")")" + mkdir -p "$REPO/.axiomcode/base/lang/$L/out"; mv "$D" "$REPO/.axiomcode/base/lang/$L/out/graph.sqlite" + done echo "$1" > "$REPO/.axiomcode/base/tree"; BASE_SAVED=1 fi fi @@ -147,14 +173,15 @@ build_head_graph(){ rm -rf "$T" } BASE_SAVED="" +LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other than the main one: /out/graph.sqlite # FRESH IS "THE FILES THE GRAPH WAS BUILT FROM", NOT "A CLEAN GIT STATUS". The file table (ax_fresh.py) records every # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANG_ARG" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && 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" - echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0 + echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 fi if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" @@ -164,9 +191,10 @@ python3 "$H/dl_program.py" || true # compile the query rules once, here where # a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so # left to first use it is killed every time, caches nothing, and repeats on the next edit forever. # a graph from before the file table: it was built from this clean commit, so the table of the files now IS its table - python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANG_ARG" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" + python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0; fi -echo "building $LANG_ARG graph for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)" +if [ -z "$OTHER_LANGS" ]; then echo "building $LANG_ARG graph for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)" +else echo "building ${LANGS//,/ + } graphs for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)"; fi # THE TREE THE GRAPH IS BUILT FROM (#1222). The graph's spans are line numbers in the working tree as the parser reads # it, and that tree need not be the commit in the stamp: uncommitted edits are indexed too. `changed` compared the # working tree against the COMMIT, so after an index with edits every position was off, and a span past the end of the @@ -175,7 +203,7 @@ echo "building $LANG_ARG graph for $SRC … (java $ja · typescript $ts · pytho # build succeeds. # THE FILE TABLE, taken now for the same reason: an edit made while the parser runs must leave the graph stale, so the # next refresh picks it up. Committed only if the build succeeds. -python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANG_ARG" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" +python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" INDEXED_TREE="" if [ -n "$GD" ]; then TMPIDX="$(mktemp "${TMPDIR:-/tmp}/axiomcode-index.XXXXXX")" @@ -200,13 +228,30 @@ if [ -d "$OUT/$LANG_ARG" ]; then if [ -f "$OUT/$LANG_ARG/graph.sqlite" ]; then ln -f "$OUT/$LANG_ARG/graph.sqlite" "$OUT/.live.sqlite" && point "$OUT/.live.sqlite"; fi mv "$OUT/$LANG_ARG" "$PREV" fi +# the other languages' engine output goes to $OUT/, which the engine wants empty. Their live graphs are not there +# (they are under .axiomcode/lang), but graph.sqlite may still point there, at a graph from a build whose main language +# was that one: it gets the same hard link first +for l in ${OTHER_LANGS//,/ }; do + if [ -d "$OUT/$l" ]; then + [ "$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$OUT/graph.sqlite" 2>/dev/null)" = "$(cd "$OUT/$l" && pwd -P)/graph.sqlite" ] \ + && ln -f "$OUT/$l/graph.sqlite" "$OUT/.live.sqlite" && point "$OUT/.live.sqlite" + rm -rf "$OUT/$l" + fi +done restore(){ rm -rf "$OUT/$LANG_ARG"; if [ -d "$PREV" ]; then mv "$PREV" "$OUT/$LANG_ARG"; [ -f "$OUT/$LANG_ARG/graph.sqlite" ] && point "$OUT/$LANG_ARG/graph.sqlite"; fi; rm -f "$OUT/.live.sqlite"; return 0; } LIB=(); [ -n "${AXIOMCODE_LIBRARY:-}" ] && LIB=(--library "$AXIOMCODE_LIBRARY") -"$ENGINE/bin/axiomcode" all "$SRC" "$OUT" --language "$LANG_ARG" ${LIB[@]+"${LIB[@]}"} > "$REPO/.axiomcode/build.log" 2>&1 || { restore; echo "build failed — the graph that was there is untouched; see $REPO/.axiomcode/build.log"; tail -5 "$REPO/.axiomcode/build.log"; exit 1; } +ENGINE_RC=0 +"$ENGINE/bin/axiomcode" all "$SRC" "$OUT" --language "$LANGS" ${LIB[@]+"${LIB[@]}"} > "$REPO/.axiomcode/build.log" 2>&1 || ENGINE_RC=$? +# one language failing does not cost the others their graphs (the engine solves each on its own): only the MAIN +# language's failure fails the build. Another language's is named below, never dropped in silence. +if [ "$ENGINE_RC" != 0 ] && { [ -z "$OTHER_LANGS" ] || [ ! -f "$OUT/$LANG_ARG/graph.sqlite" ]; }; then + restore; echo "build failed — the graph that was there is untouched; see $REPO/.axiomcode/build.log"; tail -5 "$REPO/.axiomcode/build.log"; exit 1 +fi # the new database: a FILE under $OUT/$LANG_ARG. Matched as `*/$LANG_ARG/*` it was any path with the language's name # in it, the repository's own included: a repository at .../python/app matched the graph.sqlite SYMLINK, the link was # pointed at itself, the previous graph deleted, and every verb after it reported no graph. -DB="$(find "$OUT/$LANG_ARG" -type f -name graph.sqlite 2>/dev/null | head -1)"; [ -n "$DB" ] || DB="$(find "$OUT" -type f -name graph.sqlite -not -path "$PREV/*" | head -1)" +DB="$(find "$OUT/$LANG_ARG" -type f -name graph.sqlite 2>/dev/null | head -1)"; NOT_OTHER=(); for l in ${OTHER_LANGS//,/ }; do NOT_OTHER+=(-not -path "$OUT/$l/*"); done # never another language's graph +[ -n "$DB" ] || DB="$(find "$OUT" -type f -name graph.sqlite -not -path "$PREV/*" ${NOT_OTHER[@]+"${NOT_OTHER[@]}"} | head -1)" [ -n "$DB" ] || { restore; echo "build produced no graph.sqlite — the graph that was there is untouched; see $REPO/.axiomcode/build.log"; exit 1; } # A QUERY NEVER READS A HALF-BUILT GRAPH. The new database gets its symbols, refs and views BEFORE graph.sqlite points # at it; until the pointer moves, every verb (and the hooks) keeps answering from the previous graph. Only then is the @@ -217,7 +262,7 @@ AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo # the tree and commit it describes. 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. REASON="$(printf %s "${AXIOMCODE_REFRESH_REASON:-axiomcode index}" | tr -d "'")" -python3 - "$DB" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$REASON" "$INDEXED_TREE" "$HEAD_SHA" <<'PY' 2>/dev/null || true +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 db, at, reason, tree, commit = sys.argv[1:6] with sqlite3.connect(db) as c: @@ -225,12 +270,31 @@ with sqlite3.connect(db) as c: c.executemany("INSERT INTO index_meta VALUES (?,?)", [('refreshed_at', at), ('refresh_reason', reason), ('refreshed_tree', tree), ('refreshed_commit', commit)]) PY +} +stamp_meta "$DB" +# EVERY OTHER LANGUAGE'S GRAPH, indexed where the engine wrote it and only then moved into place, as the main one is. +# One that did not build is NAMED: its previous graph, if any, keeps answering, and this build says it is behind. +READY=(); FAILED=() +for l in ${OTHER_LANGS//,/ }; do + LDB="$OUT/$l/graph.sqlite" + if [ -f "$LDB" ] && AXIOMCODE_INDEX_DB="$LDB" python3 "$H/axiomcode-index" "$REPO"; then stamp_meta "$LDB"; READY+=("$l"); else FAILED+=("$l"); fi +done echo "$STAMP" > "$OUT/stamp" if [ -n "$INDEXED_TREE" ]; then echo "$INDEXED_TREE" > "$OUT/indexed-tree"; else rm -f "$OUT/indexed-tree"; fi set_base "$INDEXED_TREE" point "$DB"; 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 -keep_base_graph; commit_base +keep_base_graph +for l in ${READY[@]+"${READY[@]}"}; do + mkdir -p "$LANGDIR/$l/out"; mv -f "$OUT/$l/graph.sqlite" "$LANGDIR/$l/out/graph.sqlite" # a rename: atomic + rmdir "$OUT/$l" 2>/dev/null || true +done +# a language the repository no longer has (or that --lang left out) must not keep answering from an old graph +for d in "$LANGDIR"/*/; do + [ -d "$d" ] || continue; l="$(basename "$d")" + case ",$OTHER_LANGS," in *",$l,"*) ;; *) rm -rf "$d" "$REPO/.axiomcode/base/lang/$l";; esac +done +commit_base rm -rf "$PREV" python3 - "$OUT/graph.sqlite" <<'PY' import sqlite3, sys @@ -245,4 +309,19 @@ python3 "$H/dl_program.py" || true # compile the query rules once, here where # a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so # left to first use it is killed every time, caches nothing, and repeats on the next edit forever. [ -n "$BASE_SAVED" ] && AXIOMCODE_GRAPH="$REPO/.axiomcode/base" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true -echo "graph at $OUT/graph.sqlite" +for l in ${READY[@]+"${READY[@]}"}; do + echo "$l: $(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")))' "$LANGDIR/$l/out/graph.sqlite")" + AXIOMCODE_GRAPH="$LANGDIR/$l" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true + [ -n "$BASE_SAVED" ] && [ -f "$REPO/.axiomcode/base/lang/$l/out/graph.sqlite" ] && { AXIOMCODE_GRAPH="$REPO/.axiomcode/base/lang/$l" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true; } +done +if [ ${#FAILED[@]} -gt 0 ]; then + for l in "${FAILED[@]}"; do + if [ -f "$LANGDIR/$l/out/graph.sqlite" ]; then echo "⚠️ the $l graph did not build: $l questions are answered from its PREVIOUS graph, which predates this tree; see $REPO/.axiomcode/build.log" + else echo "⚠️ the $l graph did not build: no query sees this repository's $l code; see $REPO/.axiomcode/build.log"; fi + done +fi +if [ ${#READY[@]} -gt 0 ]; then + echo "graph at $OUT/graph.sqlite ($LANG_ARG)$(for l in "${READY[@]}"; do printf ', %s' "$LANGDIR/$l/out/graph.sqlite ($l)"; done)" +else + echo "graph at $OUT/graph.sqlite" +fi diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 498e383be..379b72c42 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -478,10 +478,13 @@ def main(argv): # hook's before-the-edit check) the current graph is the one whose spans are in the current file's lines. # 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 + baseline = None; fanout_empty = False + sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): - sys.path.insert(0, HERE); import ax_fresh bg = ax_fresh.baseline_graph(os.path.realpath(repo)); 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 + # 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(os.path.realpath(repo))): given = None if 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 @@ -492,6 +495,14 @@ def main(argv): r, n = C.file_changes(rel, old, new); results += r; notes += n else: files = C.files(mode, rng, a) + # A REPOSITORY IN SEVERAL LANGUAGES has a graph per language, and the dispatcher asks each (ax_langs.py). Each + # answers for the files it owns, so an edit is reported once, by the graph of its language, and not also as + # "no declarations known here" by every other. + if os.path.isdir(os.path.join(C.repo, '.axiomcode', 'lang')): + import ax_langs + mine = ax_fresh.graph_lang() + files = [f for f, o in ax_langs.owners(C.repo, files).items() if o == mine] + if not files and os.environ.get('AXIOMCODE_FANOUT'): fanout_empty = True # the graph describes ONE commit. For a range whose newer side is that commit, the declarations are read on the new # text and the classification runs with the two texts swapped, then its direction is turned around flipped = False @@ -514,8 +525,8 @@ def main(argv): else: r, n = C.file_changes(rel, old, new); results += r; notes += n if flipped: notes.append(('', f"the graph is built at the newer commit {C.built_at[:10]}: declarations are the new text's; a declaration the range removed is not in it", None)) - if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes]}, indent=1)); return 0 - if not results and not notes: print("no change to a declaration the graph knows" + (f" (working tree against {C.built_at[:10]})" if C.built_at and mode == 'worktree' else '')); return 0 + if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes]}, indent=1)); return 3 if fanout_empty else 0 + if not results and not notes: print("no change to a declaration the graph knows" + (f" (working tree against {C.built_at[:10]})" if C.built_at and mode == 'worktree' else '')); 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 else f" — against the graph's commit {C.built_at[:10]}") print(f"changed declarations ({len(results)})" + (against if C.built_at and mode == 'worktree' else (f" — {rng}" if rng else '')) + ":") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index c1bcb3745..9154a8f96 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -227,9 +227,12 @@ def main(argv): # the edit is measured against the baseline, and so is who reached it: after a background refresh that is the # graph kept in .axiomcode/base, where a removed method still has its callers (#1305) - if '--range' not in passthru and not os.environ.get('AXIOMCODE_GRAPH'): - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh - bg = ax_fresh.baseline_graph(os.path.realpath(next((a for a in passthru if os.path.isdir(a)), '.'))) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + repo_ = os.path.realpath(next((a for a in passthru if os.path.isdir(a)), '.')) + given = os.environ.get('AXIOMCODE_GRAPH') + # the current graph of the language the dispatcher is asking is not a graph chosen for this edit (see changed) + if '--range' not in passthru and (not given or os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(repo_))): + bg = ax_fresh.baseline_graph(repo_) if bg: os.environ['AXIOMCODE_GRAPH'] = bg rc, out, err = run('axiomcode-changed', passthru + ['--json']) if rc != 0: diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 1372cb61e..eb73fe93a 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -35,7 +35,8 @@ Rules that decide whether an answer means anything: the background after edits, with the flags it was built with; a query waits a few seconds for it. An answer that still predates an edit says so on a `graph refresh:` line naming the files: read those for their current text. A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. -- When building: `--lang typescript --src src` on a mixed repo; `--library ` so calls into dependencies +- 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 diff --git a/tests/README.md b/tests/README.md index 581f3cd3d..d353f75a5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,6 +35,9 @@ One check needs no graph and is its own script: python3 tests/engine_choice.py axiomcode-build picks a built engine over an unbuilt clone it sits in python3 tests/no_symlink.py impact and the Datalog path answer where os.symlink is refused, as it is for an unelevated Windows user (WinError 1314); indexes a case, so it needs the engine + python3 tests/multi_language.py a repository in several languages is indexed in all of them and every query asks + each graph: nothing dropped, a one-graph answer unchanged, an edit reported once, + refreshed, upgraded from a one-language graph (builds real graphs, 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/multi_language.py b/tests/multi_language.py new file mode 100644 index 000000000..7aa2a1a1b --- /dev/null +++ b/tests/multi_language.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""tests/multi_language.py — a repository in several languages is indexed in all of them, and asked in all of them. + +It used to be indexed as the language with the most files, and every other language was dropped without a word: no +symbol, no edge, and `changed` on an edit in the dropped language said the edit touched nothing the graph knows. Each +language now has its own graph (the main one where it always was, the others in .axiomcode/lang/), and the +query verbs ask all of them. A graph holds one language; no call is followed from one to another. + +One throwaway repository in TypeScript (the main language: most files), Python and JavaScript: + + build every language gets a graph, and the build names each + queries a name declared in each language is found, in its own graph; one that is nowhere is refused once + compatible a name only the main graph holds is answered exactly as that graph answers alone (no header, same + bytes), and --json keeps its shape, adding `other_languages` only when several graphs answer + changed an edit in two languages is reported once each, by the graph of its language; a clean tree once + refresh an edit in a language other than the main one makes the graph stale, and a query sees it + --lang restricting the languages removes the others' graphs, which must not keep answering + upgrade a graph built before this (one language, a file table without `lang_auto`) is stale in a repository + with other languages, so the first refresh indexes them + control a repository in one language gets no .axiomcode/lang and no fan-out + + python3 tests/multi_language.py [-v] +""" +import json, os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') +FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') + +FILES = { + 'tsconfig.json': '{ "compilerOptions": { "strict": true }, "include": ["src"] }\n', + 'src/util.ts': 'export function square(x: number): number {\n return x * x\n}\n', + 'src/shape.ts': 'import { square } from \'./util\'\n\nexport function area(r: number): number {\n return 3 * square(r)\n}\n', + 'src/main.ts': 'import { area } from \'./shape\'\n\nexport function run(): number {\n return area(2)\n}\n', + 'src/extra.ts': 'export function onlyInTs(): number {\n return 7\n}\n', + 'tools/pkg/__init__.py': '', + 'tools/pkg/calc.py': 'def add(a, b):\n return a + b\n\n\ndef total(xs):\n t = 0\n for x in xs:\n t = add(t, x)\n return t\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', +} + + +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 settle(repo, env): + """wait until no background refresh is running: a query that refreshed starts one, and a check that reads the + graph's state or rebuilds it must not race it""" + # the refresher loops: after a rebuild it waits out the debounce and checks again, so one look between two of its + # passes is not quiet. Quiet is several seconds with no build + deadline, quiet_since = time.time() + 600, None + while time.time() < deadline: + busy = json.loads(sh(repo, sys.executable, FRESH, 'status', '.', '--json', env=env).stdout or '{}').get('state') == 'building' + quiet_since = None if busy else (quiet_since or time.time()) + if quiet_since and time.time() - quiet_since > 3: return + time.sleep(0.2) + + +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 = tempfile.mkdtemp(prefix='axiomcode-multi-') + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_REFRESH_DEBOUNCE='0.5', AXIOMCODE_FRESH_WAIT='600') + env.pop('AXIOMCODE_LANG', None); env.pop('AXIOMCODE_GRAPH', None) + quiet = dict(env, AXIOMCODE_NO_REFRESH='1') + try: + repo = os.path.join(work, 'mixed'); make(repo, FILES) + lang_dir = os.path.join(repo, '.axiomcode', 'lang') + + # ── build ───────────────────────────────────────────────────────────────────────────────────────────── + b = sh(repo, AX, 'index', '.', env=quiet) + graphs = {l: os.path.exists(os.path.join(lang_dir, l, 'out', 'graph.sqlite')) for l in ('python', 'javascript')} + check(b.returncode == 0 and all(graphs.values()) and os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')), + 'build: the main language (typescript) and every other language (python, javascript) get a graph', b.stdout + b.stderr + json.dumps(graphs)) + check('typescript + python + javascript' in b.stdout and '(python)' in b.stdout and '(javascript)' in b.stdout, + 'build: the output names every language it indexed', b.stdout) + if b.returncode: return 1 + + # ── queries ─────────────────────────────────────────────────────────────────────────────────────────── + p = sh(repo, AX, 'impact', 'add', '.', env=quiet) + check(p.returncode == 0 and '══ python graph' in p.stdout and 'tools/pkg/calc.py' in p.stdout and 'total' in p.stdout, + 'queries: a Python function in a TypeScript repository is found, with its caller, in the python graph', p.stdout + p.stderr) + j = sh(repo, AX, 'path', 'api', 'helper', '.', env=quiet) + check(j.returncode == 0 and 'verified' in j.stdout and '══ javascript graph' in j.stdout, + 'queries: a JavaScript chain is found in the javascript graph', j.stdout + j.stderr) + n = sh(repo, AX, 'impact', 'nowhereAtAll', '.', env=quiet) + check(n.returncode != 0 and n.stdout.count('nothing named') == 1, + 'queries: a name in no graph is refused, once, with a non-zero status', n.stdout + n.stderr) + + # ── compatible ──────────────────────────────────────────────────────────────────────────────────────── + alone = sh(repo, AX, 'impact', 'onlyInTs', '.', env=dict(quiet, AXIOMCODE_GRAPH=os.path.join(repo, '.axiomcode'))) + fan = sh(repo, AX, 'impact', 'onlyInTs', '.', env=quiet) + check(fan.returncode == 0 and fan.stdout == alone.stdout and '══' not in fan.stdout, + 'compatible: a name only the main graph holds is answered exactly as the main graph answers alone', f"alone:\n{alone.stdout}\nfanned out:\n{fan.stdout}") + js = sh(repo, AX, 'impact', 'square', '.', '--json', env=quiet) + try: d = json.loads(js.stdout) + except ValueError: d = {} + check('direct' in d and 'other_languages' not in d and any('shape.ts' in (e.get('at') or '') for e in d.get('direct', [])), + 'compatible: --json from one graph is that graph\'s object, unchanged in shape', js.stdout[-800:] + js.stderr) + + # ── changed ─────────────────────────────────────────────────────────────────────────────────────────── + 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) + 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) + 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) + 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) + 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) + + # ── refresh ─────────────────────────────────────────────────────────────────────────────────────────── + open(calc, 'a').write('\n\ndef added_later(xs):\n return total(xs)\n') + st = json.loads(sh(repo, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') + check(st.get('state') == 'stale' and 'tools/pkg/calc.py' in st.get('changed', []), + 'refresh: an edit in a language other than the main one makes the graph stale', json.dumps(st)) + q = sh(repo, AX, 'path', 'added_later', 'add', '.', env=env) + check(q.returncode == 0 and 'verified' in q.stdout, 'refresh: a query refreshes the graphs and finds the new Python function', q.stdout + q.stderr) + sh(repo, 'git', 'checkout', '-q', '--', '.') + settle(repo, quiet) + + # ── --lang ──────────────────────────────────────────────────────────────────────────────────────────── + r = sh(repo, AX, 'index', '.', '--lang', 'typescript', env=quiet) + check(r.returncode == 0 and (not os.path.exists(lang_dir) or not os.listdir(lang_dir)), + '--lang: restricting to one language removes the other languages\' graphs', r.stdout + r.stderr) + g = sh(repo, AX, 'impact', 'add', '.', env=quiet) + check(g.returncode != 0, '--lang: and a name in a removed language is no longer answered', g.stdout) + + # ── upgrade ─────────────────────────────────────────────────────────────────────────────────────────── + settle(repo, quiet) + tp = os.path.join(repo, '.axiomcode', 'out', 'files.json'); tab = json.load(open(tp)) + tab.pop('lang_auto', None); json.dump(tab, open(tp, 'w')) # as a build from before this wrote it + st = json.loads(sh(repo, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') + check(st.get('state') == 'stale', 'upgrade: a one-language graph from before is stale in a repository with other languages', json.dumps(st)) + q = sh(repo, AX, 'impact', 'add', '.', env=env) + check(q.returncode == 0 and '══ python graph' in q.stdout, 'upgrade: and the first query indexes them', q.stdout + q.stderr) + + # ── control ─────────────────────────────────────────────────────────────────────────────────────────── + one = os.path.join(work, 'one'); make(one, {k: v for k, v in FILES.items() if k.startswith(('src/', 'tsconfig'))}) + b = sh(one, AX, 'index', '.', env=quiet) + check(b.returncode == 0 and not os.path.exists(os.path.join(one, '.axiomcode', 'lang')) and 'building typescript graph for' in b.stdout, + 'control: a repository in one language builds one graph, as before', b.stdout + b.stderr) + tab = json.load(open(os.path.join(one, '.axiomcode', 'out', 'files.json'))) + check(tab.get('lang') == 'typescript', 'control: its file table names the one language', json.dumps({k: tab.get(k) for k in ('lang', 'lang_auto')})) + st = json.loads(sh(one, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') + check(st.get('state') == 'fresh', 'control: and it is fresh', json.dumps(st)) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAILED: ' + str(len(fails)) if fails else 'all passed'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) From 2121512398c5228e617ed21b4d9c07dc2fed0eec Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:20:14 -0700 Subject: [PATCH 08/19] javascript: a JSX element is an edge to the component it renders `` produced no call site, so impact on a React component said nothing uses it and path could not cross a prop. The parser already emits the element with its tag reference and each attribute by name; the engine now reads them (call-edge-generation/jsx.dl): - the tag's value resolves like a callee: a function component, or a class component's constructor and render; the row is kind JSX_ELEMENT, tiered like a call, and a tag with no value is ambiguous_unknown rather than silence - the attributes are an object value keyed by the element and flow into the component's first parameter (or this.props), so `onToggle(todo)` inside `TodoItem({ onToggle })` resolves to the handler the parent passed; spread attributes and a brace child (props.children) flow too - intrinsic tags (`
`) stay out, including next to a project function named `div` Golden case 62-jsx-component-render covers each shape with those controls. On a real React/Redux app: 363 -> 432 edges (known_edge 74 -> 96), no edge lost, solve time unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 1 + graph/bundle/schema.ts | 1 + graph/javascript/README.md | 6 +- .../engine/call-edge-generation/jsx.dl | 84 +++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 8 ++ graph/javascript/souffle/export_manifest.tsv | 1 + .../cases/62-jsx-component-render/src/App.jsx | 26 ++++++ .../src/components/Panel.jsx | 7 ++ .../src/components/TodoItem.jsx | 4 + .../src/components/TodoList.jsx | 9 ++ .../src/components/ui.jsx | 3 + .../62-jsx-component-render/src/main.jsx | 4 + .../expected/62-jsx-component-render.diag | 4 + .../expected/62-jsx-component-render.edges | 18 ++++ .../expected/62-jsx-component-render.oracle | 8 ++ .../test/javascript/tools/normalize_edges.py | 3 +- 16 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 graph/javascript/engine/call-edge-generation/jsx.dl create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/App.jsx create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx create mode 100644 graph/test/javascript/cases/62-jsx-component-render/src/main.jsx create mode 100644 graph/test/javascript/expected/62-jsx-component-render.diag create mode 100644 graph/test/javascript/expected/62-jsx-component-render.edges create mode 100644 graph/test/javascript/expected/62-jsx-component-render.oracle diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 525b62110..43d91eca5 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -507,6 +507,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `TAGGED_TEMPLATE_CALL` | javascript | tag`…`. | | `DYNAMIC_CODE_CALL` | javascript | `eval(…)` / `new Function(…)` — unknowable by construction. | | `DYNAMIC_IMPORT_CALL` | javascript | `import(…)` — a module load that is also a site. | +| `JSX_ELEMENT` | javascript | `` — the renderer runs the component (a function component, or a class component's constructor and `render`) with the element's attributes as its props. Engine-authored: no written call; the site is the JSX element expression. An intrinsic tag (`
`) is no site. | | `SIMPLE_CALL` | python | `f(…)` — a bare name. | | `METHOD_CALL` | python | `obj.m(…)`. | | `CHAINED_CALL` | python | `a.b().c(…)` — the receiver is itself a call. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index dad9c147e..f94f8cd50 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -640,6 +640,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'TAGGED_TEMPLATE_CALL', languages: S, meaning: 'tag`…`.' }, { table: 'call_edges', column: 'kind', value: 'DYNAMIC_CODE_CALL', languages: S, meaning: '`eval(…)` / `new Function(…)` — unknowable by construction.' }, { table: 'call_edges', column: 'kind', value: 'DYNAMIC_IMPORT_CALL', languages: S, meaning: '`import(…)` — a module load that is also a site.' }, + { table: 'call_edges', column: 'kind', value: 'JSX_ELEMENT', languages: S, meaning: '`` — the renderer runs the component (a function component, or a class component\'s constructor and `render`) with the element\'s attributes as its props. Engine-authored: no written call; the site is the JSX element expression. An intrinsic tag (`
`) is no site.' }, // — Python (the parser's callKind, plus engine-authored decorator/metaclass forms) { table: 'call_edges', column: 'kind', value: 'SIMPLE_CALL', languages: P, meaning: '`f(…)` — a bare name.' }, { table: 'call_edges', column: 'kind', value: 'METHOD_CALL', languages: P, meaning: '`obj.m(…)`.' }, diff --git a/graph/javascript/README.md b/graph/javascript/README.md index b3595b612..6a26b2abb 100644 --- a/graph/javascript/README.md +++ b/graph/javascript/README.md @@ -191,8 +191,10 @@ constructor), `TYPE_ONLY_TARGET` (the compiler named a JSDoc function type), ## What is not built -* **JSX component calls do not exist in the IR.** The JavaScript schema has no JSX call - kind, so `` is neither a site nor a miss; the oracle counts and excludes them. +* **JSX components reached through a wrapper are not followed.** `` is an edge + (kind `JSX_ELEMENT`, call-edge-generation/jsx.dl) and its attributes are the props the + component receives; a tag whose value is `memo(C)`, `connect(…)(C)` or another unstaged + library's return stays `ambiguous_unknown`. The oracle still excludes JSX sites. * **Primitive results are not typed.** `this.greet().toUpperCase()` is `ambiguous_unknown`, not `ambient_terminal`: nothing here knows `greet` returns a string. diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl new file mode 100644 index 000000000..9a63833e7 --- /dev/null +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -0,0 +1,84 @@ +// ============================================================================ +// CALL-EDGE-GEN · JSX ELEMENTS (`` renders Component) +// +// A JSX element whose tag is a reference — ``, `` — is a call +// of that component by the renderer: a function component runs with the element's +// props as its first argument; a class component is constructed with them and its +// `render` runs. The parser emits the element as a JSX_ELEMENT expression with the +// tag under JSX_TAG_NAME and each attribute as a JSX_ATTRIBUTE_VALUE row (role +// PROPERTY_KEY, name = the prop) whose PROPERTY_VALUE child is the value. No +// js_call_site row exists for it, so before this file the element was neither an +// edge nor a miss: `impact TodoList` answered "nothing uses it". +// +// An INTRINSIC tag (`
`, ``) is JSX_INTRINSIC_ELEMENT and has no +// tag reference: it names no project code and gets no row here. +// +// Two effects, both through the ordinary value layer: +// 1. an edge per element: call_chain_edge(Element, Caller, -, Component, …, +// "JSX_ELEMENT"), tiered like a call (one target known_edge, several +// multi_inferred); a tag with no value is ambiguous_unknown — a declared miss, +// never silence. +// 2. the element's attributes are an object value keyed by the element, and it is +// what the component receives: `` makes +// `onToggle(todo)` inside `TodoItem({ onToggle })` resolve to handleToggle. +// Brace children are the `children` prop (a render prop is a function). +// +// Not modelled: a component reached only through a library wrapper +// (`memo(C)`, `connect(…)(C)`, `forwardRef(fn)`) whose library is not staged — the +// tag's value is the wrapper's return, which is unknown, and the element stays an +// ambiguous_unknown row. +// ============================================================================ + +// ── the element and what its tag holds ────────────────────────────────────── +jsx_element_tag(je, tag) :- expr_kind(_, "JSX_ELEMENT", _, je), expr_child(_, je, "JSX_TAG_NAME", _, tag). +jsx_tag_value(je, k, i) :- jsx_element_tag(je, tag), expr_value(tag, k, i). +// A CommonJS module value renders through its default export, as a call of it does. +jsx_tag_value(je, k, i) :- jsx_element_tag(je, tag), expr_value(tag, "module", mod), + module_export_value(mod, "default", k, i), k != "module". + +// ── jsx_renders(Element, Component) — the callables the element runs ─────── +// A function component (a constructor FUNCTION is callable_value's "ctor" half). +jsx_renders(je, m) :- jsx_tag_value(je, k, i), callable_value(k, i, m). +// A class component: constructed, then rendered. +jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_construct_target(t, m). +jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_member(t, "render", m). + +// ── the props object ──────────────────────────────────────────────────────── +// Keyed by the element, as an object literal is keyed by the literal. +prop_value("obj", je, n, k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "PROPERTY_KEY", _, a), expr_kind(_, "JSX_ATTRIBUTE_VALUE", _, a), expr_name(_, n, a), + expr_child(_, a, "PROPERTY_VALUE", _, v), expr_value(v, k, i). +// `` hands over every property of `rest`. +prop_value("obj", je, n, k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "SPREAD_OPERAND", _, o), expr_value(o, k0, i0), prop_value(k0, i0, n, k, i). +// `{(x) => …}` — a brace child is `props.children`. +prop_value("obj", je, "children", k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "OPERAND", _, c), expr_value(c, k, i). + +// A function component's first parameter receives the props — client functions only, +// and not a fan-capped one, exactly as argument flow at a call. +param_value(p, "obj", je) :- jsx_tag_value(je, k, i), k != "ctor", callable_value(k, i, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + param_decl(_, _, "0", m, p), !param_is_rest(_, p). +// A class component: its constructor receives them, and `this.props` holds them. +param_value(p, "obj", je) :- jsx_tag_value(je, "ctor", t), type_construct_target(t, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + 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). + +// ── 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. +jsx_caller(je, m) :- jsx_element_tag(je, _), expr_owner(_, m, _, je), m != "". +jsx_caller(je, init) :- jsx_element_tag(je, _), expr_owner(_, "", mod, je), module_init(_, init, mod). +jsx_target_count(je, n) :- jsx_element_tag(je, _), n = count : { jsx_renders(je, _) }. +jsx_class(je, "known_edge") :- jsx_target_count(je, 1). +jsx_class(je, "multi_inferred") :- jsx_target_count(je, n), n >= 2. + +call_chain_edge(je, caller, "-", m, "client", cls, "JSX_ELEMENT") :- jsx_renders(je, m), + method_prov(m, "client"), jsx_caller(je, caller), jsx_class(je, cls). +call_chain_edge(je, caller, "-", m, "lib", "boundary_lib", "JSX_ELEMENT") :- jsx_renders(je, m), + method_prov(m, "lib"), jsx_caller(je, caller). +// A tag that names something the value layer could not follow: a declared unknown. +call_chain_edge(je, caller, "-", "-", "-", "ambiguous_unknown", "JSX_ELEMENT") :- + jsx_target_count(je, 0), jsx_caller(je, caller). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index c4ad3ef53..6a91bfde3 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -42,6 +42,14 @@ .decl accessor_target_count(c0:symbol, c1:symbol, c2:number) .decl accessor_class(c0:symbol, c1:symbol, c2:symbol) +// ── call-edge-generation/jsx.dl ── +.decl jsx_element_tag(c0:symbol, c1:symbol) +.decl jsx_tag_value(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_renders(c0:symbol, c1:symbol) +.decl jsx_caller(c0:symbol, c1:symbol) +.decl jsx_target_count(c0:symbol, c1:number) +.decl jsx_class(c0:symbol, c1:symbol) + // ── call-edge-generation/calls.dl ── .decl method_prov(c0:symbol, c1:symbol) .decl client_calls_client(c0:symbol, c1:symbol, c2:symbol, c3:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index b4dbc3496..b90de230f 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -40,3 +40,4 @@ site_in_partial_module site-in-partial-module.csv 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 diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx new file mode 100644 index 000000000..538db49ad --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx @@ -0,0 +1,26 @@ +import { TodoList } from './components/TodoList.jsx'; +import Panel from './components/Panel.jsx'; +import * as ui from './components/ui.jsx'; + +// CONTROL: a project function spelled like an intrinsic tag. `
` is intrinsic and +// must not become an edge to it. +function div() { return 'not a component'; } + +// CONTROL: named only in a string, never rendered — no edge. +function Unused() { return null; } +const label = 'Unused'; + +export default function App({ title }) { + const extra = { onClose: closeAll }; + return ( +
+ + + + {(item) => format(item)} +
+ ); +} + +function closeAll() { return div(); } +function format(item) { return String(item); } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx new file mode 100644 index 000000000..b3033b90a --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx @@ -0,0 +1,7 @@ +// A class component with no constructor, as most are: rendered, and `this.props` +// holds the attributes. +export default class Panel { + render() { + return
this.props.onClose()} />; + } +} diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx new file mode 100644 index 000000000..b74829bcd --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx @@ -0,0 +1,4 @@ +// Props arrive destructured; the onToggle prop is whatever the element passed. +export default function TodoItem({ todo, onToggle }) { + return
  • onToggle(todo)}>{todo.text}
  • ; +} diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx new file mode 100644 index 000000000..c09e7390a --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx @@ -0,0 +1,9 @@ +import TodoItem from './TodoItem.jsx'; + +export function TodoList({ heading }) { + function handleToggle(todo) { return save(todo); } + const todos = [{ text: heading }]; + return
      {todos.map((t) => )}
    ; +} + +function save(todo) { return todo; } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx new file mode 100644 index 000000000..743dfa1a9 --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx @@ -0,0 +1,3 @@ +export function Badge(props) { return {props.count}; } +// A render prop: the brace child is props.children. +export function List(props) { return
      {[1, 2].map((x) => props.children(x))}
    ; } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx new file mode 100644 index 000000000..cc5033bb0 --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx @@ -0,0 +1,4 @@ +import App from './App.jsx'; +// Top-level markup belongs to the module initializer. +mount(); +function mount(el) { return el; } diff --git a/graph/test/javascript/expected/62-jsx-component-render.diag b/graph/test/javascript/expected/62-jsx-component-render.diag new file mode 100644 index 000000000..6073284d3 --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.diag @@ -0,0 +1,4 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved App.jsx:26:32 FUNCTION_CALL String no_target +unresolved components/TodoList.jsx:6:15 METHOD_CALL map no_target +unresolved components/ui.jsx:3:43 METHOD_CALL map no_target diff --git a/graph/test/javascript/expected/62-jsx-component-render.edges b/graph/test/javascript/expected/62-jsx-component-render.edges new file mode 100644 index 000000000..05290ffbf --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.edges @@ -0,0 +1,18 @@ +App.jsx:17:7 JSX_ELEMENT -> known_edge components/TodoList.jsx:3:1 TodoList +App.jsx:18:7 JSX_ELEMENT -> known_edge components/Panel.jsx:4:3 render +App.jsx:19:7 JSX_ELEMENT -> known_edge components/ui.jsx:1:1 Badge +App.jsx:20:27 FUNCTION_CALL format -> known_edge App.jsx:26:1 format +App.jsx:20:7 JSX_ELEMENT {(item) => format(item)} -> known_edge components/ui.jsx:3:1 List +App.jsx:25:30 FUNCTION_CALL div -> known_edge App.jsx:7:1 div +App.jsx:26:32 FUNCTION_CALL String -> ambient_terminal - +components/Panel.jsx:5:36 METHOD_CALL this.props.onClose -> known_edge App.jsx:25:1 closeAll +components/TodoItem.jsx:3:29 FUNCTION_CALL onToggle -> known_edge components/TodoList.jsx:4:3 handleToggle +components/TodoList.jsx:4:40 FUNCTION_CALL save -> known_edge components/TodoList.jsx:9:1 save +components/TodoList.jsx:6:15 METHOD_CALL todos.map -> ambient_terminal - +components/TodoList.jsx:6:15 METHOD_CALL todos.map -> callback_registered components/TodoList.jsx:6:25 +components/TodoList.jsx:6:32 JSX_ELEMENT -> known_edge components/TodoItem.jsx:2:1 TodoItem +components/ui.jsx:3:43 METHOD_CALL [1, 2].map -> ambient_terminal - +components/ui.jsx:3:43 METHOD_CALL [1, 2].map -> callback_registered components/ui.jsx:3:54 +components/ui.jsx:3:61 METHOD_CALL props.children -> known_edge App.jsx:20:17 +main.jsx:3:1 FUNCTION_CALL mount -> known_edge main.jsx:4:1 mount +main.jsx:3:7 JSX_ELEMENT -> known_edge App.jsx:13:1 App diff --git a/graph/test/javascript/expected/62-jsx-component-render.oracle b/graph/test/javascript/expected/62-jsx-component-render.oracle new file mode 100644 index 000000000..b79c5b4f1 --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.oracle @@ -0,0 +1,8 @@ +App.jsx:20:27 FUNCTION_CALL format EXACT App.jsx:26:1 +App.jsx:25:30 FUNCTION_CALL div EXACT App.jsx:7:1 +App.jsx:26:32 FUNCTION_CALL String LIB_AMBIENT_OK +components/TodoList.jsx:4:40 FUNCTION_CALL save EXACT components/TodoList.jsx:9:1 +components/TodoList.jsx:6:15 METHOD_CALL map LIB_AMBIENT_OK +components/ui.jsx:3:43 METHOD_CALL map LIB_AMBIENT_OK +main.jsx:3:1 FUNCTION_CALL mount EXACT main.jsx:4:1 +# defects: 0 diff --git a/graph/test/javascript/tools/normalize_edges.py b/graph/test/javascript/tools/normalize_edges.py index c219389d2..8c515c322 100644 --- a/graph/test/javascript/tools/normalize_edges.py +++ b/graph/test/javascript/tools/normalize_edges.py @@ -51,7 +51,8 @@ def methods(d, tag): if h: i = {c: h.index(c) for c in ('jsExpressionUniqueHash', 'ownerModuleLinkHash', 'startLine', 'startColumn', 'expressionKind', 'text')} for r in exprs: - if r[i['expressionKind']] in ('PROPERTY_ACCESS', 'OPTIONAL_ACCESS', 'ELEMENT_ACCESS'): + # A JSX element edge is keyed on the JSX_ELEMENT expression, likewise no call site. + if r[i['expressionKind']] in ('PROPERTY_ACCESS', 'OPTIONAL_ACCESS', 'ELEMENT_ACCESS', 'JSX_ELEMENT'): access[r[i['jsExpressionUniqueHash']]] = '%s:%s:%s %%s %s' % ( modfile.get(r[i['ownerModuleLinkHash']], '?'), r[i['startLine']], r[i['startColumn']], r[i['text']].replace('\n', ' ')[:60]) From a055664e5cdcc18532a427c0e72a44e203392acf Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:36:56 -0700 Subject: [PATCH 09/19] typescript: a JSX component tag is a call to the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` in a .tsx file produced no call site, so impact on a component said nothing renders it and path between a page and anything its components call found the two independent. The parser now emits a component element (``, `…`) as a JSX_ELEMENT / JSX_SELF_CLOSING row with its tag as the callee, and a JSX_COMPONENT_CALL call site; a member tag (``) is a METHOD_CALL on its receiver. An intrinsic element (`
    `) still emits nothing. Elements nested in children, ternaries and `.map` arrows are reached as their own roots. The engine rules for JSX_COMPONENT_CALL were already written; one rule is added so a wrapped component resolves to what renders: argument 0 of `memo(...)` / `forwardRef(...)`, and the default export of the module a `lazy(() => import(...))` loader returns. Case 10's known-missing JSX edges now resolve; case 74 pins function, default export, typed const, class, memo, forwardRef and lazy, with an intrinsic element and a non-JSX factory as controls. The compiler oracle no longer crashes on an intrinsic element's synthesised signature. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- graph/bundle/SCHEMA.md | 2 +- graph/bundle/schema.ts | 2 +- .../cases/74-jsx-component-forms/src/app.tsx | 24 ++++++ .../74-jsx-component-forms/src/parts.tsx | 73 ++++++++++++++++++ .../74-jsx-component-forms/src/settings.tsx | 7 ++ .../expected/10-jsx-component-calls.edges | 4 + .../expected/10-jsx-component-calls.entries | 6 ++ .../10-jsx-component-calls.known-missing | 2 - .../expected/10-jsx-component-calls.lib.edges | 4 + .../10-jsx-component-calls.lib.known-missing | 3 - .../10-jsx-component-calls.lib.oracle | 5 +- .../expected/10-jsx-component-calls.oracle | 4 +- .../expected/74-jsx-component-forms.edges | 23 ++++++ .../expected/74-jsx-component-forms.entries | 4 + .../expected/74-jsx-component-forms.envelope | 5 ++ .../expected/74-jsx-component-forms.fields | 8 ++ .../74-jsx-component-forms.fields-oracle | 18 +++++ .../74-jsx-component-forms.known-missing | 9 +++ .../74-jsx-component-forms.lib.known-missing | 9 +++ .../expected/74-jsx-component-forms.oracle | 8 ++ .../expected/74-jsx-component-forms.type-use | 5 ++ .../74-jsx-component-forms.types-oracle | 7 ++ .../typescript/ground-truth/tsc-program.mjs | 4 +- graph/typescript/PARSER-DEFECTS.md | 5 ++ graph/typescript/README.md | 8 +- .../callee-resolution.dl | 28 +++++++ graph/typescript/souffle/decls_all.dl | 2 + .../enums/typescript/call-sites/TsCallKind.ts | 15 ++-- .../expressions/TsExpressionKind.ts | 15 ++-- .../extractors/ts-expression-extractor.ts | 76 +++++++++++++++++-- .../extractors/ts-expression-walker.ts | 13 +++- .../extractors/ts-resolution-linker.ts | 5 ++ parser/src/test/typescript-tests.ts | 65 +++++++++++----- 33 files changed, 407 insertions(+), 61 deletions(-) create mode 100644 graph/test/typescript/cases/74-jsx-component-forms/src/app.tsx create mode 100644 graph/test/typescript/cases/74-jsx-component-forms/src/parts.tsx create mode 100644 graph/test/typescript/cases/74-jsx-component-forms/src/settings.tsx create mode 100644 graph/test/typescript/expected/10-jsx-component-calls.entries create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.edges create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.entries create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.envelope create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.fields create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.fields-oracle create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.known-missing create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.lib.known-missing create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.oracle create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.type-use create mode 100644 graph/test/typescript/expected/74-jsx-component-forms.types-oracle diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 525b62110..7a2a7415e 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -491,7 +491,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `DYNAMIC_IMPORT_CALL` | typescript | `import(…)`. | | `DECORATOR_CALL` | typescript | A decorator application `@d` / `@d(…)`. | | `OPTIONAL_CALL` | typescript | `f?.(…)`. | -| `JSX_COMPONENT_CALL` | typescript | `` (reserved by the parser; emitted by nothing yet). | +| `JSX_COMPONENT_CALL` | typescript | `` — a JSX element naming a component; `
    ` emits no site. | | `PROPERTY_READ` | typescript, javascript | Reading `obj.x` where `x` is a `get` accessor runs the getter (engine-authored). No written call; the site is the property-access expression. A compound assignment or `++` reads before it writes, so it carries this and PROPERTY_WRITE. | | `PROPERTY_WRITE` | typescript, javascript | Assigning `obj.x = v` where `x` is a `set` accessor runs the setter (engine-authored). No written call; the site is the property-access expression on the left. | | `FUNCTION_CALL` | javascript | `f(…)` — a bare callee, resolved by the binder. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index dad9c147e..669136cfb 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -623,7 +623,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'DYNAMIC_IMPORT_CALL', languages: T, meaning: '`import(…)`.' }, { table: 'call_edges', column: 'kind', value: 'DECORATOR_CALL', languages: T, meaning: 'A decorator application `@d` / `@d(…)`.' }, { table: 'call_edges', column: 'kind', value: 'OPTIONAL_CALL', languages: T, meaning: '`f?.(…)`.' }, - { table: 'call_edges', column: 'kind', value: 'JSX_COMPONENT_CALL', languages: T, meaning: '`` (reserved by the parser; emitted by nothing yet).' }, + { table: 'call_edges', column: 'kind', value: 'JSX_COMPONENT_CALL', languages: T, meaning: '`` — a JSX element naming a component; `
    ` emits no site.' }, { table: 'call_edges', column: 'kind', value: 'PROPERTY_READ', languages: ['typescript', 'javascript'], meaning: 'Reading `obj.x` where `x` is a `get` accessor runs the getter (engine-authored). No written call; the site is the property-access expression. A compound assignment or `++` reads before it writes, so it carries this and PROPERTY_WRITE.' }, { table: 'call_edges', column: 'kind', value: 'PROPERTY_WRITE', languages: ['typescript', 'javascript'], meaning: 'Assigning `obj.x = v` where `x` is a `set` accessor runs the setter (engine-authored). No written call; the site is the property-access expression on the left.' }, // — JavaScript (the parser's JsCallKind) diff --git a/graph/test/typescript/cases/74-jsx-component-forms/src/app.tsx b/graph/test/typescript/cases/74-jsx-component-forms/src/app.tsx new file mode 100644 index 000000000..752806ab3 --- /dev/null +++ b/graph/test/typescript/cases/74-jsx-component-forms/src/app.tsx @@ -0,0 +1,24 @@ +import Avatar, { Badge, Field, lazy, Panel, UserCard, factory, ui } from "./parts"; + +// lazy() around a dynamic import: the component is the module's default export. +const Settings = lazy(() => import("./settings")); + +export function App(): string { + return ( +
    + + + + + + + +
    plain
    +
    + ); +} + +// CONTROL: the factory product is called, never its argument. +export function useFactory(): unknown { + return factory(); +} diff --git a/graph/test/typescript/cases/74-jsx-component-forms/src/parts.tsx b/graph/test/typescript/cases/74-jsx-component-forms/src/parts.tsx new file mode 100644 index 000000000..91fc610fe --- /dev/null +++ b/graph/test/typescript/cases/74-jsx-component-forms/src/parts.tsx @@ -0,0 +1,73 @@ +// The shapes a component is DECLARED in, each rendered from app.tsx as a JSX tag. +// `` is an invocation of X whatever X is bound to: a function, an arrow held by +// a const, a default export, a class, or a wrapper's result around one of those. +// +// No React dependency: the wrappers are declared here with the signatures that matter. +declare global { + namespace JSX { + type Element = string; + interface ElementAttributesProperty { + props: unknown; + } + interface IntrinsicElements { + [name: string]: unknown; + } + } +} + +export type Fc

    = (props: P) => string; +export declare function memo

    (component: Fc

    ): Fc

    ; +export declare function forwardRef

    (render: (props: P, ref: unknown) => string): Fc

    ; +export declare function lazy

    (load: () => Promise<{ default: Fc

    }>): Fc

    ; +export declare function makeFactory(build: () => T): () => T; + +export function formatName(n: string): string { + return n.toUpperCase(); +} + +export function trackClick(id: string): string { + return id; +} + +// memo() around a NAMED function expression. +export const UserCard = memo(function UserCard(p: { id: string }): string { + return formatName(p.id); +}); + +// forwardRef() around a function declared elsewhere, passed by name. +function FieldImpl(p: { label: string }, ref: unknown): string { + return trackClick(p.label) + String(ref); +} +export const Field = forwardRef(FieldImpl); + +// An arrow held by a typed const. +export const Badge: Fc<{ n: number }> = (p) => String(p.n); + +// A default export. +export default function Avatar(p: { src: string }): string { + return p.src; +} + +// A class component: the constructor is the nameable target. +export class Panel { + props: { title: string }; + constructor(props: { title: string }) { + this.props = props; + } + render(): string { + return this.props.title; + } +} + +export const ui = { + Chip(p: { text: string }): string { + return p.text; + }, +}; + +// CONTROL: a non-JSX wrapper. A factory's argument 0 is not what its product runs +// when called, so `factory()` must not become a call to buildDefault. +export function buildDefault(): number { + return 1; +} +export const factory = makeFactory(buildDefault); diff --git a/graph/test/typescript/cases/74-jsx-component-forms/src/settings.tsx b/graph/test/typescript/cases/74-jsx-component-forms/src/settings.tsx new file mode 100644 index 000000000..da11f44df --- /dev/null +++ b/graph/test/typescript/cases/74-jsx-component-forms/src/settings.tsx @@ -0,0 +1,7 @@ +export function saveSettings(): number { + return 1; +} + +export default function Settings(): string { + return String(saveSettings()); +} diff --git a/graph/test/typescript/expected/10-jsx-component-calls.edges b/graph/test/typescript/expected/10-jsx-component-calls.edges index bc552ab40..f60020c7d 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.edges +++ b/graph/test/typescript/expected/10-jsx-component-calls.edges @@ -1,4 +1,8 @@ ambiguous_unknown CONSTRUCTOR_CALL components#LibApp() @L54 -> - +ambiguous_unknown JSX_COMPONENT_CALL components#LibApp() @L53 -> - ambiguous_unknown METHOD_CALL components#LibApp() @L54 -> - known_edge CONSTRUCTOR_CALL components#App() @L44 -> Widget#() +known_edge JSX_COMPONENT_CALL components#App() @L38 -> components#Panel(Props) +known_edge JSX_COMPONENT_CALL components#App() @L40 -> components#Panel(Props) +known_edge JSX_COMPONENT_CALL components#App() @L41 -> components#Badge() known_edge METHOD_CALL components#App() @L45 -> Widget#render() diff --git a/graph/test/typescript/expected/10-jsx-component-calls.entries b/graph/test/typescript/expected/10-jsx-component-calls.entries new file mode 100644 index 000000000..13c3a3184 --- /dev/null +++ b/graph/test/typescript/expected/10-jsx-component-calls.entries @@ -0,0 +1,6 @@ +── entry_point (5) ── + exported_from_entry_module components#App components.tsx:37 + exported_from_entry_module components#Badge components.tsx:27 + exported_from_entry_module components#LibApp components.tsx:51 + exported_from_entry_module components#Panel components.tsx:23 + unimported_module components# components.tsx:1 diff --git a/graph/test/typescript/expected/10-jsx-component-calls.known-missing b/graph/test/typescript/expected/10-jsx-component-calls.known-missing index ca7a45cfb..dd707ab70 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.known-missing +++ b/graph/test/typescript/expected/10-jsx-component-calls.known-missing @@ -1,5 +1,3 @@ # Accepted gaps for 10-jsx-component-calls — 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. -components#App() -> components#Badge() -components#App() -> components#Panel(Props) diff --git a/graph/test/typescript/expected/10-jsx-component-calls.lib.edges b/graph/test/typescript/expected/10-jsx-component-calls.lib.edges index c7304e8ee..7914377cf 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.lib.edges +++ b/graph/test/typescript/expected/10-jsx-component-calls.lib.edges @@ -1,4 +1,8 @@ boundary_lib CONSTRUCTOR_CALL components#LibApp() @L54 -> Theme#() +boundary_lib JSX_COMPONENT_CALL components#LibApp() @L53 -> ui#Card(CardProps) boundary_lib METHOD_CALL components#LibApp() @L54 -> Theme#color() known_edge CONSTRUCTOR_CALL components#App() @L44 -> Widget#() +known_edge JSX_COMPONENT_CALL components#App() @L38 -> components#Panel(Props) +known_edge JSX_COMPONENT_CALL components#App() @L40 -> components#Panel(Props) +known_edge JSX_COMPONENT_CALL components#App() @L41 -> components#Badge() known_edge METHOD_CALL components#App() @L45 -> Widget#render() diff --git a/graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing b/graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing index 2e95727c8..dd707ab70 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing +++ b/graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing @@ -1,6 +1,3 @@ # Accepted gaps for 10-jsx-component-calls — 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. -components#App() -> components#Badge() -components#App() -> components#Panel(Props) -components#LibApp() -> ui#Card(CardProps) diff --git a/graph/test/typescript/expected/10-jsx-component-calls.lib.oracle b/graph/test/typescript/expected/10-jsx-component-calls.lib.oracle index 28738518a..fa4000b6a 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.lib.oracle +++ b/graph/test/typescript/expected/10-jsx-component-calls.lib.oracle @@ -1,4 +1 @@ -oracle=7 engine=4 agree=4 missing=3 (known 3, NEW 0) extra=0 - known components#App() -> components#Badge() - known components#App() -> components#Panel(Props) - known components#LibApp() -> ui#Card(CardProps) +oracle=7 engine=7 agree=7 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/10-jsx-component-calls.oracle b/graph/test/typescript/expected/10-jsx-component-calls.oracle index 23af1b550..518b0e5c3 100644 --- a/graph/test/typescript/expected/10-jsx-component-calls.oracle +++ b/graph/test/typescript/expected/10-jsx-component-calls.oracle @@ -1,3 +1 @@ -oracle=4 engine=2 agree=2 missing=2 (known 2, NEW 0) extra=0 - known components#App() -> components#Badge() - known components#App() -> components#Panel(Props) +oracle=4 engine=4 agree=4 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/74-jsx-component-forms.edges b/graph/test/typescript/expected/74-jsx-component-forms.edges new file mode 100644 index 000000000..4bbd01501 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.edges @@ -0,0 +1,23 @@ +ambient_terminal FUNCTION_CALL app#() @L4 -> parts#lazy(() ={ default: Fc }) +ambient_terminal FUNCTION_CALL parts#() @L33 -> parts#memo(Fc) +ambient_terminal FUNCTION_CALL parts#() @L41 -> parts#forwardRef((props: P, ref: unknown) =) +ambient_terminal FUNCTION_CALL parts#() @L73 -> parts#makeFactory(() =) +ambiguous_unknown FUNCTION_CALL parts#Badge(?) @L44 -> - +ambiguous_unknown FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 -> - +ambiguous_unknown FUNCTION_CALL settings#Settings() @L6 -> - +ambiguous_unknown METHOD_CALL app#App() @L15 -> - +ambiguous_unknown METHOD_CALL parts#formatName(string) @L25 -> - +intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L4 -> - +known_edge FUNCTION_CALL app#useFactory() @L23 -> parts#()@L22 +known_edge FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 -> parts#trackClick(string) +known_edge FUNCTION_CALL parts#UserCard({ id: string }) @L34 -> parts#formatName(string) +known_edge FUNCTION_CALL settings#Settings() @L6 -> settings#saveSettings() +known_edge JSX_COMPONENT_CALL app#App() @L12 -> parts#Avatar({ src: string }) +known_edge JSX_COMPONENT_CALL app#App() @L13 -> Panel#({ title: string }) +known_edge JSX_COMPONENT_CALL app#App() @L14 -> settings#Settings() +multi_inferred JSX_COMPONENT_CALL app#App() @L10 -> parts#(T) +multi_inferred JSX_COMPONENT_CALL app#App() @L10 -> parts#FieldImpl({ label: string },unknown) +multi_inferred JSX_COMPONENT_CALL app#App() @L11 -> parts#(T) +multi_inferred JSX_COMPONENT_CALL app#App() @L11 -> parts#Badge(?) +multi_inferred JSX_COMPONENT_CALL app#App() @L9 -> parts#(T) +multi_inferred JSX_COMPONENT_CALL app#App() @L9 -> parts#UserCard({ id: string }) diff --git a/graph/test/typescript/expected/74-jsx-component-forms.entries b/graph/test/typescript/expected/74-jsx-component-forms.entries new file mode 100644 index 000000000..18dbc9725 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + exported_from_entry_module app#App app.tsx:6 + exported_from_entry_module app#useFactory app.tsx:22 + unimported_module app# app.tsx:1 diff --git a/graph/test/typescript/expected/74-jsx-component-forms.envelope b/graph/test/typescript/expected/74-jsx-component-forms.envelope new file mode 100644 index 000000000..3b3998b48 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.envelope @@ -0,0 +1,5 @@ +value parts#@18:21 -> parts# +value parts#@18:21 -> parts#UserCard +value parts#@20:47 -> parts#FieldImpl +value parts#@21:39 -> app# +value parts#@22:47 -> parts#buildDefault diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields b/graph/test/typescript/expected/74-jsx-component-forms.fields new file mode 100644 index 000000000..e5353c7e7 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields @@ -0,0 +1,8 @@ +ambiguous_unknown read parts#() -> - +known_edge read Panel#render() -> Panel#props +known_edge read Panel#render() -> { title: string }#title +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 +known_edge read parts#UserCard({ id: string }) -> { id: string }#id +known_edge write Panel#({ title: string }) -> Panel#props diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle new file mode 100644 index 000000000..da9a8ee2c --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle @@ -0,0 +1,18 @@ +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 + 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 + WRONG parts#FieldImpl({ label: string },unknown) READ { label: string }#label + WRONG parts#UserCard({ id: string }) READ { id: string }#id + MISSING Panel#render() READ Panel#title + MISSING parts#Avatar({ src: string }) READ parts#src + MISSING parts#Badge(?) READ parts#n + MISSING parts#Chip({ text: string }) READ parts#text + MISSING parts#FieldImpl({ label: string },unknown) READ parts#label + MISSING parts#UserCard({ id: string }) READ parts#id diff --git a/graph/test/typescript/expected/74-jsx-component-forms.known-missing b/graph/test/typescript/expected/74-jsx-component-forms.known-missing new file mode 100644 index 000000000..58e80c25a --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.known-missing @@ -0,0 +1,9 @@ +# Accepted gaps for 74-jsx-component-forms — 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. +# `` — a member of an object literal named as a tag: the same receiver shape +# as `ui.Chip({...})`, which the member rules do not resolve either. +app#App() -> parts#Chip({ text: string }) +# CONTROL, not a JSX gap: `factory()` calls the product a declared factory returns, a +# bodiless function type. Argument 0 of makeFactory must NOT stand in for it. +app#useFactory() -> parts#() diff --git a/graph/test/typescript/expected/74-jsx-component-forms.lib.known-missing b/graph/test/typescript/expected/74-jsx-component-forms.lib.known-missing new file mode 100644 index 000000000..58e80c25a --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.lib.known-missing @@ -0,0 +1,9 @@ +# Accepted gaps for 74-jsx-component-forms — 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. +# `` — a member of an object literal named as a tag: the same receiver shape +# as `ui.Chip({...})`, which the member rules do not resolve either. +app#App() -> parts#Chip({ text: string }) +# CONTROL, not a JSX gap: `factory()` calls the product a declared factory returns, a +# bodiless function type. Argument 0 of makeFactory must NOT stand in for it. +app#useFactory() -> parts#() diff --git a/graph/test/typescript/expected/74-jsx-component-forms.oracle b/graph/test/typescript/expected/74-jsx-component-forms.oracle new file mode 100644 index 000000000..8f5df142b --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.oracle @@ -0,0 +1,8 @@ +oracle=12 engine=15 agree=10 missing=2 (known 2, NEW 0) extra=5 + known app#App() -> parts#Chip({ text: string }) + known app#useFactory() -> parts#() + extra app#App() -> parts#Badge(?) + extra app#App() -> parts#FieldImpl({ label: string },unknown) + extra app#App() -> parts#UserCard({ id: string }) + extra app#App() -> settings#Settings() + extra app#useFactory() -> parts#()@L22 diff --git a/graph/test/typescript/expected/74-jsx-component-forms.type-use b/graph/test/typescript/expected/74-jsx-component-forms.type-use new file mode 100644 index 000000000..1c0c61098 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.type-use @@ -0,0 +1,5 @@ +ambiguous_unknown METHOD_RETURN 1 parts [METHOD_PARAM] -> - +known_edge FIELD_TYPE 3 parts [METHOD_PARAM] -> Fc +known_edge METHOD_PARAM 0 parts [METHOD_PARAM] -> Fc +known_edge METHOD_RETURN 0 parts [METHOD] -> Fc +known_edge VARIABLE_TYPE 0 parts [VARIABLE] -> Fc diff --git a/graph/test/typescript/expected/74-jsx-component-forms.types-oracle b/graph/test/typescript/expected/74-jsx-component-forms.types-oracle new file mode 100644 index 000000000..6be78a305 --- /dev/null +++ b/graph/test/typescript/expected/74-jsx-component-forms.types-oracle @@ -0,0 +1,7 @@ +74-jsx-component-forms [types] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 7 resolved 6 (85.7%) + tiers ambiguous_unknown=1 known_edge=6 + contexts FIELD_TYPE=1 METHOD_PARAM=1 METHOD_RETURN=4 VARIABLE_TYPE=1 + not scored: 1 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 43e83318f..ff8fb6a45 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -136,7 +136,9 @@ export function loadProgram(srcDir, libDir, toolName) { function labelOf(decl) { if (decl === undefined) return undefined; const sf = decl.getSourceFile(); - if (!reachable.has(path.resolve(sf.fileName))) return undefined; + // An intrinsic JSX element (`

    `) resolves to a signature the checker + // synthesises, whose declaration hangs in no file: it names nothing to score. + if (sf === undefined || !reachable.has(path.resolve(sf.fileName))) return undefined; let owner; let p = decl.parent; diff --git a/graph/typescript/PARSER-DEFECTS.md b/graph/typescript/PARSER-DEFECTS.md index f850f6eb6..6072a233d 100644 --- a/graph/typescript/PARSER-DEFECTS.md +++ b/graph/typescript/PARSER-DEFECTS.md @@ -16,6 +16,11 @@ day the defect is fixed. ## PD-TS-1 — JSX component calls are not emitted at all +**Status: fixed.** A component element emits a JSX_ELEMENT / JSX_SELF_CLOSING row and a +JSX_COMPONENT_CALL site; `callee-resolution.dl` rule (13) resolves it, and also follows a +wrapper's argument 0 (`memo`, `forwardRef`) and a `lazy()` loader's default export. Pinned +by cases 10 and 74. The measurement below is kept as the record of the defect. + **Severity: blocking for any React / Preact / Solid codebase.** `ts_call_site.callKind = JSX_COMPONENT_CALL` is declared in the schema (§4.15.1) and diff --git a/graph/typescript/README.md b/graph/typescript/README.md index 1b315cdd2..9b8d53be0 100644 --- a/graph/typescript/README.md +++ b/graph/typescript/README.md @@ -136,10 +136,10 @@ demonstrable false positive. Stated because a graph you cannot trust the boundaries of is not useful. -* **JSX component calls do not exist in the IR.** The engine's rules are written; the - parser emits zero rows carrying `JSX_COMPONENT_CALL` (PARSER-DEFECTS.md, PD-TS-1). - 144 of zustand's 4,346 sites are invisible today, and on a React application the - ratio would invert. +* **A JSX member tag is only as good as its receiver.** `` is a + `JSX_COMPONENT_CALL` resolved by tag name, including through `memo()` / `forwardRef()` + wrappers and `lazy(() => import(...))`; `` is a `METHOD_CALL` and resolves + only where `ui.Card(...)` would. `
    ` produces no site. * **Generic inference through a callback** — `map(f: (t: T) => U): U[]` — is not substituted. `U` is bound by the argument's return type, which is inference rather than substitution, and guessing it would fabricate. Those sites stay unresolved and diff --git a/graph/typescript/engine/expression-resolution/callee-resolution.dl b/graph/typescript/engine/expression-resolution/callee-resolution.dl index e8b21dff9..0cc00ad63 100644 --- a/graph/typescript/engine/expression-resolution/callee-resolution.dl +++ b/graph/typescript/engine/expression-resolution/callee-resolution.dl @@ -511,6 +511,34 @@ expr_call_candidate(ce, ctor) :- call_site("client", "JSX_COMPONENT_CALL", cn, _ call_module(cs, mod), name_binds(mod, cn, _, "TYPE", t), constructor_in_scope(t, ctor). +// A WRAPPED component — `const Card = memo(function Card() {…})`, `forwardRef(Inner)`, +// `observer(View)`. The binding holds whatever the wrapper returned, which is typed as +// the library's own component type, so the rules above find no function behind it. +// What renders is the function handed to the wrapper as argument 0, and a wrapper that +// did not render it would not be a component wrapper. JSX only: the same shape at an +// ordinary call site is a factory, and there argument 0 says nothing about what runs. +expr_call_candidate(ce, m) :- call_site("client", "JSX_COMPONENT_CALL", cn, _, _, ce, cs), + cn != "", + call_module(cs, mod), + name_binds(mod, cn, _, "VARIABLE", v), + jsx_wrapped_component(v, m). +jsx_wrapped_component(v, m) :- var_initializer("client", _, w, v), + expr_kind("client", "CALL_EXPRESSION", _, w), + expr_child("client", w, "ARGUMENT", "0", a), + expr_callable(a, m), + !jsx_loader_function(m). +// `lazy(() => import("./Settings"))` — argument 0 is a LOADER, not the component: it +// returns a module, and the component is that module's default export. The loader +// itself never renders anything, so it is excluded above. +jsx_loader_function(f) :- method_returns_expr(f, ie), + expr_namespace(ie, _, _). +jsx_wrapped_component(v, m) :- var_initializer("client", _, w, v), + expr_kind("client", "CALL_EXPRESSION", _, w), + expr_child("client", w, "ARGUMENT", "0", a), + expr_callable(a, f), + method_returns_expr(f, ie), + expr_namespace(ie, prov, mod2), + module_export(prov, mod2, "default", "METHOD", m). // ============================================================================ // DISPATCH FAN — a supertype-typed receiver reaches its implementations diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 881cb02b6..21cc23524 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -259,6 +259,8 @@ .decl invocation_site(c0:symbol,c1:symbol) .decl jdk_max_depth(c0:number) .decl jsx_intrinsic_site(c0:symbol) +.decl jsx_loader_function(c0:symbol) +.decl jsx_wrapped_component(c0:symbol,c1:symbol) .decl lib_boundary_reason(c0:symbol,c1:symbol) .decl lib_capped() .decl lib_export_relative_base(c0:symbol,c1:symbol) diff --git a/parser/src/enums/typescript/call-sites/TsCallKind.ts b/parser/src/enums/typescript/call-sites/TsCallKind.ts index 5adf14042..78e881229 100644 --- a/parser/src/enums/typescript/call-sites/TsCallKind.ts +++ b/parser/src/enums/typescript/call-sites/TsCallKind.ts @@ -29,13 +29,14 @@ * that resolved THROUGH AN INDEX SIGNATURE, which is a fact about the receiver's * TYPE. Syntax cannot tell the two apart, so syntax does not try. * - * ## JSX_COMPONENT_CALL is RESERVED and carries ZERO rows + * ## JSX_COMPONENT_CALL — `` is a call to Badge * - * TSX is out of the first freeze. The representation is settled — a JSX element - * IS a call to its component, with the whole props object as argument 0 and - * children folded into a reserved `children` prop, because a component has - * exactly one parameter and mapping attributes positionally would be wrong. The - * gate asserts the emptiness, so switching TSX on appears as a named failure. + * A JSX element whose tag names a binding IS a call to its component, with the + * whole props object as argument 0 and children folded into the `children` + * prop, because a component has exactly one parameter and mapping attributes + * positionally would be wrong. An intrinsic element (`
    `) names no + * declaration and produces no site; a member tag (``) is a + * METHOD_CALL on its receiver. * * Schema §4.15 c0, §4.15.1. */ @@ -58,6 +59,6 @@ export enum TsCallKind { DECORATOR_CALL = 'DECORATOR_CALL', /** `a?.b()` — same target as `a.b()`, different reachability. */ OPTIONAL_CALL = 'OPTIONAL_CALL', - /** RESERVED for TSX. Must carry zero rows in freeze 1; the gate asserts it. */ + /** `` — a JSX element whose tag is an identifier naming a component. */ JSX_COMPONENT_CALL = 'JSX_COMPONENT_CALL', } diff --git a/parser/src/enums/typescript/expressions/TsExpressionKind.ts b/parser/src/enums/typescript/expressions/TsExpressionKind.ts index 49269846a..6d42c2088 100644 --- a/parser/src/enums/typescript/expressions/TsExpressionKind.ts +++ b/parser/src/enums/typescript/expressions/TsExpressionKind.ts @@ -28,12 +28,13 @@ * expects, so parentheses are transparent and the operand takes their place. * `(a).b()` therefore has exactly the shape of `a.b()`. * - * ## The JSX members are RESERVED and carry ZERO rows + * ## JSX component elements are calls * - * TSX is out of the first freeze. The representation is decided — a JSX element - * IS a call to its component, with the whole props object as argument 0 — and - * nothing emits it. The gate asserts the emptiness, so the day TSX is switched - * on it appears as a named gate failure rather than as new rows nobody noticed. + * A JSX element whose tag names a component IS a call to it, with the whole + * props object as argument 0: JSX_ELEMENT / JSX_SELF_CLOSING, 1:1 with a call + * site, with the tag as its METHOD_NAME child. Attribute values and children are + * rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element emits + * no row. * * Schema §4.14 c0, §4.15.1. */ @@ -94,9 +95,9 @@ export enum TsExpressionKind { SPREAD_ELEMENT = 'SPREAD_ELEMENT', /** `import("m")` — a module edge, and a call site of kind DYNAMIC_IMPORT_CALL. */ DYNAMIC_IMPORT = 'DYNAMIC_IMPORT', - /** RESERVED for TSX. Carries zero rows in freeze 1. */ + /** `…` naming a component. 1:1 with a call site. */ JSX_ELEMENT = 'JSX_ELEMENT', - /** RESERVED for TSX. Carries zero rows in freeze 1. */ + /** `` naming a component. 1:1 with a call site. */ JSX_SELF_CLOSING = 'JSX_SELF_CLOSING', /** `delete a`, `typeof a`, `void a` — `operatorString` says which. */ DELETE_TYPEOF_VOID = 'DELETE_TYPEOF_VOID', diff --git a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts index a8b54c166..330e71177 100644 --- a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts @@ -44,11 +44,11 @@ import { EntityUtils } from '@/utils/entity-utils'; * `ts_type_reference` — the ONE expression-to-type edge in the schema, and a * type FK precisely so that no call-graph rule can cross it (§3.3). * - * `JSX_COMPONENT_CALL`, `JSX_ELEMENT`, `JSX_SELF_CLOSING`, - * `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` are reserved and emitted by nothing. - * TSX is out of freeze 1, the representation is decided (§4.15.1), and the gate - * asserts the emptiness so that switching TSX on shows up as a gate failure - * rather than as new rows appearing unremarked. + * A JSX element naming a component is a call (§4.15.1): a JSX_ELEMENT or + * JSX_SELF_CLOSING row with its tag as the METHOD_NAME child, and a + * JSX_COMPONENT_CALL site. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved: + * attribute values and children are rooted as their own trees by the walker, and + * the gate asserts the emptiness so that switching them on shows up as a failure. */ /** One enqueued child: the node plus the edge that reaches it. */ @@ -177,7 +177,8 @@ export class TsExpressionExtractor { this.rowByNode.set(nodeId(item.node, this.sf), row); this.emitted.push({ node: item.node, row }); if (kind === TsExpressionKind.CALL_EXPRESSION || kind === TsExpressionKind.NEW_EXPRESSION - || kind === TsExpressionKind.TAGGED_TEMPLATE) { + || kind === TsExpressionKind.TAGGED_TEMPLATE || kind === TsExpressionKind.JSX_ELEMENT + || kind === TsExpressionKind.JSX_SELF_CLOSING) { this.callNodes.push({ node: item.node, owner: item.owner }); } @@ -448,6 +449,16 @@ export function expressionKindOf(node: ts.Node): TsExpressionKind | undefined { case ts.SyntaxKind.VoidExpression: { return TsExpressionKind.DELETE_TYPEOF_VOID; } + case ts.SyntaxKind.JsxElement: + case ts.SyntaxKind.JsxSelfClosingElement: { + // `` IS a call to Badge. `
    ` is an intrinsic element that no + // project declaration can bind, so it produces no row -- a call site that + // can only ever resolve into `JSX.IntrinsicElements` would be noise. + if (jsxTagReference(node) === undefined) { + return undefined; + } + return ts.isJsxElement(node) ? TsExpressionKind.JSX_ELEMENT : TsExpressionKind.JSX_SELF_CLOSING; + } case ts.SyntaxKind.ParenthesizedExpression: { // A parenthesised expression is not a fact — it is punctuation. Emitting // a row for it would put a node between a call and its receiver that no @@ -512,6 +523,13 @@ function childEdgesOf(node: ts.Node): ChildEdge[] { } return out; } + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + // The tag only. Attribute values, children and spreads are rooted by the + // walker as JSX_EMBEDDED_EXPRESSION trees, and a nested element is rooted + // there as its own component call. + push(jsxTagReference(node), TsEdgeRole.METHOD_NAME, 0); + return out; + } if (ts.isTaggedTemplateExpression(node)) { push(node.tag, TsEdgeRole.TAG_EXPRESSION, 0); if (ts.isTemplateExpression(node.template)) { @@ -844,6 +862,44 @@ export function calleeOf(node: ts.Node): ts.Node | undefined { if (ts.isTaggedTemplateExpression(node)) { return unwrapParentheses(node.tag); } + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + return jsxTagReference(node); + } + return undefined; +} + +/** + * The tag of a JSX element when it names a COMPONENT, or `undefined` for an + * intrinsic element. + * + * `` and `` reference a binding; `
    `, `` and + * `` do not, and no declaration in the project can be their + * target. The rule is the one the JSX transform applies: an identifier whose + * first character is a lowercase letter, or that is not a valid identifier at + * all (`` parses as one), is a string tag. + */ +export function jsxTagReference(node: ts.Node): ts.Expression | undefined { + const tagName = ts.isJsxElement(node) + ? node.openingElement.tagName + : ts.isJsxSelfClosingElement(node) + ? node.tagName + : undefined; + if (tagName === undefined || ts.isJsxNamespacedName(tagName)) { + return undefined; + } + if (ts.isPropertyAccessExpression(tagName)) { + return tagName; + } + if (ts.isIdentifier(tagName)) { + const text = tagName.text; + const first = text.charAt(0); + const isLowercaseLetter = first.toUpperCase() !== first; + const points = [...text].map((c) => c.codePointAt(0) ?? 0); + const isValidIdentifier = points.length > 0 + && ts.isIdentifierStart(points[0]!, ts.ScriptTarget.Latest) + && points.slice(1).every((point) => ts.isIdentifierPart(point, ts.ScriptTarget.Latest)); + return !isLowercaseLetter && isValidIdentifier ? tagName : undefined; + } return undefined; } @@ -901,6 +957,14 @@ function callKindOf(node: ts.Node, callee: ts.Node | undefined): TsCallKind { if (ts.isNewExpression(node)) { return TsCallKind.CONSTRUCTOR_CALL; } + // `` binds its tag by name. `` is a member of a value, and + // that is exactly a method call's shape: the receiver's type or namespace + // decides the target, which the member rules already resolve. + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + return callee && ts.isPropertyAccessExpression(callee) + ? TsCallKind.METHOD_CALL + : TsCallKind.JSX_COMPONENT_CALL; + } // Checked before the callee shape, because `@a.b.Get("/x")` is a decorator // call first and a property-access callee second. Which one wins decides // whether an engine sees a call that happens at class-definition time. diff --git a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts index bb653b628..ae6710e7b 100644 --- a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts +++ b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts @@ -2,6 +2,7 @@ import * as ts from 'typescript'; import { ExpressionOwner, + jsxTagReference, TsExpressionExtractor, } from '@/parsers/typescript/extractors/ts-expression-extractor'; import { TsExpressionOwnerKind, TsRootContext } from '@/enums/typescript/expressions'; @@ -502,8 +503,16 @@ export class TsExpressionWalker { // nothing ever rooted it and the call vanished. Rooting it here covers // attribute values, children, and spreads at once, wherever the JSX sits. // - // The tag is deliberately NOT walked: `` as a call to Badge is - // JSX_COMPONENT_CALL, which is reserved and stays at zero rows. + // A component ELEMENT is a call in its own right: `` calls Badge. + // One that is already a row -- `cond ? : `, `x && ` -- was + // reached by the worklist as an operand; any other is rooted here, which + // is what reaches an element nested in another element's children. + if ((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) + && jsxTagReference(child) !== undefined + && !this.options.extractor.rowByNode.has(nodeId(child, this.sf))) { + this.root(child, TsRootContext.JSX_EMBEDDED_EXPRESSION); + return; + } if (ts.isJsxExpression(child)) { this.root(child.expression, TsRootContext.JSX_EMBEDDED_EXPRESSION); return; diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts index 0704ccafe..f08089ccd 100644 --- a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -1213,6 +1213,11 @@ function callArgumentCount(node: ts.Node): number { if (ts.isNewExpression(node)) { return node.arguments?.length ?? 0; } + // A component receives ONE argument, the props object, whatever the markup + // spells as attributes and children. + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + return 1; + } return 0; } diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts index 7f8a55cd5..2e7ca86aa 100644 --- a/parser/src/test/typescript-tests.ts +++ b/parser/src/test/typescript-tests.ts @@ -1267,22 +1267,21 @@ function factBaseInvariants(): number { /** * Reserved enum values carry ZERO rows. * - * TSX is out of freeze 1 (§4.15.1). The representation is decided — a JSX - * element IS a call to its component, with the whole props object as argument 0 - * — and nothing emits it. The point of checking the emptiness is that the day - * TSX is switched on it shows up HERE, as a gate failure naming the value, and - * not as new rows appearing in a fact base with nobody noticing. + * A JSX component element IS a call to its component (§4.15.1): `` + * emits a JSX_ELEMENT / JSX_SELF_CLOSING row and a JSX_COMPONENT_CALL site. + * Attribute values and children are still rooted as JSX_EMBEDDED_EXPRESSION + * trees rather than hung off the element, so their two edge roles stay + * reserved, and switching them on must show up HERE as a named failure. */ const RESERVED_TSX_VALUES = new Set([ - 'JSX_COMPONENT_CALL', 'JSX_ELEMENT', 'JSX_SELF_CLOSING', 'JSX_ATTRIBUTE_VALUE', 'JSX_CHILD', ]); function tsxReservedButEmpty(): number { if (!parserPresent()) { return pendingCheck('TSX reserved but empty', - 'no extractor yet. TSX is out of freeze 1: JSX_COMPONENT_CALL is reserved and must ' + - 'carry ZERO rows, so switching TSX on shows up as a gate failure rather than as new rows'); + 'no extractor yet. JSX_ATTRIBUTE_VALUE and JSX_CHILD are reserved and must carry ZERO ' + + 'rows, so switching them on shows up as a gate failure rather than as new rows'); } const corpora = extractedCorpora(); const failures: string[] = []; @@ -1299,7 +1298,7 @@ function tsxReservedButEmpty(): number { const value = row[name] ?? ''; if (RESERVED_TSX_VALUES.has(value)) { failures.push(`${slug}: ${file} row at ${row.startLine}:${row.startColumn} carries ` + - `reserved value ${name}=${value} — TSX is not in freeze 1`); + `reserved value ${name}=${value} — attributes and children are rooted, not edges`); } } } @@ -1400,10 +1399,10 @@ function irCompleteness(): number { * The corpus cannot carry this. Corpus A has no `.tsx` file and its * expectations are blessed, so the fixture is built here and thrown away. * - * What must NOT appear is the component call. `` as `Badge({...})` is - * JSX_COMPONENT_CALL and stays at zero until TSX is switched on -- so this - * check asserts both directions at once: every ordinary call present, every - * reserved value still empty. + * The component element is a call too: `` is `Badge({...})`, a + * JSX_COMPONENT_CALL site, wherever it sits -- a child, a ternary branch, the + * body of a `.map` arrow. The CONTROL is the intrinsic element: `
    ` and + * `` name no declaration and must produce no call site at all. */ async function jsxBraceExpressionsWalked(): Promise { if (!parserPresent()) { @@ -1449,8 +1448,12 @@ async function jsxBraceExpressionsWalked(): Promise { const modules = relation(outputDir, 'all-typescript-modules.csv'); const modulePath = new Map(modules .map((m) => [m.tsModuleUniqueHash ?? '', m.filePath ?? ''])); - const emitted = new Set(relation(outputDir, 'all-typescript-call-sites.csv') + const siteRows = relation(outputDir, 'all-typescript-call-sites.csv'); + const emitted = new Set(siteRows .map((r) => `${modulePath.get(r.tsModuleLinkHash ?? '') ?? '?'}:${r.startLine}:${r.startColumn}`)); + const kindAt = new Map(siteRows.map((r) => [ + `${modulePath.get(r.tsModuleLinkHash ?? '') ?? '?'}:${r.startLine}:${r.startColumn}`, + r.callKind ?? ''])); const sf = ts.createSourceFile(path.join(root, 'panel.tsx'), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -1478,7 +1481,28 @@ async function jsxBraceExpressionsWalked(): Promise { } } let expected = 0; + let components = 0; + let intrinsics = 0; const walk = (node: ts.Node): void => { + if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) { + const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName; + const p = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + const key = `panel.tsx:${p.line + 1}:${p.character + 1}`; + const isComponent = /^[A-Z]/.test(tag.getText(sf)); + if (isComponent) { + components += 1; + if (kindAt.get(key) !== 'JSX_COMPONENT_CALL') { + failures.push(`no JSX_COMPONENT_CALL at ${key} for \`${node.getText(sf).slice(0, 44)}\`` + + ` (found ${kindAt.get(key) ?? 'nothing'})`); + } + } else { + intrinsics += 1; + if (emitted.has(key)) { + failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} became a call site ` + + `(${kindAt.get(key)}) -- it names no declaration`); + } + } + } if (ts.isCallExpression(node) || ts.isNewExpression(node)) { expected += 1; const p = sf.getLineAndCharacterOfPosition(node.getStart(sf)); @@ -1492,7 +1516,7 @@ async function jsxBraceExpressionsWalked(): Promise { }; ts.forEachChild(sf, walk); - // The other direction: the component invocation must still be absent. + // The edge roles that stay reserved: attributes and children are rooted trees. for (const [file, names] of [ ['all-typescript-call-sites.csv', ['callKind']], ['all-typescript-expressions.csv', ['kind', 'edgeRole']], @@ -1500,15 +1524,16 @@ async function jsxBraceExpressionsWalked(): Promise { for (const row of relation(outputDir, file)) { for (const name of names) { if (RESERVED_TSX_VALUES.has(row[name] ?? '')) { - failures.push(`${file} carries reserved ${name}=${row[name]} — the component call ` + - 'must stay empty while only the braces are walked'); + failures.push(`${file} carries reserved ${name}=${row[name]} — attributes and ` + + 'children are rooted as JSX_EMBEDDED_EXPRESSION, not hung off the element'); } } } } console.log(` ${expected} call/new node(s) in JSX attributes, children, spreads and a ` + - `nested map; ${expected - missingCalls} emitted, ${RESERVED_TSX_VALUES.size} reserved ` + + `nested map; ${expected - missingCalls} emitted; ${components} component element(s) as ` + + `calls, ${intrinsics} intrinsic element(s) as none; ${RESERVED_TSX_VALUES.size} reserved ` + 'value(s) still empty'); for (const f of failures.slice(0, 10)) console.log(` ${f}`); fs.rmSync(root, { recursive: true, force: true }); @@ -4538,8 +4563,8 @@ const CHECKS: Check[] = [ { name: 'merge partition', proves: 'the parser\'s declarationGroupKey partition equals tsc\'s symbol partition', run: mergePartition }, { name: 'tsc-adjudicated resolution', proves: 'every resolved call target equals getResolvedSignature', run: tscAdjudicatedResolution }, { name: 'type-only isolation', proves: 'no type-only construct reaches the call graph', run: typeOnlyIsolation }, - { name: 'TSX reserved but empty', proves: 'reserved enum values carry no rows until TSX is switched on', run: tsxReservedButEmpty }, - { name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site; only the component invocation is reserved', run: jsxBraceExpressionsWalked }, + { name: 'TSX reserved but empty', proves: 'the JSX attribute and child edge roles carry no rows', run: tsxReservedButEmpty }, + { name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site, a component element is a JSX_COMPONENT_CALL, an intrinsic element is none', run: jsxBraceExpressionsWalked }, { name: 'destructuring records its source', proves: 'a bound name carries the property or index it binds, so a renamed or positional binding is recoverable', run: destructuringRecordsItsSource }, { name: 'no emitted value can split a row', proves: 'no value contains a character a consumer treats as a line break, so a row cannot tear', run: noValueCanSplitARow }, { name: 'signatures link their return type', proves: 'a call through any callable shape reaches a result type, so a chain does not stop at it', run: signaturesLinkTheirReturnType }, From 47b5a94c73dbe706348299fa759f2d8a369d6dde Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:22:47 -0700 Subject: [PATCH 10/19] skill: name the MCP tool before the shell verb on the first-call surfaces (#1425) The skill description, the orient and directive hooks, and the block that `axiomcode install` writes spelled every call as a shell command, with the MCP tool in parentheses or not named at all. install pre-approves only the MCP tools, so the first call an agent made was the one left behind a permission prompt, and in a headless session it was denied. Each of those surfaces now names axiomcode_ first and keeps the shell form after it, for a host where the MCP server is absent. The Gemini copy of the skill is regenerated. tests/mcp_first.py checks the order and the shell fallback on every surface; on 0.1.3 it fails 18 of 34 checks. Headless runs, 6 tasks on 4 Java projects and a fixture: the first axiomcode call is the MCP tool in 16 of 18 runs where MCP is listed (0 of 18 before), and still Bash in 6 of 6 runs with no MCP server. --- plugins/axiomcode/hooks/direct.py | 12 ++- plugins/axiomcode/hooks/orient.py | 18 ++-- plugins/axiomcode/skills/axiomcode/SKILL.md | 2 +- .../axiomcode/scripts/axiomcode-install | 11 +- skills/axiomcode/SKILL.md | 2 +- tests/README.md | 3 + tests/mcp_first.py | 101 ++++++++++++++++++ 7 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 tests/mcp_first.py diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 297c9bc3d..9d8b1dc65 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -31,13 +31,17 @@ # the shell commands that are the search or read this directive competes with SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b') +# The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools, +# so an agent that takes the first spelling it reads should be taking the one it is already allowed to call. FULL = ( "graph: this repository has a resolved call graph, and it answers the question this search is asking.\n" - " `axiomcode impact ` — everything a change to that declaration reaches, each entry labelled\n" - " with how certain it is; finds callers that never spell the name (an interface, an override, a\n" + " the axiomcode_impact tool (targets=[]) — everything a change to that declaration reaches, each entry\n" + " labelled with how certain it is; finds callers that never spell the name (an interface, an override, a\n" " callback, DI, a config key), which is what a grep for the name cannot do.\n" - " `axiomcode path ` — how one thing reaches another. `axiomcode context \"\"` — where a\n" - " task's words land. `axiomcode changed` / `test-impact` — what an edit moved and which tests reach it.\n" + " axiomcode_path — how one thing reaches another. axiomcode_context — where a task's words land.\n" + " axiomcode_changed / axiomcode_test_impact — what an edit moved and which tests reach it.\n" + " Without those tools, the same verbs from the shell: `axiomcode impact `, `axiomcode path `,\n" + " `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`.\n" " Search raw files after that, to read or to change specific lines. If you hand this to a subagent,\n" " carry this line into its prompt — exploration done in a child is exploration done without the graph." ) diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index dbaf5511b..a3241ff06 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -174,19 +174,23 @@ 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(' `axiomcode context "" --in ` (the axiomcode_context tool) ranks the files and ' - 'declarations inside it; call it directly, no skill needs loading first.') + 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 `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: print(" " + l[:150]) # the first call, named: an agent that only has the plugin otherwise spends two turns loading the skill and then - # the tool schemas before it asks anything (#1202) + # the tool schemas before it asks anything (#1202). The MCP tool comes first and the shell form second (#1425): + # `axiomcode install` pre-approves only the tools, so a first call spelled as a shell command is the one call the + # install left behind a permission prompt, and headless it is simply denied. 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: `axiomcode context "" --source --from ` returns the call flow with ' - "each step's code; call it directly, no skill needs loading 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 `).') else: - print(' a starting point, not a conclusion: next, `axiomcode impact --in ` (the axiomcode_impact tool) ' - 'for what a change reaches; call it directly, no skill needs loading first.') + 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 `).') diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 793aebb83..bcb292c1f 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 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: `axiomcode context "" --source` 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. Java, TypeScript, Python, JavaScript, C#. + 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#. --- # axiomcode diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index 182e00a80..111cfa642 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,12 +35,15 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST — from the shell, or through the +This repository has a resolved call graph. Ask it FIRST, through the `mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading: - axiomcode context "" # where the work is, when you have no name yet - axiomcode impact # what a change reaches: contract, users, tests - axiomcode path # how A reaches B + 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 + +Only when those tools are not in your list, the same from the shell: `axiomcode context ""`, +`axiomcode impact `, `axiomcode path `. **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 diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index eb73fe93a..2f60e2f02 100644 --- a/skills/axiomcode/SKILL.md +++ b/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 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: `axiomcode context "" --source` 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. Java, TypeScript, Python, JavaScript, C#. + 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#. --- # axiomcode diff --git a/tests/README.md b/tests/README.md index d353f75a5..916a1f059 100644 --- a/tests/README.md +++ b/tests/README.md @@ -27,6 +27,9 @@ One check needs no graph and is its own script: 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 + python3 tests/mcp_first.py the description, the install block and the orient and directive hooks name the + MCP tool before the shell verb, and still carry the shell verb for a host + without the server (#1425; indexes one case, so it needs the engine) python3 tests/hosts.py each hook tells Cursor and Gemini CLI what it tells the original host, in their own event names and output shape (indexes one case, so it needs the engine) python3 tests/enrich_budget.py what a Read or a Grep adds to a session is capped: a declaration annotated once, diff --git a/tests/mcp_first.py b/tests/mcp_first.py new file mode 100644 index 000000000..39499ab56 --- /dev/null +++ b/tests/mcp_first.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""tests/mcp_first.py — every surface an agent reads before its first call names the MCP tool first (#1425). + +`axiomcode install` pre-approves the plugin's MCP tools and nothing else. The skill's description, the +prompt-time orientation and the pre-search directive used to spell the call as a shell command, with the tool +in parentheses or not at all, so an agent took the shell spelling; under the install's permissions that first +call is the one that stops for a prompt, and in a headless session it is denied outright. + +Each check is one surface: the tool's name comes before the shell form of the same verb. The control on every +surface is the other half: the shell form is still there, after it. A host without the MCP server, or a session +where the server did not start, has nothing else to call, and the skill has to keep working there. + + python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does +""" +import json, os, re, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') +HOOKS = os.path.join(PLUG, 'hooks') +SCRIPTS = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts') +AX = os.path.join(SCRIPTS, 'axiomcode') +CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'injected-into-a-field') + +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 tool_first(surface, text, verbs): + """for each verb: the MCP tool is named, the shell form is named, and the tool comes first""" + for v in verbs: + tool, shell = f"axiomcode_{v.replace('-', '_')}", f"`axiomcode {v}" + t, s = text.find(tool), text.find(shell) + check(f'{surface}: {v} is named as the {tool} tool', t >= 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: + check(f'{surface}: {tool} comes before `axiomcode {v}`', t < s, f'tool at {t}, shell at {s}') + + +def fire(hook, ev): + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), + capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + try: + return r.returncode, json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return r.returncode, out + + +# 1. the description, which is the one part of the skill every session sees +skill = open(os.path.join(PLUG, 'skills', 'axiomcode', 'SKILL.md'), encoding='utf-8').read() +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')) + +# 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')) + +# 3. the directive before the first search; it needs only a graph file to exist +with tempfile.TemporaryDirectory() as repo: + os.makedirs(os.path.join(repo, '.axiomcode', 'out')) + open(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() + rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep', + 'tool_input': {'pattern': 'f'}, 'cwd': repo, 'session_id': 'm1'}) + check('direct: the first search hears the directive', rc == 0 and bool(said), f'rc={rc}') + tool_first('direct', said, ('impact', 'path', 'context', 'changed', 'test-impact')) + +# 4. 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) + b = subprocess.run(['bash', AX, 'index', repo], capture_output=True, text=True, timeout=900) + built = os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) + check('the case indexes', built, (b.stdout + b.stderr)[-300:]) + if built: + rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o1', + 'prompt': 'What breaks if I change Repo.find and its callers?'}) + check('orient: a change question is oriented', rc == 0 and 'next,' in said, said[:300]) + tool_first('orient (change)', said, ('impact',)) + 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',)) + +# 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=") +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',)) + +print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') +sys.exit(1 if fails else 0) From 879c63ea13dc6ad04e55079f2b7ed7a766794394 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:48:58 -0700 Subject: [PATCH 11/19] java: a var local never names an external type called var (#1401) The parser records `var h = r.lookup(k)` as a LOCAL_VARIABLE type reference written `var`. Nothing resolves that name, so external-types.dl minted `external:var` and typed every var receiver with it: each call on a var local got an `external:var.` boundary, beside its real target when the local was in-source typed, alone when it was not. candidate_ref now skips a reference written `var`, and a var local takes the external type of its initializer (`var f = source()` with an unstaged return type) the way a declared local takes its written one. Case 61-var-local-type pins each var shape against its declared control. 36-generic-two-hop loses three external:var boundaries that sat beside the real library targets. --- .../java/engine/resolution/external-types.dl | 16 +++ .../java/engine/resolution/reference-types.dl | 3 +- .../61-var-local-type/src/probe/Handler.java | 5 + .../61-var-local-type/src/probe/Registry.java | 7 ++ .../src/probe/VarLocals.java | 110 ++++++++++++++++++ .../61-var-local-type/src/probe/Widget.java | 14 +++ .../java/expected/36-generic-two-hop.edges | 4 - .../java/expected/61-var-local-type.edges | 25 ++++ .../java/expected/61-var-local-type.type-use | 25 ++++ 9 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/Handler.java create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/Registry.java create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/Widget.java create mode 100644 graph/test/java/expected/61-var-local-type.edges create mode 100644 graph/test/java/expected/61-var-local-type.type-use diff --git a/graph/java/engine/resolution/external-types.dl b/graph/java/engine/resolution/external-types.dl index e8a88fb13..c60de8a6b 100644 --- a/graph/java/engine/resolution/external-types.dl +++ b/graph/java/engine/resolution/external-types.dl @@ -73,10 +73,16 @@ simple_name_declared(n) :- lib_type(n, _, _, _, _, _, _, _, _, _, _, _, _, _). name_resolves_in_file(tn, fp) :- type_resolves_in_file(_, tn, fp, _). name_resolves_in_file(tn, fp) :- ref_file(_, tn, fp), type_resolves_java_lang(_, tn, _). +// `var` is not a type name (JLS 3.9, 14.4). The parser records `var h = r.lookup(k)` as a +// LOCAL_VARIABLE reference written `var` that nothing resolves, so without this guard every call +// 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. candidate_ref(ref, tn, ctn, fp) :- java_type_reference(kind, ctx, _, _, _, _, _, "0", tn, ctn, _, _, _, _, _, _, _, ref), ext_ref_kind(kind), ext_ref_context(ctx), tn != "", + tn != "var", ref_file(ref, tn, fp), !name_resolves_in_file(tn, fp), !simple_name_declared(tn). @@ -149,6 +155,16 @@ expr_type("external", e, x) :- java_expression(_, _, _, _, _, _, _, _, _, _, nam java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), !local_creation_pinned(local), local_var_type_external(local, x). +// A `var` local's static type IS its initializer's (JLS 14.4.1), fixed at the declaration, so a +// reassignment does not widen it. When that initializer is typed external (`var f = source()` +// 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), + local_init_expr(local, init), + expr_type("external", init, x). expr_type("external", e, x) :- java_expression("CAST_EXPRESSION", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e), cast_type_external(e, x). // A chained receiver whose callee is a client method declared to return the external type. diff --git a/graph/java/engine/resolution/reference-types.dl b/graph/java/engine/resolution/reference-types.dl index 92d9bdb60..702199541 100644 --- a/graph/java/engine/resolution/reference-types.dl +++ b/graph/java/engine/resolution/reference-types.dl @@ -39,7 +39,8 @@ field_type_resolves(prov, field, ref, t) :- java_type_reference(_, "FIELD_TYPE", // position-0 references, so each caught type is still captured. local_var_type_resolves(prov, local, ref, t) :- java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref), type_ref_resolves(prov, ref, t). -// A `var` local has no written type reference, so the rule above yields nothing. Infer +// A `var` local's only reference is written `var` and resolves to nothing, so the rule above +// yields nothing (and external-types.dl keeps it from naming an external type). Infer // the type from a direct object-creation initializer (var x = new Holder()): the // created type — carrying its written type arguments — becomes the local's resolved type, // which feeds callee resolution AND type-argument binding uniformly. Gated on the isVar diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Handler.java b/graph/test/java/cases/61-var-local-type/src/probe/Handler.java new file mode 100644 index 000000000..bffaafc98 --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Handler.java @@ -0,0 +1,5 @@ +package probe; + +public interface Handler { + void handle(); +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Registry.java b/graph/test/java/cases/61-var-local-type/src/probe/Registry.java new file mode 100644 index 000000000..195d1eb6b --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Registry.java @@ -0,0 +1,7 @@ +package probe; + +public class Registry { + public Handler lookup(String key) { + return null; + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java new file mode 100644 index 000000000..bf5c79e01 --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java @@ -0,0 +1,110 @@ +package probe; + +import java.text.Format; +import java.util.Collections; +import java.util.List; +import java.util.function.BiConsumer; + +/** + * `var` is a reserved type name (JLS 14.4), so a local declared with it never has a type called + * `var`: its type is the initializer's. Each construct below has a control written with the + * declared type, which must resolve the same way. + */ +public class VarLocals { + + /** var from a client call's return: one known edge, no library label. */ + void fromCall(Registry r) { + var h = r.lookup("x"); + h.handle(); + } + + /** control: the same call with the type written down. */ + void fromCallDeclared(Registry r) { + Handler h = r.lookup("x"); + h.handle(); + } + + /** var from a static factory. */ + void fromFactory() { + var w = Widget.create(); + w.paint(); + } + + /** control: var from `new` was already pinned to the created type. */ + void fromNew() { + var w = new Widget(); + w.paint(); + } + + /** var from `new`, then reassigned: no longer pinned, still a Widget. */ + void fromNewReassigned() { + var w = new Widget(); + w = Widget.create(); + w.paint(); + } + + /** var from a literal: String's own method, and nothing named var. */ + int fromLiteral() { + var s = "abc"; + return s.length(); + } + + /** control: the literal itself, with no local; a var must answer the same way. */ + int literalInline() { + return "abc".length(); + } + + /** control: the declared String. */ + int fromLiteralDeclared() { + String s = "abc"; + return s.length(); + } + + /** var loop variable over a platform list (not staged): unresolved, not a library call. */ + void fromEnhancedFor(List ws) { + for (var x : ws) { + x.paint(); + } + } + + /** control: the declared loop variable. */ + void fromEnhancedForDeclared(List ws) { + for (Widget x : ws) { + x.paint(); + } + } + + /** var resource in try-with-resources. */ + void fromResource() { + try (var w = Widget.create()) { + w.paint(); + } + } + + /** var from an unstaged platform call: the type is unknown, so the call stays unresolved. */ + int fromUnknown() { + var xs = Collections.emptyList(); + return xs.size(); + } + + /** control: a declared external local still types its receiver as that external type. */ + String declaredExternal(Object o) { + Format f = source(); + return f.format(o); + } + + /** var from a client call declared to return an external type: that type, not `var`. */ + String varExternal(Object o) { + var f = source(); + return f.format(o); + } + + Format source() { + return null; + } + + /** var lambda parameters (Java 11). */ + BiConsumer lambdaParams() { + return (var a, var b) -> a.paint(); + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Widget.java b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java new file mode 100644 index 000000000..11ded4520 --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java @@ -0,0 +1,14 @@ +package probe; + +public class Widget implements AutoCloseable { + public static Widget create() { + return new Widget(); + } + + public void paint() { + } + + @Override + public void close() { + } +} diff --git a/graph/test/java/expected/36-generic-two-hop.edges b/graph/test/java/expected/36-generic-two-hop.edges index 6322a7de8..dbfa17bee 100644 --- a/graph/test/java/expected/36-generic-two-hop.edges +++ b/graph/test/java/expected/36-generic-two-hop.edges @@ -5,16 +5,12 @@ boundary_lib method probe.TwoHop#nestedArgOnLibRef() -> dep.Lookup#entries() boundary_lib method probe.TwoHop#nestedArgOnLibRef() -> dep.Pair#value() boundary_lib method probe.TwoHop#oneHop() -> dep.Box#get(int) boundary_lib method probe.TwoHop#oneHopVar() -> dep.Box#get(int) -boundary_lib method probe.TwoHop#oneHopVar() -> external:var.get boundary_lib method probe.TwoHop#twoHopBothVars() -> dep.Box#iterator() boundary_lib method probe.TwoHop#twoHopBothVars() -> dep.Iter#next() -boundary_lib method probe.TwoHop#twoHopBothVars() -> external:var.iterator -boundary_lib method probe.TwoHop#twoHopBothVars() -> external:var.next boundary_lib method probe.TwoHop#twoHopChain() -> dep.Box#iterator() boundary_lib method probe.TwoHop#twoHopChain() -> dep.Iter#next() boundary_lib method probe.TwoHop#twoHopVar() -> dep.Box#iterator() boundary_lib method probe.TwoHop#twoHopVar() -> dep.Iter#next() -boundary_lib method probe.TwoHop#twoHopVar() -> external:var.next known_edge method probe.TwoHop#nestedArgOnClientRef() -> probe.TwoHop#pairs() known_edge method probe.TwoHop#nestedArgOnClientRef() -> probe.TwoHop.Widget#id() known_edge method probe.TwoHop#nestedArgOnLibRef() -> probe.TwoHop#lookup() diff --git a/graph/test/java/expected/61-var-local-type.edges b/graph/test/java/expected/61-var-local-type.edges new file mode 100644 index 000000000..dcbfc0084 --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.edges @@ -0,0 +1,25 @@ +ambiguous_unknown method probe.VarLocals#fromEnhancedFor(List) -> - +ambiguous_unknown method probe.VarLocals#fromLiteral() -> - +ambiguous_unknown method probe.VarLocals#fromUnknown() -> - +ambiguous_unknown method probe.VarLocals#lambdaParams() -> - +ambiguous_unknown method probe.VarLocals#literalInline() -> - +boundary_lib method probe.VarLocals#declaredExternal(Object) -> external:java.text.Format.format +boundary_lib method probe.VarLocals#fromLiteralDeclared() -> external:String.length +boundary_lib method probe.VarLocals#varExternal(Object) -> external:java.text.Format.format +known_edge method probe.VarLocals#declaredExternal(Object) -> probe.VarLocals#source() +known_edge method probe.VarLocals#fromCall(Registry) -> probe.Handler#handle() +known_edge method probe.VarLocals#fromCall(Registry) -> probe.Registry#lookup(String) +known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Handler#handle() +known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Registry#lookup(String) +known_edge method probe.VarLocals#fromEnhancedForDeclared(List) -> probe.Widget#paint() +known_edge method probe.VarLocals#fromFactory() -> probe.Widget#create() +known_edge method probe.VarLocals#fromFactory() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromNew() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#create() +known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromResource() -> probe.Widget#create() +known_edge method probe.VarLocals#fromResource() -> probe.Widget#paint() +known_edge method probe.VarLocals#varExternal(Object) -> probe.VarLocals#source() +known_edge new probe.VarLocals#fromNew() -> probe.Widget#() +known_edge new probe.VarLocals#fromNewReassigned() -> probe.Widget#() +known_edge new probe.Widget#create() -> probe.Widget#() diff --git a/graph/test/java/expected/61-var-local-type.type-use b/graph/test/java/expected/61-var-local-type.type-use new file mode 100644 index 000000000..e1b611ace --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.type-use @@ -0,0 +1,25 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.Widget [ANNOTATION] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Widget [TYPE] -> - +ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#declaredExternal(Object) [LOCAL_VARIABLE] -> - +ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#fromLiteralDeclared() [LOCAL_VARIABLE] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Registry#lookup(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#declaredExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#varExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#declaredExternal(Object) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaParams() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#source() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#varExternal(Object) [METHOD] -> - +known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromCallDeclared(Registry) [LOCAL_VARIABLE] -> probe.Handler +known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromEnhancedForDeclared(List) [LOCAL_VARIABLE] -> probe.Widget +known_edge METHOD_PARAM 0 probe.VarLocals#fromCall(Registry) [METHOD_PARAM] -> probe.Registry +known_edge METHOD_PARAM 0 probe.VarLocals#fromCallDeclared(Registry) [METHOD_PARAM] -> probe.Registry +known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_RETURN 0 probe.Registry#lookup(String) [METHOD] -> probe.Handler +known_edge METHOD_RETURN 0 probe.Widget#create() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaParams() [METHOD] -> probe.Widget +known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNew() [EXPRESSION] -> probe.Widget +known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNewReassigned() [EXPRESSION] -> probe.Widget +known_edge OBJECT_CREATION_TYPE 0 probe.Widget#create() [EXPRESSION] -> probe.Widget From a98cacf20c153c81563fbb88ae91095476ab1feb Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:02:51 -0700 Subject: [PATCH 12/19] python: impact and path read framework_edge (#1509) The Python engine writes framework_edge for a hop no call site expresses: a task and its .delay()/.apply_async() producer, 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. Nothing under plugins/ selected it, so `impact` on a receiver said the change was local and `path` called the two ends independent. impact: ext_framework_edge is exported as `framework` facts and the end that hands over is a direct dependent with its own certainty, `[framework]`, and the reason "framework-mediated, not a call: via []". It ranks below [remote] and above every name match. One direction only, and a direct row only: it does not seed the closure, which is the contract [remote] keeps. The hooks' SQL fast path and the opt-in SQL arm list the same row, so the hook line and `impact` agree. path: when no chain of calls connects two endpoints and a framework_edge row does, the hop is printed with its mechanism and confidence, the closing line no longer says "independent", and next: points at both ends. Controls: an unrelated method sharing the dispatch name (Animation.delay) gains no framework row, a .delay() on an unregistered def joins nothing, an ordinary call beside the signal keeps its resolved row. --- .../src/app/__init__.py | 7 ++++ .../src/app/handlers.py | 15 ++++++++ .../src/app/models.py | 12 +++++++ .../src/app/producer.py | 21 +++++++++++ .../src/app/signals.py | 5 +++ .../src/app/tasks.py | 23 ++++++++++++ .../29-framework-edge-consumers.edges | 12 +++++++ .../29-framework-edge-consumers.entries | 4 +++ .../29-framework-edge-consumers.framework | 10 ++++++ .../29-framework-edge-consumers.tiers | 30 ++++++++++++++++ .../skills/axiomcode/reference/impact.md | 6 ++++ .../skills/axiomcode/reference/path.md | 4 +++ .../skills/axiomcode/scripts/ax_pages.py | 6 ++++ .../skills/axiomcode/scripts/axiomcode-impact | 29 ++++++++++++--- .../skills/axiomcode/scripts/axiomcode-path | 13 +++++++ .../skills/axiomcode/scripts/dl/impact.dl | 17 +++++++++ .../skills/axiomcode/scripts/graph_sql.py | 18 ++++++++++ skills/axiomcode/reference/impact.md | 6 ++++ skills/axiomcode/reference/path.md | 4 +++ .../app/__init__.py | 0 .../app/handlers.py | 12 +++++++ .../app/models.py | 10 ++++++ .../app/producer.py | 17 +++++++++ .../app/signals.py | 3 ++ .../framework-hop-is-a-dependent/app/tasks.py | 20 +++++++++++ .../framework-hop-is-a-dependent/case.json | 35 +++++++++++++++++++ 26 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/__init__.py create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/handlers.py create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/models.py create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/producer.py create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/signals.py create mode 100644 graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py create mode 100644 graph/test/python/expected/29-framework-edge-consumers.edges create mode 100644 graph/test/python/expected/29-framework-edge-consumers.entries create mode 100644 graph/test/python/expected/29-framework-edge-consumers.framework create mode 100644 graph/test/python/expected/29-framework-edge-consumers.tiers create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/__init__.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/handlers.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/models.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/producer.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/signals.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/app/tasks.py create mode 100644 tests/cases/python/framework-hop-is-a-dependent/case.json diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/__init__.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/__init__.py new file mode 100644 index 000000000..5c17aacf7 --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/__init__.py @@ -0,0 +1,7 @@ +"""The rows `impact` and `path` read out of framework_edge (#1509), in a package. + +The issue's own shape: every module imports its neighbours RELATIVELY, the signal and the +task live in their own modules, and each end sits in a different file. Cases 19 and 21 pin +the mechanisms; this one pins the exact rows the two query verbs consume, and the rows +that must NOT exist, because a consumer that reads framework_edge would print them. +""" diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/handlers.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/handlers.py new file mode 100644 index 000000000..a9321d30f --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/handlers.py @@ -0,0 +1,15 @@ +"""The receiver of order_placed, and `audit`, an ordinary callee beside it (the control +that keeps its resolved edge and gains no framework row).""" + +from django.dispatch import receiver + +from .signals import order_placed + + +@receiver(order_placed) +def on_placed(sender, **kwargs): + return sender + + +def audit(order): + return order diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/models.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/models.py new file mode 100644 index 000000000..3d9a57875 --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/models.py @@ -0,0 +1,12 @@ +"""The publisher: `place` sends the signal, `archive` calls `audit` directly.""" + +from .handlers import audit +from .signals import order_placed + + +class Order: + def place(self): + order_placed.send(sender=self.__class__) + + def archive(self): + audit(self) diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/producer.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/producer.py new file mode 100644 index 000000000..0965e78a8 --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/producer.py @@ -0,0 +1,21 @@ +"""Four `.delay` / `.apply_async` sites. Two join a registered task. `anim.delay` is an +ordinary method call, and `plain.delay` names a def no task decorator registered: neither +may produce a framework_edge row.""" + +from .tasks import Animation, plain, retry_report, send_report + + +def enqueue(key): + send_report.delay(key) + + +def reschedule(key): + retry_report.apply_async(args=[key], countdown=30) + + +def pause(anim: Animation): + anim.delay(10) + + +def run_plain(key): + plain.delay(key) diff --git a/graph/test/python/cases/29-framework-edge-consumers/src/app/signals.py b/graph/test/python/cases/29-framework-edge-consumers/src/app/signals.py new file mode 100644 index 000000000..bd3875071 --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/signals.py @@ -0,0 +1,5 @@ +"""The signal object, in its own module, imported by both ends.""" + +from django.dispatch import Signal + +order_placed = Signal() 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 new file mode 100644 index 000000000..4adaa970d --- /dev/null +++ b/graph/test/python/cases/29-framework-edge-consumers/src/app/tasks.py @@ -0,0 +1,23 @@ +"""Two registered tasks (one with bind=True), an unregistered function, and a class +whose method is also called `delay`.""" + +from celery import shared_task + + +@shared_task +def send_report(key): + return key + + +@shared_task(bind=True) +def retry_report(self, key): + return key + + +def plain(key): + return key + + +class Animation: + def delay(self, ms): + return ms diff --git a/graph/test/python/expected/29-framework-edge-consumers.edges b/graph/test/python/expected/29-framework-edge-consumers.edges new file mode 100644 index 000000000..b8a1c4da5 --- /dev/null +++ b/graph/test/python/expected/29-framework-edge-consumers.edges @@ -0,0 +1,12 @@ +ambiguous_unknown DECORATOR_APPLICATION app.handlers. -> - +ambiguous_unknown DECORATOR_APPLICATION app.tasks. -> - +ambiguous_unknown DECORATOR_BARE app.tasks. -> - +ambiguous_unknown METHOD_CALL app.models.Order.place -> - +ambiguous_unknown METHOD_CALL app.producer.enqueue -> - +ambiguous_unknown METHOD_CALL app.producer.reschedule -> - +ambiguous_unknown METHOD_CALL app.producer.run_plain -> - +boundary_lib DECORATOR_CALL app.handlers. -> external:receiver +boundary_lib DECORATOR_CALL app.tasks. -> external:shared_task +boundary_lib SIMPLE_CALL app.signals. -> external:Signal +known_edge METHOD_CALL app.producer.pause -> app.tasks.Animation.delay +known_edge SIMPLE_CALL app.models.Order.archive -> app.handlers.audit diff --git a/graph/test/python/expected/29-framework-edge-consumers.entries b/graph/test/python/expected/29-framework-edge-consumers.entries new file mode 100644 index 000000000..21c1275af --- /dev/null +++ b/graph/test/python/expected/29-framework-edge-consumers.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + signal_receiver app.handlers.on_placed app/handlers.py:10 + task app.tasks.retry_report app/tasks.py:13 + task app.tasks.send_report app/tasks.py:8 diff --git a/graph/test/python/expected/29-framework-edge-consumers.framework b/graph/test/python/expected/29-framework-edge-consumers.framework new file mode 100644 index 000000000..5c99eef84 --- /dev/null +++ b/graph/test/python/expected/29-framework-edge-consumers.framework @@ -0,0 +1,10 @@ +── framework_edge (3) ── + 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 + 2 task_dispatch delay +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/29-framework-edge-consumers.tiers b/graph/test/python/expected/29-framework-edge-consumers.tiers new file mode 100644 index 000000000..3465118ce --- /dev/null +++ b/graph/test/python/expected/29-framework-edge-consumers.tiers @@ -0,0 +1,30 @@ +distinct call sites emitted: 12 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 7 rows 7 sites ambiguous_unknown + 3 rows 3 sites boundary_lib + 2 rows 2 sites known_edge + +--- edge rows by call kind --- + 2 DECORATOR_APPLICATION + 1 DECORATOR_BARE + 2 DECORATOR_CALL + 5 METHOD_CALL + 2 SIMPLE_CALL + +--- unresolved reasons --- + 2 decorator_factory_result_untyped + 1 unmodelled_decorator + 4 untyped_receiver:local_untyped + +--- the engine's own conservation ledger --- + 12 _total_sites + 7 ambiguous_unknown + 3 boundary_lib + 2 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 12 + minus extra rows from multi-target sites 0 + = tier/site pairs 12 + engine's conserved site total 12 diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index d594cf69c..967f21f51 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -29,6 +29,12 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **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. +- **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 diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md index 9edd45e07..79e272fce 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ b/plugins/axiomcode/skills/axiomcode/reference/path.md @@ -40,6 +40,10 @@ - **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 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, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 89fb79522..4fd3c498a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -114,6 +114,12 @@ 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 '')) + # 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: + a, la, b, lb = fw[0] + return (f"next: no call connects them, the framework does: read {a} at {la}, where it hands over, and {b} at {lb}, " + f"which the framework runs; `impact {b}` for everything else that depends on it") if re.search(r'no (chain|route|path)', text, re.I): return ("next: no resolved chain — the graph loses the call at one of the unresolved sites named above, so the " "code may still connect them; read the start's body from those sites on") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4c6def0a0..78712aa14 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -110,8 +110,10 @@ def ckey(k): # `remote` follows the same rule for the same reason (#1108): a cross-process hop is an edge the # engine asserted, joined on a destination it resolved at BOTH ends, so it outranks every name match -- # and it is not a call to the declaration at all, so it cannot outrank a target set. -CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, - 'in scope': 5, 'by name': 6, 'text': 7, 'alongside': 8} +# `framework` (#1509) sits directly below `remote` for the same reason: the engine joined both ends on the +# framework's own registration, so it outranks every name match, and it is not a call to the declaration. +CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, + 'in scope': 6, 'by name': 7, 'text': 8, 'alongside': 9} 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_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names @@ -689,7 +691,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 = '26' # 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 = '27' # 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 @@ -1138,6 +1140,17 @@ class Impact: g.q("SELECT c0, c1, c2, c3, c4 FROM ext_remote_edge") if r[0] and r[1] and r[0] != r[1]}) W('remote', remote) + # A FRAMEWORK HOP INSIDE ONE PROCESS WAS UNREAD IN EXACTLY THE SAME WAY (#1509). The Python engine writes + # framework_edge for a task and its .delay() producer, a signal and its @receiver, a route table and its + # view, a Depends() default and its provider, and nothing here selected it: `impact` on a receiver said the + # change was local, and a producer showed up only as a same-named [by name] lead. Its own relation and its + # own certainty for the reason `remote` has one: it is not a call and must not read as one. + fw = [] + if g.has('ext_framework_edge'): + fw = sorted({(r[0], r[1], r[2], r[3], r[4]) for r in + g.q("SELECT c0, c1, c2, c3, c4 FROM ext_framework_edge") + if r[0] and r[1] and r[0] != r[1]}) + W('framework', fw) open(stamp, 'w').write(want); return D # ── one query: the target facts, the text-level facts, one Soufflé run ────────────────────────────────────── @@ -1992,7 +2005,7 @@ def main(argv): # reference. Letting the new label fall out of this list would turn "something still uses this" into # "safe to delete", which is the one verdict here that costs anything to get wrong. calls = [x for x in D if (x[3] == 'resolved' and ('calls' in x[2] or 'instantiates' in x[2] or 'through' in x[2] or 'inject' in x[2])) - or x[3] in ('registered', 'capped set')] + 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)}) @@ -2126,11 +2139,17 @@ def main(argv): # target reached ONLY through hand-offs there were no by-name entries at all, so the label the whole # answer was written in went unexplained. print("note: " + REGISTERED_NOTE) + if any(x[3] == 'framework' for x in D): + print("note: [framework] is a hop the framework makes and no call site expresses (a task enqueued, a signal sent, " + "a route table entry, a Depends() default, a fixture parameter): the engine joined both ends on the " + "framework's own registration, so it is a dependent and not a call, and it may run in another worker or later") # NEXT: ONE STEP, SO THE ANSWER CLOSES THE QUESTION (#1202). An answer that ends in a list of callers reads as a # list of leads, and agents opened every one: a run that `context` had already pointed at the one file to fix # read six more, following callers an answer had listed. The resolved rows are verified above -- this says what # to do with them, and what NOT to spend reads on. - STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'capped set') + # `framework` (#1509): the producer or publisher the engine joined to this through the framework's own + # registration is a place bound to it, not a same-named lead, so it is named here rather than counted as one + STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'capped set', 'framework') must = [g.loc(c) for c, _ in contract] users = sorted({loc for c, role, why, cert, loc, n in D if cert in STRONG_DIRECT and not _is_t(c) and loc}) weak_n = len({c for c, role, why, cert, loc, n in D if cert not in STRONG_DIRECT and cert != 'alongside'}) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 111dae6f8..9b61e1b84 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1083,6 +1083,19 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): print(f" no call site expresses this hop, so no chain of calls can contain it;" f" `impact {g.disp(to)}` lists it as a [remote] dependent.") 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. + 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_))), + *(list(A_) + list(B_) + list(B_) + list(A_))) + for fr, to, mech, detail, conf in sorted({(r[0], r[1], r[2], r[3], r[4]) for r in rows if r[0] != r[1]}): + print(f" BUT a framework connects them: {g.disp(fr)} {g.loc(fr)} → {g.disp(to)} {g.loc(to)} [framework: {mech} via {detail}] ({conf})") + print(f" framework-mediated, not a call: no call site expresses this hop, so no chain of calls can contain it;" + f" `impact {g.disp(to)}` lists {g.disp(fr)} as a [framework] dependent.") + remote_found = True for q, srcs, word in (('fwd', A_, f"{la} → {lb}"), ('rev', B_, f"{lb} → {la}")): hits = sorted(res['hit_opt'][q], key=lambda h: (int(h[1]), h[0])) if not hits: continue diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 04de0f400..573c57a0c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -267,6 +267,23 @@ direct(q, c, "uses", cat("calls it across a process boundary (", cat(tr, cat(") direct(q, c, "uses", cat("serves what this sends across a process boundary (", cat(tr, cat(") at ", cat(dest, cat(" [", cat(conf, "]")))))), "remote", "", 0) :- target(q, "method", m, _), remote(m, c, tr, dest, conf), c != m. +// ── a hop a framework makes inside one process (#1509) ────────────────────────────────────────────── +// framework(From, To, Mechanism, Detail, Confidence) is the engine's framework_edge: a task enqueued with +// .delay() and the task body, a signal sent and its @receiver, a route table and its view, a Depends() +// default and its provider, a test and the fixture it names. Both ends are client code and no call site +// joins them, so every rule above is silent: `impact` on a receiver said the change was local while the +// publisher sat one row away. +// +// ONE DIRECTION ONLY. The end that hands over (the publisher, the producer, the handler declaring the +// dependency) depends on the end the framework runs. The reverse is not a dependency for every mechanism, +// a provider does not depend on the handler asking for it, so it is claimed for none. +// Certainty is `framework`, never `resolved`: the reason carries the mechanism, what joined the two ends and +// the engine's own confidence (`registered`, or `by_name` for a fixture), so a reader can tell the hop from +// an invocation. A direct row only, not a seed of the closure: the contract `remote` keeps, for the same reason. +.decl framework(f:symbol, t:symbol, mech:symbol, detail:symbol, conf:symbol) .input framework +direct(q, c, "uses", cat("framework-mediated, not a call: ", cat(mech, cat(" via ", cat(detail, cat(" [", cat(conf, "]")))))), "framework", "", 0) + :- target(q, "method", m, _), framework(c, m, mech, detail, conf), c != m. + direct(q, c, "uses", cat("receives it by dependency injection (", kind, ") — the container hands it over, no call site"), "resolved", "", 0) :- target(q, "type", t, _), injected(t, c, kind), !inside_target(q, c). direct(q, c, "uses", cat("receives it by dependency injection (", kind, ") — the container hands it over, no call site"), "resolved", "", 0) :- target(q, "clinit", t, _), injected(t, c, kind). // a method that DEFINES a bean (@Bean): whoever is injected with the type it returns. Keyed on `bean_factory`, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 4c177cef9..c7182f06a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -161,6 +161,14 @@ def _at(f_, l_): for d, tier in q(f"""SELECT DISTINCT s.display, ce.tier FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id WHERE ce.callee_method_id IN ({ph})""", ids): _reads[d].add(ax_edges.direct_cert(tier)) + # …and the end a FRAMEWORK hands it over from (#1509): a task's .delay() producer, a signal's sender, a route + # table, a Depends() default. The rules list it as a `framework` dependent, so this does too, or the hook + # line and `impact` name different dependents for the same declaration. A direct row only, as in the rules: + # 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 + 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 / uses it, by name: a site naming this method whose receiver the engine could not type. The parser @@ -2311,6 +2319,11 @@ def _throws_catches(code, f, ln, en): +def framework_why(mech, detail, conf): + """the reason a `framework` direct row carries, exactly as dl/impact.dl writes it (#1509)""" + return f"framework-mediated, not a call: {mech} via {detail} [{conf}]" + + def _has_framework_hops(q, at=None, site_file=None): """True when this graph carries a hop the rules traverse and this port does not. @@ -2404,6 +2417,11 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non mph = ','.join('?' * len(mids)) con += contract_for_method(q, mids) d = direct_for_method(q, mids, code, rel, at, lines) + # direct(q,c,"uses",…,"framework","",0) :- target(q,"method",m,_), framework(c,m,…) (#1509) + if _has(q, 'ext_framework_edge'): + d += [(c, 'uses', framework_why(mech, det, conf), 'framework', '', 0) for c, mech, det, conf in + sorted({tuple(r) for r in q(f"""SELECT c0, c2, c3, c4 FROM ext_framework_edge + WHERE c1 IN ({mph}) AND c0 <> c1 AND c0 <> ''""", *mids)})] dr += d # seed_of(q,m) for a method target is the target and what the contract binds to it seeds |= set(mids) | {c for c, _ in con} diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index d594cf69c..967f21f51 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -29,6 +29,12 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **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. +- **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 diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md index 9edd45e07..79e272fce 100644 --- a/skills/axiomcode/reference/path.md +++ b/skills/axiomcode/reference/path.md @@ -40,6 +40,10 @@ - **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 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, diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/__init__.py b/tests/cases/python/framework-hop-is-a-dependent/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/handlers.py b/tests/cases/python/framework-hop-is-a-dependent/app/handlers.py new file mode 100644 index 000000000..77748fde3 --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/app/handlers.py @@ -0,0 +1,12 @@ +from django.dispatch import receiver + +from .signals import order_placed + + +@receiver(order_placed) +def on_placed(sender, **kwargs): + return sender + + +def audit(order): + return order diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/models.py b/tests/cases/python/framework-hop-is-a-dependent/app/models.py new file mode 100644 index 000000000..e5bce21b3 --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/app/models.py @@ -0,0 +1,10 @@ +from .handlers import audit +from .signals import order_placed + + +class Order: + def place(self): + order_placed.send(sender=self.__class__) + + def archive(self): + audit(self) diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/producer.py b/tests/cases/python/framework-hop-is-a-dependent/app/producer.py new file mode 100644 index 000000000..f00eebde3 --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/app/producer.py @@ -0,0 +1,17 @@ +from .tasks import Animation, plain, retry_report, send_report + + +def enqueue(key): + send_report.delay(key) + + +def reschedule(key): + retry_report.apply_async(args=[key], countdown=30) + + +def pause(anim: Animation): + anim.delay(10) + + +def run_plain(key): + plain.delay(key) diff --git a/tests/cases/python/framework-hop-is-a-dependent/app/signals.py b/tests/cases/python/framework-hop-is-a-dependent/app/signals.py new file mode 100644 index 000000000..d7a2d23cf --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/app/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +order_placed = Signal() 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 new file mode 100644 index 000000000..2f470f4c9 --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/app/tasks.py @@ -0,0 +1,20 @@ +from celery import shared_task + + +@shared_task +def send_report(key): + return key + + +@shared_task(bind=True) +def retry_report(self, key): + return key + + +def plain(key): + return key + + +class Animation: + def delay(self, ms): + return ms diff --git a/tests/cases/python/framework-hop-is-a-dependent/case.json b/tests/cases/python/framework-hop-is-a-dependent/case.json new file mode 100644 index 000000000..3459e369b --- /dev/null +++ b/tests/cases/python/framework-hop-is-a-dependent/case.json @@ -0,0 +1,35 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a signal's sender is a dependent of its @receiver: the engine joins them in framework_edge, and impact used to say nothing touches the receiver and the change is local", + "run": ["impact", "on_placed"], + "want": ["[framework] Order.place", "framework-mediated, not a call: signal_dispatch via signal [registered]", "note: [framework]"], + "avoid": ["directly touches it: nothing the graph can see", "nothing resolved depends on it, so the change is local", "[resolved] Order.place"]}, + {"why": "path reports the signal hop instead of calling the two ends independent, and says it is not a call", + "run": ["path", "Order.place", "on_placed"], "expect_error": true, + "want": ["a framework connects them: Order.place", "[framework: signal_dispatch via signal] (registered)", "framework-mediated, not a call", "next: no call connects them, the framework does"], + "avoid": ["the two are independent in this graph"]}, + {"why": "a task's .delay() producer is a framework dependent of the task body, not only a same-named lead", + "run": ["impact", "send_report"], + "want": ["[framework] enqueue", "task_dispatch via delay [registered]"]}, + {"why": "apply_async on a bind=True task is the same hop", + "run": ["impact", "retry_report"], + "want": ["[framework] reschedule", "task_dispatch via apply_async [registered]"]}, + {"why": "path finds the producer-to-task hop", + "run": ["path", "enqueue", "send_report"], "expect_error": true, + "want": ["a framework connects them: enqueue", "[framework: task_dispatch via delay]"], + "avoid": ["the two are independent in this graph"]}, + {"why": "CONTROL: an unrelated method that is also called delay gains no framework dependent from the task dispatch site", + "run": ["impact", "Animation.delay"], + "want": ["[resolved] pause"], + "avoid": ["[framework]", "task_dispatch"]}, + {"why": "CONTROL: .delay() on a function no task decorator registered joins nothing, so nothing is claimed", + "run": ["impact", "plain"], + "avoid": ["[framework]", "task_dispatch"]}, + {"why": "CONTROL: two declarations with no call and no framework hop between them are still independent", + "run": ["path", "pause", "send_report"], "expect_error": true, + "want": ["the two are independent in this graph"], + "avoid": ["a framework connects them"]}, + {"why": "CONTROL: an ordinary call beside the signal keeps its resolved chain and its resolved row", + "run": ["impact", "audit"], + "want": ["[resolved] Order.archive"], + "avoid": ["[framework]"]}]} From 8615740588d17d755ce3e460ea42749a68da4be3 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:49 -0700 Subject: [PATCH 13/19] java: the oracle-agreement golden counts the case added by #1563 #1563 added a Java case that both ground-truth oracles agree on, but left graph/test/java/expected/oracle-agreement.txt at 45 cases, so the engine (java) job fails its oracle-agreement check on 0.1.3. CI measured 46 cases compared, 46 agreeing, 0 disagreements; this records that. Refs #1401. --- graph/test/java/expected/oracle-agreement.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graph/test/java/expected/oracle-agreement.txt b/graph/test/java/expected/oracle-agreement.txt index a204958ea..22a372c44 100644 --- a/graph/test/java/expected/oracle-agreement.txt +++ b/graph/test/java/expected/oracle-agreement.txt @@ -1,4 +1,4 @@ -cases compared 45 agreeing 45 +cases compared 46 agreeing 46 CONSTRUCTOR-RULE disagreements: 0 cases, 0 rows OTHER disagreements: 0 rows naming no method: 0 From 33cc5e4b7124c0c40ab0962e30d45f6b185f27a5 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:56:52 -0700 Subject: [PATCH 14/19] java: record the three var shapes case 61 leaves untyped as known-missing The bytecode oracle in CI finds three edges case 61-var-local-type does not produce: a var element of an enhanced for loop, a try-with-resources var, and var lambda parameters. Before #1401 each was a wrong external:var edge; now the receiver is an honest unknown. Record them as accepted gaps so a new miss still fails and a fixed one reports NOW-FIXED. Refs #1401. --- .../java/expected/61-var-local-type.known-missing | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 graph/test/java/expected/61-var-local-type.known-missing diff --git a/graph/test/java/expected/61-var-local-type.known-missing b/graph/test/java/expected/61-var-local-type.known-missing new file mode 100644 index 000000000..996b5ec5e --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.known-missing @@ -0,0 +1,13 @@ +# Accepted gap, measured by the bytecode oracle in CI: a `var` local whose type comes from something +# other than its initializer expression is left a declared unknown. Before #1401 each of these was a +# wrong `external:var.` edge; now the receiver is honestly untyped. Three shapes: +# - the element variable of an enhanced for loop (`for (var w : widgets)`): its type is the +# iterable's element type; +# - a try-with-resources variable (`try (var w = open())` where open() is not typed client-side); +# - `var` lambda parameters (`(var w) -> w.paint()`): the type comes from the target functional +# interface. +# Follow-up: type `var` in these three shapes. It cannot rot: when one starts resolving, this line +# reports NOW-FIXED and the case fails until it is removed. +probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() +probe.VarLocals#fromResource -> probe.Widget#close() +probe.VarLocals#lambdaParams -> probe.Widget#paint() From 6ace6300296b12dad4c88356223b4dfb90d6deea Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:12:25 -0700 Subject: [PATCH 15/19] java: case 61 gets its oracle golden CI's second run on this branch failed 61-var-local-type with 'no oracle golden'. The golden is oracle_diff.py's report, and CI measured every value in it: oracle=20 engine=17 agree=17, the three known-missing var shapes, no extra edge. Refs #1401. --- graph/test/java/expected/61-var-local-type.oracle | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 graph/test/java/expected/61-var-local-type.oracle diff --git a/graph/test/java/expected/61-var-local-type.oracle b/graph/test/java/expected/61-var-local-type.oracle new file mode 100644 index 000000000..b29608363 --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.oracle @@ -0,0 +1,4 @@ +oracle=20 engine=17 agree=17 missing=3 (known 3, NEW 0) extra=0 + known-missing probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() + known-missing probe.VarLocals#fromResource -> probe.Widget#close() + known-missing probe.VarLocals#lambdaParams -> probe.Widget#paint() From 7a03c15a649eb6adf6bf8f4fbf9b784140833e91 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:49:10 -0700 Subject: [PATCH 16/19] typescript: a JSX tag through a wrapper, a loader or a prop lands on what renders Three wrong edges from the JSX component rules: - Any call's argument 0 was taken as the component, so `makeThing(() => build())` rendered `build` and `useMemo(() => Comp, [])` rendered the arrow. Only the component wrappers now count: `memo`, `forwardRef`, `observer` and a `withX` higher-order component, read off the callee name so `React.memo` matches. - A `lazy` / `dynamic` / `loadable` loader was matched on a namespace import anywhere under its return, so `import("./m").then((m) => m.Named)` rendered the module's default export. The returned value is read at its root; the module renders its default, and a projection (`.then` callback returning `m.Named`, `{ default: m.Named }`, `await import(...)`) renders the named component. - A tag or call through a parameter, plain or destructured (`({ icon: Button }) =>