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/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..028d57fd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -47,6 +47,7 @@ 'dispatch': 2, # a base method to an override that is actually instantiated 'callback_registered': 3, # handed over as a value and invoked by whoever holds it 'event_dispatch': 3, # emitted here, handled there + '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 @@ -69,6 +70,7 @@ 'dispatch': 'a base method to an override the project instantiates', 'callback_registered': 'handed over as a value and invoked by whoever holds it', 'event_dispatch': 'emitted here, handled there', + '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', @@ -176,6 +178,7 @@ def legend(tiers): '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 +188,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 +267,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/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..7048d582 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -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,7 +801,9 @@ 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'] @@ -826,6 +830,9 @@ class Impact: if n.endswith('/__init__'): mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # a JavaScript file that loads a module by a COMPUTED specifier (`require(path.join(dir, file))`, `import(name)`): + # what it loads is not in these facts, so a closure through it is not complete (imports_dynamic) + self._dyn_import = set() # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, # so whatever a conftest imports is imported for each test file beneath it conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] @@ -844,6 +851,7 @@ class Impact: if not rx: continue try: text = '\n'.join(self.lines(f)) except Exception: continue + if ext != '.py' and self.DYN_IMPORT.search(text): self._dyn_import.add(f) for m in rx.finditer(text): raw = next((x for x in m.groups() if x), '') if not raw: continue @@ -866,12 +874,40 @@ class Impact: if tgt and tgt != f: out.add((f, tgt)) return sorted(out) + DYN_IMPORT = re.compile(r"""(? certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '72' # 72: (70 on its branch) rejected, the leads an asserted-links rejection (or a link that settles the site) takes out of the walks (ax_links.suppressed); 71: (70 on its branch) imports_fact resolves a JavaScript directory import (`require('..')`, a folder with a package.json); imports_dynamic, the files that load a module by a computed name, and load_run (dl), test code that reaches the change through calls alone; 70: fn_returns / fn_returns_param / decorated_call (a by-name decorator hop lands on the wrapper the decorator returns), a decoration string naming a parameter of the declaration is no registration key; 69: a library-callback site is a library receiver only where a hand-back from it is summarised (ax_edges.library_receiver_sites); 68: both 67s below, from two branches; 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1402,7 +1451,7 @@ class Impact: if stack: lex.append((i, stack[-1][2])) stack.append((a, b, i)) W('lex_parent', sorted(set(lex))) - W('imports_fact', self.imports()) + W('imports_fact', self.imports()); W('imports_dynamic', sorted((f,) for f in self._dyn_import)) # a servlet filter a test's context holds runs on every request that test sends (ax_registration.filter_links) W('filter_run', sorted(set(ax_registration.filter_links(g.q)))) # a test that runs a script as a child process by its path: the path is the only link (ax_spawn.py) @@ -1549,8 +1598,14 @@ class Impact: stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() # a chained route link's edge sits on the line of its own verb and path, where `registration` labels it link_line = ax_registration.route_site_lines(g.q, g.site_file) + # LEADS A LINK REJECTED (ax_links.py): a site's by-name match or set member someone read and found wrong is not walked; + # the engine's row stays in graph.sqlite. `rejected` feeds the by-name rules, the set members leave `calls` here. + import ax_links + _rej, _rej_edges = ax_links.suppressed(g.q, g.site_file) + W('rejected', sorted(_rej)) W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) - for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL") + if not (r['tier'] in ax_links.LEAD_TIERS and (r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) in _rej)] + self.protected_name_stubs()) # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the @@ -1745,6 +1800,18 @@ class Impact: a, b = bym.get(r[0]), bym.get(r[1]) if a and b and a != b: deconame.append((a, b)) W('decorated_name', sorted(set(deconame))) + # …and where the engine could NOT resolve the decoration, what the decorator the name denotes RETURNS, so the + # by-name decorator hop (dl/impact.dl `deco_wrapper`) lands on the wrapper the callers run, not on the + # decorator's own body: `fn_returns(m, w)` m returns the function w, `fn_returns_param(m)` m returns an argument + # it was handed (the registering `return f`), `decorated_call(s, d)` the decoration is written as a call + # (`@route("/x")`: the decorator applied is what `route` returned, not `route`). + bym = {sy['method_id']: i for i, sy in g.sym.items() if sy.get('method_id')} + W('fn_returns', sorted({(bym[a], bym[b]) for a, b in (g.q("SELECT c1, c2 FROM ext_method_returns_method") if g.has('ext_method_returns_method') else []) + if a in bym and b in bym and a != b})) + W('fn_returns_param', sorted({(bym[a],) for (a,) in (g.q("SELECT c1 FROM ext_method_returns_param") if g.has('ext_method_returns_param') else []) + if a in bym})) + W('decorated_call', sorted({(r[0], r[1].split('.')[-1]) for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []) + if r[1] and re.match(r'\s*@?\s*[\w.]+\s*\(', r[2] or '')})) # ── the two spellings of one route ────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered as `/orders/{order_id}/price`. Neither # string contains the other, and no call site joins them — the router does, at run time, by matching the @@ -2215,7 +2282,7 @@ class Impact: prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws', 'load_run'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -2544,6 +2611,7 @@ def main(argv): # cut short and so lose every route that passes through code outside the path) to test files containing it tests_only = '--tests-only' in args; want_tests = '--tests' in args or tests_only; want_why = '--why' in args want_delete = '--delete' in args; want_warm = '--warm' in args; want_depends = '--depends' in args + want_unknown = '--unknown' in args; args = [a for a in args if a != '--unknown'] # the default view collapses a flood of [by name] rows; an explicit --limit or --page all asks for every row # A LATER PAGE CONTINUES THE ROWS (ax_pages.paginate). Page 1 is the default view; page 2 on holds the rows that # view left out. So whenever the pager captures the answer every row is printed, the ones the default view hides @@ -2852,6 +2920,13 @@ def main(argv): ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if m in ent_why] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 + # THE GAPS AS A WORK LIST (ax_links.py): the unresolved sites behind that count, nearest the change first, each with the + # call as written and the engine's own reason, so an agent can read them and link the one its task depends on + import ax_links + unk, _unk_n = ax_links.unknown_sites(g.q, inside, g.site_file, limit=30 if as_json else 200, + order=[*seeds, *sorted(direct_ids, key=g.disp), *sorted(reached, key=lambda m: (reached[m], g.disp(m)))], repo=g.repo) + links_note = ax_links.note(g.q) + rejected_note = ax_links.rejected_note(g.q) # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} # the ROUTE a record carries: a test reached through a fixture (a setUp, a @MethodSource factory) has no edge of @@ -2866,29 +2941,39 @@ def main(argv): # resolved to a table in a service the test never starts -- is a lead further out, not a test of the change: on # one monorepo 19 test files were selected for a handler table's module and 2 of them import it. None where the # language has no import graph here (imports_fact covers Python, JavaScript and TypeScript). - _rev = None; _importers = {} + _rev = None; _importers = {}; _fwd = None; _dyn = set() + # THE FILE ASKED ABOUT, not the end of the nearest route. That route is the shortest one, and the closure is a + # union over every caller: a test reaches `Option`'s constructor through a subclass ANOTHER test file declares + # (`class MyOption extends Option`), whose file it never loads, while the route it really takes is one hop + # longer. Read off the route's end, the test was said never to load the change; read off the asked declaration's + # own file, it loads it through the package entry like every other test. + tgt_files = {g.sym[x]['file'] for _k, _lab, pay in targets if isinstance(pay, list) for x in pay + if isinstance(x, str) and x in g.sym and g.sym[x].get('file')} def loads_change(m, fx): - nonlocal _rev + nonlocal _rev, _fwd tf = g.sym[m].get('file') or '' route = test_route(m, fx) seed_file = (g.sym.get(route[-1]) or {}).get('file') if route else None if not tf or not seed_file or os.path.splitext(tf)[1].lower() not in I.IMPORT_RE: return None - if tf == seed_file or test_cert.get(m) in ('spawns', 'at import'): return True + if tf == seed_file or tf in tgt_files or test_cert.get(m) in ('spawns', 'at import'): return True if _rev is None: - _rev = collections.defaultdict(set) + _rev = collections.defaultdict(set); _fwd = collections.defaultdict(set) try: for ln in open(os.path.join(g.facts, 'impact', 'imports_fact.facts'), encoding='utf-8', errors='replace'): a, _, b = ln.rstrip('\n').partition('\t') - if a and b: _rev[b].add(a) + if a and b: _rev[b].add(a); _fwd[a].add(b) + _dyn.update(ln.rstrip('\n') for ln in open(os.path.join(g.facts, 'impact', 'imports_dynamic.facts'), encoding='utf-8', errors='replace')) except OSError: _rev = {} # no import facts: nothing can be said either way if not _rev: return None - if seed_file not in _importers: - seen, todo = {seed_file}, [seed_file] - while todo: - for a in _rev.get(todo.pop(), ()): - if a not in seen: seen.add(a); todo.append(a) - _importers[seed_file] = seen + def importers(f): + if f not in _importers: + seen, todo = {f}, [f] + while todo: + for a in _rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + _importers[f] = seen + return _importers[f] loaders = {tf} | ({g.sym[fx].get('file')} if fx and fx in g.sym else set()) if tf.endswith('.py'): d_ = os.path.dirname(tf) @@ -2896,7 +2981,18 @@ def main(argv): loaders.add(f"{d_}/conftest.py" if d_ else 'conftest.py') if not d_: break d_ = os.path.dirname(d_) - return bool(loaders & _importers[seed_file]) + files_ = tgt_files or {seed_file} + # a file NOTHING here imports is loaded some other way (a plugin required by a computed name, a bundle a + # runner config names), and a test file that imports nothing here reads the package through a path alias + # this scan does not resolve: in both the import graph cannot say, so it says nothing + if not any(len(importers(f)) > 1 for f in files_) or not any(_fwd.get(x) for x in loaders): return None + # and where the test, or a file it loads, loads a module by a computed name, it may load the changed one + seen, todo = set(loaders), list(loaders) + while todo: + for y in _fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + if seen & _dyn: return None + return any(loaders & importers(f) for f in files_) # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} _runs = {} @@ -2969,7 +3065,9 @@ def main(argv): 'filter': 'registered', # a library summarised (from its source) to call a client member back: a member name on a type the client # handed over, not a resolved call (framework-behavior/library-callbacks.dl) - 'library callback': 'library callback'} + 'library callback': 'library callback', + # a link someone recorded (ax_links.py): an edge nobody resolved — its own rung, beside `registered` + 'asserted': 'asserted'} # The numbers that used to stand here (sound 0.645, one of a set 0.414, by key 0.396, fixture 0.233, by name # 0.133) were measured against a truth set that was SHORT: a pytest file which fails to COLLECT is emitted in # junit XML with classname="" and the module in `name`, the oracle read classname alone, and so every @@ -3007,7 +3105,7 @@ def main(argv): # is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow # the word that means one. CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'at load': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, - 'library callback': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, + 'library callback': 5, 'asserted': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, 'by name': 12} # `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote # hop's whole route and verb on both ends, where a key join is this file's own rules matching one literal. Not @@ -3069,6 +3167,35 @@ def main(argv): if CERT_RANK[c] > CERT_RANK[worst]: worst = c return worst test_cert = {m: cert_of(m) for m in tests} + # RUNS WHILE THE FILE LOADS: the route is an import (`at import`), or the test is credited through its file's module + # body or the describe() block around it and that block reaches the change through calls alone (load_run), so the + # change runs while the runner loads the file, before any test + load_run = {x[0] for x in res.get('load_run', [])} + _DESCRIBE = re.compile(r'(? 4 else '')) + if not_loading: + nlf = sorted({g.sym[m]['file'] for m in not_loading}) + print(f" not counted: {len(nlf)} test file(s) reach it only through code they never load — none of their imports, followed" + " through the project, imports the changed file (a callback another test file hands over, a plugin they never import): " + + ', '.join(nlf[:4]) + (f" … +{len(nlf) - 4}" if len(nlf) > 4 else '')) # A PROGRAM THE SUITES START AS A SUBPROCESS (a CLI script, a hook, a subcommand its dispatcher execs): no test # calls it, so the count is 0, and a reader took that as untested and ran nothing if not tests: @@ -3694,7 +3834,7 @@ def main(argv): # called local, because `next:` read only the direct rows. # `remote` (#1468): the client that calls it across a process is a dependent the engine joined at both ends, so it # is a place to read, not a by-name lead, and it makes the declaration as non-local as a resolved caller does - STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'capped set', 'remote', 'framework') + STRONG_DIRECT = ('resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'remote', 'framework') prod_users = {c for c, role, why, cert, loc, n in D if cert in STRONG_DIRECT and not _is_t(c)} prod_reach = sorted((m for m in reached if m in sure_set and not _is_t(m)), key=lambda m: (reached[m], g.loc(m))) lone = not prod_users and not prod_reach @@ -3738,7 +3878,7 @@ def main(argv): # reference. Letting the new label fall out of this list would turn "something still uses this" into # "safe to delete", which is the one verdict here that costs anything to get wrong. calls = [x for x in D if (x[3] == 'resolved' and ('calls' in x[2] or 'instantiates' in x[2] or 'through' in x[2] or 'inject' in x[2])) - or x[3] in ('registered', 'capped set', 'framework')] # a task's producer, a signal's sender (#1509) + or x[3] in ('registered', 'asserted', 'capped set', 'framework')] # a task's producer, a signal's sender (#1509) weak = [x for x in D if x[3] in ('one of a set', 'by name', 'text', 'in scope')] lits = [r for n_ in names for r in g.q("SELECT file, line FROM literals WHERE value = ?", n_)] if g.has('literals') else [] decs = [f"{n_} ({f_}:{l_})" if f_ and l_ else n_ for n_, f_, l_ in own_decs] @@ -3868,6 +4008,8 @@ def main(argv): print("bound: the closure could not start — no dependent above is an edge it can walk back from, so what " "reaches THEM is not in this answer") if u: print(f"bound: {u} unresolved call(s) inside the impacted set — what those reach is unknown, so the set is a lower bound") + if want_unknown and unk: print('\n'.join(ax_links.unknown_lines(g.repo, unk, u))) + if links_note: print(links_note) # A STRUCTURAL TYPE IS SATISFIED WITHOUT BEING NAMED (#784). `return { kind: 'LiteralNode', fragments }` checks # against the interface and never writes it, so an added required property or a narrowed one breaks sites no row # above can name. Assignability is the compiler's job; the answer says so rather than looking complete. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 9dae7ca8..9add01cd 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -70,7 +70,11 @@ if not HAVE_IR and prev.get('ir_present') == 'true' and '--force' not in sys.arg raw_paths = {r[0] for t in ('call_sites', 'methods', 'types') for r in c.execute(f"SELECT DISTINCT file_path FROM {t} WHERE file_path IS NOT NULL")} c.executemany("INSERT OR IGNORE INTO paths VALUES (?,?)", ((p, rel(p)) for p in raw_paths)) c.execute("DELETE FROM index_meta WHERE key='index_version'"); c.execute("INSERT INTO index_meta VALUES ('index_version', ?)", (INDEX_VERSION,)) # or every query would retry this - con.commit(); print(f"index kept: IR is gone but the index was built from it ({prev.get('built_at')}); newer tables added empty. Rebuild the graph to fill them."); sys.exit(0) + con.commit() + try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_links; ax_links.apply_db(DB, REPO, ax_links.Reader(REPO)) + except Exception: pass + print(f"index kept: IR is gone but the index was built from it ({prev.get('built_at')}); newer tables added empty. Rebuild the graph to fill them."); sys.exit(0) # ── the adapter table: which IR file and which HEADER NAMES carry each thing, per language ──────── # ref: expression rows that are a use of a name. kinds -> the expression kinds; name -> the @@ -791,4 +795,16 @@ meta = dict(language=LANG, repo=REPO, source_dir=SRC, ir=IR if HAVE_IR else '', c.executemany("INSERT INTO index_meta VALUES (?,?)", [(k, str(v)) for k, v in meta.items()]) con.commit() con.execute("VACUUM") # the tables above are dropped and recreated on every run; without this the file only grows +con.close() +# THE ASSERTED LINKS (ax_links.py, axiomcode-links.tsv) are applied to every graph as it is indexed, after the engine's +# export and before the graph is published, so a rebuild keeps them and no query ever sees a graph without them: each is +# validated against THIS graph and this tree's text, O(links), and one whose line changed is dropped and reported. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +try: + import ax_links + _t0 = time.time(); _res = ax_links.apply_db(DB, REPO, ax_links.Reader(REPO)) + if _res: print(f"links: {sum(1 for _l, r in _res if r['status'] in ('applied', 'moved'))} of {len(_res)} asserted link(s) applied in {(time.time() - _t0) * 1000:.0f} ms") +except Exception as e: # a links file can never cost the graph + print(f"links: not applied ({e})") +con = sqlite3.connect(DB); c = con.cursor() print(f"indexed {DB} [{LANG}]: symbols={meta['symbols']} refs={meta['refs']} literals={meta['literals']} comments={meta['comments']} nested_types={meta['nested_types']}" + ("" if HAVE_IR else " (IR absent: refs/literals/comments empty — `uses` will say so)")) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link new file mode 100755 index 00000000..d8f96c6c --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-link @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""axiomcode link [] [] +axiomcode link [] every asserted link, and whether the graph took it (--json) +axiomcode link - [] remove the links at that site (or --remove []) + +Records that the call written at file:line reaches — a declaration as the graph names it (Owner.method, +function) or its file:line — where the graph could not resolve it. The link is kept in axiomcode-links.tsv at the +repository root, validated against the graph (a call written there, consistent with the target; the target declared; +the line's text unchanged), and applied at once as an edge of tier `asserted`, which impact, path and tests walk and +label [asserted]. A stale or rejected link is listed with its reason and never applied. See ax_links.py. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ax_links + +if __name__ == '__main__': + sys.exit(ax_links.main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index dfdf5441..f88c14c0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -57,6 +57,19 @@ UNRESOLVED = [] # endpoints G.resolve found # Filled as the human output is produced, from the SAME chain objects, so the two cannot disagree: # a second traversal for the machine shape is a second implementation to keep in step. RESULT = {'query': None, 'answers': [], 'reached': [], 'bound': None, 'verified': None} +WANT_UNKNOWN = False + + +def unknown_block(g, callers): + """THE GAPS AS A WORK LIST (ax_links.py): the unresolved sites in these callables, each with the call as written and + the engine's reason, in --json always and in the prose with --unknown; and the note when a link was not applied""" + import ax_links + sites, n = ax_links.unknown_sites(g.q, list(callers), g.site_file, limit=30 if not WANT_UNKNOWN else 200, repo=g.repo) + RESULT['unknown_sites'] = sites; RESULT['unknown_total'] = n + if WANT_UNKNOWN and sites: + for l in ax_links.unknown_lines(g.repo, sites, n): print(' ' + l) + n_ = ax_links.note(g.q) + if n_: RESULT['links_note'] = n_; print(' ' + n_) def chain_json(g, chain): @@ -994,8 +1007,8 @@ class G: # is summarised (ax_edges.library_receiver_sites); 4: both 3s below, which landed on two branches under one number; # 3 (0.1.5): on one line a JavaScript anonymous callable nests only where its name does (#1598); # 3 (0.1.6): a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites); - # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. - EXPORT_VERSION = '5' + # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. 6: rejected_edge, the leads an asserted-links rejection takes out. + EXPORT_VERSION = '6' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" def fresh(): @@ -1015,8 +1028,11 @@ class G: # `mock.Setup(r => r.Find(1))`): the receiver is a mock, so the real body never runs there, and walked as a call # it made every test that stubs a method a `sound` route to it. impact lists those sites as "stubs it". stubs = ax_edges.stub_sites(lambda s, p: self.q(s, *p)) if self.has('call_sites') else set() + # leads an asserted-links rejection takes out (ax_links.suppressed): a set member every site of which was rejected + import ax_links + _rej, rej_edges = ax_links.suppressed(self.q, self.site_file) edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')") - if r['call_site_id'] not in stubs] + if r['call_site_id'] not in stubs and not (r['tier'] in ax_links.LEAD_TIERS and (r['caller_id'], r['callee_method_id']) in rej_edges)] # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] @@ -1048,6 +1064,7 @@ class G: libsites = ax_edges.library_receiver_sites(lambda s, p: self.q(s, *p)) self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs and r[0] not in libsites})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) + self.write('rejected_edge', sorted(rej_edges)) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): # written aside and renamed into place: a query reading the facts while another process (the build's warm-up, @@ -1103,7 +1120,7 @@ def _facts_rows(g, programs): """the input relations the rules read, as lists. `edge` carries g.EXTRA exactly as run_dl appends it.""" rows = {'edge': g.edges()} if 'path-opt.dl' in programs: - for n in ('byname', 'named'): + for n in ('byname', 'named', 'rejected_edge'): f = os.path.join(g.facts, n + '.facts') rows[n] = [tuple(l.rstrip('\n').split('\t')) for l in open(f)] if os.path.exists(f) else [] return rows @@ -1887,10 +1904,49 @@ def grep_for(g, name): """the exact search that finds what the graph could not: the name as a whole word in the source roots""" return f'grep -rnw "{name}" {grep_roots(g)}' +# ── a JavaScript test file that never loads the target ──────────────────────────────────────────────────────────── +JS_EXT = ('.js', '.mjs', '.cjs', '.jsx') +def never_loads(g, A, B): + """(test file, target files) when every start is in ONE JavaScript test file whose imports, followed through the + project, never reach a file the target is declared in; else None. Such a file cannot run the target, whatever route + the call graph has: the route goes through a callable another test file declares (a callback handed to a shared + function) or an override a plugin installs only where it is loaded. Read from impact's import facts + (imports_fact), only when they were written for this graph; where either end has no import at all the scan cannot + say (a path alias, a file loaded by a computed name), and nothing is gated. impact drops the same tests the same way.""" + fa = {g.sym[m].get('file') for m in A if m in g.sym} + fb = {g.sym[m].get('file') for m in B if m in g.sym} - {None, '', ''} + if len(fa) != 1 or not fb: return None + fa = next(iter(fa)) + if not fa or not fa.endswith(JS_EXT) or fa in fb or not all(g.sym[m].get('is_test') for m in A if m in g.sym): return None + D = os.path.join(g.facts, 'impact') + try: + if not open(os.path.join(D, 'stamp')).read().startswith(f"{g.db_mtime}:"): return None + fwd, rev = collections.defaultdict(set), collections.defaultdict(set) + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + x, _, y = ln.rstrip('\n').partition('\t') + if x and y: fwd[x].add(y); rev[y].add(x) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + return None + if not fwd.get(fa) or not all(rev.get(f) for f in fb): return None + seen, todo = {fa}, [fa] + while todo: + for y in fwd.get(todo.pop(), ()): + if y not in seen: seen.add(y); todo.append(y) + # a file on the way that loads a module by a computed name (`require(path.join(dir, f))`) may load the target + return (fa, sorted(fb)) if not (seen & fb) and not (seen & dyn) else None + # ── the query──────────────────────────────────────────────────────────────────────────────────────────────────── def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): g.export() la, A = g.resolve(a); lb, B = g.resolve(b) + nl = never_loads(g, A, B) + if nl: + RESULT['never_loads'] = {'test_file': nl[0], 'target_files': nl[1]} + print(f"no route: {nl[0]} never loads {', '.join(nl[1])} — none of the files it imports, followed through the project," + f" imports it, so no run of that test executes {lb}. The call graph's routes between them go through code that" + f" file never loads (a callback another test file hands to a shared function, an override a plugin installs).") + return 1 g.add_outside_call_edges() res = run(g, {'fwd': (A, B), 'rev': (B, A)}, ('path.dl', 'path-every.dl') if every else ('path.dl',)) if not res['hit']['fwd'] and not res['hit']['rev']: # nothing resolved connects them: now, and only now, the by-name closure @@ -1903,7 +1959,10 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if not hits: continue near = read_back(res['parent'], q, hits[0][0], srcs) or [] outside = sorted({t for _, t in near[1:] if t in ax_edges.OUTSIDE_CALL}) + # a hop someone ASSERTED (axiomcode link) is not a resolved call: the header says the chain rests on it + nas = sum(1 for _, t in near[1:] if t == 'asserted') print(f"{word}: {len(hits)} of {len(dsts)} target(s) reached through resolved calls" + + (f" and {nas} asserted link(s) (an edge someone recorded, not one the engine resolved)" if nas else '') + (f" and a hop no call site expresses ([{'] and ['.join(outside)}], below)" if outside else '') + f"; nearest at {hits[0][1]} hop(s)") if len(hits) > limit and not show_all: @@ -1955,9 +2014,15 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if u: RESULT['bound'] = f"the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see" print(f" bound: {RESULT['bound']}") + unknown_block(g, blind) return 0 # nothing resolved either way: say so, then whether unresolved sites would connect them print(f"no chain of resolved calls connects {la} and {lb} in either direction (searched {len(g.edges())} edges, depth ≤ 40)") + # where a missing hop can be: an unresolved call somewhere A reaches (the start's own closure, nearest first) + _fa = [i for i in A_ if i in g.sym]; _seen = set(_fa); _fr = list(_fa) + while _fr: + _fr = [y for y in dict.fromkeys(y for x in _fr for y in adj.get(x, ())) if y not in _seen and y in g.sym] + _seen.update(_fr); _fa += _fr # A CROSS-PROCESS HOP IS A CONNECTION, AND THIS SAID THERE WAS NONE (#1108). A gRPC client and the # handler it calls, or a producer and its consumer, are joined by an edge no call site expresses, so # the BFS above cannot reach it and the honest-sounding "the two are independent in this graph" was @@ -2006,6 +2071,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): for c, m2 in bridges: sites = g.q("SELECT s.file_path f, s.start_line ln FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.caller_id = ? AND s.callee_name = ?", c, g.sym[m2]['name']) print(f" `{g.sym[m2]['name']}` called in {g.disp(c)} at " + ', '.join(f"{g.site_file(s['f'])}:{s['ln']}" for s in sites[:4]) + f" — receiver not typed; if it is {g.disp(m2)}, the chain is real") + unknown_block(g, _fa) return 1 # A LIBRARY CALL IS WHERE THE GRAPH STOPS, NOT WHERE THE CODE DOES (#1385). A start that publishes an event # through a framework's publisher reaches the listener at run time, and the call that does it is a resolved @@ -2168,6 +2234,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): + (f" — named like the target: if it holds {other}, the chain is real" if r['n'] in names[lx] else '')) if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) + unknown_block(g, _fa) return 1 # ── self-test: the engine's expected edges, through the whole tool ─────────────────────────────────────────────── @@ -2244,6 +2311,7 @@ if __name__ == '__main__': if not args or args[0] in ('-h', '--help'): print(__doc__); sys.exit(0) if args[0] == '--selftest': sys.exit(selftest(args[1] if len(args) > 1 else 'typescript')) as_json = '--json' in args; args = [a for a in args if a != '--json'] + WANT_UNKNOWN = '--unknown' in args; args = [a for a in args if a != '--unknown'] want_why = '--why' in args; args = [a for a in args if a != '--why'] show_all = '--all' in args; args = [a for a in args if a != '--all'] depth = 40 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 29e9f97e..2d29b424 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1425,12 +1425,12 @@ def _script_for(repo, pkg, prel): return None -def js_plan(repo, files): +def js_plan(repo, files, cap=40): """(commands, files no runner collects): each file with the runner its own package would run it with.""" by_runner = collections.OrderedDict() singles, unrun = [], [] repo = os.path.realpath(repo) - for f in sorted(files)[:40]: + for f in (sorted(files)[:cap] if cap else sorted(files)): # the nearest directory whose runner collects the file runs it: a member's own config first, then the # root's (a workspace member with no runner of its own is run by the root's, with paths from the root) hit, any_pkg, d = None, False, os.path.dirname(f) @@ -1990,6 +1990,10 @@ def main(argv): # say it back, not a declaration the graph lacks. `Owner.m(p)` still refused is asked as `Owner.m` (the tests # that reach the method are the ones a parameter edit can fail); a batch still refused is asked one by one, so # one bad target does not cost the others their tests + # a JavaScript function that is also a constructor (`function MarkdownIt(opts)`, `function Route(path)`) is a type + # to `changed` and has no type declaration impact can name at that line: it is asked as the function it is + if rc != 0 and tk == 'type': + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) if rc != 0 and len(batch) == 1 and '(' in batch[0]: bare = re.sub(r'\(.*\)$', '', batch[0]) rc, o, e = run('axiomcode-impact', [bare, repo, '--tests', '--json', '--kind', 'method'] + scope) @@ -2067,6 +2071,74 @@ def main(argv): ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at') and not r.get('script')] # a script is its file run_files = sorted(set(files) | set(edited)) lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) + # A SELECTED JAVASCRIPT FILE NO RUNNER COLLECTS is not a test run: a benchmark or interop script under test/ that no + # include glob or package script names (`test/autobahn.js` beside `test/*.test.js`), or a file the runner only + # reaches through another test file that requires it (`require('./es2017/asyncFunctions.js')`). It is replaced by + # the collected test files that import it, through the project's imports, and where none does it moves to + # `not_collected` and out of the selection. Where a test file loads modules by a computed name + # (`require(path.join(dir, f))`) nothing can be said, and the file stays. + rehomed = {} + if lang == 'javascript' and (files or further): + sel = sorted(set(files) | set(further)) + unrun = [f for f in js_plan(repo, sel, cap=None)[1] if f.endswith(('.js', '.mjs', '.cjs', '.jsx'))] + if unrun: + D = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'dl', 'impact') + rev = collections.defaultdict(set); dyn = None + try: + for ln in open(os.path.join(D, 'imports_fact.facts'), encoding='utf-8', errors='replace'): + a, _, b = ln.rstrip('\n').partition('\t') + if a and b: rev[b].add(a) + dyn = {ln.rstrip('\n') for ln in open(os.path.join(D, 'imports_dynamic.facts'), encoding='utf-8', errors='replace')} + except OSError: + dyn = None # no import facts: say nothing, move nothing + # a collected test file that loads modules by a computed name (`fs.readdirSync(dir).forEach(f => require(f))`) + # may be what runs a file nothing requires by its path: such a file stays + dyn_runner = dyn is not None and any(not js_plan(repo, [x], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, x), 400_000) or '') + for x in sorted(dyn) if x.endswith(('.js', '.mjs', '.cjs', '.jsx'))) + if dyn is not None: + for f in unrun: + seen, todo = {f}, [f] + while todo: + for a in rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + if seen & dyn: continue + ups = sorted(seen - {f}) + if not ups and dyn_runner: continue + runs = [u for u in ups if not js_plan(repo, [u], cap=None)[1] and graph_sql.TEST_REGISTRAR.search(_read(os.path.join(repo, u), 400_000) or '')] + rehomed[f] = runs + if rehomed: + new_near = [x for x in files if x not in rehomed] + [r for f in files if f in rehomed for r in rehomed[f]] + new_far = [x for x in further if x not in rehomed] + [r for f in further if f in rehomed for r in rehomed[f]] + files = sorted(set(new_near)); further = sorted(set(new_far) - set(files)) + run_files = sorted(set(files) | set(edited)) + # A CHANGE THAT RUNS WHILE MOCHA LOADS THE SPECS FAILS THE WHOLE RUN. mocha requires every spec file, and runs every + # describe() body, in ONE process before it runs a single test; a throw there aborts the run and every spec file + # in it fails, the ones that never touch the change included. A test reached at load time -- through its file's + # module body (an `at import` route, or the module as the route) or through the describe() block around it -- is + # therefore every file that mocha run collects, not just its own. jest and vitest load each file in its own + # module registry, where the same throw fails that file alone, so only a mocha run is widened. + load_wide = {} + if lang == 'javascript' and tests: + for f in sorted({(r.get('at') or '').rpartition(':')[0] for r in tests.values() if r.get('at_load')} - {''}): + d = os.path.dirname(f); hit = None + while hit is None: + drel = f[len(d) + 1:] if d else f + hit = next(((d, name, collects) for name, collects, _c in _runners_of(repo, d) if collects(drel)), None) + if not d: break + d = os.path.dirname(d) + if not hit or hit[1] != 'mocha' or hit[0] in load_wide: continue + base = os.path.join(repo, hit[0]) if hit[0] else repo + run_list = [] + for root, dirs, fs in os.walk(base): + dirs[:] = [x for x in dirs if x not in ('node_modules', '.git', '.axiomcode', 'dist', 'coverage') and not x.startswith('.')] + for x in fs: + rel = os.path.relpath(os.path.join(root, x), base).replace(os.sep, '/') + if x.endswith(('.js', '.mjs', '.cjs', '.jsx', '.ts')) and hit[2](rel): + run_list.append(f"{hit[0]}/{rel}" if hit[0] else rel) + if len(run_list) > 1: load_wide[hit[0]] = sorted(run_list) + if load_wide: + files = sorted(set(files) | {x for v in load_wide.values() for x in v}); further = sorted(set(further) - set(files)) + run_files = sorted(set(files) | set(edited)) # the graph `impact` answered from, asked again for which selected classes are abstract (command_for) db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'graph.sqlite') # the classes the command names: every owner, and a test that has none by its file (a class that inherits a @@ -2090,7 +2162,9 @@ def main(argv): 'edited_test_files': edited, 'command': cmd_all, 'abstract_replaced': replaced, - 'not_collected': js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else [], + 'not_collected': sorted(set(js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else []) | set(rehomed)), + **({'run_by_importer': {f: r for f, r in rehomed.items() if r}} if any(rehomed.values()) else {}), + **({'whole_run_at_load': {d or '.': len(v) for d, v in load_wide.items()}} if load_wide else {}), 'not_reached_from': unseen, 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], @@ -2195,6 +2269,17 @@ def main(argv): if cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) + for d_, v_ in sorted(load_wide.items()): + print(f"\n the change runs while mocha loads the specs (a module body or a describe() block reaches it), and a throw" + f" there fails the whole run: every one of the {len(v_)} spec file(s) mocha collects in {d_ or '.'} is listed") + moved = {f: r for f, r in rehomed.items() if r}; gone = sorted(f for f, r in rehomed.items() if not r) + if moved: + print(f"\n {len(moved)} file(s) the graph reached are run only through the test files that require them, which are" + " listed instead: " + '; '.join(f"{f} <- {', '.join(r[:2])}" + (f" +{len(r) - 2}" if len(r) > 2 else '') for f, r in sorted(moved.items())[:4]) + + (f" … +{len(moved) - 4}" if len(moved) > 4 else '')) + if gone: + print(f"\n not selected: {len(gone)} file(s) under the tests reach it but no runner collects them and no test file requires" + " them (a script or benchmark beside the tests): " + ', '.join(gone[:4]) + (f" … +{len(gone) - 4}" if len(gone) > 4 else '')) if cmd_further: print("\n then, further out: " + cmd_further.replace("\n", "\n ")) elif edited and cmd_all: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 6c5f1978..858e808d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -46,6 +46,8 @@ .decl calls(c:symbol, m:symbol, tier:symbol, f:symbol, l:number) .input calls .decl handoff_at(c:symbol, m:symbol, f:symbol, l:number) .input handoff_at .decl unresolved(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input unresolved +// rejected(c, m, f, l) a lead at c's site f:l to m someone rejected (axiomcode link --not; ax_links.suppressed): not walked +.decl rejected(c:symbol, m:symbol, f:symbol, l:number) .input rejected .decl named_site(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input named_site // a `new T()` of a type with no written constructor (C#): no constructor to call, so no `calls` row, but the engine knows the type (#1473) .decl implicit_new(c:symbol, t:symbol, f:symbol, l:number) .input implicit_new @@ -420,7 +422,7 @@ direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", k != "library", !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", k != "library", !is_target_decl(q, c), !rejected(c, m, f, l). direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). // A site whose receiver the engine traced to a package's value (`request(app).get('/x')`, ax_edges.library_receiver_sites): // the kind "library". A row of its own, listed after the untyped-receiver matches, and no seed of the closure. @@ -865,7 +867,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", k != "library", !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), k != "stub", k != "library", !seed(q, c), !rejected(c, m, f, l). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the @@ -957,6 +959,19 @@ up(q, a, d+1) :- up_running(q, b, d), import_hop(q, a, b), d < 40. parent_up(q, a, b, "at import") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, import_hop(q, a, b). .output reach .output parent_up +// WHAT RUNS WHILE A TEST FILE LOADS: a declaration in test code that reaches the change through calls alone (a resolved +// call or one of a resolved set; never `defines`, never a hand-over, which runs later or not at all), so a module body +// or a describe() block that is one of these runs the change before any test does. A mocha run requires every spec and +// runs every describe() in one process, and a throw there fails the whole run (axiomcode-test-impact reads this as +// `at_load` on the test it credits). Walked only as far as test code: the first test-file declaration ends the walk. +.decl load_up(q:symbol, m:symbol) +load_up(q, m) :- seed(q, m). +load_up(q, a) :- load_up(q, b), !load_stop(b), edge(a, b, t), (t = "known_edge" ; t = "multi_inferred"), !state_gate(a, b, _). +.decl load_stop(m:symbol) +load_stop(m) :- decl_file(m, f), is_test_file(f), !kind(m, "module"). +.decl load_run(q:symbol, m:symbol) +load_run(q, m) :- load_up(q, m), decl_file(m, f), is_test_file(f). +.output load_run // the same closure from the seeds that are an exact edge only: what is reached ONLY through a [by name] / [text] / // [one of a set] dependent is everything outside it, and the answer says how much of itself rests on those @@ -1069,7 +1084,35 @@ fw_edge(a, b, "spawns") :- spawns_fact(a, b), a != b. .decl dec_decl(d:symbol, w:symbol) dec_decl(d, w) :- decorated(_, d), named(d, w), 1 = count : { named(d, _) }. dec_decl(d, w) :- decorated(s, d), named(d, w), decl_file(s, f), decl_file(w, f). -fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b. +// WHO RUNS WHAT THE DECORATOR RETURNED, NOT WHO WAS DECORATED. That hop, taken from the decorated declaration to +// the decorator's body, made the decorated function a caller of the decorator: `@bp.route("/") def index` put `index`, +// and through it every test that requests "/", above everything `route` calls. Nothing runs `route` when `index` is +// called: the decoration ran it once, where the `def` is. What a caller of the decorated NAME runs is whatever the +// decorator RETURNED for it: a wrapper (`def wrapper(*a): … return f(*a)`, which may reach the change), or the +// function itself (`return f`, a registration), which adds nothing. Where the decoration is written as a call +// (`@route("/")`), the decorator applied is what `route` returned, and it is THAT one's return the name denotes. +// fn_returns(m, w) m returns the function w (Python ext_method_returns_method) +// fn_returns_param(m) m returns an argument it was handed +// decorated_call(s, d) s carries the decoration d written as a call +// Where what the decorator returns is unknown, the hop stays as it was: the walk keeps a route it cannot judge. +.decl fn_returns(m:symbol, w:symbol) .input fn_returns +.decl fn_returns_param(m:symbol) .input fn_returns_param +.decl decorated_call(s:symbol, d:symbol) .input decorated_call +.decl deco_applied(s:symbol, x:symbol) +deco_applied(s, b) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d). +deco_applied(s, x) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x). +.decl deco_wrapper(s:symbol, w:symbol) +deco_wrapper(s, w) :- deco_applied(s, x), fn_returns(x, w), w != s. +.decl deco_known(s:symbol, d:symbol) +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns(b, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), !decorated_call(s, d), fn_returns_param(b). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns(x, _). +deco_known(s, d) :- decorated(s, d), dec_decl(d, b), decorated_call(s, d), fn_returns(b, x), fn_returns_param(x). +// The decorated declaration stands for its wrapper: whatever reaches the name (a call, a route, a registry key) runs it. +// The engine leaves a call to a name whose decorator it could not resolve unresolved, so the hop is taken from the +// declaration itself rather than from its callers. +fw_edge(s, w, "decorator by name") :- deco_wrapper(s, w). +fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b, !deco_known(a, d). // A CLIENT ACROSS A PROCESS: the sender depends on the handler it reaches (#1293). `remote` above makes the client a // DIRECT row; walked only there, a test that drives the client never reached a change to the handler, so // test-impact stopped at the route while impact named the client one line up. Walked here as its own tier, the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl index 6305ccf7..fd36c7f4 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/path-opt.dl @@ -5,11 +5,12 @@ .decl dst(q:symbol, m:symbol) .input dst .decl byname(c:symbol, n:symbol) .input byname .decl named(n:symbol, m:symbol) .input named +.decl rejected_edge(c:symbol, m:symbol) .input rejected_edge // leads an asserted-links rejection takes out (ax_links.py) // ── the optimistic closure: unresolved sites taken by name ───────────────────────────────────────────────────── .decl edge_opt(a:symbol, b:symbol, t:symbol) edge_opt(a, b, t) :- edge(a, b, t). -edge_opt(c, m, "by-name") :- byname(c, n), named(n, m). +edge_opt(c, m, "by-name") :- byname(c, n), named(n, m), !rejected_edge(c, m). .decl step_opt(q:symbol, m:symbol, d:number) step_opt(q, m, 0) :- src(q, m). step_opt(q, b, d+1) :- step_opt(q, a, d), edge_opt(a, b, _), d < 40. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index f8398f79..6ea7d9cf 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -4089,6 +4089,10 @@ def solve_path(rows, queries, every=False, opt=False, cap=MAX_HOP): cur.execute("CREATE TABLE edge_opt(a TEXT, b TEXT, t TEXT)") cur.execute("INSERT INTO edge_opt SELECT a, b, t FROM edge") cur.execute("INSERT INTO edge_opt SELECT DISTINCT b.c, n.m, 'by-name' FROM byname b JOIN named n ON n.n = b.n") + rej = list(rows.get('rejected_edge', ())) # leads an asserted-links rejection takes out (ax_links.py) + if rej: + cur.execute("CREATE TEMP TABLE rejected_edge(c TEXT, m TEXT)"); cur.executemany("INSERT INTO rejected_edge VALUES(?,?)", rej) + cur.execute("DELETE FROM edge_opt WHERE t = 'by-name' AND EXISTS (SELECT 1 FROM rejected_edge r WHERE r.c = edge_opt.a AND r.m = edge_opt.b)") cur.execute("CREATE INDEX eo_a ON edge_opt(a)"); cur.execute("CREATE INDEX eo_b ON edge_opt(b)") out = {n: collections.defaultdict(list) for n in ('hit', 'parent', 'hit_opt', 'parent_opt', 'between_edge', 'dist_up', 'dist')} for q, (s, d) in queries.items(): diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 852dd585..ba96f81f 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -17,6 +17,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 ` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. @@ -56,6 +57,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/tests/cases/csharp/asserted-links-derive/App/Route.cs b/tests/cases/csharp/asserted-links-derive/App/Route.cs new file mode 100644 index 00000000..340b46be --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/App/Route.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; + +namespace App +{ + public static class Route + { + public static string Handle(dynamic handler, object req) + { + return handler(req).Render(); + } + + public static string HandleLocal(dynamic handler, object req) + { + var resp = handler(req); + string text = resp.Render(); + resp.Close(); + return text; + } + + public static string HandleOptional(dynamic handler, object req) + { + return handler(req)?.Render(); + } + + public static string HandleBuilder(dynamic handler, object req) + { + return handler(req).Step().Done().Render(); + } + + public static async Task HandleAsync(dynamic handler, object req) + { + var resp = await handler(req); + return resp.Render(); + } + + public static void HandleMany(dynamic handler, object req) + { + foreach (var r in handler(req)) + { + r.Render(); + } + } + } +} diff --git a/tests/cases/csharp/asserted-links-derive/App/Views.cs b/tests/cases/csharp/asserted-links-derive/App/Views.cs new file mode 100644 index 00000000..ffa59f9f --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/App/Views.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace App +{ + public class Response + { + public string Render() => "ok"; + public void Close() { } + } + + public class Builder + { + public Builder Step() => this; + public Response Done() => new Response(); + } + + public static class Views + { + public static Response MakeResponse(object req) => new Response(); + public static Response? MaybeResponse(object req) => new Response(); + public static Builder MakeBuilder(object req) => new Builder(); + public static async Task FetchResponse(object req) { await Task.Yield(); return new Response(); } + public static IEnumerable Many(object req) => new List { new Response() }; + public static void Untyped(object req) { } + } +} diff --git a/tests/cases/csharp/asserted-links-derive/case.json b/tests/cases/csharp/asserted-links-derive/case.json new file mode 100644 index 00000000..9fa33167 --- /dev/null +++ b/tests/cases/csharp/asserted-links-derive/case.json @@ -0,0 +1,26 @@ +{"lang": "csharp", + "checks": [ + {"why": "BASE: a call through a dynamic value is unknown, and so is the call on its result", + "run": ["path", "Route.Handle", "Response.Render"], "expect_error": true, + "want": ["no chain of resolved calls connects Route.Handle and Response.Render"]}, + {"why": "declared return type: the call chained on the result resolves", + "run": ["link", "App/Route.cs:9", "Views.MakeResponse"], + "want": ["linked App/Route.cs:9:20 `handler` → Views.MakeResponse [asserted]; derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "path walks the derived hop", + "run": ["path", "Route.Handle", "Response.Render"], "want": ["[asserted · call @ App/Route.cs:9] Response.Render"]}, + {"why": "var assigned from the linked call", + "run": ["link", "App/Route.cs:14", "Views.MakeResponse"], "want": ["derived 2 edge(s) on its result (Response, declared)"]}, + {"why": "T? and ?. are transparent", + "run": ["link", "App/Route.cs:22", "Views.MaybeResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "a builder returning itself", + "run": ["link", "App/Route.cs:27", "Views.MakeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, declared)"]}, + {"why": "await on Task", + "run": ["link", "App/Route.cs:32", "Views.FetchResponse"], "want": ["derived 1 edge(s) on its result (Response, declared)"]}, + {"why": "foreach over IEnumerable: the element", + "run": ["link", "App/Route.cs:38", "Views.Many"], "want": ["derived 1 edge(s) on its result (IEnumerable, declared)"]}, + {"why": "a void target derives nothing, and says so", + "run": ["link", "App/Route.cs:9", "Views.Untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "editing the linked line drops the links and what they derived", + "edit": ["App/Route.cs", " return handler(req).Render();\n }\n\n public static string HandleLocal", " return handler(req, 1).Render();\n }\n\n public static string HandleLocal"], + "run": ["path", "Route.Handle", "Response.Render", "--fresh"], "expect_error": true, + "want": ["no chain of resolved calls connects Route.Handle and Response.Render"], "avoid": ["[asserted"]}]} diff --git a/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs b/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs new file mode 100644 index 00000000..a34a43ba --- /dev/null +++ b/tests/cases/csharp/asserted-links/App.Tests/DispatchTests.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using App; +using Xunit; + +namespace App.Tests +{ + public class DispatchTests + { + [Fact] + public void Saves() + { + Assert.NotNull(Dispatch.Run("OnSave", new Dictionary())); + } + } +} diff --git a/tests/cases/csharp/asserted-links/App/Dispatch.cs b/tests/cases/csharp/asserted-links/App/Dispatch.cs new file mode 100644 index 00000000..e7ef7591 --- /dev/null +++ b/tests/cases/csharp/asserted-links/App/Dispatch.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace App +{ + public static class Dispatch + { + public static object Run(string evt, Dictionary doc) + { + MethodInfo m = typeof(Handlers).GetMethod(evt); + return m.Invoke(null, new object[] { doc }); + } + + public static object RunTable(Dictionary, object>> table, string key, Dictionary doc) + { + return table[key](doc); + } + + public static object Purge(Dictionary doc) + { + MethodInfo m = typeof(Handlers).GetMethod("OnPurge"); + return m.Invoke(null, new object[] { doc }); + } + + public static object SaveAndAudit(Dictionary doc) + { + return Handlers.Audit(doc); + } + } +} diff --git a/tests/cases/csharp/asserted-links/App/Handlers.cs b/tests/cases/csharp/asserted-links/App/Handlers.cs new file mode 100644 index 00000000..53f1cb9b --- /dev/null +++ b/tests/cases/csharp/asserted-links/App/Handlers.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace App +{ + public static class Handlers + { + public static Dictionary OnSave(Dictionary doc) + { + return Audit(doc); + } + + public static Dictionary OnLoad(Dictionary doc) + { + return doc; + } + + public static Dictionary OnPurge(Dictionary doc) + { + return doc; + } + + public static Dictionary Audit(Dictionary doc) + { + doc["audited"] = true; + return doc; + } + } +} diff --git a/tests/cases/csharp/asserted-links/case.json b/tests/cases/csharp/asserted-links/case.json new file mode 100644 index 00000000..3ecbb83b --- /dev/null +++ b/tests/cases/csharp/asserted-links/case.json @@ -0,0 +1,169 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "BASE: a reflective MethodInfo.Invoke ends in the library, so no chain reaches the handler it runs", + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects DispatchTests.Saves and Handlers.Audit" + ] + }, + { + "why": "the reflective call is listed as a work item: resolved into the library, what it runs unknown", + "run": [ + "impact", + "Dispatch.Run", + "--unknown" + ], + "want": [ + "App/Dispatch.cs:12:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke]" + ] + }, + { + "why": "link records the target of the reflective call", + "run": [ + "link", + "App/Dispatch.cs:12", + "Handlers.OnSave" + ], + "want": [ + "linked App/Dispatch.cs:12:22 `Invoke` → Handlers.OnSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit" + ], + "want": [ + "[asserted · call @ App/Dispatch.cs:12] Handlers.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", + "Handlers.OnSave", + "--tests" + ], + "want": [ + "[asserted] Dispatch.Run", + "DispatchTests::Saves", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "a delegate held in a table, called by its holder's name, can be linked too", + "run": [ + "link", + "App/Dispatch.cs:17", + "Handlers.OnLoad" + ], + "want": [ + "linked App/Dispatch.cs:17:20 `table` → Handlers.OnLoad [asserted]" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "App/Dispatch.cs:12", + "Handlers.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", + "App/Dispatch.cs:3", + "Handlers.OnLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at App/Dispatch.cs:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "App/Dispatch.cs:28", + "Handlers.OnLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `Audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling reflective site stays unknown", + "run": [ + "impact", + "Dispatch.Purge", + "--unknown" + ], + "want": [ + "App/Dispatch.cs:23:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke]" + ], + "avoid": [ + "App/Dispatch.cs:23:22 return m.Invoke(null, new object[] { doc }); [runs a value through MethodInfo.Invoke] → linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "App/Dispatch.cs", + "using System.Reflection;\n", + "using System.Reflection;\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit", + "--fresh" + ], + "want": [ + "[asserted · call @ App/Dispatch.cs:14] Handlers.OnSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "App/Dispatch.cs", + " return m.Invoke(null, new object[] { doc });\n }\n\n public static object RunTable", + " return m.Invoke(null, new object[] { new Dictionary(doc) });\n }\n\n public static object RunTable" + ], + "run": [ + "path", + "DispatchTests.Saves", + "Handlers.Audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "not applied", + "1 stale" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/java/asserted-links-derive/case.json b/tests/cases/java/asserted-links-derive/case.json new file mode 100644 index 00000000..d6633fa7 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/case.json @@ -0,0 +1,11 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a reflective invoke linked to its target; the cast already types what follows, so nothing is derived twice", + "run": ["link", "src/pkg/Route.java:13", "Views.makeResponse"], + "want": ["linked src/pkg/Route.java:13:47 `invoke` → Views.makeResponse [asserted]; returns Response (declared); no call on its result here"]}, + {"why": "CONTROL: the engine's own edge on the cast result is kept, no asserted duplicate", + "run": ["impact", "Response.render"], + "want": ["[resolved] Route.handleLocal"], "avoid": ["[asserted] Route.handleLocal"]}, + {"why": "a chained call after a cast resolved by the engine: no duplicate derived edge", + "run": ["link", "src/pkg/Route.java:9", "Views.makeResponse"], + "want": ["no call on its result here"]}]} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Builder.java b/tests/cases/java/asserted-links-derive/src/pkg/Builder.java new file mode 100644 index 00000000..b924342c --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Builder.java @@ -0,0 +1,6 @@ +package pkg; + +public class Builder { + public Builder step() { return this; } + public Response done() { return new Response(); } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Response.java b/tests/cases/java/asserted-links-derive/src/pkg/Response.java new file mode 100644 index 00000000..b6a5ff21 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Response.java @@ -0,0 +1,6 @@ +package pkg; + +public class Response { + public String render() { return "ok"; } + public void close() { } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Route.java b/tests/cases/java/asserted-links-derive/src/pkg/Route.java new file mode 100644 index 00000000..bfed1f7f --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Route.java @@ -0,0 +1,29 @@ +package pkg; + +import java.lang.reflect.Method; + +public class Route { + static Method find(String name) throws Exception { return Views.class.getMethod(name, Object.class); } + + public static String handle(String name, Object req) throws Exception { + return ((Response) find(name).invoke(null, req)).render(); + } + + public static String handleLocal(String name, Object req) throws Exception { + Response resp = (Response) find(name).invoke(null, req); + String text = resp.render(); + resp.close(); + return text; + } + + public static String handleVar(String name, Object req) throws Exception { + var resp = find(name).invoke(null, req); + return ((Response) resp).render(); + } + + public static Response handleCtor(java.util.function.Supplier make) { + Response r = make.get(); + r.close(); + return r; + } +} diff --git a/tests/cases/java/asserted-links-derive/src/pkg/Views.java b/tests/cases/java/asserted-links-derive/src/pkg/Views.java new file mode 100644 index 00000000..c0202007 --- /dev/null +++ b/tests/cases/java/asserted-links-derive/src/pkg/Views.java @@ -0,0 +1,12 @@ +package pkg; + +import java.util.List; +import java.util.Optional; + +public class Views { + public static Response makeResponse(Object req) { return new Response(); } + public static Optional maybeResponse(Object req) { return Optional.of(new Response()); } + public static Builder makeBuilder(Object req) { return new Builder(); } + public static void untyped(Object req) { } + public static List many(Object req) { return List.of(new Response()); } +} diff --git a/tests/cases/java/asserted-links/case.json b/tests/cases/java/asserted-links/case.json new file mode 100644 index 00000000..07f9684c --- /dev/null +++ b/tests/cases/java/asserted-links/case.json @@ -0,0 +1,161 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "BASE: a reflective Method.invoke ends in the library, so no chain reaches the handler it runs", + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects DispatchTest.save and Handlers.audit" + ] + }, + { + "why": "the reflective call is listed as a work item: resolved into the library, what it runs unknown", + "run": [ + "impact", + "Dispatch.dispatch", + "--unknown" + ], + "want": [ + "src/pkg/Dispatch.java:10:18 return m.invoke(null, doc); [runs a value through java.lang.reflect.Method.invoke]" + ] + }, + { + "why": "link records the target of the reflective call", + "run": [ + "link", + "src/pkg/Dispatch.java:10", + "Handlers.onSave" + ], + "want": [ + "linked src/pkg/Dispatch.java:10:18 `invoke` → Handlers.onSave [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit" + ], + "want": [ + "[asserted · call @ src/pkg/Dispatch.java:10] Handlers.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", + "Handlers.onSave", + "--tests" + ], + "want": [ + "[asserted] Dispatch.dispatch", + "DispatchTest::save", + "reaches those through resolved calls and 1 asserted link(s)" + ], + "avoid": [ + "DispatchTest::table" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/pkg/Dispatch.java:10", + "Handlers.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/pkg/Dispatch.java:3", + "Handlers.onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/pkg/Dispatch.java:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/pkg/Dispatch.java:23", + "Handlers.onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling reflective site stays unknown", + "run": [ + "impact", + "Dispatch.purge", + "--unknown" + ], + "want": [ + "src/pkg/Dispatch.java:19:18 return m.invoke(null, doc);" + ], + "avoid": [ + "src/pkg/Dispatch.java:19:18 return m.invoke(null, doc); [runs a value through java.lang.reflect.Method.invoke] → linked" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "src/pkg/Dispatch.java", + "import java.util.Map;\n", + "import java.util.Map;\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "DispatchTest.save", + "Handlers.audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/pkg/Dispatch.java:12] Handlers.onSave" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/pkg/Dispatch.java", + " return m.invoke(null, doc);\n }\n\n public static Object runTable", + " return m.invoke(null, new java.util.HashMap<>(doc));\n }\n\n public static Object runTable" + ], + "run": [ + "path", + "DispatchTest.save", + "Handlers.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/java/asserted-links/src/pkg/Dispatch.java b/tests/cases/java/asserted-links/src/pkg/Dispatch.java new file mode 100644 index 00000000..c7ceb0da --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/Dispatch.java @@ -0,0 +1,25 @@ +package pkg; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.function.Function; + +public class Dispatch { + public static Object dispatch(String event, Map doc) throws Exception { + Method m = Handlers.class.getMethod(event, Map.class); + return m.invoke(null, doc); + } + + public static Object runTable(Map, Object>> table, String key, Map doc) { + return table.get(key).apply(doc); + } + + public static Object purge(Map doc) throws Exception { + Method m = Handlers.class.getMethod("onPurge", Map.class); + return m.invoke(null, doc); + } + + public static Object saveAndAudit(Map doc) { + return Handlers.audit(doc); + } +} diff --git a/tests/cases/java/asserted-links/src/pkg/DispatchTest.java b/tests/cases/java/asserted-links/src/pkg/DispatchTest.java new file mode 100644 index 00000000..b833d440 --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/DispatchTest.java @@ -0,0 +1,20 @@ +package pkg; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.junit.Test; + +public class DispatchTest { + @Test + public void save() throws Exception { + assert Dispatch.dispatch("onSave", new HashMap<>()) != null; + } + + @Test + public void table() { + Map, Object>> t = new HashMap<>(); + t.put("load", Handlers::onLoad); + assert Dispatch.runTable(t, "load", new HashMap<>()) != null; + } +} diff --git a/tests/cases/java/asserted-links/src/pkg/Handlers.java b/tests/cases/java/asserted-links/src/pkg/Handlers.java new file mode 100644 index 00000000..e3f2d64c --- /dev/null +++ b/tests/cases/java/asserted-links/src/pkg/Handlers.java @@ -0,0 +1,22 @@ +package pkg; + +import java.util.Map; + +public class Handlers { + public static Map onSave(Map doc) { + return audit(doc); + } + + public static Map onLoad(Map doc) { + return doc; + } + + public static Map onPurge(Map doc) { + return doc; + } + + public static Map audit(Map doc) { + doc.put("audited", true); + return doc; + } +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/case.json b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json new file mode 100644 index 00000000..3478cd58 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/case.json @@ -0,0 +1,110 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a plugin's override of a prototype method runs only where the plugin is loaded: the test that loads it is a test of it, the one that never imports the plugin file is not, though the receiver's dispatch set holds the override for every caller", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "upper.test.js (", + "not counted: 1 test file(s)", + "plain.test.js" + ], + "avoid": [ + "plain.test.js (", + "later.test.js", + "audited.test.js" + ] + }, + { + "why": "a callback one test file hands to a shared function is not run by another test file that calls the same function: later.test.js never loads lib/audit.js", + "run": [ + "impact", + "audit", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "not counted: 1 test file(s)" + ], + "avoid": [ + "later.test.js (" + ] + }, + { + "why": "CONTROL: a test that loads a module by a computed name (`require(path.join(...))`) may load the plugin, so it stays", + "run": [ + "impact", + "plugins/upper.js:4", + "--tests-only" + ], + "want": [ + "by-name.test.js (" + ] + }, + { + "why": "CONTROL: `require('..')`, `require('../')` and `require('../index.js')` load the package root's main file, so a test of the core class through any of them loads lib/core.js and stays selected", + "run": [ + "impact", + "Fmt.render", + "--tests-only" + ], + "want": [ + "plain.test.js (", + "upper.test.js (", + "by-name.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "CONTROL: a test that imports the package by its own name (`require('fmtlib')`) loads its main file too", + "run": [ + "impact", + "later", + "--tests-only" + ], + "want": [ + "audited.test.js (", + "later.test.js (" + ], + "avoid": [ + "not counted" + ] + }, + { + "why": "path from a test file that never loads the target's file says so instead of printing a route through another test file's callback", + "run": [ + "path", + "test/later.test.js", + "audit" + ], + "want": [ + "never loads src/lib/audit.js" + ], + "avoid": [ + "call(s):" + ], + "expect_error": true + }, + { + "why": "CONTROL: path from the test that loads the plugin still prints its route", + "run": [ + "path", + "test/upper.test.js", + "plugins/upper.js:4" + ], + "want": [ + "call(s)" + ], + "avoid": [ + "never loads" + ] + } + ] +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js new file mode 100644 index 00000000..7b150bf5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/core.js') diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js new file mode 100644 index 00000000..8d1cd9b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/audit.js @@ -0,0 +1,5 @@ +function audit(value) { + return 'audited ' + value +} + +module.exports = { audit } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js new file mode 100644 index 00000000..44aaed5a --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/lib/core.js @@ -0,0 +1,13 @@ +class Fmt { + constructor(v) { this.v = v } + render() { return '[' + this.parse(this.v) + ']' } + parse(v) { return String(v) } +} + +function extend(plugin) { plugin(Fmt) } + +function later(value, done) { + setTimeout(() => done(value), 0) +} + +module.exports = { Fmt, extend, later } diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json new file mode 100644 index 00000000..9bdf58c6 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/package.json @@ -0,0 +1 @@ +{"name": "fmtlib", "version": "1.0.0", "main": "index.js"} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js new file mode 100644 index 00000000..9242ca15 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/plugins/upper.js @@ -0,0 +1,7 @@ +module.exports = function upper(C) { + const proto = C.prototype + const old = proto.parse + proto.parse = function (v) { + return old.call(this, v).toUpperCase() + } +} diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js new file mode 100644 index 00000000..96e878b5 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/audited.test.js @@ -0,0 +1,6 @@ +const { later } = require('fmtlib') +const { audit } = require('../lib/audit') + +test('audits what it is handed', (cb) => { + later(2, function check(v) { expect(audit(v)).toBe('audited 2'); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js new file mode 100644 index 00000000..52ad807e --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/by-name.test.js @@ -0,0 +1,7 @@ +const path = require('path') +const { Fmt, extend } = require('..') + +test('loads a plugin by its name', () => { + extend(require(path.join(__dirname, '..', 'plugins', process.env.PLUGIN || 'upper'))) + expect(new Fmt('b').render()).toBe('[B]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js new file mode 100644 index 00000000..dd8791d3 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/later.test.js @@ -0,0 +1,5 @@ +const { later } = require('../') + +test('hands the value on', (cb) => { + later(1, function seen(v) { expect(v).toBe(1); cb() }) +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js new file mode 100644 index 00000000..4b566b09 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/plain.test.js @@ -0,0 +1,5 @@ +const { Fmt } = require('..') + +test('renders a value', () => { + expect(new Fmt('a').render()).toBe('[a]') +}) diff --git a/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js new file mode 100644 index 00000000..32555671 --- /dev/null +++ b/tests/cases/javascript/a-test-that-never-loads-the-change/src/test/upper.test.js @@ -0,0 +1,8 @@ +const { Fmt, extend } = require('../index.js') +const upper = require('../plugins/upper') + +extend(upper) + +test('renders upper case', () => { + expect(new Fmt('a').render()).toBe('[A]') +}) diff --git a/tests/cases/javascript/asserted-links-derive/case.json b/tests/cases/javascript/asserted-links-derive/case.json new file mode 100644 index 00000000..f326b350 --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/case.json @@ -0,0 +1,24 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "BASE: the handler read by a key built at run time 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": "JSDoc @returns types the result: the chained call resolves", + "run": ["link", "src/route.js:5", "makeResponse"], + "want": ["linked src/route.js:5:10 `handler` → makeResponse [asserted]; derived 1 edge(s) on its result (Response, JSDoc)"]}, + {"why": "path walks the derived hop", + "run": ["path", "handle", "Response.render"], "want": ["[asserted · call @ src/route.js:5] Response.render"]}, + {"why": "a const assigned from the linked call", + "run": ["link", "src/route.js:10", "makeResponse"], "want": ["derived 2 edge(s) on its result (Response, JSDoc)"]}, + {"why": "?. is transparent", + "run": ["link", "src/route.js:18", "makeResponse"], "want": ["derived 1 edge(s) on its result (Response, JSDoc)"]}, + {"why": "no JSDoc: the type every return constructs, then a builder returning this", + "run": ["link", "src/route.js:23", "makeBuilder"], "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "CONTROL: a JSDoc above ANOTHER function is not this one's; no return type, nothing derived", + "run": ["link", "src/route.js:28", "untyped"], "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on an async function's returned value", + "run": ["link", "src/route.js:34", "fetchResponse"], "want": ["derived 1 edge(s) on its result (Response, inferred from its returns)"]}, + {"why": "editing the linked line drops the link and what it derived", + "edit": ["src/route.js", " 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/javascript/asserted-links-derive/src/route.js b/tests/cases/javascript/asserted-links-derive/src/route.js new file mode 100644 index 00000000..950320d7 --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/src/route.js @@ -0,0 +1,36 @@ +import * as views from './views'; + +export function handle(name, req) { + const handler = views['make' + name]; + return handler(req).render(); +} + +export function handleLocal(name, req) { + const handler = views['make' + name]; + const resp = handler(req); + const text = resp.render(); + resp.close(); + return text; +} + +export function handleOptional(name, req) { + const handler = views['make' + name]; + return handler(req)?.render(); +} + +export function handleBuilder(name, req) { + const handler = views['make' + name]; + return handler(req).step().done().render(); +} + +export function handleUntyped(name, req) { + const handler = views['un' + name]; + const out = handler(req); + return out.render(); +} + +export async function handleAsync(name, req) { + const handler = views['fetch' + name]; + const resp = await handler(req); + return resp.render(); +} diff --git a/tests/cases/javascript/asserted-links-derive/src/views.js b/tests/cases/javascript/asserted-links-derive/src/views.js new file mode 100644 index 00000000..aad7e73d --- /dev/null +++ b/tests/cases/javascript/asserted-links-derive/src/views.js @@ -0,0 +1,20 @@ +export class Response { + render() { return 'ok'; } + close() {} +} + +export class Builder { + step() { return this; } + done() { return new Response(); } +} + +/** @returns {Response} */ +export function makeResponse(req) { return build(req); } + +export function makeBuilder(req) { return new Builder(); } + +export function untyped(req) { return req; } + +export async function fetchResponse(req) { return new Response(); } + +function build(req) { return new Response(); } diff --git a/tests/cases/javascript/asserted-links/case.json b/tests/cases/javascript/asserted-links/case.json new file mode 100644 index 00000000..5525987c --- /dev/null +++ b/tests/cases/javascript/asserted-links/case.json @@ -0,0 +1,170 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "BASE: a call through a value read by a key built at run time is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "purge", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects purge and audit" + ] + }, + { + "why": "the unresolved site is listed as a work item", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "src/dispatch.js:15:10 return fn(doc);" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "src/dispatch.js:15", + "onPurge" + ], + "want": [ + "linked src/dispatch.js:15:10 `fn` → onPurge [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted]", + "run": [ + "path", + "purge", + "audit" + ], + "want": [ + "[asserted · call @ src/dispatch.js:15] onPurge", + "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", + "onPurge", + "--tests" + ], + "want": [ + "[asserted] purge", + "dispatch.test.js", + "reaches those through resolved calls and 1 asserted link(s)" + ] + }, + { + "why": "a link duplicating an edge the engine resolved adds nothing", + "run": [ + "link", + "src/dispatch.js:10", + "onLoad" + ], + "want": [ + "the graph already had this edge; recorded, nothing added" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "src/dispatch.js:15", + "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.js:3", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at src/dispatch.js:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "src/dispatch.js:19", + "onLoad" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "reload", + "--unknown" + ], + "want": [ + "src/dispatch.js:24: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.js", + "import * as handlers from './handlers';\n", + "import * as handlers from './handlers';\n// dispatch by event name\n\n" + ], + "run": [ + "path", + "purge", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ src/dispatch.js:17] onPurge" + ] + }, + { + "why": "the linked line itself edited: after the rebuild the link is dropped and the answer says so", + "edit": [ + "src/dispatch.js", + " return fn(doc);\n}\n\nexport function saveAndAudit", + " return fn({ ...doc });\n}\n\nexport function saveAndAudit" + ], + "run": [ + "path", + "purge", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects", + "not applied", + "1 stale" + ], + "avoid": [ + "[asserted" + ] + } + ] +} diff --git a/tests/cases/javascript/asserted-links/src/dispatch.js b/tests/cases/javascript/asserted-links/src/dispatch.js new file mode 100644 index 00000000..024a1da4 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/dispatch.js @@ -0,0 +1,25 @@ +import * as handlers from './handlers'; +// a document is a plain object + +export function dispatch(event, doc) { + const fn = (handlers)[event]; + return fn(doc); +} + +export function runTable(table, key, doc) { + return table[key](doc); +} + +export function purge(doc) { + const fn = (handlers)['on' + 'Purge']; + return fn(doc); +} + +export function saveAndAudit(doc) { + return handlers.audit(doc); +} + +export function reload(doc) { + const fn = (handlers)['on' + 'Load']; + return fn(doc); +} diff --git a/tests/cases/javascript/asserted-links/src/dispatch.test.js b/tests/cases/javascript/asserted-links/src/dispatch.test.js new file mode 100644 index 00000000..4dd450e0 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/dispatch.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { purge, runTable } from './dispatch'; +import { onLoad } from './handlers'; + +describe('dispatch', () => { + it('purges', () => { + expect(purge({}).audited).toBe(true); + }); + it('runs a table', () => { + expect(runTable({ load: onLoad }, 'load', {})).toEqual({}); + }); +}); diff --git a/tests/cases/javascript/asserted-links/src/handlers.js b/tests/cases/javascript/asserted-links/src/handlers.js new file mode 100644 index 00000000..fbc084f0 --- /dev/null +++ b/tests/cases/javascript/asserted-links/src/handlers.js @@ -0,0 +1,18 @@ +// a document is a plain object + +export function onSave(doc) { + return audit(doc); +} + +export function onLoad(doc) { + return doc; +} + +export function onPurge(doc) { + return audit(doc); +} + +export function audit(doc) { + doc.audited = true; + return doc; +} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json new file mode 100644 index 00000000..944a65ef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/case.json @@ -0,0 +1,89 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a change a describe() block runs while mocha loads the specs fails the whole mocha run, so every spec file it collects is selected, not only the one that calls it", + "run": [ + "test-impact", + "{repo}", + "lib/build.js" + ], + "want": [ + "test/a.spec.js", + "test/b.spec.js", + "test/c.spec.js", + "test/d.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: a change only a test body runs (inside it()) fails that spec alone", + "run": [ + "test-impact", + "{repo}", + "lib/parse.js" + ], + "want": [ + "test/c.spec.js" + ], + "avoid": [ + "test/a.spec.js", + "test/b.spec.js", + "fails the whole run" + ] + }, + { + "why": "CONTROL: jest loads each file in its own module registry, so the same describe()-time call fails that file alone", + "run": [ + "test-impact", + "{repo}", + "jestpkg/lib/build.js" + ], + "want": [ + "jestpkg/test/a.test.js" + ], + "avoid": [ + "jestpkg/test/b.test.js", + "fails the whole run" + ] + }, + { + "why": "a file under the tests that no runner collects is not a test run: one a collected spec requires is replaced by that spec, and a script nothing requires is not selected", + "run": [ + "test-impact", + "{repo}", + "lib/fmt.js" + ], + "want": [ + "test/b.spec.js", + "run only through the test files that require them", + "not selected: 1 file(s)", + "test/bench.js" + ], + "avoid": [ + "test/parts/extra.js (", + "test/bench.js (" + ] + }, + { + "why": "a function that is also a constructor (`function Thing(n)`) whose header line changes is a changed TYPE to `changed`; test-impact asks for it as the function it is and finds the spec that constructs it", + "run": [ + "test-impact", + "{repo}", + "--old", + "{repo}/edits/ctor-old.txt", + "--new", + "{repo}/edits/ctor-new.txt", + "--file", + "lib/ctor.js" + ], + "want": [ + "test/d.spec.js" + ], + "avoid": [ + "could not be resolved to a graph symbol" + ] + } + ] +} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt new file mode 100644 index 00000000..878c2762 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-new.txt @@ -0,0 +1,9 @@ +function Thing(n, unit) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/edits/ctor-old.txt @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js new file mode 100644 index 00000000..3e4e5e0f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/lib/build.js @@ -0,0 +1,5 @@ +function jbuild(o) { + return Object.keys(o) +} + +module.exports = { jbuild } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json new file mode 100644 index 00000000..e6e5ed84 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/package.json @@ -0,0 +1 @@ +{"name": "jestpkg", "version": "1.0.0", "scripts": {"test": "jest"}, "devDependencies": {"jest": "^29.0.0"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js new file mode 100644 index 00000000..d18814b4 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/a.test.js @@ -0,0 +1,8 @@ +const { jbuild } = require('../lib/build') + +describe('jbuild', () => { + const keys = jbuild({ y: 1 }) + test('lists the keys', () => { + expect(keys).toEqual(['y']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js new file mode 100644 index 00000000..17bdeac5 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/jestpkg/test/b.test.js @@ -0,0 +1,3 @@ +test('adds', () => { + expect(1 + 1).toBe(2) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js new file mode 100644 index 00000000..f5598d58 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/build.js @@ -0,0 +1,5 @@ +function build(o) { + return Object.keys(o) +} + +module.exports = { build } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js new file mode 100644 index 00000000..c4bd522f --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/ctor.js @@ -0,0 +1,9 @@ +function Thing(n) { + this.n = n +} + +Thing.prototype.twice = function twice() { + return this.n * 2 +} + +module.exports = Thing diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js new file mode 100644 index 00000000..d4d9d97e --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/fmt.js @@ -0,0 +1,5 @@ +function fmt(s) { + return '<' + s + '>' +} + +module.exports = { fmt } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js new file mode 100644 index 00000000..8499cfe3 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/lib/parse.js @@ -0,0 +1,5 @@ +function parse(s) { + return s.trim() +} + +module.exports = { parse } diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json new file mode 100644 index 00000000..0826e6db --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/package.json @@ -0,0 +1,2 @@ +{"name": "loadtime", "version": "1.0.0", "scripts": {"test": "mocha"}, "devDependencies": {"mocha": "^10.0.0"}, + "mocha": {"spec": "test/*.spec.js"}} diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js new file mode 100644 index 00000000..12865cef --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/a.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { build } = require('../lib/build') + +describe('build', function () { + const keys = build({ x: 1 }) + it('lists the keys', function () { + assert.deepStrictEqual(keys, ['x']) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js new file mode 100644 index 00000000..c16be893 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/b.spec.js @@ -0,0 +1,9 @@ +const assert = require('assert') +const { fmt } = require('../lib/fmt') + +describe('fmt', function () { + it('wraps', function () { + assert.strictEqual(fmt('x'), '') + }) + require('./parts/extra') +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js new file mode 100644 index 00000000..570352a9 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/bench.js @@ -0,0 +1,5 @@ +const { fmt } = require('../lib/fmt') + +const t = Date.now() +for (let i = 0; i < 1e5; i++) fmt('b') +console.log(Date.now() - t) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js new file mode 100644 index 00000000..e96d3693 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/c.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const { parse } = require('../lib/parse') + +describe('parse', function () { + it('trims', function () { + assert.strictEqual(parse(' x '), 'x') + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js new file mode 100644 index 00000000..e10d1828 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/d.spec.js @@ -0,0 +1,8 @@ +const assert = require('assert') +const Thing = require('../lib/ctor') + +describe('Thing', function () { + it('builds one', function () { + assert.strictEqual(new Thing(2).n, 2) + }) +}) diff --git a/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js new file mode 100644 index 00000000..456484a0 --- /dev/null +++ b/tests/cases/javascript/mocha-load-time-fails-the-whole-run/test/parts/extra.js @@ -0,0 +1,6 @@ +const assert = require('assert') +const { fmt } = require('../../lib/fmt') + +it('wraps the empty string', function () { + assert.strictEqual(fmt(''), '<>') +}) diff --git a/tests/cases/python/asserted-links-derive/app/__init__.py b/tests/cases/python/asserted-links-derive/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/asserted-links-derive/app/route.py b/tests/cases/python/asserted-links-derive/app/route.py new file mode 100644 index 00000000..ad15c371 --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/route.py @@ -0,0 +1,47 @@ +from app import views + + +def handle(name, req): + handler = getattr(views, name) + return handler(req).render() + + +def handle_local(name, req): + handler = getattr(views, name) + resp = handler(req) + text = resp.render() + resp.close() + return text + + +def handle_optional(name, req): + handler = getattr(views, name) + return handler(req).render() + + +def handle_builder(name, req): + handler = getattr(views, name) + return handler(req).step().done().render() + + +def handle_untyped(name, req): + handler = getattr(views, name) + out = handler(req) + return out.render() + + +async def handle_async(name, req): + handler = getattr(views, name) + resp = await handler(req) + return resp.render() + + +def handle_reassigned(name, req, other): + handler = getattr(views, name) + resp = handler(req) + resp = other + return resp.render() + + +def two_on_a_line(a, b): + return a.run() + b.run() diff --git a/tests/cases/python/asserted-links-derive/app/runners.py b/tests/cases/python/asserted-links-derive/app/runners.py new file mode 100644 index 00000000..e315fddd --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/runners.py @@ -0,0 +1,8 @@ +class Fast: + def run(self): + return 1 + + +class Slow: + def run(self): + return 2 diff --git a/tests/cases/python/asserted-links-derive/app/views.py b/tests/cases/python/asserted-links-derive/app/views.py new file mode 100644 index 00000000..a3b7efb9 --- /dev/null +++ b/tests/cases/python/asserted-links-derive/app/views.py @@ -0,0 +1,37 @@ +from typing import Optional + + +class Response: + def render(self) -> str: + return "ok" + + def close(self) -> None: + return None + + +class Builder: + def step(self) -> "Builder": + return self + + def done(self) -> Response: + return Response() + + +def make_response(req) -> Response: + return Response() + + +def maybe_response(req) -> Optional[Response]: + return Response() + + +def make_builder(req): + return Builder() + + +def untyped(req): + return req + + +async def fetch(req) -> Response: + return Response() diff --git a/tests/cases/python/asserted-links-derive/case.json b/tests/cases/python/asserted-links-derive/case.json new file mode 100644 index 00000000..4d65a0da --- /dev/null +++ b/tests/cases/python/asserted-links-derive/case.json @@ -0,0 +1,53 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "BASE: the handler getattr returns 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": "a link names a declaration with a return annotation: the call chained on the result resolves too", + "run": ["link", "app/route.py:6", "make_response"], + "want": ["linked app/route.py:6:12 `handler` → make_response [asserted]; derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "path walks the link, then the derived hop, both asserted", + "run": ["path", "handle", "Response.render"], + "want": ["[asserted · call @ app/route.py:6] Response.render"]}, + {"why": "a local assigned from the linked call: each call on it resolves", + "run": ["link", "app/route.py:11", "make_response"], + "want": ["derived 2 edge(s) on its result (Response, annotation)"]}, + {"why": "Optional[T] is T", + "run": ["link", "app/route.py:19", "maybe_response"], + "want": ["derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "no annotation: the type every return names, then a builder chain returning self and a typed method", + "run": ["link", "app/route.py:24", "make_builder"], + "want": ["derived 3 edge(s) on its result (Builder, inferred from its returns)"]}, + {"why": "a target with no return type derives nothing, and says so", + "run": ["link", "app/route.py:29", "untyped"], + "want": ["return type unknown, calls on its result stay unknown"]}, + {"why": "await on an async def gives its annotation", + "run": ["link", "app/route.py:35", "fetch"], + "want": ["derived 1 edge(s) on its result (Response, annotation)"]}, + {"why": "CONTROL: a local assigned twice is not followed", + "run": ["link", "app/route.py:41", "make_response"], + "want": ["`resp` is assigned more than once in handle_reassigned: calls on it stay unknown"]}, + {"why": "two calls of one name on a line: no column, no choice", + "run": ["link", "app/route.py:47", "Fast.run"], "expect_error": true, + "want": ["2 calls on that line could be it (`run` at column 14, `run` at column 24): give the column"]}, + {"why": "with the column each call takes its own target", + "run": ["link", "app/route.py:47:14", "Fast.run"], + "want": ["linked app/route.py:47:14 `run` → Fast.run [asserted]"]}, + {"why": "…and the other", + "run": ["link", "app/route.py:47:24", "Slow.run"], + "want": ["linked app/route.py:47:24 `run` → Slow.run [asserted]"]}, + {"why": "a column with no call is refused, naming the columns there", + "run": ["link", "app/route.py:47:20", "Slow.run"], "expect_error": true, + "want": ["no call's name starts at column 20 of app/route.py:47 (calls there: column 14, 24)"]}, + {"why": "each column reached its own target", + "run": ["path", "two_on_a_line", "Slow.run"], + "want": ["[asserted · call @ app/route.py:47] Slow.run"]}, + {"why": "the line re-spaced: the columns follow the text", + "edit": ["app/route.py", " return a.run() + b.run()", " return a.run() + b.run()"], + "run": ["path", "two_on_a_line", "Slow.run", "--fresh"], + "want": ["[asserted · call @ app/route.py:47] Slow.run"]}, + {"why": "editing the linked line drops the link AND what was derived from it", + "edit": ["app/route.py", " return handler(req).render()\n\n\ndef handle_local", " return handler(req, 1).render()\n\n\ndef handle_local"], + "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/python/asserted-links/app/__init__.py b/tests/cases/python/asserted-links/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/asserted-links/app/dispatch.py b/tests/cases/python/asserted-links/app/dispatch.py new file mode 100644 index 00000000..3b6b5c50 --- /dev/null +++ b/tests/cases/python/asserted-links/app/dispatch.py @@ -0,0 +1,27 @@ +from app import handlers + + +def dispatch(event, doc): + fn = getattr(handlers, event) + return fn(doc) + + +def run_table(table, key, doc): + return table[key](doc) + + +def purge(doc): + fn = getattr(handlers, "on_" + "purge") + return fn(doc) + + +def save_and_audit(doc): + return handlers.audit(doc) + + +def copy_doc(doc): + return doc.copy() + + +def refresh(doc, store): + return store.on_load(doc) diff --git a/tests/cases/python/asserted-links/app/handlers.py b/tests/cases/python/asserted-links/app/handlers.py new file mode 100644 index 00000000..e7410a6c --- /dev/null +++ b/tests/cases/python/asserted-links/app/handlers.py @@ -0,0 +1,15 @@ +def on_save(doc): + return audit(doc) + + +def on_load(doc): + return doc + + +def on_purge(doc): + return doc + + +def audit(doc): + doc["audited"] = True + return doc diff --git a/tests/cases/python/asserted-links/case.json b/tests/cases/python/asserted-links/case.json new file mode 100644 index 00000000..c1a2b8ff --- /dev/null +++ b/tests/cases/python/asserted-links/case.json @@ -0,0 +1,360 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "BASE: a call through a value getattr returned is unresolved, so no chain reaches the handler it runs", + "run": [ + "path", + "test_save", + "audit" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_save and audit" + ] + }, + { + "why": "the unresolved site is listed as a work item, with the call as written and the engine's reason", + "run": [ + "path", + "test_save", + "audit", + "--json" + ], + "expect_error": true, + "want": [ + "\"unknown_sites\"", + "app/dispatch.py:6", + "\"reason\": \"no_rule\"" + ] + }, + { + "why": "link records the target of the call at that line", + "run": [ + "link", + "app/dispatch.py:6", + "on_save" + ], + "want": [ + "linked app/dispatch.py:6:12 `fn` → on_save [asserted]" + ] + }, + { + "why": "path walks the asserted edge, labelled [asserted], never [known_edge]", + "run": [ + "path", + "test_save", + "audit" + ], + "want": [ + "[asserted · call @ app/dispatch.py:6] on_save", + "ASSERTED by a link", + "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", + "on_save", + "--tests" + ], + "want": [ + "[asserted] dispatch app/dispatch.py:6", + "test_dispatch.py::test_save", + "reaches those through resolved calls and 1 asserted link(s)" + ], + "avoid": [ + "[resolved] dispatch", + "axiomcode-links.tsv:" + ] + }, + { + "why": "a second target at the same site is a fan, each its own edge", + "run": [ + "link", + "app/dispatch.py:6", + "on_load" + ], + "want": [ + "linked app/dispatch.py:6:12 `fn` → on_load [asserted]" + ] + }, + { + "why": "CONTROL: a link to a declaration that does not exist is rejected", + "run": [ + "link", + "app/dispatch.py:6", + "no_such_handler" + ], + "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", + "app/dispatch.py:3", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — no call is written at app/dispatch.py:3" + ] + }, + { + "why": "CONTROL: a call that names another declaration cannot be linked to an unrelated target", + "run": [ + "link", + "app/dispatch.py:19", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — the call as written names `audit`" + ] + }, + { + "why": "CONTROL: a member call on an untyped receiver names a library method, not an unrelated function", + "run": [ + "link", + "app/dispatch.py:23", + "on_load" + ], + "expect_error": true, + "want": [ + "rejected — the call is to the member `copy` of a receiver the graph could not type" + ] + }, + { + "why": "CONTROL: an unlinked sibling site stays unknown", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "app/dispatch.py:15:12 return fn(doc) [computed_attribute_name:on_*]" + ], + "avoid": [ + "app/dispatch.py:15:12 return fn(doc) [computed_attribute_name:on_*] → linked" + ] + }, + { + "why": "CONTROL: the table call nobody linked still reaches nothing", + "run": [ + "path", + "test_table", + "on_load" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_table and on_load" + ] + }, + { + "why": "FRONT DOOR: a by-name lead alone is listed, and its site is to resolve, with the declaration of its name as a candidate", + "env": { + "AXIOMCODE_FRONT": "1" + }, + "run": [ + "impact", + "on_load" + ], + "want": [ + "by name · refresh", + "to resolve:", + "app/dispatch.py:27:18 return store.on_load(doc)", + "candidates: on_load app/handlers.py:5" + ] + }, + { + "why": "a computed name's constant prefix gives candidates", + "run": [ + "impact", + "purge", + "--unknown" + ], + "want": [ + "candidates: on_save app/handlers.py:1 (named on_…, the constant part of the computed name)" + ] + }, + { + "why": "a lead can be rejected: it is no longer walked", + "run": [ + "link", + "app/dispatch.py:27", + "--not", + "on_load" + ], + "want": [ + "rejected the lead app/dispatch.py:27:18 → on_load" + ] + }, + { + "why": "…impact no longer lists the rejected lead", + "run": [ + "impact", + "on_load" + ], + "avoid": [ + "refresh app/dispatch.py:27" + ] + }, + { + "why": "…and the list shows it as a rejection", + "run": [ + "link" + ], + "want": [ + "→ NOT on_load (a lead rejected: not walked)" + ] + }, + { + "why": "CONTROL: an edge the engine resolved cannot be rejected", + "run": [ + "link", + "app/dispatch.py:19", + "--not", + "audit" + ], + "expect_error": true, + "want": [ + "the engine resolved this call; if it is wrong that is an engine defect — not hidden" + ] + }, + { + "why": "removing the rejection restores the lead", + "run": [ + "link", + "app/dispatch.py:27:18", + "-" + ], + "want": [ + "removed 1 link(s)" + ] + }, + { + "why": "…it is walked again", + "run": [ + "impact", + "on_load" + ], + "want": [ + "[by name] refresh app/dispatch.py:27" + ] + }, + { + "why": "a lead and an asserted link at the same site: the site is listed ONCE, as confirmed", + "run": [ + "link", + "app/dispatch.py:27", + "on_load" + ], + "want": [ + "linked app/dispatch.py:27:18 `on_load` → on_load [asserted]" + ] + }, + { + "why": "FRONT DOOR: confirmed, not a lead; the settled site is not to resolve", + "env": { + "AXIOMCODE_FRONT": "1" + }, + "run": [ + "impact", + "on_load" + ], + "want": [ + "asserted · refresh" + ], + "avoid": [ + "by name · refresh", + "app/dispatch.py:27:18 return store.on_load(doc) [untyped" + ] + }, + { + "why": "clean up the site link before the edit steps", + "run": [ + "link", + "app/dispatch.py:27:18", + "-" + ], + "want": [ + "removed" + ] + }, + { + "why": "lines inserted above the site: after the rebuild the link follows the line by its text", + "edit": [ + "app/dispatch.py", + "from app import handlers\n", + "from app import handlers\n# dispatch by event name\n\n" + ], + "run": [ + "path", + "test_save", + "audit", + "--fresh" + ], + "want": [ + "[asserted · call @ app/dispatch.py:8] on_save" + ] + }, + { + "why": "the list says where the link now is", + "run": [ + "link" + ], + "want": [ + "[moved] app/dispatch.py:6:12 (now line 8) → on_save" + ] + }, + { + "why": "the linked line itself edited: the list says so at once, before any rebuild (another function's identical line is not it)", + "edit": [ + "app/dispatch.py", + " return fn(doc)\n\n\ndef run_table", + " return fn(dict(doc))\n\n\ndef run_table" + ], + "run": [ + "link" + ], + "want": [ + "[changed] app/dispatch.py:6:12 (now line 8) → on_save" + ] + }, + { + "why": "…and after the rebuild it is dropped: the site is unknown again, and the answer says a link was not applied", + "run": [ + "path", + "test_save", + "audit", + "--fresh" + ], + "expect_error": true, + "want": [ + "no chain of resolved calls connects test_save and audit", + "links: 2 of 2 asserted link(s) not applied (2 stale)" + ], + "avoid": [ + "[asserted" + ] + }, + { + "why": "…and the list gives the reason", + "run": [ + "link" + ], + "want": [ + "[stale] app/dispatch.py:6:12 → on_save — stale: the line was edited (no line with its text and that call is left in dispatch)" + ] + } + ] +} diff --git a/tests/cases/python/asserted-links/tests/test_dispatch.py b/tests/cases/python/asserted-links/tests/test_dispatch.py new file mode 100644 index 00000000..cf1bafe0 --- /dev/null +++ b/tests/cases/python/asserted-links/tests/test_dispatch.py @@ -0,0 +1,10 @@ +from app.dispatch import dispatch, run_table + + +def test_save(): + assert dispatch("on_save", {})["audited"] + + +def test_table(): + from app import handlers + assert run_table({"load": handlers.on_load}, "load", {}) == {} diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json new file mode 100644 index 00000000..8fc2466f --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/case.json @@ -0,0 +1,26 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decorator matched only by its name that REGISTERS the function and hands it back unchanged: calling the decorated function never runs the decorator, so a change to what the decorator calls does not reach the decorated function (it used to be listed as a caller of the decorator, and everything reaching it with it)", + "run": [ + "impact", + "remember" + ], + "avoid": [ + "on_order_created" + ] + }, + { + "why": "the control: a decorator matched only by its name that hands back a WRAPPER. The decorated name now denotes the wrapper, so a change the wrapper reaches still reaches the decorated declaration", + "run": [ + "impact", + "tick" + ], + "want": [ + "crunch_numbers" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py new file mode 100644 index 00000000..1e560ecd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/handlers.py @@ -0,0 +1,12 @@ +from loader import load + +tools = load() + + +@tools.registered("order-created") +def on_order_created(order): + return order + + +def dispatch(kind, payload): + return tools.REGISTRY[kind](payload) diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py new file mode 100644 index 00000000..f0d989ca --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/loader.py @@ -0,0 +1,2 @@ +def load(): + return __import__("tools") diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py new file mode 100644 index 00000000..09b903cd --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_handlers.py @@ -0,0 +1,5 @@ +from handlers import dispatch + + +def test_dispatch(): + assert dispatch("order-created", 1) == 1 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py new file mode 100644 index 00000000..63ad00a4 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tests/test_work.py @@ -0,0 +1,5 @@ +from work import run_crunch + + +def test_crunch(): + assert run_crunch() == 6 diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py new file mode 100644 index 00000000..093c1318 --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/tools.py @@ -0,0 +1,25 @@ +"""Two decorators the project declares: one hands back a wrapper, one registers and hands back the function.""" +REGISTRY = {} + + +def timed(fn): + def wrapper(*args): + tick() + return fn(*args) + return wrapper + + +def tick(): + return 1 + + +def registered(name): + def register(fn): + REGISTRY[name] = fn + remember(name) + return fn + return register + + +def remember(name): + return name diff --git a/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py new file mode 100644 index 00000000..372c130d --- /dev/null +++ b/tests/cases/python/decorator-by-name-runs-its-wrapper/work.py @@ -0,0 +1,13 @@ +"""The decorator is reached through a value the graph cannot type, so the decoration is matched by its name.""" +from loader import load + +tools = load() + + +@tools.timed +def crunch_numbers(n): + return n * 2 + + +def run_crunch(): + return crunch_numbers(3) diff --git a/tests/cases/python/decorator-the-project-declares/case.json b/tests/cases/python/decorator-the-project-declares/case.json index 0fcb2134..ee7d61f0 100644 --- a/tests/cases/python/decorator-the-project-declares/case.json +++ b/tests/cases/python/decorator-the-project-declares/case.json @@ -3,7 +3,7 @@ "src": ".", "checks": [ { - "why": "a decorator the project declares is a dependency of everything it wraps: `@exporter(\"csv\") def export_csv` means export_csv IS what exporter returned, so a test that drives the handler must run when the decorator changes — and nothing but the @ line says so, since the decorator has no call site and the table is read by a key. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", + "why": "a decorator the project declares is a dependency of everything it wraps' MODULE: `@exporter(\"csv\") def export_csv` runs exporter(\"csv\") and the function it returned while app.py is being imported, so a test that imports app must run when the decorator changes. Calling export_csv later never runs exporter (it registered the function and handed it back unchanged), so the route is the import, not a hop from the decorated function. The control is a module of its OWN: a sibling in the same file is reached whatever the decorator does, because the decoration runs while that file is being imported", "run": [ "impact", "exporter", @@ -11,7 +11,7 @@ ], "want": [ "test_export.py", - "decorator by name" + "at import" ], "avoid": [ "test_plain.py" @@ -32,14 +32,14 @@ ] }, { - "why": "the hop is named for what it is. The decoration carries a simple NAME, so the declaration it denotes is matched by that name and not resolved, and the answer must not sell that as a resolved call", + "why": "the hop is named for what it is: the decoration runs at import, and the answer says so rather than selling it as a resolved call", "run": [ "impact", "exporter", "--tests" ], "want": [ - "decorator by name" + "at import" ], "avoid": [ "how sure each route is: 3 sound" diff --git a/tests/cases/python/option-parameter-name-is-no-key/case.json b/tests/cases/python/option-parameter-name-is-no-key/case.json new file mode 100644 index 00000000..dd288be8 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a decoration string that names a parameter of the declaration it decorates binds that parameter; it is not a key anything dispatches on. Every dict with a `level` key wrote it, and was joined to the command as a caller", + "run": ["impact", "describe", "--tests"], + "avoid": ["defaults", "test_config.py"] + }, + { + "why": "the control: the flag is what a caller writes to reach the command, and a test that writes it still reaches the change", + "run": ["impact", "describe", "--tests"], + "want": ["test_cli.py"] + } + ] +} diff --git a/tests/cases/python/option-parameter-name-is-no-key/cli.py b/tests/cases/python/option-parameter-name-is-no-key/cli.py new file mode 100644 index 00000000..0bd55772 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/cli.py @@ -0,0 +1,17 @@ +"""A command whose decorations declare its parameters: the flags are what a caller writes, the last name is the +parameter the value is bound to.""" + + +def option(*decls, **settings): + def attach(fn): + return fn + return attach + + +@option("--level", "-l", "level", type=int) +def main(level=0): + return describe(level) + + +def describe(level): + return level diff --git a/tests/cases/python/option-parameter-name-is-no-key/config.py b/tests/cases/python/option-parameter-name-is-no-key/config.py new file mode 100644 index 00000000..6cf1e6bb --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/config.py @@ -0,0 +1,2 @@ +def defaults(): + return {"level": 3} diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py new file mode 100644 index 00000000..9becc312 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_cli.py @@ -0,0 +1,6 @@ +from cli import main + + +def test_level_flag(): + args = ["--level", "2"] + assert main(int(args[1])) == 2 diff --git a/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py new file mode 100644 index 00000000..b5c2cfb5 --- /dev/null +++ b/tests/cases/python/option-parameter-name-is-no-key/tests/test_config.py @@ -0,0 +1,5 @@ +from config import defaults + + +def test_defaults(): + assert defaults()["level"] == 3 diff --git a/tests/cases/python/optional-class-object-parameter/case.json b/tests/cases/python/optional-class-object-parameter/case.json new file mode 100644 index 00000000..b0f17354 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/case.json @@ -0,0 +1,16 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a class-object parameter spelled with its default (`cls: type[CmdType] | None = None`, `Optional[Type[Command]]`) holds a class just as `type[Command]` does: `cls(name)` constructs it, so the constructor's callers include the code that calls through the parameter, in a closure or not", + "run": ["impact", "Command.__init__"], + "want": ["decorator", "option"] + }, + { + "why": "the control: the bare `type[Command]` spelling was already read", + "run": ["impact", "Command.__init__"], + "want": ["fixed"] + } + ] +} diff --git a/tests/cases/python/optional-class-object-parameter/commands.py b/tests/cases/python/optional-class-object-parameter/commands.py new file mode 100644 index 00000000..7ec1b9f1 --- /dev/null +++ b/tests/cases/python/optional-class-object-parameter/commands.py @@ -0,0 +1,31 @@ +"""A factory whose class parameter defaults to None: the body puts the default class in and constructs it.""" +import typing as t + + +class Command: + def __init__(self, name): + self.name = name + + +class Group(Command): + pass + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +def command(name, cls: type[CmdType] | None = None): + if cls is None: + cls = t.cast("type[CmdType]", Command) + + def decorator(f): + return cls(name) + return decorator + + +def option(name, cls: t.Optional[t.Type[Command]] = None): + return (cls or Command)(name) + + +def fixed(name, cls: type[Command]): + return cls(name) 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/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 = {