diff --git a/README.md b/README.md index c388a185..4879d548 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,7 @@ left to your own search: bring the name you found to these commands. | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | | `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them | +| `axiomcode link ` | record where a call the graph could not resolve lands (kept in `axiomcode-links.tsv`); impact, path and tests then walk it, labelled `[asserted]`. Alone, lists the links and whether each was applied | | `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it | A name is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or diff --git a/graph/python/engine/resolution/generics.dl b/graph/python/engine/resolution/generics.dl index 20e9e93e..964f502a 100644 --- a/graph/python/engine/resolution/generics.dl +++ b/graph/python/engine/resolution/generics.dl @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :- type_ref_nesting("client", r, _, "1", child), type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", typevar_bound("client", vn, t). +// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref. +param_class_object_bound("client", ph, t) :- + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "", + typevar_bound("client", vn, t). expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), param_class_object_bound("client", ph, t). diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 6fd224af..51b73f51 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -450,6 +450,22 @@ expr_type_class_object(p, e, t) :- annotation_names_a_class("type"). annotation_names_a_class("Type"). +// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ───────────────────── +// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is. +// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None +// spelling of the same class-object parameter: the body replaces None with a default class +// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did +// every constructor reached through it. +param_class_object_ref("client", ph, r) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", _, "METHOD_PARAM", tn, _, r), + annotation_names_a_class(tn). +param_class_object_ref("client", ph, s) :- + type_ref_owner("client", ph, "METHOD_PARAM", r), + union_operand("client", r, s), + type_ref("client", _, _, tn, _, s), + annotation_names_a_class(tn). + expr_type_class_object("client", e, t) :- expr_names_param("client", e, ph), type_ref_owner("client", ph, "METHOD_PARAM", r), @@ -457,6 +473,12 @@ expr_type_class_object("client", e, t) :- annotation_names_a_class(tn), type_ref_nesting("client", r, _, "1", child), type_ref_resolved("client", t, child). +expr_type_class_object("client", e, t) :- + expr_names_param("client", e, ph), + param_class_object_ref("client", ph, s), + type_ref_owner("client", ph, "METHOD_PARAM", r), s != r, + type_ref_nesting("client", s, _, _, child), + type_ref_resolved("client", t, child). // A name written MORE THAN ONCE takes the union, for the same reason the instance side // does: a sound set beats a blank, and the tier follows from the target count. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 08cc0035..db011f11 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -244,6 +244,7 @@ .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) .decl union_operand(c0:symbol,c1:symbol,c2:symbol) +.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 284c6e88..acc74483 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -431,6 +431,22 @@ value_branch(root, x) :- value_branch(root, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, x), edge_role_is_operand(r). +// `Object.assign(target, …sources)` evaluates to its TARGET. A callable API is often a function object built this way +// (`export const widget: Factory = assign(createWidget, members)`, `Object.assign(task, { started, finished })`) +// and typed by a callable interface whose call signature has no body: the holder held the call's result, which the +// platform's merge never returned a function for, so a call of it ran nothing. The merge is read the same way when +// it is reached through an alias (`export const assign = Object.assign`), never by the name `assign` alone. +value_branch(root, x) :- value_branch(root, e), + object_assign_call(e), + expr_child("client", e, "ARGUMENT", "0", x). +object_assign_ref(x) :- property_access_recv(x, "assign", q), + expr_name("client", "Object", q), + expr_kind("client", "IDENTIFIER_REFERENCE", _, q). +object_assign_call(e) :- call_callee_expr(_, e, ne), object_assign_ref(ne). +object_assign_call(e) :- call_callee_expr(_, e, ne), + expr_holder(ne, h), + holder_value(h, v), + object_assign_ref(v). binary_op_yields_operand("??", "LEFT_OPERAND"). binary_op_yields_operand("??", "RIGHT_OPERAND"). binary_op_yields_operand("||", "LEFT_OPERAND"). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 500217c8..c8e368d1 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -188,6 +188,8 @@ .decl field_has_declared_type(c0:symbol) .decl field_in_enclosing(c0:symbol,c1:symbol,c2:symbol) .decl holder_value(c0:symbol,c1:symbol) +.decl object_assign_ref(c0:symbol) +.decl object_assign_call(c0:symbol) // #1208: nested element access, static callable fields, expression callees, object literals typed by context .decl ref_via_alias(c0:symbol,c1:symbol) .decl ref_elem_ref(c0:symbol,c1:symbol) diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index f85c960e..4d8b8abb 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -8,6 +8,8 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + link(site, target) record where an unresolved call lands, when the code makes it certain; + impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index cfd5138e..a4f5d2c4 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -243,6 +243,16 @@ def path(start: str, end: str) -> str: written on. start / end as written in the code (Owner.method, function, Type).""" return plain(run(['path', start, end, os.getcwd()])) +@srv.tool() +def link(site: str = '', target: str = '') -> str: + """Record where an unresolved call lands, when you have read the code and the target is CERTAIN: site is the call's + file:line as an answer's `unknown:` block lists it, target the declaration it reaches (Owner.method, function, or + its file:line). impact, path and tests then walk the edge, labelled [asserted]. With no arguments: every link and + whether the graph took it (a link whose line changed is dropped, never trusted). target "-" removes the site's links; + target "not:" rejects a lead (a by-name or one-of-a-set guess) at that site, which is then not walked. + Never link a guess, and never link a candidate for its rank alone.""" + return plain(run(['link'] + ([site] if site.strip() else []) + ([target] if site.strip() and target.strip() else []) + [os.getcwd()])) + @srv.tool() def tests() -> str: """The tests your uncommitted edits reach, each with its code, and the command that runs exactly those.""" diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 0432af2d..d36b30c5 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -13,6 +13,8 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + link(site, target) record where an unresolved call lands, when the code makes it certain; + impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index b3a302d2..838be50f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -18,6 +18,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | +| an answer lists an unresolved call I can see the target of | `link(site, target)` | `axiomcode link ` | | how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "" --source` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that @@ -59,6 +60,26 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## link + +Answers are in three parts. CONFIRMED places are backed by an edge: `resolved` by the engine, or `asserted` by a link — +act on them. LEADS are reached only through a guess (`by name`, `by key`, `one of a set`, `text`) — check each before +relying on it. TO RESOLVE lists the calls the answer stopped at: the site as `file:line:col`, the call as written, why +the engine could not follow it (a value from `getattr`, a handler table, reflection, a callback) and the graph's +candidate targets with their `file:line`. + +When the task depends on one of those sites, read the call. Only if the code makes the target CERTAIN, record it: +`link(site="app/dispatch.py:6:12", target="on_save")`, or `axiomcode link app/dispatch.py:6:12 on_save` from the shell. +A candidate is a lead: confirm it by reading the call, never link one because it is ranked first. From then on impact, +path and tests walk that edge, labelled `[asserted]`, never `resolved`; when the target declares a return type, the +calls made on its result (chained, or on a variable assigned from it) resolve too. When a lead at a site is wrong, +reject it: `link(site, "not:")` — it is no longer walked; only a guess can be rejected, never an edge the +engine resolved. The links are kept in `axiomcode-links.tsv` at the repository root, which is worth committing. +`link()` with no arguments lists them and whether the graph took each one; `axiomcode link -` removes +one. A link is refused when the call written there names a different declaration, or the target is not one; when +the line it was made on is edited, it is dropped and listed as stale, and the site is to resolve again. Never link a +guess: an asserted edge is trusted by every answer after it. + ## context How something works, from a task in your own words: the files and callables the task touches and, for a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 6fc1541c..86ce35f8 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -19,6 +19,7 @@ import ax_grep CAP = 10 # places shown; the rest are counted +LEAD_CERTS = {'by name', 'by key', 'decorator by name', 'one of a set', 'text', 'in scope', 'capped set', 'protocol', 'library callback'} FAR = 8 # places more than one hop away, named without code DIRECT_CODE = 3 # direct callers shown with code even when a word grep also finds them PLAIN_WHY = ('calls it', 'reads it', 'writes it', 'writes/reads it', 'references it', 'instantiates it') @@ -140,7 +141,18 @@ def greppable(p): far = [p for p in places.values() if is_far(p) and not is_test(p)] places = {k: p for k, p in places.items() if not is_far(p) and not is_test(p)} out = [] - for i, p in enumerate(list(places.values())[:CAP], 1): + # CONFIRMED FIRST, THEN LEADS: a place backed by an edge (resolved, or asserted by a link) before one reached only + # through a guess (by name, by key, one of a set, text). One place per function, so a function reached both ways is + # listed once, as confirmed; the guesses get a heading of their own only when both kinds are present. + def is_lead(p): + certs = [t.split(' · ')[0].strip() for t in p['tags'] if ' · ' in t] + return bool(certs) and all(c in LEAD_CERTS for c in certs) + ordered = [p for p in places.values() if not is_lead(p)] + [p for p in places.values() if is_lead(p)] + places = {id(p): p for p in ordered} + any_confirmed = any(not is_lead(p) for p in ordered) + for i, p in enumerate(ordered[:CAP], 1): + if is_lead(p) and any_confirmed and (i == 1 or not is_lead(ordered[i - 2])): + out.append("leads — reached only through a guess; check each before relying on it:") where = f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" out.append(f"{i}. {where}" + (f" [{' | '.join(p['tags'][:2])}]" if p['tags'] else '')) body = block(repo, p['f'], p['marks'], p['span']) @@ -172,7 +184,17 @@ def greppable(p): # from these places needs it as much as the verified: line, so it is never tidied away here. # run: stays LAST: the answer ends with the command to run, whatever else the foot carries. kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3] - out += kept + [x for x in foot if x.startswith('run:')][:1] + out += kept + unknown(doc, repo) + [x for x in foot if x.startswith('run:')][:1] + return out + + +UNKNOWN_SHOWN = 5 # unresolved sites listed under an answer: the nearest; the verbs' --json carries up to 30 +def unknown(doc, repo): + """the answer's gaps as a short work list (ax_links.py): where it stops being complete, and how to close one""" + import ax_links + sites = doc.get('unknown_sites') or [] + out = ax_links.unknown_lines(repo, sites, doc.get('unknown_total') or len(sites), shown=UNKNOWN_SHOWN) if sites else [] + if doc.get('links_note'): out.append(doc['links_note']) return out @@ -258,7 +280,9 @@ def main(argv): lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None if lines is None: # a refusal or an answer with no place in it: the verb's own words are the answer - print('\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip()); return r.returncode + prose = '\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip() + extra = [l for l in unknown(doc, repo) if l not in prose] + print('\n'.join([prose] + extra)); return r.returncode print('\n'.join(lines)) return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index b85276e6..41a89cb5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -45,8 +45,10 @@ 'runtime_observed': 1, # seen in a runtime trace; real, but no call site stands behind it 'multi_inferred': 1, # several declarations fit; each one is a real candidate 'dispatch': 2, # a base method to an override that is actually instantiated + 'overload': 0, # the overload signature a call selected runs its set's implementation: one body, certain 'callback_registered': 3, # handed over as a value and invoked by whoever holds it 'event_dispatch': 3, # emitted here, handled there + 'asserted': 3, # a link someone recorded (axiomcode link) where the engine resolved nothing: read, not derived 'remote': 5, # a request crosses a process to its handler (remote_edge): no call site names it. 'framework': 5, # a framework runs the other end for this one (framework_edge). Both 5, the default # impact's route reader already gave them (P.TIER_RANK.get(t, 5)), so its routes do not move @@ -67,8 +69,10 @@ 'known_edge': 'resolved to one declaration', 'multi_inferred': 'several declarations fit; each is a real candidate', 'dispatch': 'a base method to an override the project instantiates', + 'overload': 'the call selected an overload signature; this is the implementation that runs', 'callback_registered': 'handed over as a value and invoked by whoever holds it', 'event_dispatch': 'emitted here, handled there', + 'asserted': 'ASSERTED by a link (axiomcode-links.tsv): someone read the call and recorded its target; the engine did not resolve it', 'remote': 'NOT a call site: a request crosses a process to the handler that serves it (transport and destination on the hop)', 'framework': 'NOT a call site: a framework runs the other end for this one (mechanism and registration on the hop)', 'defines': 'NOT a call — written inside that body, so it runs only after it', @@ -169,13 +173,14 @@ def legend(tiers): # as a value, and a capped fan-out exists precisely BECAUSE the candidate set was too large to # enumerate, so what is in the graph is a sample of it. DIRECT_CERT = { - 'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved', + 'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved', 'overload': 'resolved', 'implicit_constructor': 'resolved', 'written': 'resolved', 'known_implicit_ctor': 'resolved', 'known_builtin_operator': 'resolved', 'runtime_observed': 'resolved', 'multi_inferred': 'one of a set', 'callback_registered': 'registered', 'event_dispatch': 'registered', 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered', 'fan_capped': 'capped set', + 'asserted': 'asserted', # a link someone recorded (ax_links.py): an edge, never `resolved` 'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run 'remote': 'remote', 'framework': 'framework', # impact's own rung names for the same two hops (#1469) } @@ -185,6 +190,7 @@ def legend(tiers): DIRECT_WHY = { 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', + 'asserted': 'calls it — asserted by a link (axiomcode-links.tsv), not resolved by the engine', 'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change', } # …and where the TIER says something more specific than its certainty. A request or event is not handed over as a @@ -263,12 +269,12 @@ def entry_outside(reason): # instead: the membership is exactly what it was before this table existed, so no row leaves any # set — only the label it is printed under changes. It matters most for the --delete verdict, where # dropping a hand-off would turn "something still holds this" into "safe to delete". -EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'}) +EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it'}) # most certain first. A caller with several call sites to the same callee can hold sites of different # tiers; a summary that names the caller once takes the best of them, which is the honest reading of # "at least one resolved call exists here". -DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it') +DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it') # ── `defines`: a callable written inside another one's body ──────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index d763c2b4..b0192551 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -1505,6 +1505,12 @@ def query(repo, verb, argv, fresh=False): """run a query verb (argv) against the last good graph, the stale-while-revalidate way (above). Returns its exit code""" import ax_exec argv = ax_exec.program(argv) # `python3` may be a shell shim no native process can start (#1331) + # THE ASSERTED LINKS FOLLOW THEIR FILE (ax_links.py): a links file edited by hand, pulled or removed since the graphs + # were last given it is re-applied here, O(links), with the derived facts patched in place — never a rebuild + try: + import ax_links; ax_links.sync(repo) + except Exception: + pass def run(): return subprocess.run(argv, stdout=subprocess.PIPE) def passthrough(): ax_exec.become(argv) # never os.execvp: on Windows it returns 0 before the answer (#1640) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py new file mode 100644 index 00000000..65988c8c --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_links.py @@ -0,0 +1,1241 @@ +#!/usr/bin/env python3 +"""ax_links.py — call edges an agent (or a person) ASSERTS where the graph could not resolve the call. + +An answer that stops at an unresolved site says so ("N unresolved call(s) inside — a lower bound") and lists the sites +(`unknown_sites`). Someone who has read the code and is certain where such a call lands records it: + + axiomcode link the call written at file:line reaches + axiomcode link every link, and whether the graph took it + axiomcode link - remove the links at that site (or: --remove []) + +THE FILE. Links live in `axiomcode-links.tsv` at the repository root (AXIOMCODE_LINKS overrides it), not under +.axiomcode/. Everything under .axiomcode/ is derived and is deleted freely (a corrupt graph, an engine change, a user +clearing it); a link is knowledge someone read the code to get, so it must outlive the graph, and it is reviewed and +shared like any other file a team commits. The line's text is hashed into each row, so a committed link that no longer +describes the code is dropped by the graph rather than trusted. + +THE RULE. A link may only ADD an edge, never remove or relabel one (the rule runtime-observed.dl states for a trace). +Each is validated against the graph it is applied to, and applied only if: + (a) a call site is written at that line (followed by the line's TEXT when lines above it moved: the nearest line with + the same text and a call of the same name), and the call as written is consistent with the target: the same name, + the class a constructor belongs to, or a call through a value (the name as written declares nothing in the graph, + a computed or reflective call, or a site the engine itself says is a call through a parameter or value); + (b) the target is a declaration in the graph (a client callable, or a staged library method), named as the graph + names it, in the file the link recorded; + (c) the line's text still hashes the same. +An applied link is a call_edges row of tier `asserted` (certainty `asserted`, never `resolved`). A rejected or stale +one is recorded in the graph's `asserted_links` table with the reason, listed by `axiomcode link`, and counted on the +next answer's note line. Applying is O(links): it runs at the end of every index (axiomcode-index), so a rebuild keeps +them, and on `link` itself against the existing graphs, with the derived facts patched in place (no re-solve). +""" +import hashlib, json, os, re, sqlite3, subprocess, sys, time + +FILE_NAME = 'axiomcode-links.tsv' +COLS = ('file', 'line', 'line_sha', 'callee', 'caller', 'target', 'target_file', 'by', 'at', 'col', 'ncol', 'not') +HEADER = ('# axiomcode links: call edges asserted where the graph could not resolve the call. ' + 'Written by `axiomcode link`; one per line, tab-separated: ' + ' '.join(COLS)) +TIER = 'asserted' +# a call whose callee is computed, or invoked through a value: the name as written says nothing about what runs +VALUE_KINDS = {'DYNAMIC_CALL', 'SUBSCRIPT_CALL', 'UNKNOWN_CALLEE_CALL', 'COMPUTED_CALL', 'FUNCTION_CALL_APPLY', + 'FUNCTION_CALL_CALL', 'FUNCTION_CALL_BIND', 'IIFE_CALL', 'DYNAMIC_CODE_CALL'} +# the engine's own reason (ext_call_site_unresolved) when it says the callee is a value it could not follow +VALUE_REASONS = ('callee_is_parameter', 'dynamic_call', 'escape_hatch', 'unbound_name', 'untyped_receiver:subscript_untyped', + 'member_absent_from_type', 'untyped_receiver:attribute_absent_on_type', 'computed_attribute_name', + 'value_callee', 'callee_is_value', 'callee_is_local', 'callee_is_field') +# the methods every language invokes a held callable or a reflected member through +REFLECTIVE = {'invoke', 'Invoke', 'DynamicInvoke', 'InvokeMember', 'apply', 'call', 'accept', 'test', 'run', + 'applyAsInt', 'applyAsLong', 'applyAsDouble', 'handle', 'execute', 'Execute', '__call__', 'emit', 'dispatch'} +RANK = {'applied': 0, 'moved': 1, 'redundant': 2, 'changed': 3, 'stale': 3, 'rejected': 4, 'malformed': 5} +# the library methods that run a callable or a member someone else chose: a site resolved to one of them is as unknown as an +# unresolved one, and is listed with them (unknown_sites) +REFLECTIVE_LIB = {'invoke', 'Invoke', 'DynamicInvoke', 'InvokeMember', 'newInstance', 'CreateInstance', 'apply', 'accept', + 'test', 'run', 'call', 'applyAsInt', 'applyAsLong', 'applyAsDouble', 'Execute'} +# a call written on a receiver (`x.m()`): its callee as written is a member name, not a value +MEMBER_KINDS = {'METHOD_CALL', 'SELF_CALL', 'SUPER_CALL', 'CHAINED_CALL', 'OPTIONAL_CALL', 'PROPERTY_READ', 'PROPERTY_WRITE', + 'CONTEXT_MANAGER', 'ITERATION_PROTOCOL', 'BUILTIN_PROTOCOL', 'DECORATOR_ATTRIBUTE'} +CTOR_KINDS = {'new', 'CONSTRUCTOR_CALL', 'anon_new', 'METACLASS_CREATION', 'object_creation', 'OBJECT_CREATION'} +CTOR_NAMES = {'__init__', '', 'constructor', '.ctor', '__new__'} + + +def norm(text): + return re.sub(r'\s+', ' ', (text or '').strip()) + + +def line_sha(text): + return hashlib.sha1(norm(text).encode('utf-8', 'replace')).hexdigest()[:12] + + +def norm_col(text, col): + """a 1-based column of a line as an offset into the line's normalized text (norm): the same call keeps it when the + line is re-indented or its spacing changes, which is how a link follows a call by its place within the line""" + pre = re.sub(r'\s+', ' ', (text or '')[:max(0, col - 1)].lstrip()) + return len(pre) + + +def denorm_col(text, off): + """the 1-based column of the line whose normalized offset is off (norm_col's inverse), or None past the end""" + i, n, t = 0, 0, text or '' + while i < len(t) and t[i].isspace(): i += 1 + while i < len(t): + if n == off: return i + 1 + if t[i].isspace(): + while i < len(t) and t[i].isspace(): i += 1 + n += 1 + else: i += 1; n += 1 + return i + 1 if n == off else None + + +def links_path(repo): + return os.environ.get('AXIOMCODE_LINKS') or os.path.join(repo, FILE_NAME) + + +def file_sha(repo): + try: + with open(links_path(repo), 'rb') as fh: return hashlib.sha1(fh.read()).hexdigest()[:16] + except OSError: return '' + + +def read_links(repo): + """([link dict with 'n' = its line in the file], [(n, raw, why)] malformed lines). A bad line costs only itself.""" + out, bad = [], [] + try: + with open(links_path(repo), encoding='utf-8', errors='replace') as fh: rows = fh.read().split('\n') + except OSError: return out, bad + for n, raw in enumerate(rows, 1): + if not raw.strip() or raw.lstrip().startswith('#'): continue + parts = raw.split('\t') + if len(parts) < 6: + bad.append((n, raw, f'expected at least 6 tab-separated fields ({", ".join(COLS[:6])}), found {len(parts)}')); continue + d = dict(zip(COLS, parts + [''] * (len(COLS) - len(parts)))) + if not d['file'] or not d['target'] or not re.fullmatch(r'\d+', d['line'].strip() or 'x'): + bad.append((n, raw, 'the file, a numeric line and the target are required')); continue + d['line'] = int(d['line']); d['n'] = n + d['col'] = int(d['col']) if str(d.get('col') or '').isdigit() else None + d['ncol'] = int(d['ncol']) if str(d.get('ncol') or '').isdigit() else None + d['not'] = str(d.get('not') or '').strip().lower() in ('1', 'not', 'yes', 'true') + out.append(d) + return out, bad + + +def write_links(repo, links): + p = links_path(repo); tmp = f"{p}.{os.getpid()}.tmp" + keep = [] + try: + with open(p, encoding='utf-8', errors='replace') as fh: + keep = [l for l in fh.read().split('\n') if l.lstrip().startswith('#') and l.strip() != HEADER] + except OSError: pass + with open(tmp, 'w', encoding='utf-8') as fh: + fh.write(HEADER + '\n') + for l in keep: fh.write(l + '\n') + for d in links: + d = dict(d, **{'not': 'not' if d.get('not') else ''}) + fh.write('\t'.join(('' if d.get(c) is None else str(d.get(c))).replace('\t', ' ').replace('\n', ' ') for c in COLS).rstrip('\t') + '\n') + os.replace(tmp, p) + + +# ── the graphs of a repository and the text each describes ────────────────────────────────────────────────── +def graph_dbs(repo): + """[(db path, source reader, live?)] for every graph under .axiomcode: the main and each language's, and the baseline + graphs `changed` / `tests` read, whose text is the tree they were built from""" + ax = os.path.join(repo, '.axiomcode'); out, seen = [], set() + def add(db, reader, live): + try: rp = os.path.realpath(db) + except OSError: return + if os.path.isfile(rp) and rp not in seen: seen.add(rp); out.append((db, reader, live)) + work = Reader(repo) + add(os.path.join(ax, 'out', 'graph.sqlite'), work, True) + lang = os.path.join(ax, 'lang') + for l in sorted(os.listdir(lang)) if os.path.isdir(lang) else []: add(os.path.join(lang, l, 'out', 'graph.sqlite'), work, True) + base = os.path.join(ax, 'base') + if os.path.isdir(base): + try: tree = open(os.path.join(base, 'tree')).read().strip() + except OSError: tree = '' + br = Reader(repo, tree) if tree else None + if br: + add(os.path.join(base, 'out', 'graph.sqlite'), br, False) + bl = os.path.join(base, 'lang') + for l in sorted(os.listdir(bl)) if os.path.isdir(bl) else []: add(os.path.join(bl, l, 'out', 'graph.sqlite'), br, False) + return out + + +class Reader: + """a file's lines: the working tree's, or (tree given) the text of that git tree""" + def __init__(self, repo, tree=None): + self.repo, self.tree, self.cache = repo, tree, {} + def lines(self, f): + if f not in self.cache: + txt = None + if self.tree: + try: + r = subprocess.run(['git', 'cat-file', 'blob', f'{self.tree}:{f}'], cwd=self.repo, capture_output=True, timeout=20) + if r.returncode == 0: txt = r.stdout.decode('utf-8', 'replace') + except (OSError, subprocess.SubprocessError): pass + else: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as fh: txt = fh.read() + except OSError: pass + self.cache[f] = txt.split('\n') if txt is not None else None + return self.cache[f] + + +# ── one graph ──────────────────────────────────────────────────────────────────────────────────────────────── +class Graph: + def __init__(self, con): + self.con = con + self.tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + self._raw = None; self._names = None + self.lang = '' + if 'run' in self.tables: + r = self.q("SELECT value FROM run WHERE key = 'language'") + self.lang = r[0][0] if r else '' + # call_sites columns: 1-based for the TypeScript / JavaScript front ends, 0-based for the others + self.base = 1 if self.lang in ('typescript', 'javascript') else 0 + self.reader = None + def q(self, sql, *a): return self.con.execute(sql, a).fetchall() + def raw_paths(self, f): + """the spellings call_sites.file_path uses for the repo-relative file f""" + if 'paths' in self.tables: + r = [x[0] for x in self.q("SELECT raw FROM paths WHERE rel = ?", f)] + if r: return r + return [f] + def rel(self, raw): + if self._raw is None: + self._raw = dict(self.q("SELECT raw, rel FROM paths")) if 'paths' in self.tables else {} + return self._raw.get(raw, raw) + def sites_on(self, f, line, text=None): + """the call sites starting on that line, each with `col`: the 1-based column of its callee's NAME as written (of + the call's start when it has none), the column an answer prints and `link` takes; s0/e0 its span on the line""" + raws = self.raw_paths(f); ph = ','.join('?' * len(raws)) + rows = self.q(f"SELECT id, caller_id, kind, callee_name, start_line, end_line, start_column, end_column FROM call_sites " + f"WHERE file_path IN ({ph}) AND start_line = ?", *raws, line) + out = [] + for sid, c, k, n, l, el, sc, ec in rows: + s0 = max(0, (sc or 0) - self.base) + e0 = ((ec or 0) - self.base) if el == l and ec is not None else len(text or '') + out.append(dict(id=sid, caller=c, kind=k, callee=n, line=l, end=el, s0=s0, e0=e0, col=name_col(text, n, s0, e0))) + return out + def reason(self, sid): + if 'ext_call_site_unresolved' not in self.tables: return '' + r = self.q("SELECT c2 FROM ext_call_site_unresolved WHERE c0 = ? LIMIT 1", sid) + return r[0][0] if r else '' + def unresolved(self, sid): + return bool('unresolved_sites' in self.tables and self.q("SELECT 1 FROM unresolved_sites WHERE call_site_id = ? LIMIT 1", sid)) + def declares(self, name): + """does a CALLABLE in this graph (a client or staged library method or function, or a type) carry this simple name? + A field, a variable or a parameter of that name holds a value, which is exactly what a link may name the target of""" + if self._names is None: + self._names = {r[0] for r in self.q("SELECT DISTINCT name FROM symbols WHERE name IS NOT NULL AND (method_id IS NOT NULL OR type_id IS NOT NULL)")} if 'symbols' in self.tables else set() + self._names |= {r[0] for r in self.q("SELECT DISTINCT name FROM methods")} + if 'types' in self.tables: + try: self._names |= {r[0] for r in self.q("SELECT DISTINCT name FROM types")} + except sqlite3.Error: pass + return name in self._names + def holds_value(self, name): + """is this name declared as a field / variable / constant (an attribute that can hold a callable)?""" + if getattr(self, '_fields', None) is None: + self._fields = {r[0] for r in self.q("SELECT DISTINCT name FROM symbols WHERE name IS NOT NULL AND method_id IS NULL AND type_id IS NULL")} if 'symbols' in self.tables else set() + if 'fields' in self.tables: + try: self._fields |= {r[0] for r in self.q("SELECT DISTINCT name FROM fields")} + except sqlite3.Error: pass + return name in self._fields + def display(self, sid): + r = self.q("SELECT display FROM symbols WHERE id = ? LIMIT 1", sid) + return r[0][0] if r else sid + + def targets(self, target, tfile=''): + """[(method id, display, file, provenance, simple name, kind, owner)] for a target as the graph names it""" + out = [] + hint = None + mh = re.fullmatch(r'(.+):(\d+)', tfile or '') + if mh: tfile, hint = mh.group(1), int(mh.group(2)) + m = re.fullmatch(r'(.+\.\w+):(\d+)', target) # a declaration by its file:line + if m: + rows = self.q("SELECT method_id, display, file, name, kind, owner, line FROM symbols WHERE file = ? AND line = ? AND method_id IS NOT NULL " + "AND kind <> 'module'", m.group(1), int(m.group(2))) + else: + rows = self.q("SELECT method_id, display, file, name, kind, owner, line FROM symbols WHERE (display = ? OR qualified_name = ?) " + "AND method_id IS NOT NULL AND kind <> 'module'", target, target) + rows = [r for r in rows if not tfile or r[2] == tfile] + # several declarations of one name in one file (overloads, an @overload stub beside its body): the one nearest the + # line the link recorded it at, which survives an edit that moves it a few lines + if hint is not None and len({r[0] for r in rows}) > 1: + rows = sorted(rows, key=lambda r: abs((r[6] or 0) - hint))[:1] + for mid, disp, f, name, kind, owner, _ln in rows: + out.append((mid, disp, f, 'client', name, kind, owner)) + if not out and not tfile: + for mid, qn, name, prov in self.q("SELECT id, qualified_name, name, provenance FROM methods WHERE qualified_name = ? AND provenance <> 'client'", target): + out.append((mid, qn, '', prov, name, 'method', qn.rsplit('.', 2)[-2] if qn.count('.') >= 1 else '')) + seen, uniq = set(), [] + for t in out: + if t[0] not in seen: seen.add(t[0]); uniq.append(t) + return uniq + + +def name_col(text, callee, s0, e0): + """1-based column of the callee's name inside the call's span on its line (the LAST occurrence: `h(x).run()` names run + after h), else of the span's start""" + nm = (callee or '').split('.')[-1].split('::')[-1] + if text and nm: + seg = text[s0:max(e0, s0)] if e0 and e0 > s0 else text[s0:] + k = -1 + for m in re.finditer(r'(?= 0: return s0 + k + 1 + return s0 + 1 + + +def stable_name(g, mid, display, tfile): + """the name a link records for its target: the display when it names one declaration in that file, else the qualified + name (a nested `decorator` is one of five in its file), else the display as it is. A line number would go stale with + the first edit above it, so it is never what is recorded.""" + if len(g.targets(display, tfile)) <= 1: return display, tfile + r = g.q("SELECT qualified_name FROM symbols WHERE method_id = ? AND qualified_name IS NOT NULL LIMIT 1", mid) + if r and r[0][0] and len(g.targets(r[0][0], tfile)) == 1: return r[0][0], tfile + # overloads share both names: the file carries the declaration's line as a hint (Graph.targets takes the nearest) + ln = g.q("SELECT line FROM symbols WHERE method_id = ? AND file = ? LIMIT 1", mid, tfile) + return display, (f"{tfile}:{ln[0][0]}" if ln else tfile) + + +def is_value_lib(g, sid): + """a site resolved only into a library method that runs a value (Method.invoke, Function.apply)""" + rows = g.q("SELECT e.tier, s.callee_name FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id WHERE e.call_site_id = ?", sid) + return bool(rows) and all(t == 'boundary_lib' and (n or '').split('.')[-1] in REFLECTIVE_LIB for t, n in rows) + + +def consistent(g, site, tname, tkind, towner): + """'' when the call as written may be a call to the target, else why not""" + callee = (site['callee'] or '').split('.')[-1].split('::')[-1] + owner_simple = (towner or '').split('.')[-1] + if callee and callee == tname: return '' + if callee and (tname in CTOR_NAMES or tkind in ('constructor', 'class')) and callee in (owner_simple, tname): return '' + # a CONSTRUCTION names its type: `new Error(…)` builds an Error whatever the graph declares, never some other function + if site['kind'] in CTOR_KINDS: + return f"the call constructs `{site['callee']}`; a construction is linked only to that type's constructor" + if site['kind'] in VALUE_KINDS: return '' + if callee in REFLECTIVE: return '' + rsn = g.reason(site['id']) + if any(rsn.startswith(v) for v in VALUE_REASONS): return '' + # a MEMBER call on a receiver the engine could not type (`x.index()`): the member's name is what it calls, most often + # a library method's; it is linked only to a declaration of that name + member = site['kind'] in MEMBER_KINDS or rsn.startswith('untyped_receiver') + if not callee or (not g.declares(callee) and (not member or g.holds_value(callee))): return '' # a local, a parameter, a field holding a value + if member and not g.declares(callee): + return (f"the call is to the member `{site['callee']}` of a receiver the graph could not type — most likely a library " + f"method of that name, not `{tname}`; a member call is linked only to a declaration of its own name, or where " + f"the member is a field holding a callable") + return (f"the call as written names `{site['callee']}`, which is a declaration in the graph and not `{tname}`; " + f"a link is taken where the call goes through a value (a parameter, a local, a table, getattr / reflection), " + f"or names the target itself") + + +def locate(g, reader, link): + """(line now, [sites on it], status reason). Follows the line by its text when lines above it moved.""" + L = reader.lines(link['file']) + if L is None: return None, [], 'stale: the file is gone' + want = link.get('line_sha') or '' + n = link['line'] + at = lambda i: g.sites_on(link['file'], i, L[i - 1] if 0 < i <= len(L) else '') + if 0 < n <= len(L) and (not want or line_sha(L[n - 1]) == want): + return n, at(n), '' + if not want: return None, [], 'stale: the line is past the end of the file' + cands = [i for i, t in enumerate(L, 1) if line_sha(t) == want] + callee = link.get('callee') or '' + with_site = [] + for i in cands: + # the same text in ANOTHER callable is another call: a moved line is followed only within the callable it was + # written in (`return fn(doc)` is a line many functions share) + ss = [s for s in at(i) if (not callee or s['callee'] == callee) + and (not link.get('caller') or g.display(s['caller']) == link['caller'])] + if ss: with_site.append((abs(i - n), i, ss)) + if not with_site: + return None, [], 'stale: the line was edited (no line with its text and that call is left in ' + (f"{link['caller']})" if link.get('caller') else 'the file)') + with_site.sort() + if len(with_site) > 1 and with_site[0][0] == with_site[1][0]: + return None, [], f"stale: the line's text now appears at lines {with_site[0][1]} and {with_site[1][1]}, equally near" + return with_site[0][1], with_site[0][2], '' + + +def resolve(g, reader, link): + """-> dict(status, reason, line, site, caller, target id, display, provenance)""" + r = dict(status='rejected', reason='', line=None, site=None, caller=None, callee_id=None, target_display=link['target'], prov='client') + ts = g.targets(link['target'], link.get('target_file') or '') + if not ts: + r['reason'] = ('the target is not a declaration in this graph' + (f" in {link['target_file']}" if link.get('target_file') else '') + + ' (renamed, deleted or moved?)'); r['status'] = 'stale' if link.get('target_file') else 'rejected' + return r + if len(ts) > 1: + r['reason'] = f"the target names {len(ts)} declarations ({', '.join(sorted({t[2] or '' for t in ts})[:4])}): name it as file:line"; return r + mid, disp, tf, prov, tname, tkind, towner = ts[0] + r.update(callee_id=mid, target_display=disp, prov=prov, target_file=tf) + line, sites, why = locate(g, reader, link) + if why: r.update(status='stale', reason=why); return r + r['line'] = line + if not sites: + r['reason'] = f"no call is written at {link['file']}:{line}"; return r + callee = link.get('callee') or '' + if callee: sites = [s for s in sites if s['callee'] == callee] or sites + # A COLUMN NAMES ONE CALL: the call whose name starts there, read through the line's normalized text so a re-indented + # or re-spaced line keeps it. A column that names no call on the line is stale, never a nearby guess. + if link.get('col') or link.get('ncol') is not None: + L = reader.lines(link['file']) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + col = denorm_col(text, link['ncol']) if link.get('ncol') is not None else link['col'] + at_line = sites + sites = [s for s in sites if s['col'] == col] + if not sites: + have = ', '.join(str(x['col']) for x in sorted(at_line, key=lambda x: x['col'])) + r.update(status='stale', reason=f"stale: no call's name starts at column {col} of {link['file']}:{line} (calls there: column {have})"); return r + # the engine already made this very edge: nothing to add, whatever the call as written says + for s_ in sites: + if g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ? LIMIT 1", s_['id'], mid, TIER): + r.update(status='redundant', reason='the graph already has this edge', site=s_['id'], caller=s_['caller'], kind=s_['kind'], + callee_written=s_['callee']) + return r + ok = [(s, consistent(g, s, tname, tkind, towner)) for s in sites] + good = [s for s, w in ok if not w] + if not good: + r['reason'] = ok[0][1]; return r + if len(good) > 1: + # the one call named like the target; else the calls the graph could not follow (an unresolved site, or one into a + # library method that runs a value) over the ones it resolved + un = [s for s in good if g.unresolved(s['id']) or is_value_lib(g, s['id'])] + named = [s for s in good if (s['callee'] or '').split('.')[-1] == tname] + good = named if len(named) == 1 else un if un else good + if len(good) > 1 and len({(x['callee'], x['col']) for x in good}) == 1: + # one call written once and recorded twice (a decorator factory's call and the decoration applying its result): + # the call itself + good = sorted(good, key=lambda x: (x['kind'] or '').endswith('APPLICATION'))[:1] + if len(good) > 1: + # NEVER CHOOSE between two calls the link could mean (`a.run() + b.run()`, `f(a)(b)`): the column says which + which = ', '.join(f"`{x['callee'] or '?'}` at column {x['col']}" for x in sorted(good, key=lambda x: x['col'])) + r['reason'] = (f"{len(good)} calls on that line could be it ({which}): " + f"give the column, `axiomcode link {link['file']}:{line}: {link['target']}`"); return r + s = good[0] + r.update(site=s['id'], caller=s['caller'], kind=s['kind'], callee_written=s['callee'], col=s['col'], s0=s['s0'], e0=s['e0']) + if g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ? LIMIT 1", s['id'], mid, TIER): + r.update(status='redundant', reason='the graph already has this edge'); return r + r['status'] = 'applied' if line == link['line'] else 'moved' + if r['status'] == 'moved': r['reason'] = f"followed by its text from line {link['line']} to {line}" + return r + + + +# ── what a link makes resolvable AFTER it: calls on the linked call's result ──────────────────────────────────── +# A link names a declaration, so the type its call returns is known: a call chained on the result (`h(req).render()`) +# or made on a local assigned from it (`x = h(req)` … `x.render()`) resolves to that type's member, at apply time and with +# no re-solve. Each such edge is `asserted` too, remembers the link it came from, and goes when that link goes. +WRAPPERS = {'Optional', 'Promise', 'PromiseLike', 'Task', 'ValueTask', 'Awaitable', 'Coroutine', 'Future', 'CompletableFuture', + 'Final', 'Annotated', 'Readonly', 'Mono', 'Deferred'} +ELEMENT_OF = {'list', 'List', 'Iterable', 'Iterator', 'Sequence', 'Collection', 'Set', 'set', 'tuple', 'Tuple', 'Array', 'ReadonlyArray', + 'IEnumerable', 'IList', 'ICollection', 'IReadOnlyList', 'IReadOnlyCollection', 'Stream', 'Generator', 'AsyncIterable', + 'AsyncIterator', 'IAsyncEnumerable', 'ArrayList', 'LinkedList', 'HashSet', 'frozenset'} +DECL_WORDS = r'(?:const|let|var|val|final|auto|readonly)' + + +def split_generic(t): + """`Optional[Response]` / `Task>` -> ('Optional', ['Response']) ; `Response` -> ('Response', [])""" + t = (t or '').strip().strip('"\'').strip() + m = re.match(r'^([\w.$]+)\s*[\[<](.*)[\]>]\s*$', t) + if not m: return t, [] + args, depth, cur = [], 0, '' + for ch in m.group(2): + if ch in '[<(': depth += 1 + elif ch in ']>)': depth -= 1 + if ch == ',' and depth == 0: args.append(cur.strip()); cur = '' + else: cur += ch + if cur.strip(): args.append(cur.strip()) + return m.group(1), args + + +def clean_type(t, owner=''): + """an annotation as written -> the simple name of the type a call of it returns (wrappers, Optional and `| None` off)""" + t = (t or '').strip().strip('"\'').strip().rstrip('?!').strip() + if not t: return '' + parts = [x.strip() for x in re.split(r'\|', t) if x.strip() not in ('None', 'null', 'undefined', 'void')] if '|' in t and '<' not in t and '[' not in t else [t] + if len(parts) != 1: return '' + t = parts[0] + if t.endswith('[]'): return '' # an array: its element is element_type's + head, args = split_generic(t) + last = head.split('.')[-1] + if last in WRAPPERS and args: return clean_type(args[0], owner) + if last in ('Self', 'this') and owner: return owner.split('.')[-1] + return last + + +def element_type(t): + head, args = split_generic((t or '').strip().strip('"\'')) + if head.split('.')[-1] in WRAPPERS and args: return element_type(args[0]) + if head.split('.')[-1] in ELEMENT_OF and args: return clean_type(args[0]) + m = re.match(r'^(.+)\[\]$', (t or '').strip()) # T[] + return clean_type(m.group(1)) if m else '' + + +class Types: + def __init__(self, g, reader): + self.g, self.reader, self.memo = g, reader, {} + def type_named(self, name): + if not name: return None + rows = {(t, d) for t, d in self.g.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL AND (name = ? OR display = ?)", name, name)} + return sorted(rows)[0] if len(rows) == 1 else None + def owner_type(self, mid): + r = self.g.q("SELECT owner_type_id FROM methods WHERE id = ?", mid) + if r and r[0][0]: + d = self.g.q("SELECT display FROM symbols WHERE type_id = ? AND method_id IS NULL LIMIT 1", r[0][0]) + return (r[0][0], d[0][0] if d else r[0][0]) + return None + def declared(self, mid): + """the return type as WRITTEN: (text, how) from the declaration's own header (generics and arrays as written), else the graph's type_use""" + g = self.g + sy = g.q("SELECT name, file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) + if not sy: return None + name, f, ln, end = sy[0] + L = self.reader.lines(f) or [] + # the header only: up to the line that opens the body (a Python `:` at the end of a line, else `{` / `=>` / `;`) + hl = [] + for t in L[ln - 1: min(len(L), ln + 11)]: + hl.append(t) + if (g.lang == 'python' and re.search(r':\s*(#.*)?$', t)) or (g.lang != 'python' and re.search(r'[{;]|=>', t)): break + head = ' '.join(hl) + lang = g.lang + if lang == 'python': + m = re.search(r'\bdef\s+' + re.escape(name) + r'\s*\(.*?\)\s*->\s*(.+?)\s*:(?:\s|$)', head) + if m: return (m.group(1), 'annotation') + elif lang == 'typescript': + m = re.search(re.escape(name) + r'\s*(?:<[^>]*>)?\s*\((?:[^()]|\([^()]*\))*\)\s*:\s*([^={;]+?)\s*(?:\{|=>|;|$)', head) + if m: return (m.group(1), 'declared') + elif lang in ('java', 'csharp'): + m = re.search(r'([\w.$]+(?:\s*<[^()]*?>)?(?:\[\])?\??)\s+' + re.escape(name) + r'\s*(?:<[^>]*>)?\s*\(', head) + if m and m.group(1) not in ('new', 'return', 'void', 'else'): return (m.group(1), 'declared') + if lang in ('javascript', 'typescript'): + # the doc comment directly above the declaration, and only that one + k = ln - 2 + while k >= 0 and k >= ln - 40 and re.match(r'\s*(/\*\*|\*|\*/|//|@)', L[k]): + m = re.search(r'@returns?\s*\{([^}]+)\}', L[k]) + if m: return (m.group(1), 'JSDoc') + k -= 1 + # the graph's own type_use when the header says nothing the patterns read + if 'type_use' in g.tables: + rows = g.q("SELECT tu.depth, COALESCE(sy.display, ty.name) FROM type_use tu LEFT JOIN symbols sy ON sy.type_id = tu.type_id AND sy.method_id IS NULL " + "LEFT JOIN types ty ON ty.id = tu.type_id WHERE tu.owner_method_id = ? AND tu.context = 'METHOD_RETURN' ORDER BY tu.depth", mid) if 'types' in g.tables else [] + names = [n for _d, n in rows if n] + if names: return (names[0] + (f"<{', '.join(names[1:])}>" if len(names) > 1 else ''), 'declared') + return None + + def inferred(self, mid): + """the type every `return` of the body names: `self` / `this` (the owner), `new T(…)` / `T(…)` of a type in the graph""" + sy = self.g.q("SELECT file, line, end_line FROM symbols WHERE method_id = ? AND file IS NOT NULL LIMIT 1", mid) + if not sy: return None + f, ln, end = sy[0] + L = self.reader.lines(f) or [] + got = set() + for t in L[ln - 1: (end or ln)]: + m = re.search(r'\breturn\s+(?:await\s+)?(.+?)\s*;?\s*}?\s*$', t) + if not m: continue + v = m.group(1) + if re.fullmatch(r'(self|this)', v): o = self.owner_type(mid); got.add(o[1].split('.')[-1] if o else '?'); continue + m2 = re.match(r'(?:new\s+)?([A-Za-z_$][\w$.]*)\s*(?:<[^>]*>)?\s*\(', v) + if m2 and self.type_named(m2.group(1).split('.')[-1]): got.add(m2.group(1).split('.')[-1]); continue + got.add('?') + return (got.pop(), 'inferred from its returns') if len(got) == 1 and '?' not in got else None + def returns(self, mid): + """-> (type_id, display, how, element type text or '') for what a call of mid returns, or None""" + if mid in self.memo: return self.memo[mid] + g = self.g; out = None + sy = g.q("SELECT name, kind FROM symbols WHERE method_id = ? LIMIT 1", mid) + nm, kind = sy[0] if sy else ('', '') + if nm in CTOR_NAMES or kind == 'constructor': + o = self.owner_type(mid) + if o: out = (o[0], o[1], 'constructs it', '') + if out is None: + d = self.declared(mid) + o = self.owner_type(mid) + if d: + t = clean_type(d[0], o[1] if o else '') + tt = self.type_named(t) + if tt: out = (tt[0], tt[1], d[1], '') + else: + el = element_type(d[0]) + if el and self.type_named(el): out = (None, d[0], d[1], el) + if out is None: + i = self.inferred(mid) + tt = self.type_named(i[0]) if i else None + if tt: out = (tt[0], tt[1], i[1], '') + self.memo[mid] = out + return out + def member(self, type_id, name): + """(method id, display) of the member `name` on the type or the nearest base declaring it""" + g = self.g; seen = []; todo = [type_id] + while todo and len(seen) < 30: + t = todo.pop(0) + if t in seen: continue + seen.append(t) + r = g.q("SELECT m.id, COALESCE(sy.display, m.qualified_name) FROM methods m LEFT JOIN symbols sy ON sy.method_id = m.id " + "WHERE m.owner_type_id = ? AND m.name = ? LIMIT 1", t, name) + if r: return r[0] + if 'type_ancestors' in g.tables: + todo += [a for (a,) in g.q("SELECT ancestor_type_id FROM type_ancestors WHERE type_id = ?", t)] + return None + + +def derive(g, reader, res, cap=60): + """-> ([(site id, caller id, method id, label, how)], note) for the calls on the linked call's result""" + T = Types(g, reader) + rt = T.returns(res['callee_id']) + if not rt: return [], 'return type unknown, calls on its result stay unknown' + f, line = res['file'], res['line'] + L = reader.lines(f) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + out = [] + def site_at(ln, name, col0): + for s in g.sites_on(f, ln, L[ln - 1] if 0 < ln <= len(L) else ''): + if (s['callee'] or '').split('.')[-1] == name and s['col'] == col0 + 1: return s + return None + def chain(ln, pos, t, how): + """walk `.m()` / `.attr` segments after column pos (0-based) of line ln, starting from type t""" + seg_text = L[ln - 1] if 0 < ln <= len(L) else '' + depth = 0 + while t and t[0] and depth < 8 and len(out) < cap: + m = re.match(r'\s*(?:\)\s*)*(?:!\s*)?\??\.\s*([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*(\()?', seg_text[pos:]) + if not m: return + name, is_call = m.group(1), bool(m.group(2)) + mem = T.member(t[0], name) + if not mem: return + name0 = pos + m.start(1) + if is_call: + s = site_at(ln, name, name0) + if not s: return + if not g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ?", s['id'], mem[0], TIER): + out.append((s['id'], s['caller'], mem[0], mem[1], f"{how}: on the result, {t[1]}", s['kind'])) + pos = s['e0'] + else: + s = site_at(ln, name, name0) # a property read the parser recorded as a call + if s and not g.q("SELECT 1 FROM call_edges WHERE call_site_id = ? AND callee_method_id = ? AND tier <> ?", s['id'], mem[0], TIER): + out.append((s['id'], s['caller'], mem[0], mem[1], f"{how}: a property of the result, {t[1]}", s['kind'])) + pos = name0 + len(name) + t = T.returns(mem[0]); how = 'derived'; depth += 1 + # (1) chained on the same expression + chain(line, res['e0'], rt, 'derived') + # (2) a local assigned from it, or an element of it + before = text[:res['s0']] + ma = (re.search(r'(?:^|[\s(,;])(?:' + DECL_WORDS + r'\s+|[\w.$<>\[\],?]+\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=]+)?(?])=(?!=)\s*(?:await\s+)?\(?\s*$', before) + or re.search(r'\(\s*([A-Za-z_]\w*)\s*:=\s*(?:await\s+)?$', before)) + mw = re.match(r'\s*(?:\)\s*)?as\s+([A-Za-z_]\w*)', text[res['e0']:]) if g.lang == 'python' else None + mf = re.search(r'\bfor(?:each)?\s*\(?\s*(?:' + DECL_WORDS + r'\s+|[\w<>?]+\s+)?([A-Za-z_$][\w$]*)\s+(?:of|in|:)\s*(?:await\s+)?$', before) + local, lt = None, None + if mf and rt[3]: + el = T.type_named(rt[3]); local, lt = mf.group(1), ((el[0], el[1], 'derived', '') if el else None) + elif ma or mw: + local = (ma or mw).group(1); lt = rt + if mw: + ent = T.member(rt[0], '__enter__') + if ent: lt = T.returns(ent[0]) or rt + if local and lt and lt[0]: + cs = g.q("SELECT file, line, end_line FROM symbols WHERE id = ? LIMIT 1", res['caller']) + lo, hi = (cs[0][1], cs[0][2]) if cs and cs[0][2] else (line, min(len(L), line + 200)) + body = L[lo - 1: hi] + assign = re.compile(r'(?:^|[^\w$.])' + re.escape(local) + r'\s*(?::[^=()]+)?(?])=(?!=)|\bfor\s*\(?\s*(?:\w+\s+)?' + re.escape(local) + + r'\s+(?:in|of)\b|\bas\s+' + re.escape(local) + r'\b|\(\s*' + re.escape(local) + r'\s*:=') + if sum(1 for t in body if assign.search(t)) > 1: + return out, f"`{local}` is assigned more than once in {g.display(res['caller'])}: calls on it stay unknown" + use = re.compile(r'(? --not `. Only a GUESS can be rejected: a by-name match at an unresolved site, or one +# member of a target set the engine could not narrow (one of a set, a capped fan). Nothing is deleted: the pair is +# skipped where the walks read their facts (impact's calls / rejected, path's edge / rejected_edge), so removing the +# rejection restores it at once. An edge the engine resolved is never hidden this way. +LEAD_TIERS = ('multi_inferred', 'fan_capped') +REJ_TABLE = ("CREATE TABLE IF NOT EXISTS asserted_rejections(n INT, call_site_id TEXT, caller_id TEXT, callee_id TEXT, file TEXT, line INT, " + "status TEXT, reason TEXT)") + + +def resolve_not(g, reader, link, asserted_pairs): + """-> dict(status, reason, site, caller, callee_id, line) for a rejection""" + r = dict(status='rejected', reason='', site=None, caller=None, callee_id=None, line=None) + ts = g.targets(link['target'], link.get('target_file') or '') + if not ts: + r.update(status='stale' if link.get('target_file') else 'rejected', + reason='the target is not a declaration in this graph (renamed, deleted or moved?)'); return r + if len(ts) > 1: r['reason'] = f"the target names {len(ts)} declarations: name it as file:line"; return r + mid, disp, tf, prov, tname, tkind, towner = ts[0] + r['callee_id'] = mid; r['target_file'] = tf; r['target_display'] = disp + line, sites, why = locate(g, reader, link) + if why: r.update(status='stale', reason=why); return r + r['line'] = line + if link.get('col') or link.get('ncol') is not None: + L = reader.lines(link['file']) or [] + text = L[line - 1] if 0 < line <= len(L) else '' + col = denorm_col(text, link['ncol']) if link.get('ncol') is not None else link['col'] + sites = [x for x in sites if x['col'] == col] + if not sites: r.update(status='stale', reason=f"stale: no call's name starts at column {col}"); return r + if not sites: r['reason'] = f"no call is written at {link['file']}:{line}"; return r + lead, refuse = [], '' + for x in sites: + tiers = {t for (t,) in g.q("SELECT tier FROM call_edges WHERE call_site_id = ? AND callee_method_id = ?", x['id'], mid)} + if (x['id'], mid) in asserted_pairs or tiers == {TIER}: + refuse = refuse or "that edge is an asserted link, not a guess: remove the link instead (`axiomcode link -`)" + elif tiers & set(LEAD_TIERS) and not (tiers - set(LEAD_TIERS) - {TIER}): + lead.append(x) + elif tiers: + refuse = refuse or "the engine resolved this call; if it is wrong that is an engine defect — not hidden" + elif g.unresolved(x['id']) and (x['callee'] or '').split('.')[-1] == tname: + lead.append(x) + if not lead: + r['reason'] = refuse or f"no by-name or one-of-a-set lead at {link['file']}:{line} reaches {disp}"; return r + if len(lead) > 1: + r['reason'] = f"{len(lead)} calls on that line lead to {disp}: give the column"; return r + x = lead[0] + r.update(status='applied' if line == link['line'] else 'moved', site=x['id'], caller=x['caller'], col=x['col'], callee_written=x['callee']) + return r + + +def prefer_on(): + """AXIOMCODE_LINKS_PREFER=1: at a site an asserted link settles, the site's own guesses are not walked either""" + return os.environ.get('AXIOMCODE_LINKS_PREFER', '').lower() in ('1', 'on', 'true', 'yes') + + +def suppressed(q, site_file=lambda f: f): + """-> (pairs {(caller, callee, file, line)}, edges {(caller, callee)}): the leads the walks skip. A pair is a site and + a target; an edge (path's by-name / set edges carry no site) is skipped only when EVERY site of that caller leading + to that callee is suppressed.""" + tabs = {r[0] for r in q("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + sites = set() # (site id, callee) + if 'asserted_rejections' in tabs: + sites |= {(a, b) for a, b in q("SELECT call_site_id, callee_id FROM asserted_rejections WHERE status IN ('applied','moved')")} + if prefer_on(): + linked = {a for (a,) in q("SELECT DISTINCT call_site_id FROM call_edges WHERE tier = 'asserted'")} + for sid in linked: + keep = {b for (b,) in q("SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier = 'asserted'", sid)} + sites |= {(sid, b) for (b,) in q(f"SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier IN ('multi_inferred','fan_capped')", sid) if b not in keep} + if 'unresolved_sites' in tabs and q("SELECT 1 FROM unresolved_sites WHERE call_site_id = ?", sid): + n = q("SELECT callee_name FROM call_sites WHERE id = ?", sid) + nm = (n[0][0] or '').split('.')[-1] if n else '' + if nm: sites |= {(sid, b) for (b,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL", nm) if b not in keep} + if not sites: return set(), set() + info = {} + ids = sorted({a for a, _ in sites}) + for i in range(0, len(ids), 500): + ch = ids[i:i + 500] + for sid, c, n, f, l in q(f"SELECT id, caller_id, callee_name, file_path, start_line FROM call_sites WHERE id IN ({','.join('?' * len(ch))})", *ch): + info[sid] = (c, (n or '').split('.')[-1], site_file(f) if f else '', l or 0) + pairs = {(info[a][0], b, info[a][2], info[a][3]) for a, b in sites if a in info} + # an edge is gone only when all of its sites are: the caller's other sites of that name / set still lead there + edges = set() + by_edge = {} + for a, b in sites: + if a in info: by_edge.setdefault((info[a][0], b), set()).add(a) + for (c, b), ss in by_edge.items(): + multi = {x for (x,) in q("SELECT call_site_id FROM call_edges WHERE caller_id = ? AND callee_method_id = ? AND tier IN ('multi_inferred','fan_capped')", c, b)} + nm = {info[x][1] for x in ss} + byname = {x for n_ in nm if n_ for (x,) in (q("SELECT s.id FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ? AND (s.callee_name = ? OR s.callee_name LIKE ?)", c, n_, '%.' + n_) if 'unresolved_sites' in tabs else [])} + if (multi | byname) <= ss: edges.add((c, b)) + return pairs, edges + + +DERIVED_TABLE = "CREATE TABLE IF NOT EXISTS asserted_derived(n INT, call_site_id TEXT, callee_id TEXT, label TEXT, how TEXT)" +LINK_TABLE = ("CREATE TABLE IF NOT EXISTS asserted_links(n INT, file TEXT, line INT, at_line INT, target TEXT, target_id TEXT, " + "call_site_id TEXT, caller_id TEXT, status TEXT, reason TEXT)") + + +def apply_db(db, repo, reader, links=None, bad=None, sha=None): + """validate every link against this graph and make its asserted edges exactly the valid ones. Only rows of tier + `asserted` are ever deleted. -> [(link, result)]""" + if links is None: links, bad = read_links(repo) + if sha is None: sha = file_sha(repo) + con = sqlite3.connect(db, timeout=30) + try: + g = Graph(con) + if 'call_sites' not in g.tables or 'call_edges' not in g.tables: return [] + had = bool(con.execute("SELECT 1 FROM call_edges WHERE tier = ? LIMIT 1", (TIER,)).fetchone()) or 'asserted_links' in g.tables or 'asserted_rejections' in g.tables + if not links and not bad and not had: return [] + rejs = [l for l in links if l.get('not')] + links = [l for l in links if not l.get('not')] + res = [(l, resolve(g, reader, l)) for l in links] + # what each applied link makes resolvable after it, read before any row is written (the engine's own edges decide) + derived = {} + for l, r in res: + if r['status'] in ('applied', 'moved') and r.get('e0') is not None: + try: derived[l['n']] = derive(g, reader, dict(r, file=l['file'])) + except Exception as e: derived[l['n']] = ([], f'nothing derived ({type(e).__name__})') + con.execute("DELETE FROM call_edges WHERE tier = ?", (TIER,)) + con.execute(LINK_TABLE); con.execute("DELETE FROM asserted_links") + con.execute(DERIVED_TABLE); con.execute("DELETE FROM asserted_derived") + done = set() + ins = lambda sid, c, m, lab, prov, k: con.execute( + "INSERT INTO call_edges(call_site_id, caller_id, callee_method_id, callee_label, callee_provenance, tier, kind) VALUES (?,?,?,?,?,?,?)", + (sid, c, m, lab, prov, TIER, k or 'call')) + for l, r in res: + if r['status'] in ('applied', 'moved') and (r['site'], r['callee_id']) not in done: + done.add((r['site'], r['callee_id'])) + ins(r['site'], r['caller'], r['callee_id'], r['target_display'], r['prov'], r.get('kind')) + if l['n'] in derived: + edges, why = derived[l['n']] + for sid, c, m, lab, how, k in edges: + con.execute("INSERT INTO asserted_derived VALUES (?,?,?,?,?)", (l['n'], sid, m, lab, how)) + if (sid, m) not in done: done.add((sid, m)); ins(sid, c, m, f"{lab} (derived from link {l['n']})", 'client', k) + r['reason'] = ('; '.join(x for x in (r['reason'], why) if x)) + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", + (l['n'], l['file'], l['line'], r['line'], l['target'], r['callee_id'], r['site'], r['caller'], r['status'], r['reason'])) + con.execute(REJ_TABLE); con.execute("DELETE FROM asserted_rejections") + apairs = {(r['site'], r['callee_id']) for _l, r in res if r['status'] in ('applied', 'moved')} + for l in rejs: + r = resolve_not(g, reader, l, apairs) + res.append((l, r)) + con.execute("INSERT INTO asserted_rejections VALUES (?,?,?,?,?,?,?,?)", (l['n'], r['site'], r['caller'], r['callee_id'], l['file'], r['line'], r['status'], r['reason'])) + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", + (l['n'], l['file'], l['line'], r['line'], 'not ' + l['target'], r['callee_id'], r['site'], r['caller'], r['status'], r['reason'])) + for n, raw, why in bad or []: + con.execute("INSERT INTO asserted_links VALUES (?,?,?,?,?,?,?,?,?,?)", (n, '', None, None, raw[:120], None, None, None, 'malformed', why)) + if 'index_meta' in g.tables: + con.execute("DELETE FROM index_meta WHERE key = 'links_sha'"); con.execute("INSERT INTO index_meta VALUES ('links_sha', ?)", (sha,)) + con.commit() + return res + finally: + con.close() + + +# ── the derived facts, patched rather than re-exported ──────────────────────────────────────────────────────── +def _rewrite(path, keep, add): + try: + with open(path, encoding='utf-8') as fh: rows = [l for l in fh.read().split('\n') if l and keep(l.split('\t'))] + except OSError: return False + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, 'w', encoding='utf-8') as fh: + for l in rows: fh.write(l + '\n') + for r in add: fh.write('\t'.join(str(x) for x in r) + '\n') + os.replace(tmp, path); return True + + +def patch_facts(db, old_mtime, rows): + """the path and impact facts exported for this graph are keyed on its mtime (axiomcode-path / axiomcode-impact + export()). Writing the asserted rows moved the mtime, which would make the next query re-export every relation + (minutes on a large graph). The asserted rows reach those facts in exactly three relations — path's edge, impact's + calls and cert_tier — so those are rewritten (rows of tier `asserted` dropped, the new ones added) and the stamps + moved to the new mtime. A facts directory stamped for some other graph is left alone: it re-exports as before.""" + try: new = os.stat(db).st_mtime + except OSError: return + gdir = os.path.dirname(os.path.dirname(db)) if os.path.basename(os.path.dirname(db)) == 'out' else os.path.dirname(db) + facts = os.path.join(gdir, 'out', 'dl') + import ax_edges + for stamp, files in ((os.path.join(facts, 'stamp'), 'path'), (os.path.join(facts, 'impact', 'stamp'), 'impact')): + try: cur = open(stamp).read() + except OSError: continue + if not cur.startswith(f"{old_mtime}:"): continue + ok = True + if files == 'path': + ok = _rewrite(os.path.join(facts, 'edge.facts'), lambda p: len(p) < 3 or p[2] != TIER, + sorted({(c, m, TIER) for c, m, prov, _f, _l in rows if prov in ('client', 'generated')})) + else: + D = os.path.join(facts, 'impact') + ok = _rewrite(os.path.join(D, 'calls.facts'), lambda p: len(p) < 3 or p[2] != TIER, + sorted({(c, m, TIER, f, l) for c, m, prov, f, l in rows if prov == 'client'})) + ok = ok and _rewrite(os.path.join(D, 'cert_tier.facts'), lambda p: p[0] != TIER, + [(TIER, ax_edges.direct_cert(TIER), ax_edges.direct_why(TIER))]) + if ok: + tmp = f"{stamp}.{os.getpid()}.tmp"; open(tmp, 'w').write(f"{new}:{cur.split(':', 1)[1]}"); os.replace(tmp, stamp) + + +def asserted_rows(db): + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + g = Graph(con) + return [(c, m, prov, g.rel(f) if f else '', l or 0) for c, m, prov, f, l in con.execute( + "SELECT e.caller_id, e.callee_method_id, e.callee_provenance, s.file_path, s.start_line FROM call_edges e " + "LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = ?", (TIER,))] + finally: + con.close() + + +def _has_rejections(db): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: return bool(con.execute("SELECT 1 FROM asserted_rejections LIMIT 1").fetchone()) + finally: con.close() + except sqlite3.Error: return False + + +def apply_repo(repo, quiet=True): + """apply the links file to every graph of the repository, patching each one's facts. -> {graph: results}""" + links, bad = read_links(repo); sha = file_sha(repo); out = {} + for db, reader, live in graph_dbs(repo): + try: old = os.stat(db).st_mtime + except OSError: continue + had_rej = _has_rejections(db) + try: res = apply_db(db, repo, reader, links, bad, sha) + except sqlite3.Error as e: + if not quiet: print(f"axiomcode link: {db}: {e}", file=sys.stderr) + continue + # a rejection (or the prefer mode) changes the by-name and set facts too: those graphs re-export on the next query + if (res or links or bad) and not prefer_on() and not had_rej and not any(l.get('not') for l in links): patch_facts(db, old, asserted_rows(db)) + out[db] = (live, res) + return out + + +def sync(repo): + """before a query: re-apply when the links file differs from what the graphs were last given (edited by hand, + pulled, removed). One read of a small file and one row per graph; nothing at all with no links file and no links.""" + sha = file_sha(repo) + for db, _r, _live in graph_dbs(repo): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: got = con.execute("SELECT value FROM index_meta WHERE key = 'links_sha'").fetchone() + except sqlite3.Error: got = None + con.close() + except sqlite3.Error: continue + if (got[0] if got else '') != sha: + apply_repo(repo); return True + return False + + +# ── what a query says about them ────────────────────────────────────────────────────────────────────────────── +def status_rows(q): + try: return q("SELECT n, file, line, at_line, target, status, reason FROM asserted_links ORDER BY n") + except sqlite3.Error: return [] + + +def note(q): + """one line for an answer when some link was not applied, else ''""" + rows = status_rows(q) + off = [r for r in rows if r[5] in ('stale', 'rejected', 'malformed')] + nrej = sum(1 for r in rows if str(r[4]).startswith('not ') and r[5] in ('applied', 'moved')) + if not off: return '' + by = {} + for r in off: by[r[5]] = by.get(r[5], 0) + 1 + return (f"links: {len(off)} of {len(rows)} asserted link(s) not applied ({', '.join(f'{n} {s}' for s, n in sorted(by.items()))})" + f" — `axiomcode link` lists them") + + +def rejected_note(q): + rows = status_rows(q) + n = sum(1 for r in rows if str(r[4]).startswith('not ') and r[5] in ('applied', 'moved')) + return f"links: {n} lead(s) rejected by a link are not walked — `axiomcode link` lists them" if n else '' + + + +# ── CANDIDATES: what the graph already knows about where an unknown site may land ─────────────────────────────── +# Never from a runtime trace: the engine's own target set at the site, the callables handed into the called value by +# the caller's callers, a computed name's constant prefix, callables registered as values in the same file, and +# declarations of the callee's own name. Ranked in that order, at most `cap`. A candidate is a LEAD: the agent confirms +# it by reading the call, never by its rank. +def candidates(q, sid, caller, callee, kind, file_, line, reason, reader, cap=5): + out, seen = [], set() + def add(mid, why): + if not mid or mid in seen or len(out) >= cap: return + r = q("SELECT display, file, line FROM symbols WHERE method_id = ? AND kind <> 'module' LIMIT 1", mid) + if not r: return + seen.add(mid); out.append(dict(target=r[0][0], at=f"{r[0][1]}:{r[0][2]}", why=why)) + for (m,) in q("SELECT callee_method_id FROM call_edges WHERE call_site_id = ? AND tier IN ('multi_inferred','fan_capped') AND callee_provenance = 'client'", sid): + add(m, "the engine's own candidate set") + nm = (callee or '').split('.')[-1] + L = reader.lines(file_) if reader and file_ else None + cs = q("SELECT name, file, line, end_line, signature FROM symbols WHERE id = ? LIMIT 1", caller) + # declarations of the callee's own name (a member call on an untyped receiver) + if nm: + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' LIMIT 10", nm): + add(m, "a declaration of the name called") + # a parameter called: what the callers pass in that position + if nm and cs and L is not None: + cname, cf, cl, ce, sig = cs[0] + params = [re.split(r'[:=\s]', p.strip().lstrip('*&'))[0] for p in re.sub(r'^[^(]*\(|\)[^)]*$', '', sig or '').split(',') if p.strip()] + if nm in params: + k = params.index(nm) + for s_f, s_l, s_sc in q("SELECT s.file_path, s.start_line, s.start_column FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + "WHERE e.callee_method_id = (SELECT method_id FROM symbols WHERE id = ?) AND e.tier <> 'asserted' LIMIT 20", caller): + rel = q("SELECT rel FROM paths WHERE raw = ?", s_f); rel = rel[0][0] if rel else s_f + LL = reader.lines(rel) or [] + t = LL[s_l - 1] if 0 < (s_l or 0) <= len(LL) else '' + mm = re.search(re.escape(cname) + r'\s*\((.*)', t) + if not mm: continue + args = [a.strip() for a in re.split(r',(?![^()\[\]{}]*[)\]}])', mm.group(1).rsplit(')', 1)[0])] + a = args[k - (1 if params and params[0] in ('self', 'cls', 'this') else 0)] if k < len(args) + 1 else '' + a = (a or '').split('=')[-1].strip().split('.')[-1] + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module'", a): + add(m, f"passed in by a caller at {rel}:{s_l}") + # a name computed from a constant prefix (the engine's reason carries it: computed_attribute_name:on_*) + pref = '' + mr = re.search(r'computed_attribute_name:(\w*)\*', reason or '') + if mr: pref = mr.group(1) + elif L is not None and cs: + body = '\n'.join(L[cs[0][2] - 1: cs[0][3] or cs[0][2]]) + mp = re.search(r'["\'`](\w{2,})["\'`]\s*\+|`(\w{2,})\$\{|f["\'](\w{2,})\{', body) + if mp: pref = next(g_ for g_ in mp.groups() if g_) + if pref: + for (m,) in q("SELECT method_id FROM symbols WHERE name LIKE ? ESCAPE '\\' AND method_id IS NOT NULL AND kind <> 'module' ORDER BY file = ? DESC, line LIMIT 20", + pref.replace('\\', '').replace('%', '').replace('_', '\\_') + '%', file_): + add(m, f"named {pref}…, the constant part of the computed name") + # callables registered as VALUES in the same file (a table, a list, a register(...) call) + if L is not None: + vals = set() + for t in L: + for v in re.findall(r'[:\[,(=]\s*([A-Za-z_$][\w$]*)\s*(?=[,\]})])', t): vals.add(v) + for v in sorted(vals): + for (m,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind NOT IN ('module', 'class') LIMIT 3", v): + add(m, "handed over as a value in this file") + return out + + +def unknown_sites(q, callers, site_file, limit=30, order=None, repo=None): + """the unresolved call sites inside these callables: where the answer stops being complete. Each with the call as + written, the engine's own reason, and the targets it was linked to (if any).""" + callers = [c for c in dict.fromkeys(callers) if c] + if not callers: return [], 0 + tabs = {r[0] for r in q("SELECT name FROM sqlite_master WHERE type IN ('table','view')")} + if 'unresolved_sites' not in tabs: return [], 0 + rows = []; libcall = {} + for i in range(0, len(callers), 500): + ch = callers[i:i + 500]; ph = ','.join('?' * len(ch)) + rows += [tuple(r) for r in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line FROM unresolved_sites u " + f"JOIN call_sites s ON s.id = u.call_site_id WHERE u.caller_id IN ({ph})", *ch)] + # A CALL RESOLVED INTO A LIBRARY THAT RUNS A VALUE: Method.invoke, a delegate's Invoke, Function.apply. The engine + # resolved it (to the library method), so it is no unresolved site, and what it runs is as unknown as one's + for sid, c, k, n, f, l, lib in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line, e.callee_label FROM call_edges e " + f"JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = 'boundary_lib' AND e.caller_id IN ({ph})", *ch): + if (n or '').split('.')[-1] in REFLECTIVE_LIB: rows.append((sid, c, k, n, f, l)); libcall[sid] = lib + # …and a typed front end's call through a holder of a FUNCTION TYPE: resolved to a signature with no body, so the + # function the holder is given is what runs (axiomcode-path's value calls, FUNCTION_TYPE_KINDS there) + for sid, c, k, n, f, l, qn in q(f"SELECT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line, m.qualified_name FROM call_edges e " + f"JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id " + f"WHERE m.kind IN ('FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE') AND e.caller_id IN ({ph})", *ch): + rows.append((sid, c, k, n, f, l)); libcall[sid] = 'a function-type signature, not a body' + # …and a site whose every engine candidate a link rejected: never silently empty, it is to resolve again + if 'asserted_rejections' in tabs: + for sid, c, k, n, f, l in q(f"SELECT DISTINCT s.id, s.caller_id, s.kind, s.callee_name, s.file_path, s.start_line FROM asserted_rejections r " + f"JOIN call_sites s ON s.id = r.call_site_id WHERE r.status IN ('applied','moved') AND s.caller_id IN ({ph})", *ch): + left = q("SELECT 1 FROM call_edges e WHERE e.call_site_id = ? AND e.tier IN ('multi_inferred','fan_capped') AND NOT EXISTS " + "(SELECT 1 FROM asserted_rejections r WHERE r.call_site_id = e.call_site_id AND r.callee_id = e.callee_method_id AND r.status IN ('applied','moved')) LIMIT 1", sid) + if not left: rows.append((sid, c, k, n, f, l)) + rows = list(dict.fromkeys(rows)) + total = len(rows) + rank = {c: i for i, c in enumerate(order or callers)} + rows.sort(key=lambda r: (rank.get(r[1], len(rank)), str(r[4]), r[5] or 0)) + reasons, linked = {}, {} + ids = [r[0] for r in rows[:limit]] + if ids: + ph = ','.join('?' * len(ids)) + if 'ext_call_site_unresolved' in tabs: + for sid, why in q(f"SELECT c0, c2 FROM ext_call_site_unresolved WHERE c0 IN ({ph})", *ids): reasons.setdefault(sid, why) + for sid, lab in q(f"SELECT call_site_id, callee_label FROM call_edges WHERE tier = 'asserted' AND call_site_id IN ({ph})", *ids): + linked.setdefault(sid, []).append(lab) + # the column of each call's name, so a site is written file:line:col and two calls on one line are told apart + base = 0 + try: + lg = q("SELECT value FROM run WHERE key = 'language'") + base = 1 if lg and lg[0][0] in ('typescript', 'javascript') else 0 + except sqlite3.Error: pass + spans = {r[0]: (r[1], r[2], r[3], r[4]) for r in (q(f"SELECT id, start_line, start_column, end_line, end_column FROM call_sites WHERE id IN ({ph})", *ids) if ids else [])} + rd = Reader(repo) if repo else None + def col_of(sid, f, callee): + sl, sc, el, ec = spans.get(sid, (0, 0, 0, None)) + L = rd.lines(f) if rd and f else None + text = L[sl - 1] if L and 0 < (sl or 0) <= len(L) else '' + s0 = max(0, (sc or 0) - base); e0 = ((ec or 0) - base) if el == sl and ec is not None else len(text) + return name_col(text, callee, s0, e0) + disp = {} + out = [] + for sid, caller, kind, callee, f, ln in rows[:limit]: + if caller not in disp: + d = q("SELECT display FROM symbols WHERE id = ? LIMIT 1", caller); disp[caller] = d[0][0] if d else caller + lc = str(libcall.get(sid, '')) + why = reasons.get(sid) or ((f"calls a value typed by {lc}" if ' ' in lc else f"runs a value through {lc.split(':')[-1]}") if sid in libcall else 'unresolved') + rf = site_file(f) if f else '?' + try: cands = candidates(q, sid, caller, callee, kind, rf, ln, reasons.get(sid), rd) + except Exception: cands = [] + site = f"{rf}:{ln or 0}:{col_of(sid, rf, callee)}" + out.append(dict(at=f"{rf}:{ln or 0}", site=site, call=callee or '', kind=kind, caller=disp[caller], candidates=cands, + command=f"axiomcode link {site} {cands[0]['target'] if cands else ''}", + reason=why, linked=sorted(linked.get(sid, [])))) + return out, total + + +def code_at(repo, at): + f, _, n = at.rpartition(':') + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as fh: L = fh.read().split('\n') + return L[int(n) - 1].strip() if 0 < int(n) <= len(L) else '' + except (OSError, ValueError): return '' + + +def unknown_lines(repo, sites, total, shown=None): + """the `unknown:` block of an answer""" + if not sites: return [] + # a site a link settled is no longer to resolve: counted, not listed + nlinked = sum(1 for x in sites if x['linked']) + sites = [x for x in sites if not x['linked']] + total = max(0, total - nlinked) + if not sites: return [f"to resolve: nothing — {nlinked} site(s) the answer stopped at are settled by links"] if nlinked else [] + shown = sites[:shown] if shown else sites + out = [f"to resolve: {total} call site(s) the answer stopped at — what each reaches is not in it" + + (f" (first {len(shown)})" if total > len(shown) else '') + ':'] + for s in shown: + code = code_at(repo, s['at']) + out.append(f" {s.get('site') or s['at']} {code[:90]} [{s['reason']}]" + (f" → linked: {', '.join(s['linked'])} [asserted]" if s['linked'] else '')) + if not s['linked']: + cs_ = s.get('candidates') or [] + out.append(" candidates: " + ('; '.join(f"{c['target']} {c['at']} ({c['why']})" for c in cs_[:3]) + (f" +{len(cs_) - 3}" if len(cs_) > 3 else '') + if cs_ else 'no candidate')) + out.append(" read the call first; when it makes the target certain: `axiomcode link ` (a candidate is a lead, never link it by its rank)") + return out + + +# ── the verb ────────────────────────────────────────────────────────────────────────────────────────────────── +USAGE = """axiomcode link [] record that the call written there reaches +axiomcode link list every link and whether the graph took it +axiomcode link - remove the links at that site (--remove []) + + is a declaration as the graph names it (Owner.method, function) or its file:line. Only a call the graph could +not resolve the same way is worth a link, and only when the code makes the target CERTAIN: an asserted edge is walked by +impact, path and tests like any other, labelled [asserted].""" + + +def find_repo(args): + for a in reversed(args): + if os.path.isdir(a) and not re.search(r':\d+$', a): return os.path.realpath(a), [x for x in args if x is not a] + return os.path.realpath(os.getcwd()), args + + +def site_arg(repo, s): + """file:line or file:line:col -> (file, line, col or None)""" + m = re.fullmatch(r'(.+?):(\d+)(?::(\d+))?', s or '') + if not m: return None, None, None + f = m.group(1) + if os.path.isabs(f): f = os.path.relpath(os.path.realpath(f), repo) + f = f[2:] if f.startswith('./') else f + return f.replace(os.sep, '/'), int(m.group(2)), (int(m.group(3)) if m.group(3) else None) + + +def list_links(repo, as_json=False): + links, bad = read_links(repo) + status = {} + for db, _r, live in graph_dbs(repo): + if not live: continue + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + for n, f, l, at, t, st, why in status_rows(lambda s, *a: con.execute(s, a).fetchall()): + prev = status.get(n) + # a link lives in one language's graph: the best verdict any graph gave it + if prev is None or RANK.get(st, 9) < RANK.get(prev[0], 9): status[n] = (st, why, at) + con.close() + except sqlite3.Error: pass + # what the graph holds may predate an edit: the line's text is checked against the file now as well + rows = [] + for l in links: + st, why, at = status.get(l['n'], ('not applied', 'the graph has not been given this link yet', None)) + L = Reader(repo).lines(l['file']) + same = lambda k: bool(k) and 0 < k <= len(L) and line_sha(L[k - 1]) == l.get('line_sha') + if L is None: st, why = 'stale', 'the file is gone' + elif st in ('applied', 'moved', 'redundant') and l.get('line_sha') and not same(at or l['line']): + st, why = 'changed', 'the line was edited since the graph was built; the next refresh re-validates it (followed if it only moved)' + rows.append(dict(link=l['n'], site=f"{l['file']}:{l['line']}" + (f":{l['col']}" if l.get('col') else ''), now=at, callee=l.get('callee'), target=l['target'], + rejects=bool(l.get('not')), + target_file=l.get('target_file'), status=st, reason=why, by=l.get('by'), at=l.get('at'))) + for n, raw, why in bad: + rows.append(dict(link=n, site='', target=raw[:80], status='malformed', reason=why)) + rows.sort(key=lambda r: r['link']) + if as_json: + print(json.dumps(dict(file=links_path(repo), links=rows), indent=1)); return 0 + if not rows: + print(f"no asserted links ({links_path(repo)} has none)"); return 0 + print(f"{len(rows)} asserted link(s) in {os.path.relpath(links_path(repo), repo)}:") + for r in rows: + where = r['site'] + (f" (now line {r['now']})" if r.get('now') and r['site'] and int(r['site'].split(':')[1]) != r['now'] else '') + tgt = f"NOT {r['target']} (a lead rejected: not walked)" if r.get('rejects') else r['target'] + print(f" {r['link']:>3}. [{r['status']}] {where} → {tgt}" + (f" — {r['reason']}" if r['reason'] else '')) + return 0 + + +def main(argv): + as_json = '--json' in argv + argv = [a for a in argv if a != '--json'] + remove = '--remove' in argv + argv = [a for a in argv if a != '--remove'] + reject = '--not' in argv + argv = [a for a in argv if a != '--not'] + if '--list' in argv: argv = [a for a in argv if a != '--list']; return list_links(find_repo(argv)[0], as_json) + if argv and argv[0] in ('-h', '--help', 'help'): print(USAGE); return 0 + repo, args = find_repo(argv) + if not os.path.isdir(os.path.join(repo, '.axiomcode')): + print(f"axiomcode link: no graph for {repo} — ask a question first (impact, path) so it is built", file=sys.stderr); return 2 + if not args: return list_links(repo, as_json) + f, line, col = site_arg(repo, args[0]) + if f is None: + print(f"axiomcode link: the site is written file:line (as an answer's `unknown:` block prints it), not {args[0]!r}\n\n{USAGE}", file=sys.stderr); return 2 + links, bad = read_links(repo) + if remove or (len(args) > 1 and args[1] == '-'): + tgt = args[1] if len(args) > 1 and args[1] != '-' else None + keep = [l for l in links if not (l['file'] == f and l['line'] == line and (tgt is None or l['target'] == tgt) + and (col is None or l.get('col') in (None, col)))] + gone = len(links) - len(keep) + write_links(repo, keep + []) # malformed lines are dropped only by hand; they stay listed until fixed + _restore_bad(repo, bad) + apply_repo(repo) + print(f"removed {gone} link(s) at {f}:{line}" if gone else f"no link at {f}:{line}") + return 0 if gone else 1 + if len(args) < 2: + print(USAGE, file=sys.stderr); return 2 + target = args[1]; callee = args[2] if len(args) > 2 else '' + if target.startswith('not:'): reject, target = True, target[4:] + reader = Reader(repo); L = reader.lines(f) + if L is None or not (0 < line <= len(L)): + print(f"axiomcode link: rejected — {f}:{line} is not a line of a file in this repository", file=sys.stderr); return 1 + new = dict(file=f, line=line, line_sha=line_sha(L[line - 1]), callee=callee, caller='', target=target, target_file='', + col=col, ncol=norm_col(L[line - 1], col) if col else None, + by=os.environ.get('AXIOMCODE_LINK_BY') or os.environ.get('USER') or 'agent', at=time.strftime('%Y-%m-%dT%H:%M:%S'), n=0) + # validate against the live graphs first: a link no graph would take is refused, not written + verdicts = [] + for db, rd, live in graph_dbs(repo): + if not live: continue + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + g = Graph(con) + if reject: + ap = {(a, b) for a, b in g.q("SELECT call_site_id, callee_method_id FROM call_edges WHERE tier = ?", TIER)} + verdicts.append((resolve_not(g, rd, new, ap), g)) + else: verdicts.append((resolve(g, rd, new), g)) + except sqlite3.Error: pass + good = [(r, g) for r, g in verdicts if r['status'] in ('applied', 'redundant')] + if not good: + why = sorted(verdicts, key=lambda x: (x[0]['reason'].startswith('no call is written'), x[0]['reason'].startswith('the target is not'))) + print(f"axiomcode link: rejected — {why[0][0]['reason'] if why else 'no graph to check it against'}", file=sys.stderr); return 1 + r, g = good[0] + new.update(callee=r.get('callee_written') or callee, caller=g.display(r['caller']) if r.get('caller') else '', + target=r['target_display'], target_file=r.get('target_file') or '', **({'not': True} if reject else {})) + new['target'], new['target_file'] = stable_name(g, r['callee_id'], r['target_display'], new['target_file']) + g.con.close() + # the column the link resolved to is recorded even when none was given: it is what the link names from now on + if r.get('col') and not new.get('col'): new['col'] = r['col']; new['ncol'] = norm_col(L[line - 1], r['col']) + dup = [l for l in links if l['file'] == f and l['line'] == line and l['target'] == new['target'] and l.get('col') == new.get('col') + and bool(l.get('not')) == bool(new.get('not'))] + links = [l for l in links if l not in dup] + [new] + t0 = time.time() + write_links(repo, links); _restore_bad(repo, bad) + res = apply_repo(repo) + ms = (time.time() - t0) * 1000 + note_ = next((x['reason'] for live, rs in res.values() if live for l_, x in rs + if l_['file'] == f and l_['line'] == line and l_['target'] == new['target'] and l_.get('col') == new.get('col') and x['status'] in ('applied', 'moved')), '') + where = f"{f}:{line}" + (f":{new['col']}" if new.get('col') else '') + if reject: + print(f"rejected the lead {where} → {new['target']}: no walk takes it from now on (the engine's row is kept; " + f"`axiomcode link {where} -` restores it) — applied to {sum(1 for v in res.values() if v[0])} graph(s) in {ms:.0f} ms") + return 0 + print(f"linked {where} `{new['callee']}` → {new['target']} [asserted]" + (" (the graph already had this edge; recorded, nothing added)" if r['status'] == 'redundant' else '') + + (f"; {note_}" if note_ else '') + f" — applied to {sum(1 for v in res.values() if v[0])} graph(s) in {ms:.0f} ms") + return 0 + + +def _restore_bad(repo, bad): + """a hand-written line write_links could not parse is kept as written, so a link is never lost to a typo""" + if not bad: return + with open(links_path(repo), 'a', encoding='utf-8') as fh: + for _n, raw, _w in bad: fh.write(raw + '\n') + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6eb56111..0167e2c4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -671,12 +671,17 @@ def _string_literals(q): # 3. a string that names a MEMBER OF A TYPE THE SAME DECORATION NAMES: `@SelectProvider(type = StockSql.class, # method = "byShelf")` points at StockSql.byShelf; it is a reference to that method, not a key for this one. # Only decided with the graph (`names_member(type, name)`); without it the string is kept. -_STRING = re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') +# 4. a string that names a PARAMETER OF THE DECLARATION IT DECORATES: `@option("--params", "-p", "params")` on +# `def main(url, params)` binds the value a caller passes after `--params` to `params`. The flags are what a caller +# writes to reach the declaration; the parameter name is written by every function that builds a dict with a +# `params` key, and joined as a key it made each of them a caller of the command. +# 5. a string inside ANOTHER call in the decoration: `type=File("wb")` configures a value, it names nothing registered. +_STRING =re.compile(r'"([^"]{1,120})"|\'([^\']{1,120})\'') _KEYWORD_BEFORE = re.compile(r'(\w+)\s*[=:]\s*[\[{(]?\s*(?:(?:"[^"]*"|\'[^\']*\')\s*,\s*)*$') _TYPE_ARG = re.compile(r'(? 1: continue + if params and key in params: continue out.add(key) return sorted(out) +def _call_depth(t, i): + """how many parentheses are open at offset i of a decoration's text, strings blanked: 1 is the decoration's own + argument list""" + return _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count('(') - \ + _STRING.sub(lambda m: '"' + ' ' * (len(m.group(0)) - 2) + '"', t[:i]).count(')') + + +def _params_of(signature): + """the parameter names a `name(a, b=1, *c)` signature declares""" + m = re.search(r'\((.*)\)', signature or '') + if not m: return set() + return {re.sub(r'[:=].*$', '', p).strip().lstrip('*') for p in m.group(1).split(',')} - {''} + + def member_names(q): """names_member for decoration_key_strings, read from the graph: does a type of this simple name declare a member of that name""" @@ -720,13 +741,14 @@ def decoration_keys(q, site_file=None): # production code; nothing is lost by declining to read a test's own decoration as a registration. tests = {r[0] for r in q("SELECT id FROM symbols WHERE is_test = 1")} if _has(q, 'symbols') else set() members = member_names(q) + sigs = dict(q("SELECT id, signature FROM symbols WHERE signature IS NOT NULL")) if _has(q, 'symbols') else {} out = [] for owner, name, text, f, l in q("""SELECT owner_id, name, text, file, line FROM decorations WHERE text IS NOT NULL AND text <> '' AND owner_id IS NOT NULL"""): if owner in tests: continue short = (name or '').split('.')[-1] - for key in decoration_key_strings(text, name, members): + for key in decoration_key_strings(text, name, members, _params_of(sigs.get(owner))): kind = 'route' if key.startswith('/') else 'key' why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py new file mode 100644 index 00000000..67c74191 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py @@ -0,0 +1,85 @@ +"""The set-up files a JavaScript / TypeScript test runner runs before the tests it collects. + +A vitest or jest configuration names files the runner loads before every test file of the project +(`setupFiles`, jest's `setupFilesAfterEnv`) and files whose exported `setup` / `teardown` (or default export) it calls +once, before anything is collected (`globalSetup`). No test file imports them and nothing calls them, so a function they +reach — a client the set-up file configures at module level, a code generator the global set-up runs — reached no test +at all, while breaking it fails every test of the run. + +What is read, and nothing else: string literals in the value of those keys in a runner configuration file +(vitest.config.*, vite.config.*, vitest.workspace.*, vitest.shared.*, jest.config.*), resolved against the configuration's +directory (`/` and `__dirname + '/…'` included; jest's `rootDir: '..'` is honoured). A package specifier (a +library's set-up such as `@testing-library/jest-dom/vitest`) is not this repository's code and is skipped. + +Which tests each file runs before: + - setupFiles / setupFilesAfterEnv: the test files under the configuration's root (the project it configures); + - globalSetup: every test file of the RUN — a failing global set-up aborts the whole run, every project of a + workspace with it — so the root is the nearest directory above holding a configuration that lists projects + (`projects:` / `workspace`, or a vitest.workspace file), else the configuration's own root. +""" +import os +import re + +CONFIG_NAME = re.compile(r'^(vitest\.config|vite\.config|vitest\.workspace|vitest\.shared|jest\.config)(\.[\w-]+)*\.(c|m)?[jt]s$') +KEY = re.compile(r'\b(setupFiles|setupFilesAfterEnv|globalSetup)\s*:\s*(\[[^\]]*\]|[^,\n}]+)') +STRING = re.compile(r'''(['"`])((?:(?!\1).)*)\1''') +ROOTDIR = re.compile(r'''\brootDir\s*:\s*(['"])([^'"]*)\1''') +LISTS_PROJECTS = re.compile(r'\b(projects|workspace)\s*:\s*\[') +EXTS = ('', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '/index.ts', '/index.js') +SKIP_DIRS = {'node_modules', '.git', 'dist', 'build', 'coverage', '.axiomcode', '.next', 'out'} + + +def _configs(repo): + for d, dirs, files in os.walk(repo): + dirs[:] = [x for x in dirs if x not in SKIP_DIRS and not x.startswith('.')] + for f in files: + if CONFIG_NAME.match(f): + yield os.path.relpath(os.path.join(d, f), repo) + + +def _read(repo, rel): + try: + with open(os.path.join(repo, rel), encoding='utf-8', errors='replace') as h: return h.read() + except OSError: + return '' + + +def _resolve(repo, base, spec, files): + """the repository file a set-up entry names, or None (a package, or nothing there)""" + spec = spec.replace('', '.') + if not spec.startswith(('.', '/')): return None # a package's own set-up + rel = os.path.normpath(os.path.join(base, spec.lstrip('/') if spec.startswith('/') else spec)) + for e in EXTS: + if (rel + e).replace(os.sep, '/') in files: return (rel + e).replace(os.sep, '/') + return None + + +def setup_files(repo, files): + """[(setup file, kind, scope dir)] for every set-up file a runner configuration here names; `files` is the set of + repository-relative files the graph holds. kind is 'each' (setupFiles*) or 'global' (globalSetup).""" + cfgs = sorted(_configs(repo)) + lister = {os.path.dirname(c) for c in cfgs if os.path.basename(c).startswith('vitest.workspace') + or LISTS_PROJECTS.search(_read(repo, c))} + out = set() + for c in cfgs: + text = _read(repo, c) + if not KEY.search(text): continue + cdir = os.path.dirname(c) + rd = ROOTDIR.search(text) + root = os.path.normpath(os.path.join(cdir, rd.group(2))) if rd else cdir + root = '' if root == '.' else root + for key, val in KEY.findall(text): + kind = 'global' if key == 'globalSetup' else 'each' + for _q, spec in STRING.findall(val): + f = _resolve(repo, root if spec.startswith('') else cdir, spec, files) + if f is None: continue + scope = root + if kind == 'global': + up = [d for d in lister if d == '' or scope == d or scope.startswith(d + '/')] + if up: scope = min(up, key=len) + out.add((f, kind, scope)) + return sorted(out) + + +def in_scope(file, scope): + return bool(file) and (scope == '' or file == scope or file.startswith(scope + '/')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 2ebaa16d..6ab4eb36 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -12,6 +12,9 @@ # axiomcode context "" [--source] # how something works, as a narrative: the files and callables the task touches and, for a # how-does-X-work question, the call flow step by step; --source carries each step's code. +# axiomcode link +# record where an unresolved call lands, when the code makes it certain: impact, path and tests then walk it, +# labelled [asserted]. Alone, lists every link and whether the graph took it; `link -` removes one. # axiomcode index [] [--lang [,…]] [--src ] [--library [,…]] # build the graph (the first query builds it too). defaults to the current directory. # @@ -111,7 +114,7 @@ public_verbs(){ helptext | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; } # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in impact|path|tests) + case "$1" in impact|path|tests|link) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -230,6 +233,7 @@ case "$cmd" in impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + link) exec python3 "$H/axiomcode-link" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # the dispatch table, internal verbs included; tests/surfaces.py audits it ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; *) echo "axiomcode: unknown subcommand '$cmd' — the entry point has: $(public_verbs | tr '\n' ' ')" >&2 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9f3a4100..58ba1cbb 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -60,7 +60,7 @@ about values: a callable that is reached may or may not change behaviour; a call resolved chain to the change or reaches it through something the graph cannot see (reflection, a framework, a library callback) — the bound line says how many such sites there are. """ -import collections, contextlib, importlib.machinery, importlib.util, io, json, os, re, shutil, subprocess, sys, tempfile, time +import collections, contextlib, fnmatch, importlib.machinery, importlib.util, io, json, os, re, shutil, subprocess, sys, tempfile, time PROF = os.environ.get('AXIOMCODE_PROFILE'); _t0 = time.time() def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) @@ -159,7 +159,9 @@ def ckey(k): # and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. # `by key` (a handler table's entry under the type a publisher writes, dl/impact.dl) is joined on a string both ends # spell and nothing the engine resolved: the weakest hop after a name match, as it is in the closure. -CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, +# `asserted` (a link someone recorded where the engine resolved nothing, ax_links.py) is an edge, so it outranks every +# name match, and nobody resolved it, so it sits below `registered`, which the engine recorded itself. +CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'asserted': 2.5, 'capped set': 3, 'remote': 4, 'framework': 5, 'stubs it': 6, 'in scope': 7, 'by name': 8, 'by key': 8.5, 'text': 9, 'alongside': 10} def also_text(whys, shown=2): """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" @@ -799,9 +801,12 @@ class Impact: # REPOSITORY is kept: a stdlib or third-party import names no file here and is dropped rather than guessed. IMPORT_RE = { '.py': re.compile(r'^\s*(?:from\s+(\.*[\w.]*)\s+import|import\s+([\w.]+))', re.M), - '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|\brequire\s*\(\s*)['"]([^'"]+)['"]""", re.M), + # `import x from`, `export … from`, `require(`, and the two forms that load a module for its effect or later: + # `import './locale/fr'` (a side-effect import names no binding, so it has no `from`) and `import('./x')` + '.ts': re.compile(r"""(?:^\s*import\b[^'"\n]*from\s*|^\s*export\b[^'"\n]*from\s*|^\s*import\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]""", re.M), } IMPORT_RE['.tsx'] = IMPORT_RE['.js'] = IMPORT_RE['.jsx'] = IMPORT_RE['.mjs'] = IMPORT_RE['.cjs'] = IMPORT_RE['.ts'] + IMPORT_RE['.mts'] = IMPORT_RE['.cts'] = IMPORT_RE['.vue'] = IMPORT_RE['.svelte'] = IMPORT_RE['.ts'] # a component's diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alias.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alias.test.ts new file mode 100644 index 00000000..aae7b942 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alias.test.ts @@ -0,0 +1,6 @@ +import { run } from '@app/runner' +import { alpha } from '@app/mw/alpha' + +test('alpha by its alias', () => { + expect(run(alpha, 2)).toBe(3) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alpha.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alpha.test.ts new file mode 100644 index 00000000..7ec2ebe6 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/alpha.test.ts @@ -0,0 +1,10 @@ +import { + run, +} from '../src/runner' +import { + alpha, +} from '../src/mw/alpha' + +test('alpha', () => { + expect(run(alpha, 1)).toBe(2) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta-alias.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta-alias.test.ts new file mode 100644 index 00000000..912f87b9 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta-alias.test.ts @@ -0,0 +1,6 @@ +import { run } from '@app/runner' +import { beta } from '@app/mw/beta' + +test('beta by its alias', () => { + expect(run(beta, 2)).toBe(4) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta.test.ts new file mode 100644 index 00000000..56a0f7c2 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/beta.test.ts @@ -0,0 +1,6 @@ +import { run } from '../src/runner' +import { beta } from '../src/mw/beta' + +test('beta', () => { + expect(run(beta, 1)).toBe(2) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/unread.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/unread.test.ts new file mode 100644 index 00000000..e87c5005 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/unread.test.ts @@ -0,0 +1,6 @@ +import { run } from '../src/runner' +import { alpha } from '~/mw/alpha' + +test('an alias no config here declares', () => { + expect(run(alpha, 1)).toBe(2) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/widget.test.ts b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/widget.test.ts new file mode 100644 index 00000000..32c2c476 --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/test/widget.test.ts @@ -0,0 +1,6 @@ +import { run } from '../src/runner' +import Widget from './Widget.vue' + +test('a component the reading cannot open', () => { + expect(run(Widget.handler, 1)).toBe(1) +}) diff --git a/tests/cases/typescript/a-test-that-never-imports-the-change/src/tsconfig.json b/tests/cases/typescript/a-test-that-never-imports-the-change/src/tsconfig.json new file mode 100644 index 00000000..5ef4a8ea --- /dev/null +++ b/tests/cases/typescript/a-test-that-never-imports-the-change/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + // the alias a test may import the code by + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "strict": true, + "baseUrl": ".", + "paths": { "@app/*": ["src/*"] } + } +} diff --git a/tests/cases/typescript/a-type-test-runs-no-code/case.json b/tests/cases/typescript/a-type-test-runs-no-code/case.json new file mode 100644 index 00000000..eed919da --- /dev/null +++ b/tests/cases/typescript/a-type-test-runs-no-code/case.json @@ -0,0 +1,16 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "vitest only type-checks a *.test-d.ts file and runs none of its code: a body change cannot fail it, so it is listed apart and not counted", + "run": ["impact", "price", "--tests-only"], + "want": ["tests: 2 of", "[type test] 1 test(s) in 1 file(s)", "src/test/price.test-d.ts (1)"] + }, + { + "why": "control: a runtime test file is counted, including one whose name only says `types`", + "run": ["impact", "price", "--tests-only"], + "want": ["src/test/price.test.ts (1)", "src/test/price.types.test.ts (1)"] + } + ] +} diff --git a/tests/cases/typescript/a-type-test-runs-no-code/src/src/price.ts b/tests/cases/typescript/a-type-test-runs-no-code/src/src/price.ts new file mode 100644 index 00000000..61e0e98f --- /dev/null +++ b/tests/cases/typescript/a-type-test-runs-no-code/src/src/price.ts @@ -0,0 +1,3 @@ +export function price(n: number): number { + return n * 2 +} diff --git a/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test-d.ts b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test-d.ts new file mode 100644 index 00000000..4edcad06 --- /dev/null +++ b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test-d.ts @@ -0,0 +1,6 @@ +import { expectTypeOf } from 'vitest' +import { price } from '../src/price' + +test('price types', () => { + expectTypeOf(price(2)).toEqualTypeOf() +}) diff --git a/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test.ts b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test.ts new file mode 100644 index 00000000..092b79f5 --- /dev/null +++ b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.test.ts @@ -0,0 +1,5 @@ +import { price } from '../src/price' + +test('price', () => { + expect(price(2)).toBe(4) +}) diff --git a/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.types.test.ts b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.types.test.ts new file mode 100644 index 00000000..e4a66f8a --- /dev/null +++ b/tests/cases/typescript/a-type-test-runs-no-code/src/test/price.types.test.ts @@ -0,0 +1,5 @@ +import { price } from '../src/price' + +test('a runtime test whose name says types', () => { + expect(typeof price(1)).toBe('number') +}) diff --git a/tests/cases/typescript/asserted-links-derive/case.json b/tests/cases/typescript/asserted-links-derive/case.json new file mode 100644 index 00000000..a5ef3915 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/case.json @@ -0,0 +1,27 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "BASE: the handler read by a computed key is unknown, and so is the call on its result", + "run": ["path", "handle", "Response.render"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"]}, + {"why": "declared return type: the call chained on the result resolves", + "run": ["link", "src/route.ts:5", "makeResponse"], + "want": ["linked src/route.ts:5:10 `handler` → makeResponse [asserted]; derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "path walks the derived hop", + "run": ["path", "handle", "Response.render"], + "want": ["[asserted · call @ src/route.ts:5] Response.render"]}, + {"why": "a const assigned from the linked call", + "run": ["link", "src/route.ts:10", "makeResponse"], "want": ["derived 2 edge(s) on its result (Response, declared)"]}, + {"why": "T | undefined and ?. are transparent", + "run": ["link", "src/route.ts:18", "maybeResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "no declared type: the type every return constructs, then a builder returning this", + "run": ["link", "src/route.ts:23", "makeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "no return type at all: nothing derived, and it says so", + "run": ["link", "src/route.ts:28", "untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on a Promise", + "run": ["link", "src/route.ts:34", "fetchResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "for…of over a T[]: the element", + "run": ["link", "src/route.ts:40", "many"], "want": ["derived 1 edge(s) on its result (Response[], declared)"]}, + {"why": "editing the linked line drops the link and what it derived", + "edit": ["src/route.ts", " return handler(req).render();\n}\n\nexport function handleLocal", " return handler(req, 1).render();\n}\n\nexport function handleLocal"], + "run": ["path", "handle", "Response.render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects handle and Response.render"], "avoid": ["[asserted"]}]} diff --git a/tests/cases/typescript/asserted-links-derive/src/route.ts b/tests/cases/typescript/asserted-links-derive/src/route.ts new file mode 100644 index 00000000..1369a747 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/src/route.ts @@ -0,0 +1,43 @@ +import * as views from './views'; + +export function handle(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req).render(); +} + +export function handleLocal(name: string, req: unknown) { + const handler = (views as any)[name]; + const resp = handler(req); + const text = resp.render(); + resp.close(); + return text; +} + +export function handleOptional(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req)?.render(); +} + +export function handleBuilder(name: string, req: unknown) { + const handler = (views as any)[name]; + return handler(req).step().done().render(); +} + +export function handleUntyped(name: string, req: unknown) { + const handler = (views as any)[name]; + const out = handler(req); + return out.render(); +} + +export async function handleAsync(name: string, req: unknown) { + const handler = (views as any)[name]; + const resp = await handler(req); + return resp.render(); +} + +export function handleMany(name: string, req: unknown) { + const handler = (views as any)[name]; + for (const r of handler(req)) { + r.render(); + } +} diff --git a/tests/cases/typescript/asserted-links-derive/src/views.ts b/tests/cases/typescript/asserted-links-derive/src/views.ts new file mode 100644 index 00000000..5452a5b9 --- /dev/null +++ b/tests/cases/typescript/asserted-links-derive/src/views.ts @@ -0,0 +1,16 @@ +export class Response { + render(): string { return 'ok'; } + close(): void {} +} + +export class Builder { + step(): Builder { return this; } + done(): Response { return new Response(); } +} + +export function makeResponse(req: unknown): Response { return new Response(); } +export function maybeResponse(req: unknown): Response | undefined { return new Response(); } +export async function fetchResponse(req: unknown): Promise { return new Response(); } +export function makeBuilder(req: unknown) { return new Builder(); } +export function untyped(req: unknown) { return req; } +export function many(req: unknown): Response[] { return [new Response()]; } diff --git a/tests/cases/typescript/asserted-links/case.json b/tests/cases/typescript/asserted-links/case.json new file mode 100644 index 00000000..c20d8668 --- /dev/null +++ b/tests/cases/typescript/asserted-links/case.json @@ -0,0 +1,169 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "BASE: a call through a value read by a computed key is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "dispatch", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects dispatch and audit" + ] + }, + { + "why": "the unresolved site and the call through a function-typed table are listed as work items", + "run": [ + "impact", + "dispatch", + "--unknown" + ], + "want": [ + "src/dispatch.ts:6:10 return fn(doc);" + ] + }, + { + "why": "a call through a holder of a function type is listed too: the signature has no body", + "run": [ + "impact", + "runTable", + "--unknown" + ], + "want": [ + "src/dispatch.ts:10:10 return table[key](doc); [calls a value typed by a function-type signature, not a body]" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "src/dispatch.ts:6", + "onSave" + ], + "want": [ + "linked src/dispatch.ts:6:10 `fn` → onSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "dispatch", + "audit" + ], + "want": [ + "[asserted · call @ src/dispatch.ts:6] onSave", + "reached through resolved calls and 1 asserted link(s)", + "[asserted] ASSERTED by a link" + ], + "avoid": [ + "target(s) reached through resolved calls;" + ] + }, + { + "why": "impact lists the caller as asserted and selects the test through it", + "run": [ + "impact", + "onSave", + "--tests" + ], + "want": [ + "[asserted] dispatch", + "dispatch.test.ts", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/dispatch.ts:6", + "onArchive" + ], + "expect_error": true, + "want": [ + "rejected — the target is not a declaration in this graph" + ] + }, + { + "why": "CONTROL: a link at a line with no call is rejected", + "run": [ + "link", + "src/dispatch.ts:3", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/dispatch.ts:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/dispatch.ts:19", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "src/dispatch.ts:15:10 return fn(doc);" + ], + "avoid": [ + "→ linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "src/dispatch.ts", + "import * as handlers from './handlers';\n", + "import * as handlers from './handlers';\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "dispatch", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/dispatch.ts:8] onSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/dispatch.ts", + " return fn(doc);\n}\n\nexport function runTable", + " return fn({ ...doc });\n}\n\nexport function runTable" + ], + "run": [ + "path", + "dispatch", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "links: 1 of 1 asserted link(s) not applied (1 stale)" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/typescript/asserted-links/src/dispatch.test.ts b/tests/cases/typescript/asserted-links/src/dispatch.test.ts new file mode 100644 index 00000000..4fe07e03 --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/dispatch.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { dispatch, runTable } from './dispatch'; +import { onLoad } from './handlers'; + +describe('dispatch', () => { + it('saves', () => { + expect(dispatch('onSave', {}).audited).toBe(true); + }); + it('runs a table', () => { + expect(runTable({ load: onLoad }, 'load', {})).toEqual({}); + }); +}); diff --git a/tests/cases/typescript/asserted-links/src/dispatch.ts b/tests/cases/typescript/asserted-links/src/dispatch.ts new file mode 100644 index 00000000..e79fd43c --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/dispatch.ts @@ -0,0 +1,20 @@ +import * as handlers from './handlers'; +type Doc = Record; + +export function dispatch(event: string, doc: Doc): Doc { + const fn = (handlers as any)[event]; + return fn(doc); +} + +export function runTable(table: Record Doc>, key: string, doc: Doc): Doc { + return table[key](doc); +} + +export function purge(doc: Doc): Doc { + const fn = (handlers as any)['on' + 'Purge']; + return fn(doc); +} + +export function saveAndAudit(doc: Doc): Doc { + return handlers.audit(doc); +} diff --git a/tests/cases/typescript/asserted-links/src/handlers.ts b/tests/cases/typescript/asserted-links/src/handlers.ts new file mode 100644 index 00000000..2d1a1e97 --- /dev/null +++ b/tests/cases/typescript/asserted-links/src/handlers.ts @@ -0,0 +1,18 @@ +type Doc = Record; + +export function onSave(doc: Doc): Doc { + return audit(doc); +} + +export function onLoad(doc: Doc): Doc { + return doc; +} + +export function onPurge(doc: Doc): Doc { + return doc; +} + +export function audit(doc: Doc): Doc { + doc.audited = true; + return doc; +} diff --git a/tests/cases/typescript/object-assign-function-object/case.json b/tests/cases/typescript/object-assign-function-object/case.json new file mode 100644 index 00000000..1eb20f60 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/case.json @@ -0,0 +1,54 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "Object.assign(fn, members) evaluates to fn: a call of the merged function object, imported and typed by a callable interface, runs fn, here through a re-exported alias of Object.assign", + "run": [ + "impact", + "track", + "--tests-only" + ], + "want": [ + "src/factory.test.ts (" + ], + "avoid": [] + }, + { + "why": "the same with Object.assign written out and an inline function as the target", + "run": [ + "impact", + "trackDirect", + "--tests-only" + ], + "want": [ + "src/factory.test.ts (" + ], + "avoid": [] + }, + { + "why": "control: Object.assign evaluates to its first argument only; a function passed as a later source is not what a call of the result runs", + "run": [ + "impact", + "sourceOnly", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/external.test.ts (" + ] + }, + { + "why": "…while the target it was merged into is", + "run": [ + "impact", + "target", + "--tests-only" + ], + "want": [ + "src/external.test.ts (" + ], + "avoid": [] + } + ] +} diff --git a/tests/cases/typescript/object-assign-function-object/src/external.test.ts b/tests/cases/typescript/object-assign-function-object/src/external.test.ts new file mode 100644 index 00000000..0443cbc7 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/external.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from 'vitest' +import { merged } from './factory' + +it('calls the merged target', () => { + expect(merged(1)).toBe(1) +}) diff --git a/tests/cases/typescript/object-assign-function-object/src/factory.test.ts b/tests/cases/typescript/object-assign-function-object/src/factory.test.ts new file mode 100644 index 00000000..bd5be17a --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/factory.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { direct, factory } from './factory' + +it('builds through the factory', () => { + expect(factory(1)).toBe(2) +}) + +it('builds through the direct merge', () => { + expect(direct(1)).toBe(2) +}) diff --git a/tests/cases/typescript/object-assign-function-object/src/factory.ts b/tests/cases/typescript/object-assign-function-object/src/factory.ts new file mode 100644 index 00000000..84b07202 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/factory.ts @@ -0,0 +1,42 @@ +import { assign } from './utils' + +// A callable API built as a FUNCTION OBJECT: the function is merged with its members by Object.assign, +// and the result is typed by a callable interface whose call signature has no body. +export interface Factory { + (v: number): number + box(v: number): number +} + +export function track(v: number): number { + return v + 1 +} + +function create(v: number): number { + return track(v) +} + +const members = { + box(v: number): number { + return v + }, +} + +export const factory: Factory = assign(create, members) + +export function trackDirect(v: number): number { + return v * 2 +} + +// written with Object.assign itself and an inline function +export const direct = Object.assign((v: number) => trackDirect(v), { kind: 'direct' }) + +// control: the merge evaluates to its TARGET; a function passed as a later argument is not what a call of it runs +export function sourceOnly(v: number): number { + return v +} + +function target(v: number): number { + return v +} + +export const merged = Object.assign(target, { extra: sourceOnly }, sourceOnly) diff --git a/tests/cases/typescript/object-assign-function-object/src/utils.ts b/tests/cases/typescript/object-assign-function-object/src/utils.ts new file mode 100644 index 00000000..90ee85d3 --- /dev/null +++ b/tests/cases/typescript/object-assign-function-object/src/utils.ts @@ -0,0 +1,2 @@ +// the platform's merge, re-exported under a short name +export const assign = Object.assign diff --git a/tests/cases/typescript/overload-implementation/case.json b/tests/cases/typescript/overload-implementation/case.json new file mode 100644 index 00000000..d7123979 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/case.json @@ -0,0 +1,76 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a call binds to an overload SIGNATURE, which has no body; the implementation is what runs, so a test calling the function reaches an edit to it", + "run": [ + "impact", + "src/lib.ts:5", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of", + "not found" + ] + }, + { + "why": "the same for an overloaded method", + "run": [ + "impact", + "src/lib.ts:16", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of" + ] + }, + { + "why": "and further down: what the implementation calls is reached through it", + "run": [ + "impact", + "normalize", + "--tests-only" + ], + "want": [ + "src/lib.test.ts (" + ], + "avoid": [ + "0 of" + ] + }, + { + "why": "path walks from the test into the implementation through the signature the call selected", + "run": [ + "path", + "src/lib.test.ts", + "src/lib.ts:5" + ], + "want": [ + "[overload] pick", + "verified: every printed hop" + ], + "avoid": [ + "the two are independent" + ] + }, + { + "why": "control: a same-named function in another module is not in that overload set", + "run": [ + "impact", + "src/other.ts:2", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/lib.test.ts (" + ] + } + ] +} diff --git a/tests/cases/typescript/overload-implementation/src/lib.test.ts b/tests/cases/typescript/overload-implementation/src/lib.test.ts new file mode 100644 index 00000000..be2ade80 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/lib.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { Box, pick } from './lib' + +it('picks', () => { + expect(pick(1)).toBe(1) +}) + +it('boxes', () => { + expect(new Box().get('a')).toBe('a') +}) diff --git a/tests/cases/typescript/overload-implementation/src/lib.ts b/tests/cases/typescript/overload-implementation/src/lib.ts new file mode 100644 index 00000000..77a26550 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/lib.ts @@ -0,0 +1,19 @@ +// An overloaded function: the compiler binds every call to one of the bodiless signatures, and the implementation +// below them is the only body that runs. +export function pick(x: number): number +export function pick(x: string): string +export function pick(x: any): any { + return normalize(x) +} + +export function normalize(x: T): T { + return x +} + +export class Box { + get(k: string): string + get(k: number): number + get(k: any): any { + return k + } +} diff --git a/tests/cases/typescript/overload-implementation/src/other.ts b/tests/cases/typescript/overload-implementation/src/other.ts new file mode 100644 index 00000000..5ef981e1 --- /dev/null +++ b/tests/cases/typescript/overload-implementation/src/other.ts @@ -0,0 +1,4 @@ +// control: a function of the same name in another module, not part of that overload set +export function pick(x: boolean): boolean { + return x +} diff --git a/tests/cases/typescript/runner-setup-files/case.json b/tests/cases/typescript/runner-setup-files/case.json new file mode 100644 index 00000000..40011c84 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/case.json @@ -0,0 +1,48 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a setupFiles entry runs before every test file of its project and nothing imports it: what its module calls is reached by each of those tests, even one that never names it", + "run": [ + "impact", + "setNotifyFunction", + "--tests-only" + ], + "want": [ + "src/app/test/math.test.ts (", + "src/app/test/schedule.test.ts (" + ], + "avoid": [ + "src/other/plain.test.ts" + ] + }, + { + "why": "a globalSetup file's exported setup is called by the runner once before the run: the generator it holds reaches every test of the run", + "run": [ + "impact", + "renderSchema", + "--tests-only" + ], + "want": [ + "src/app/test/math.test.ts (" + ], + "avoid": [ + "src/other/plain.test.ts" + ] + }, + { + "why": "control: a helper file that no configuration names is no set-up file, so what it calls reaches no test", + "run": [ + "impact", + "unusedHelper", + "--tests-only" + ], + "want": [], + "avoid": [ + "src/app/test/math.test.ts (", + "src/app/test/schedule.test.ts (" + ] + } + ] +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts b/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts new file mode 100644 index 00000000..ec2adcba --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/lib/client.ts @@ -0,0 +1,15 @@ +let notify: (fn: () => void) => void = (fn) => fn() + +// a set-up file calls this at module level, before any test of the project runs +export function setNotifyFunction(fn: (cb: () => void) => void): void { + notify = fn +} + +export function schedule(cb: () => void): void { + notify(cb) +} + +// control: called only by a helper no configuration names and no test imports +export function unusedHelper(): number { + return 1 +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts b/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts new file mode 100644 index 00000000..176506c1 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/lib/generate.ts @@ -0,0 +1,4 @@ +// what the global set-up's code generator runs, once, before the whole run +export function renderSchema(name: string): string { + return `schema ${name}` +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts b/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts new file mode 100644 index 00000000..5228b513 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/helpers.ts @@ -0,0 +1,3 @@ +import { unusedHelper } from '../lib/client' + +export const value = unusedHelper() diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts b/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts new file mode 100644 index 00000000..e7ad3e8f --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/math.test.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest' + +it('adds', () => { + expect(1 + 1).toBe(2) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts b/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts new file mode 100644 index 00000000..d092a4a7 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/schedule.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import { schedule } from '../lib/client' + +it('schedules', () => { + let ran = false + schedule(() => { ran = true }) + expect(ran).toBe(true) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts new file mode 100644 index 00000000..8f263622 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/codegen.ts @@ -0,0 +1,5 @@ +import { renderSchema } from '../../lib/generate' + +export async function codegen(): Promise { + renderSchema('api') +} diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts new file mode 100644 index 00000000..ce8d03fc --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/scripts/globalSetup.ts @@ -0,0 +1,3 @@ +import { codegen } from './codegen' + +export const setup = codegen diff --git a/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts b/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts new file mode 100644 index 00000000..2100a894 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/test/setup.ts @@ -0,0 +1,3 @@ +import { setNotifyFunction } from '../lib/client' + +setNotifyFunction((cb) => cb()) diff --git a/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts b/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts new file mode 100644 index 00000000..5a4ec2cd --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/app/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['@testing-library/jest-dom/vitest', './test/setup.ts'], + globalSetup: ['./test/scripts/globalSetup.ts'], + }, +}) diff --git a/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts b/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts new file mode 100644 index 00000000..9c381044 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/other/plain.test.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest' + +it('is plain', () => { + expect(true).toBe(true) +}) diff --git a/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts b/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts new file mode 100644 index 00000000..aee034e1 --- /dev/null +++ b/tests/cases/typescript/runner-setup-files/src/other/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ test: {} }) diff --git a/tests/cases/typescript/test-registrar-modifiers/case.json b/tests/cases/typescript/test-registrar-modifiers/case.json new file mode 100644 index 00000000..38a1e6f5 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/case.json @@ -0,0 +1,118 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a formatter puts a long test name on its own line: `test.each(rows)(` opens the call and the arrow starts a later line naming no registrar, yet the runner runs it, so what it calls is tested by the file", + "run": [ + "impact", + "total", + "--tests-only" + ], + "want": [ + "src/wrapped.test.ts (" + ], + "avoid": [] + }, + { + "why": "the same layout for a plain `it(`: the name on its own line, the body on the next", + "run": [ + "impact", + "packaging", + "--tests-only" + ], + "want": [ + "src/wrapped.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "control: a callback on its own line handed to an ordinary call inside a test body is that call's argument, not a second test", + "run": [ + "impact", + "late", + "--tests-only" + ], + "want": [ + "of 8 test method(s)", + "src/settle.test.ts (" + ], + "avoid": [ + "of 9 test method(s)" + ] + }, + { + "why": "test.each(rows) returns the registrar and the body is handed to THAT call: the body is a test, so what it calls is tested by the file", + "run": [ + "impact", + "total", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "describe.each(rows)(name, body) runs its suite body, so the tests registered inside it are tests", + "run": [ + "impact", + "discount", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "a modifier written as a member (it.concurrent) is still the runner registering a test", + "run": [ + "impact", + "shipping", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "a chain of modifiers ending in a table (test.concurrent.each(rows)) registers a test too", + "run": [ + "impact", + "rounding", + "--tests-only" + ], + "want": [ + "src/table.test.ts (" + ], + "avoid": [ + "reach it by a route this count does not credit" + ] + }, + { + "why": "control: `each` called on an object that is not a test runner is no test registrar; the audit is reached only through the test that calls runAudit", + "run": [ + "impact", + "audit", + "--tests-only" + ], + "want": [ + "src/batch.test.ts (" + ], + "avoid": [ + "src/table.test.ts", + "batch.ts::" + ] + } + ] +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts new file mode 100644 index 00000000..fdc247d6 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/batch.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from 'vitest' +import { runAudit } from './batch' + +it('runs the audit', () => { + expect(runAudit()).toBeUndefined() +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/batch.ts b/tests/cases/typescript/test-registrar-modifiers/src/batch.ts new file mode 100644 index 00000000..a47b04c8 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/batch.ts @@ -0,0 +1,15 @@ +import { audit } from './pricing' + +// control: `each` on an object that is not a test runner hands over a plain callback; +// the function it runs is not a test +const rows = { + each(values: number[]) { + return (label: string, fn: (v: number) => void) => values.forEach(fn) + }, +} + +export function runAudit(): void { + rows.each([1, 2])('audit', (v) => { + audit(v) + }) +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts b/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts new file mode 100644 index 00000000..8a766227 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/pricing.ts @@ -0,0 +1,35 @@ +export function total(items: number[]): number { + return items.reduce((a, b) => a + b, 0) +} + +export function discount(amount: number): number { + return amount > 100 ? amount * 0.9 : amount +} + +export function shipping(weight: number): number { + return weight * 2 +} + +export function rounding(value: number): number { + return Math.round(value * 100) / 100 +} + +export function tax(amount: number): number { + return amount * 0.2 +} + +export function audit(amount: number): number { + return amount +} + +export function settle(fn: () => number): number { + return fn() +} + +export function late(): number { + return 1 +} + +export function packaging(count: number): number { + return count * 3 +} diff --git a/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts new file mode 100644 index 00000000..7c26a667 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/settle.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { late, settle } from './pricing' + +// control: inside a test body, a callback on its own line is the argument of `settle(`, not of `it(` +it('settles late', () => { + const got = settle( + () => late(), + ) + expect(got).toBe(1) +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts new file mode 100644 index 00000000..11bb1a29 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/table.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, test } from 'vitest' +import { discount, rounding, shipping, total } from './pricing' + +// a table-driven test: test.each(rows) returns the registrar, which is then called +test.each([[[1, 2], 3], [[4], 4]])('total of %o', (items, want) => { + expect(total(items as number[])).toBe(want) +}) + +// a table-driven suite: the suite body registers ordinary tests +describe.each([150, 50])('discount of %d', (amount) => { + it('is never more than the amount', () => { + expect(discount(amount)).toBeLessThanOrEqual(amount) + }) +}) + +// a modifier written as a member: the runner still runs it +it.concurrent('ships by weight', () => { + expect(shipping(2)).toBe(4) +}) + +// a modifier chain ending in a table +test.concurrent.each([1.005, 2.5])('rounds %d', (v) => { + expect(rounding(v)).toBeGreaterThan(0) +}) diff --git a/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts b/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts new file mode 100644 index 00000000..7f85ee89 --- /dev/null +++ b/tests/cases/typescript/test-registrar-modifiers/src/wrapped.test.ts @@ -0,0 +1,17 @@ +import { expect, it, test } from 'vitest' +import { packaging, total } from './pricing' + +// a formatter's layout for a long name: the call opens on one line, the name and the body follow on their own +test.each([[[1, 2], 3]])( + 'adds every item of a long table row, whatever its order: %o', + async (items, want) => { + expect(total(items as number[])).toBe(want) + }, +) + +it( + 'charges for packaging per item, rounding nothing on the way through the order', + () => { + expect(packaging(2)).toBe(6) + }, +) diff --git a/tests/front_door.py b/tests/front_door.py index 67566e8e..a281856d 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -12,7 +12,7 @@ path answer with numbered places and a fenced code block; after an edit, impact with no name starts with `your edits:`, and tests lists the test with its code and ends with a `run:` line. A verb off the surface is refused with the supported list. - b. the MCP server lists exactly context, impact, path and tests, each with at most two parameters, and a call to one + b. the MCP server lists exactly context, impact, path, tests and link, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -127,7 +127,7 @@ def main(): # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── got = mcp(repo, [('path', {'start': 'total', 'end': 'vat_rate'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly context, impact, path and tests (search is grep\'s)', set(tools) == {'context', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly context, impact, path, tests and link (search is grep\'s)', set(tools) == {'context', 'impact', 'path', 'tests', 'link'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) check('MCP path answers as numbered places with their code', places(text(3)), text(3)[:600]) diff --git a/tests/mcp.py b/tests/mcp.py index b6e43791..f85b37d3 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -32,7 +32,7 @@ # THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped # at ten places with the rest counted, so no tool is paged. context is the one narrative verb: a task in words, # answered as the verb's own flow rather than as places. -TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': [], 'link': ['site', 'target']} def exchange(cmd, cwd, env=None, workdir=None): diff --git a/tests/run.py b/tests/run.py index 772ba182..90b9be30 100755 --- a/tests/run.py +++ b/tests/run.py @@ -22,6 +22,9 @@ CONCATENATED, so no substring can express "this must not be inside the document" — which is how a `note:` line sat in --json for every name declared as both a field and a method. "expect_error": true a non-zero exit is the answer, not a fault (`path` exits 1 when it finds no chain). + "env": {name: value} the check runs with these environment variables set (AXIOMCODE_FRONT=1 for the front door). + "edit": [file, old, new] the case edits that file before the check runs; edited files and a links file the case + wrote are restored when the case ends. "pending": "" the check states behaviour the tool does NOT have yet. It still RUNS. Failing prints PEND and is not a suite failure; PASSING is a failure reading "remove the marker", so a gap that closes cannot keep a marker claiming it is open. @@ -57,9 +60,20 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); return buf.getvalue(), 0, 1, 0 for stmt in spec.get('sql', []): # facts a framework extension would have written subprocess.run(['sqlite3', os.path.join(path, '.axiomcode', 'out', 'graph.sqlite'), stmt], capture_output=True, text=True) + # "edit": [file, old, new] — the case edits one of its files before that check runs (an asserted link whose line moved + # or changed); every edited file, and a links file the case wrote, is put back when the case ends + links_file = os.path.join(path, 'axiomcode-links.tsv'); links_had = open(links_file).read() if os.path.exists(links_file) else None + edited = {} for ch in spec['checks']: tot += 1 - out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True) + if ch.get('edit'): + ef, old, new = ch['edit']; fp = os.path.join(path, ef); txt = open(fp).read() + edited.setdefault(fp, txt) + if old not in txt: print(f"FAIL {l}/{name}: the edit's old text is not in {ef}"); fail += 1; continue + open(fp, 'w').write(txt.replace(old, new, 1)) + # "env": {…} — the check runs with these set (AXIOMCODE_FRONT=1: the answer the installed command and MCP give) + out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True, + env=dict(os.environ, **ch['env']) if ch.get('env') else None) text = out.stdout + out.stderr # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the # case file lies in the searched tree): its own `avoid` strings there are not the tool's answer @@ -101,6 +115,10 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) # a traceback's last line is the error itself: keep the head (what it answered) and the tail (why it stopped) print(' ' + '\n '.join(lines[:14] + (['…'] + lines[-12:] if len(lines) > 26 else lines[14:]))) elif verbose: print(f"ok {l}/{name}: {ch['why']}") + for fp, txt in edited.items(): open(fp, 'w').write(txt) + if links_had is None: + if os.path.exists(links_file): os.remove(links_file) + else: open(links_file, 'w').write(links_had) if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) return buf.getvalue(), tot, fail, pend diff --git a/tests/surfaces.py b/tests/surfaces.py index ea2e81f7..c4eb20ab 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,7 +29,7 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'impact', 'path', 'tests', 'context'] +PUBLIC = ['index', 'impact', 'path', 'tests', 'context', 'link'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = {