diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 44fba37d9..a547f630c 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -15,7 +15,7 @@ those, the tests) — ≤ 3 declarations per event, in parallel, a few lines each.""" import concurrent.futures, json, os, re, subprocess, sys, tempfile sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) -import graph_sql +import graph_sql, ax_evidence sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _host, _graphline, _where @@ -114,6 +114,12 @@ def impact(d): # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and # 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that # was the fast path answering, the rules answering, or the CLI having given up. + # with evidence on (AXIOMCODE_EVIDENCE, ax_evidence.py), the line that decides the strongest uncertain readers + if ax_evidence.on() and reads: + if not any(x.get('evidence') for x in reads): ax_evidence.impact_doc({'targets': j.get('targets') or [{'label': d['symbol']}], 'direct': reads}, cwd) + for x in [x for x in reads if (x.get('evidence') or {}).get('decider')][:2]: + dd = x['evidence']['decider'] + lines.append(f" decided: [{x['certainty']}] {x['at'].split('/')[-1]} ← {dd['at'].split('/')[-1]}: {dd['text'][:100]} [{dd['kind']}]") lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") if bodies: diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 4bfb91aa7..8a91153db 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -122,10 +122,12 @@ def scripts_module(name): PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh'} + '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh', + '--drop': 'drop', '--exact': 'exact', '--alongside': 'alongside'} # a CLI switch that turns a parameter OFF: `--no-refresh` is refresh=False here NEGATED = {'--no-refresh'} -SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh'} +SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh', + '--exact', '--alongside'} PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(? str: """Build (or refresh) the call graph of a repository: parser → engine → /.axiomcode/out/graph.sqlite. Run once before path/impact/graph. lang: java|typescript|python|javascript|csharp when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" @@ -288,43 +308,48 @@ def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str return run(a) @srv.tool() -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True) -> str: +@_doc +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`), capped with a count of the rest; limit=N lists more, full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) flow = source or explain or from_.strip() or _paged(page) or budget a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) paged = _paged(page); full = full or why a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or delete or _paged(page) or budget a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True) -> str: +@_doc +def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or _paged(page) a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py new file mode 100644 index 000000000..4a625ae25 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Evidence for the rows an answer is not sure of. One module, used by every verb and every surface. + +A row that is not an exact edge -- [by name], [one of a set], [registered], [text], a dispatch or key join -- is a +lead: the graph matched a NAME, or a set, and could not say which declaration the call reaches. Printed alone, the +reader has to open the file to decide it, and measured over the loops' traces about half of the answers that named +files were followed within three tool calls by a Read or a grep of one of them (55% when the answer had [by name] +rows). What decides such a row is almost always ONE line: where the call's receiver (or its dispatch key) gets its +value -- a parameter's annotation, a field declared or assigned in the constructor, a local, an import, the constant +a registration names. So each non-exact row carries two lines: + + call the line the call is written on (file:line and its trimmed text) + decider the line that decides what the receiver is, and its kind (param, field, local, assigned, import, key …) + +and `only_through`: how many callables and tests the answer reaches ONLY through that row, with the way to ask again +without it (`--drop `, or `--exact` for exact edges alone). Evidence is given for the five strongest +non-exact rows; the rest are counted. Exact rows get none, so an answer with no uncertain row does not grow. + +The switch: AXIOMCODE_EVIDENCE=on|off (the dispatcher sets it from --evidence / --no-evidence; the MCP server from its +`evidence` parameter; the hooks inherit it). OFF by default: with it off nothing here runs and every answer is the +answer it was. AXIOMCODE_DROP (comma-separated file:line or row ids), AXIOMCODE_EXACT=1 and AXIOMCODE_ALONGSIDE=1 are +the ask-again and list-alongside switches, set from --drop, --exact and --alongside. + +The decider is read from the source text, not from the graph: the graph has already said it could not type the +receiver, so what it knows is exactly what is missing. It is a heuristic over the file's own lines, per language, and +it says what KIND of line it found; when it finds none the row carries only its call line. +""" +import collections, os, re + +TOP = 5 # rows that get evidence; the rest are counted +TEXT = 150 # a line's text in --json, trimmed (a line is bounded at 160 characters) +PRINTED = 90 # and as printed under a row +UP = 250 # lines searched above a call for its receiver's parameter or local + +# a certainty that is an exact edge (or not a row about a call at all): no evidence +EXACT = {None, '', 'resolved', 'sound', 'entry', 'defines', 'defines (not a call)', 'must change', 'alongside', + 'stubs it', 'at import', 'decorator', 'test'} + + +def _env(k): + return (os.environ.get(k) or '').strip().lower() + + +def on(): + return _env('AXIOMCODE_EVIDENCE') in ('on', '1', 'true', 'yes') + + +def drops(): + return {x.strip() for x in (os.environ.get('AXIOMCODE_DROP') or '').split(',') if x.strip()} + + +def exact_only(): + return _env('AXIOMCODE_EXACT') in ('1', 'on', 'true', 'yes') + + +def list_alongside(): + return _env('AXIOMCODE_ALONGSIDE') in ('1', 'on', 'true', 'yes') + + +def is_exact(cert): + return cert in EXACT + + +def lang_of(f): + e = os.path.splitext(f or '')[1].lower() + if e in ('.py', '.pyi'): return 'python' + if e == '.java': return 'java' + if e == '.cs': return 'csharp' + if e in ('.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'): return 'ts' + return 'python' if not e else 'ts' + + +def trim(s): + s = (s or '').strip() + return s if len(s) <= TEXT else s[:TEXT - 1] + '…' + + +class Source: + """the lines of the repository's files, read once each""" + def __init__(self, repo): + self.repo, self.files = repo or '.', {} + + def lines(self, f): + if f not in self.files: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as h: self.files[f] = h.read().split('\n') + except OSError: self.files[f] = None + return self.files[f] + + +def split_at(at): + f, _, n = (at or '').rpartition(':') + return (f, int(n)) if f and n.isdigit() else (None, None) + + +def simple(name): + """the name a call writes: `Owner.get (at x.ts:3)` → get, `Owner.m(int)` → m""" + n = (name or '').split(' (at ')[0].split('(')[0].strip() + n = re.split(r'[.#:$/]', n)[-1] if n else '' + return '' if n.startswith('<') else n + + +# ── the receiver of a call ───────────────────────────────────────────────────────────────────────────────────────────── +ID = r'[A-Za-z_$][\w$]*' +RECV = re.compile(r'((?:' + ID + r'(?:\(\s*\))?\s*(?:\?\.|!\.|\.|->)\s*)*' + ID + r'(?:\((?:[^()]|\([^()]*\))*\))?)\s*(?:\?\.|!\.|\.|->)\s*$') + + +def receiver(text, name): + """the receiver expression written before `.name(` on a line, or None for a bare call / no call of the name""" + for m in re.finditer(r'(?:\?\.|!\.|\.|->)\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', text): + r = RECV.search(text[:m.start()] + '.') + if r: return re.sub(r'\s+', '', r.group(1)) + return None + + +def parts(recv): + """`this.a.b()` → ('this', ['a', 'b()']); a separator inside parentheses does not split""" + ps, cur, depth, s, i = [], '', 0, recv or '', 0 + while i < len(s): + c = s[i] + if c == '(': depth += 1 + elif c == ')': depth -= 1 + if depth == 0 and (s.startswith('->', i) or c == '.' or (c in '?!' and s.startswith('.', i + 1))): + ps.append(cur); cur = '' + i += 2 if (s.startswith('->', i) or c in '?!') else 1 + continue + cur += c; i += 1 + ps.append(cur) + return ps[0], ps[1:] + + +TS_MOD = r'(?:(?:private|public|protected|readonly|static|declare|override|abstract|export|async)\s+)*' +JV_MOD = r'(?:(?:private|public|protected|internal|static|final|readonly|volatile|transient|const|required|new|override|virtual|sealed|unsafe)\s+)*' +JV_KW = {'return', 'new', 'throw', 'await', 'yield', 'else', 'case', 'in', 'is', 'as', 'out', 'ref', 'using', 'import', 'package', 'var', 'val'} + + +def _type_in(lang, line, x, annotated_only=False): + """the type a declaration line gives x, if it writes one (a parameter's only from its annotation)""" + if lang in ('ts', 'python'): + m = re.search(r'\b' + re.escape(x) + r'\s*[?!]?\s*:\s*([A-Za-z_$][\w$.]*)', line) + if m and m.group(1) not in ('function',): return m.group(1) + else: + m = re.search(r'([A-Za-z_][\w.]*)(?:<[^;=()]*>)?(?:\[\])*\??\s+' + re.escape(x) + r'\b', line) + if m and m.group(1) not in JV_KW: return m.group(1) + if annotated_only: return None + m = re.search(r'=\s*(?:new\s+)?([A-Za-z_$][\w$.]*)\s*(?:<[^()]*>)?\s*\(', line) + if m and m.group(1) not in ('async', 'function', 'await', 'lambda') and (lang != 'python' or m.group(1)[:1].isupper() or '.' in m.group(1)): + return ('new ' if 'new ' + m.group(1) in line else '') + m.group(1) + '(…)' + return None + + +CLASS = re.compile(r'^(\s*)(?:(?:export|default|abstract|public|private|protected|internal|static|final|sealed|partial|data|open|declare)\s+)*' + r'(?:class|interface|struct|record|object)\s+' + ID) + + +def _indent(s): + return len(s) - len(s.lstrip()) + + +def _class_span(L, n): + """[lo, hi) the lines of the class that encloses line n, else the whole file: a field is the enclosing class's own, + and the same name declared by another class in the file decides nothing""" + ind = _indent(L[n - 1]) + for i in range(n - 1, -1, -1): + m = CLASS.match(L[i]) + if m and len(m.group(1)) < ind: + ci = len(m.group(1)) + for j in range(i + 1, len(L)): + t = L[j].strip() + if t and _indent(L[j]) <= ci and not re.match(r'[{})\]]|//|#|/?\*|@|where\b|:', t): return i, j + return i, len(L) + return 0, len(L) + + +def _field(lang, L, x, n): + """where a field x of the class enclosing line n gets its value: its declaration, a constructor parameter property, + or an assignment""" + ex = re.escape(x) + if lang == 'ts': + pats = [(re.compile(r'(?:private|public|protected|readonly)\s+(?:readonly\s+)?' + ex + r'\s*[?!]?\s*:'), 'constructor parameter'), + (re.compile(r'^\s*' + TS_MOD + r'#?' + ex + r'\s*[?!]?\s*[:=](?!=)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + elif lang == 'python': + pats = [(re.compile(r'^\s+' + ex + r'\s*:\s*[A-Za-z_]'), 'field'), + (re.compile(r'\bself\.' + ex + r'\s*(?::[^=]+)?=(?!=)'), 'assigned'), + (re.compile(r'^\s+' + ex + r'\s*=(?!=)'), 'class attribute')] + else: + pats = [(re.compile(r'^\s*(?:\[[^\]]*\]\s*|@\w+(?:\([^)]*\))?\s+)*' + JV_MOD + r'[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:=(?!=)|;|\{|=>)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + lo, hi = _class_span(L, n) + for rx, kind in pats: + for i in range(lo, hi): + line = L[i] + if rx.search(line) and not re.match(r'\s*(//|#|\*|/\*)', line): + if kind == 'assigned': + # `self.gw = gw` / `this.loader = loader` decides nothing on its own: the constructor parameter it + # copies may say the type + rhs = re.search(r'=\s*(' + ID + r')\s*;?\s*$', line.split('#')[0].split('//')[0]) + if rhs: + p = _param(lang, L, rhs.group(1), i + 1) + if p and p[2]: return (p[0], 'constructor parameter', p[2]) + return (i + 1, kind, _type_in(lang, line, x)) + return None + + +JV_NOT = r'(?!(?:return|new|throw|else|case|await|yield|if|for|while|switch|catch|using|lock|do|try|var)\b)' +NAMED = { + 'python': re.compile(r'^\s*(?:async\s+)?def\s+\w+\s*\('), + 'ts': re.compile(r'\bfunction\b\s*\*?\s*[\w$]*\s*(?:<[^>]*>)?\s*\(|\bconstructor\s*\(|^\s*' + TS_MOD + r'(?:get\s+|set\s+)?' + ID + + r'\s*(?:<[^>]*>)?\s*\((?:.*\)\s*(?::\s*[^;{=]+)?\{\s*$|\s*$)'), + 'java': re.compile(r'^\s*' + JV_NOT + r'(?:(?:\[[^\]]*\]|@\w+(?:\([^)]*\))?)\s*)*' + JV_MOD + r'(?:[\w<>\[\],.?]+\s+)?' + ID + r'\s*\([^;]*$'), +} +ANON = re.compile(r'\(([^()]*)\)\s*(?::\s*[^=]+?)?\s*(?:=>|->)|\b(' + ID + r')\s*(?:=>|->)|\blambda\b([^:]*):') + + +def _signatures(lang, L, n): + """the signatures enclosing line n, innermost first, up to and including the nearest named one: (line index, the + signature's text over up to 8 lines, named?)""" + named = NAMED['java' if lang in ('java', 'csharp') else lang] + for i in range(n - 1, max(-1, n - 1 - UP), -1): + line = L[i] + if named.search(line): + text = ' '.join(L[i:i + 8]) + text = text[:text.find('{')] if '{' in text else text + yield i, text, True + return + a = ANON.search(line) + if a: yield i, a.group(0), False + + +def _param(lang, L, x, n, up=UP): + """the parameter x of a callable enclosing line n: of the nearest named signature, or of a closure inside it""" + ex = re.escape(x) + if lang in ('ts', 'python'): + rx = re.compile(r'(?:^|[(,]|\blambda\b)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:(?:private|public|protected|readonly)\s+)*(?:\*{1,2})?' + ex + r'\s*[?]?\s*(?::|=[^=>]|,|\)|=>)') + else: + rx = re.compile(r'(?:[(,]|^)\s*(?:(?:final|this|params|ref|out|in)\s+|@\w+(?:\([^)]*\))?\s+)*[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:[,)=]|$)' + r'|\(\s*' + ex + r'\s*\)\s*(?:->|=>)|\b' + ex + r'\s*(?:->|=>)') + for i, text, named in _signatures(lang, L, n): + if rx.search(text) or (not named and re.search(r'\b' + ex + r'\b', text)): + # the line of the signature that writes it (a parameter list over several lines) + k = next((j for j in range(i, min(len(L), i + 8)) if re.search(r'\b' + ex + r'\b', L[j])), i) + return (k + 1, 'param', _type_in(lang, L[k], x, annotated_only=True)) + return None + + +def _local(lang, L, x, n, up=UP): + ex = re.escape(x) + if lang == 'ts': + rx = [re.compile(r'\b(?:const|let|var)\s+' + ex + r'\b'), re.compile(r'\b(?:const|let|var)\s*[{\[][^=]*\b' + ex + r'\b'), + re.compile(r'\bfor\s*\(\s*(?:const|let|var)\s+' + ex + r'\b')] + elif lang == 'python': + rx = [re.compile(r'^\s*' + ex + r'\s*(?::[^=]+)?=(?!=)'), re.compile(r'\bfor\s+(?:[\w, ]*\b)?' + ex + r'\b[\w, ]*\s+in\b'), + re.compile(r'\bas\s+' + ex + r'\s*[:,)]'), re.compile(r'^\s*(?:[\w.]+\s*,\s*)*' + ex + r'\s*(?:,\s*[\w.]+\s*)*=(?!=)')] + else: + rx = [re.compile(r'(?:^|[;({])\s*(?:final\s+)?[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*=(?!=)'), + re.compile(r'\b(?:var|val)\s+' + ex + r'\b'), re.compile(r'\bforeach\s*\([^)]*\s' + ex + r'\s+in\b'), + re.compile(r'\bfor\s*\([^:;]*\s' + ex + r'\s*:')] + # within the enclosing callable (a module-level `let` above a test's callbacks is the test file's own local) + top = [i for i, _t, named in _signatures(lang, L, n) if named] + stop = top[0] if top and lang != 'ts' else max(-1, n - 1 - up) + for i in range(n - 1, stop - 1 if stop >= 0 else -1, -1): + if any(r.search(L[i]) for r in rx) and not re.match(r'\s*(//|#|\*)', L[i]): + # a member declared with a modifier is the class's field, not a local (a one-line method has no signature + # line above the call to stop the search at) + if lang in ('java', 'csharp') and re.match(r'\s*(?:private|public|protected|internal|static|readonly|const)\b', L[i]): return None + return (i + 1, 'local', _type_in(lang, L[i], x)) + return None + + +def _module(lang, L, x): + """x declared at the top of the file, or imported into it""" + ex = re.escape(x) + for i, line in enumerate(L): + if re.search(r'^\s*(?:import\s+(?:type\s+)?(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r'\b|\*\s+as\s+' + ex + r'\b|[\w$]+\s*,\s*\{[^}]*\b' + ex + r'\b)' + r'|import\s+(?:static\s+)?[\w.]+\.' + ex + r'\s*;|import\s+(?:[\w.]+\s+as\s+)?' + ex + r'\s*$|from\s+\S+\s+import\b[^#]*\b' + ex + r'\b' + r'|using\s+' + ex + r'\s*=|(?:const|let|var)\s+(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r')\s*=\s*require\b)', line): + return (i + 1, 'import', None) + for i, line in enumerate(L): + if re.search(r'^(?:export\s+)?(?:default\s+)?(?:(?:const|let|var)\s+' + ex + r'\b|(?:async\s+)?function\s*\*?\s*' + ex + r'\b|class\s+' + ex + r'\b|' + ex + r'\s*(?::[^=]+)?=(?!=)|def\s+' + ex + r'\b)', line): + return (i + 1, 'module', _type_in(lang, line, x)) + return None + + +def _root_decl(lang, L, x, n): + """where the name x, as written at line n, gets its value: a local or parameter above it, else a field, else the module""" + if x in ('this', 'self', 'cls', 'super', 'base'): return None + return _local(lang, L, x, n) or _param(lang, L, x, n) or _field(lang, L, x, n) or _module(lang, L, x) + + +KEY_ARG = re.compile(r'\b(?:\w+\.)*\w+\s*\(\s*(?:f?["\']([^"\']+)["\']|([A-Za-z_$][\w$.]*))\s*,') + + +def decide(src, at, name, lang=None): + """the deciding line of a non-exact call at `at` to a method called `name`: (line, text, kind) or None""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + lang = lang or lang_of(f) + # a call written over several lines: the name is on the line of the site or just below it + rows = [(k, L[k - 1]) for k in range(n, min(len(L), n + 3) + 1)] + for k, text in rows: + rv = receiver(text, name) if name else None + if not rv: continue + root, rest = parts(rv) + d = None + if root in ('this', 'self', 'base') and rest: + fld = re.sub(r'\(.*$', '', rest[0]) + d = _field(lang, L, fld, k) + if d: kind = d[1] if len(rest) == 1 else d[1] + ' ' + fld + elif root.endswith(')'): + fn = root.split('(')[0] + d = _root_decl(lang, L, fn, k) + if d: kind = 'returned by ' + fn + '()' + else: + d = _root_decl(lang, L, root, k) + if d: kind = d[1] if not rest else d[1] + ' ' + root + if d: + return (d[0], L[d[0] - 1], kind + (f" · type {d[2]}" if d[2] else '')) + return None + # no `.name(` on the line: a dispatch key or a registration decides which declaration runs + text = ' '.join(t for _k, t in rows[:2]) + g = re.search(r'getattr\s*\([^,]+,\s*f?["\']([^"\']*)["\']|getattr\s*\([^,]+,\s*(' + ID + r')', text) + if g: + ids = re.findall(r'\{(' + ID + r')', g.group(1) or '') or ([g.group(2)] if g.group(2) else []) + for x in ids: + d = _root_decl(lang, L, x, n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + x) + return None + s = re.search(r'\[\s*(' + ID + r')\s*\]\s*\(', text) + if s: + d = _root_decl(lang, L, s.group(1), n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + s.group(1)) + return None + if name and re.search(r'\b' + re.escape(name) + r'\b(?!\s*\()', text): + k = KEY_ARG.search(text) + if k and k.group(2): + x = k.group(2).split('.')[-1] if k.group(2).split('.')[0] in ('this', 'self') else k.group(2).split('.')[0] + d = _root_decl(lang, L, x, n) if k.group(2).split('.')[0] not in ('this', 'self') else _field(lang, L, x, n) + if d and d[1] == 'import': + far = _imported(src, f, L[d[0] - 1], x, lang) + if far: return far + ('registration key ' + k.group(2),) + if d: return (d[0], L[d[0] - 1], 'registration key ' + k.group(2)) + return None + + +def _imported(src, f, line, x, lang): + """(file:line, text) where a name imported from a module of this repository is defined, or None""" + m = re.search(r'from\s+[\'"](\.[^\'"]+)[\'"]', line) or re.search(r'require\(\s*[\'"](\.[^\'"]+)[\'"]', line) + cands = [] + if m: + base = os.path.normpath(os.path.join(os.path.dirname(f), m.group(1))) + cands = [base + e for e in ('', '.ts', '.tsx', '.js', '.mjs', '.jsx', '/index.ts', '/index.js')] + else: + m = re.search(r'^\s*from\s+(\.*)([\w.]*)\s+import\b', line) + if m: + up = os.path.dirname(f) + for _ in range(max(0, len(m.group(1)) - 1)): up = os.path.dirname(up) + rel = m.group(2).replace('.', '/') + cands = [os.path.join(up, rel + '.py'), os.path.join(up, rel, '__init__.py'), rel + '.py'] + for c in cands: + c = c.replace(os.sep, '/') + L2 = src.lines(c) if c.rsplit('.', 1)[-1] in ('ts', 'tsx', 'js', 'mjs', 'jsx', 'py') else None + if not L2: continue + d = _module(lang, L2, x) + if d and d[1] == 'module': return (f'{c}:{d[0]}', L2[d[0] - 1]) + return None + + +def evidence(src, at, name): + """{call: {at, text}, decider: {at, text, kind}} for one site; decider absent when no line decides it""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + k = n + if name: + # the site's own line when it writes the name, or is a call that does not (a dispatch through a key); else the + # site is where the callable starts (a decorator, a doc comment above it) and the call is the first line below + # that writes `.name(`, else the first that writes the name at all + own, t = re.search(r'\b' + re.escape(name) + r'\b', L[n - 1]), L[n - 1].strip() + if not own and not ('(' in t and not re.match(r'[@*/#]', t)): + near = range(n, min(len(L), n + 8) + 1) + k = next((j for j in near if re.search(r'\.\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', L[j - 1])), None) \ + or next((j for j in range(n, min(len(L), n + 3) + 1) if re.search(r'\b' + re.escape(name) + r'\b', L[j - 1])), n) + ev = {'call': {'at': f'{f}:{k}', 'text': trim(L[k - 1])}} + try: d = decide(src, f'{f}:{k}', name) + except re.error: d = None + if d: + ev['decider'] = {'at': d[0] if isinstance(d[0], str) else f'{f}:{d[0]}', 'text': trim(d[1]), 'kind': d[2]} + return ev + + +# ── only through it ──────────────────────────────────────────────────────────────────────────────────────────────────── +def _up(callers_of, seeds, within): + seen, stack = set(), list(seeds) + while stack: + x = stack.pop() + for a in callers_of.get(x, ()): + if a in within and a not in seen: + seen.add(a); stack.append(a) + return seen + + +def only_through(callers_of, keep, row, within): + """the members of `within` reached upward from `row` and from none of `keep` (the other dependents): the row's own + callable among them, since asked again without the row it leaves the answer too""" + mine = _up(callers_of, [row], within) | ({row} & within) + if not mine: return set() + others = [k for k in keep if k != row] + return mine - _up(callers_of, others, within) - set(others) + + +def rank(rows, cert_rank): + """the strongest non-exact rows first: the surer rung, then the more the answer stands on it, then source order""" + return sorted(rows, key=lambda r: (cert_rank(r.get('certainty')), -((r.get('only_through') or {}).get('callables', 0) + + (r.get('only_through') or {}).get('tests', 0)), + r.get('at') or '')) + + +# ── one document ─────────────────────────────────────────────────────────────────────────────────────────────────────── +ORDER = ['one of a set', 'dispatch', 'registered', 'remote', 'capped set', 'in scope', 'spawns', 'by key', + 'decorator by name', 'protocol', 'fixture', 'by name', 'text'] + + +def cert_rank(c): + return ORDER.index(c) if c in ORDER else len(ORDER) + + +def attach(rows, src, name_of, top=TOP): + """evidence on the `top` strongest non-exact rows (already carrying only_through when it is known); returns how many + non-exact rows were left without it""" + weak = [r for r in rows if not is_exact(r.get('certainty'))] + chosen = rank(weak, cert_rank)[:top] + for r in chosen: + ev = evidence(src, r.get('call_at') or r.get('at'), name_of(r)) + if ev: r['evidence'] = ev + return max(0, len(weak) - len(chosen)) + + +def impact_doc(doc, repo, callers_of=None, test_via=None): + """annotate an impact --json document in place: only_through on every non-exact direct row (when the edges are + given; a test reached through a fixture counts with its fixture), evidence on the strongest five""" + names = [simple(t.get('label')) for t in doc.get('targets', [])] + names = [n for n in names if n] + src = Source(repo) + direct = doc.get('direct', []) + if callers_of is not None: + seeds = [r['id'] for r in direct if r.get('certainty') not in ('alongside', 'stubs it')] + [r['id'] for r in doc.get('contract', [])] + within = {r['id'] for r in doc.get('reached', [])} | {t['id'] for t in doc.get('tests', [])} + tests = {t['id'] for t in doc.get('tests', [])} + via = test_via or {} + for r in direct: + if is_exact(r.get('certainty')): continue + o = only_through(callers_of, seeds, r['id'], within) + ts = {t for t in tests if t in o or (via.get(t) and via[t] in o)} + r['only_through'] = {'callables': len(o - tests), 'tests': len(ts)} + def name_of(r): + f, n = split_at(r.get('at')) + L = src.lines(f) if f else None + line = (L[n - 1] if L and 0 < n <= len(L) else '') + return next((x for x in names if re.search(r'\b' + re.escape(x) + r'\b', line)), names[0] if names else '') + rest = attach(direct, src, name_of) + _more(doc, rest) + return doc + + +def path_doc(doc, repo): + src = Source(repo) + hops = [] + for a in doc.get('answers', []): + for h in a.get('hops', []): + h.setdefault('certainty', h.get('cert')) + hops.append(h) + import ax_edges + for h in hops: + h['certainty'] = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(h.get('tier')) + _more(doc, attach(hops, src, lambda h: simple(h.get('to')))) + for h in hops: h.pop('certainty', None) + return doc + + +def context_doc(doc, repo): + src = Source(repo) + flow = doc.get('flow', []) + parent = {} + stack = [] + for s in flow: + while stack and stack[-1].get('depth', 0) >= s.get('depth', 0): stack.pop() + if stack and s.get('called_at_line'): + s['call_at'] = f"{split_at(stack[-1].get('at'))[0]}:{s['called_at_line']}" + stack.append(s) + rows = [s for s in flow if s.get('call_at') and not s.get('repeat_of')] + _more(doc, attach(rows, src, lambda s: simple(s.get('name')))) + for s in flow: s.pop('call_at', None) + return doc + + +def tests_doc(doc, repo): + """a test reached through a non-exact hop: the line in the test's body that starts the route, and what decides it""" + src = Source(repo) + changed = [simple(c.get('symbol') or c.get('target') or c.get('display') or '') for c in doc.get('changed', []) if isinstance(c, dict)] + rows = [] + for t in doc.get('tests', []): + if is_exact(t.get('certainty')) or t.get('certainty') == 'fixture': continue + ch = t.get('chain') or [] + want = [simple(ch[1])] if len(ch) > 1 else [x for x in changed if x] + f, n = split_at(t.get('at')) + L = src.lines(f) if f else None + if not L or not n: continue + for k in range(n, min(len(L), n + 80) + 1): + hit = next((w for w in want if w and re.search(r'\b' + re.escape(w) + r'\s*[(`<]', L[k - 1])), None) + if hit: + t['call_at'] = f'{f}:{k}'; t['_name'] = hit; rows.append(t); break + _more(doc, attach(rows, src, lambda t: t.get('_name', ''))) + for t in doc.get('tests', []): t.pop('call_at', None); t.pop('_name', None) + return doc + + +def _more(doc, n): + """the count of non-exact rows left without evidence, said only when there are some: an answer with none is unchanged""" + if n: doc['evidence_more'] = n + + +def annotate(verb, doc, repo, **kw): + """the one entry point for a --json document, and every other language's inside it""" + if not on() or not isinstance(doc, dict): return doc + f = {'impact': impact_doc, 'path': path_doc, 'context': context_doc, 'test-impact': tests_doc, 'tests': tests_doc}.get(verb) + if f: f(doc, repo, **kw) if verb == 'impact' else f(doc, repo) + return doc + + +# ── how it is printed ────────────────────────────────────────────────────────────────────────────────────────────────── +def lines(r, call=True, indent=' '): + """the evidence of one row as at most two lines (and a third, the only-through count and how to ask without it)""" + ev = r.get('evidence') or {} + out = [] + c = ev.get('call') or {} + # a line in the file the row already names is written L: the path is on the row + here = lambda at: 'L' + at.rpartition(':')[2] if at.rpartition(':')[0] == (r.get('at') or '').rpartition(':')[0] else at + # printed lines are shorter than the --json text: five rows of them are the whole cost of the evidence view + short = lambda s: s if len(s) <= PRINTED else s[:PRINTED - 1] + '…' + if call and c: out.append(f"{indent}call {here(c['at'])}: {short(c['text'])}") + d = ev.get('decider') + if d and d['at'] == c.get('at'): out.append(f"{indent}decided on the call's own line [{d['kind']}]") + elif d: out.append(f"{indent}decided {here(d['at'])}: {short(d['text'])} [{d['kind']}]") + # the row's own file:line is what --drop takes: it is on the row already, so it is said once, in DROP_HINT + o = r.get('only_through') + if o and (o.get('callables') or o.get('tests')): + out.append(f"{indent}only through it: {o['callables']} callable(s), {o['tests']} test(s)") + return out + + +DROP_HINT = "--drop asks again without that row and what stands only on it; --exact with exact edges only" + + +def prose_block(rows, more, what='rows'): + """the evidence section of a prose answer: the rows that carry evidence, each with its lines""" + ev = [r for r in rows if r.get('evidence')] + if not ev: return [] + out = [f"evidence for the {len(ev)} strongest non-exact {what} (the line that decides each; the rest are leads):"] + for r in ev: + out.append(f" [{r.get('certainty')}] {r.get('display') or r.get('name') or r.get('to') or ''} {r.get('at')}") + out += lines(r) + if more: out.append(f" +{more} more non-exact {what} without evidence (only the {TOP} strongest carry it)") + if any(r.get('only_through') for r in ev): out.append(' ' + DROP_HINT) + return out + + +def ask_again_note(): + """the line an answer asked again without some rows starts with""" + d, x = drops(), exact_only() + if not d and not x: return '' + return 'asked again ' + ('with exact edges only' if x else '') + (' and ' if x and d else '') + \ + (f"without {', '.join(sorted(d))}" if d else '') + ' — what was reached only through those rows is left out' + + +def dropped(r): + """is this direct row one the caller asked to leave out""" + d = drops() + if exact_only() and not is_exact(r.get('certainty')) and r.get('certainty') != 'alongside': return True + if not d: return False + return r.get('id') in d or r.get('at') in d or any(x.get('at') in d for x in r.get('reasons', []) or []) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d163..b87c214f1 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -25,7 +25,7 @@ H = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, H) -import ax_edges +import ax_edges, ax_evidence CAP = 30 TEXT_ROWS = 5 # rows of a name written in a non-source file: leads, so a few and a count @@ -87,6 +87,20 @@ def more_in_file(k): return f" · +{k} more in this file" if k else '' +def ev(row, r): + """a row and, when it carries evidence (ax_evidence.py), the line that decides it under it: one entry, so a cap + never separates them""" + return row + ''.join('\n' + l for l in ax_evidence.lines(r, call=False)) if r.get('evidence') else row + + +def ev_foot(d): + n = d.get('evidence_more') + out = [f"evidence: the {ax_evidence.TOP} strongest non-exact rows carry the line that decides them, {n} more do not"] if n else [] + rows = d.get('direct', []) + d.get('tests', []) + [h for a in d.get('answers', []) for h in a.get('hops', [])] + if any(r.get('only_through') for r in rows if isinstance(r, dict)): out.append(ax_evidence.DROP_HINT) + return out + + # ── impact ─────────────────────────────────────────────────────────────────────────────────────────────────────────── def impact(d, code): rows, rest = [], {} @@ -95,11 +109,19 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n rows.append(('contract', site(code, r['at'], f"must change · {r['why']}{stale(r)}", r['display']))) seen = set() if d.get('alongside'): more('alongside (no call, no reference)', len(d['alongside'])) - for r in d.get('direct', []): + if d.get('alongside_count'): more('alongside (no call, no reference; --alongside lists them)', d['alongside_count']) + if d.get('asked_again'): rows.append(('note', d['asked_again']['note'])) + # a row with evidence leads its rung: it is one of the strongest, and the cap must not cut it from its evidence + direct = d.get('direct', []) + if any(r.get('evidence') for r in direct): + rung = {} + for i, r in enumerate(direct): rung.setdefault(r.get('certainty'), i) + direct = sorted(direct, key=lambda r: (rung[r.get('certainty')], not r.get('evidence'))) + for r in direct: cert = r.get('certainty') or 'resolved' if cert == 'alongside': more('alongside (no call, no reference)'); continue seen.add(r['id']) - rows.append((cert, site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']))) + rows.append((cert, ev(site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']), r))) tests = {t['id'] for t in d.get('tests', [])} # a module's top level reaches it too, but its row is the file's first line (an import), which says nothing: those # come after the tests, so a cap spends its lines on callables and on what to run @@ -127,6 +149,7 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n foot = [] nt = len(d.get('tests', [])) if 'test_universe' in d: foot.append(f"tests: {nt} of {d['test_universe']} reach it" + ("; `axiomcode test-impact` runs them" if nt else '')) + foot += ev_foot(d) foot.append(verified(d.get('verified'), d.get('checked_hops'))) if d.get('unresolved_inside'): foot.append(f"bound: {d['unresolved_inside']} unresolved call(s) inside — a lower bound") return rows, rest, foot @@ -142,7 +165,7 @@ def path(d, code): cert = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(t) at = h.get('call_at') or h.get('declared_at') # caller → callee on every hop: the line is in the caller, and a chain found from B back to A reads right - rows.append(('hop', site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"))) + rows.append(('hop', ev(site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"), h))) prev = h['to'] for r in d.get('reached', []): rows.append(('reached', site(code, r['at'], f"hop {r['hops']}{stale(r)}", r['name']))) @@ -154,7 +177,7 @@ def path(d, code): ans = d.get('answers', []) v = d.get('verified') if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False - foot = [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] + foot = ev_foot(d) + [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot @@ -169,7 +192,7 @@ def context(d, code): tag = f"step {s['step']} · {'entry' if c == 'entry' else TAG.get(c, c)}" if s.get('called_at_line'): tag += f" · called at L{s['called_at_line']}" if s.get('unresolved'): tag += ' · ⚠ ' + ', '.join(s['unresolved'][:2]) - rows.append(('flow', site(code, s['at'], tag + stale(s), s['name']))); listed.add(s['at']) + rows.append(('flow', ev(site(code, s['at'], tag + stale(s), s['name']), s))); listed.add(s['at']) if not d.get('flow'): for e in d.get('entry_points', []): if e['at'] in listed: continue @@ -181,7 +204,7 @@ def context(d, code): tb = d.get('text_bindings', []) for t in tb[:TEXT_ROWS]: rows.append(('text', site(code, f"{t['file']}:{t['line']}", 'text · names ' + (t.get('name') or ', '.join(t.get('terms', []))) + stale(t)))) - foot = [] + foot = ev_foot(d) if d.get('not_indexed'): foot.append('not indexed: ' + ', '.join(map(str, d['not_indexed'][:3]))) foot.append("bound: follows call edges and names; a constant, config key, string or reflection does not appear") return rows, ({'[text]': len(tb) - TEXT_ROWS} if len(tb) > TEXT_ROWS else {}), foot @@ -201,7 +224,7 @@ def test_impact(d, code): for f in d.get('edited_test_files', []): rows.append(('test', f"{f}:1: (edited test file) [test · edited]")) for t, k in per_test_file(d.get('tests', [])): - rows.append(('test', site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']))) + rows.append(('test', ev(site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']), t))) # a changed file no graph follows (a script, a fixture) is run by the test files that name it in their text names = {} for f, v in (d.get('named_in_test_text') or {}).items(): @@ -209,7 +232,7 @@ def test_impact(d, code): names.setdefault(t, []).append((v or {}).get('needle') or f) for t, ns in names.items(): rows.append(('text test', f"{t}:1: (names {', '.join(dict.fromkeys(ns))}) [test · text]")) - foot = [] + foot = ev_foot(d) if d.get('command'): foot.append(f"run: {d['command']}") if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 924046101..c503f33dd 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -66,6 +66,10 @@ # --grep (context, path, impact, test-impact) prints the answer's sites one per line, as grep does: `path:line: [resolved | one of a set | by name | text | hop N | test]`, the first 30 (--grep-limit N) and a count of # the rest, then whether it was verified and its bound. The MCP tools answer this way by default; full=True is the prose. +# --evidence / --no-evidence (or AXIOMCODE_EVIDENCE=on|off; off by default): each of the five strongest rows that is not an +# exact edge ([by name], [one of a set], [registered], …) carries the line that decides it — where its receiver or key +# gets its value — and how much of the answer is reached only through it. --drop (repeatable) asks again +# without that row, --exact with exact edges only; --alongside lists the `alongside` rows the evidence view counts. # ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, # C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and # every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. @@ -123,6 +127,12 @@ while [ $# -gt 0 ]; do --no-refresh) export AXIOMCODE_NO_REFRESH=1; shift ;; --grep) GREP=1; shift ;; --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; + # evidence for the uncertain rows, and asking again without them (ax_evidence.py): read by every verb from the env + --evidence) export AXIOMCODE_EVIDENCE=on; shift ;; + --no-evidence) export AXIOMCODE_EVIDENCE=off; shift ;; + --drop) export AXIOMCODE_DROP="${AXIOMCODE_DROP:+$AXIOMCODE_DROP,}$2"; shift 2 ;; + --exact) export AXIOMCODE_EXACT=1; shift ;; + --alongside) export AXIOMCODE_ALONGSIDE=1; shift ;; *) ARGS+=("$1"); shift ;; esac done diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c8..14ec00d65 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1321,14 +1321,21 @@ def cli(argv): """--json: the same answer as one document. The prose is produced by the same code path and carried in `prose`, so the machine shape is never LESS than the human one — a second traversal to build it would be a second implementation to keep in step.""" + import ax_evidence + repo = next((a for a in reversed([x for x in argv if not x.startswith('--')]) if os.path.isdir(a)), '.') if '--json' not in argv: code = main(argv) + # the line that decides each flow step reached through a call that is not an exact edge (ax_evidence.py) + if ax_evidence.on() and RESULT.get('flow'): + ax_evidence.annotate('context', RESULT, repo) + for l in ax_evidence.prose_block(RESULT['flow'], RESULT.get('evidence_more', 0), 'steps'): print(l) if code in (0, 2): quoted_text(argv) sys.exit(code) import contextlib, io, json buf = io.StringIO() with contextlib.redirect_stdout(buf): code = main(argv) RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('context', RESULT, repo) print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fbd91552f..1363e354c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -66,7 +66,7 @@ def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn +sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn, ax_evidence _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) P = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(P) G, run_dl, die, BODILESS_KINDS = P.G, P.run_dl, P.die, P.BODILESS_KINDS @@ -2399,6 +2399,14 @@ def main(argv): parent = collections.defaultdict(list) for a, b, t, q_ in res['parent_up']: parent[a].append((b, t)) prof('outputs read') + # who calls each callable, for what stands ONLY on a row (ax_evidence.only_through): built on first use, so an answer + # with evidence off and no --drop never pays for it + _callers_memo = [] + def callers_of(): + if not _callers_memo: + _callers_memo.append(collections.defaultdict(set)) + for a, b, _ in g.edges(): _callers_memo[0][b].add(a) + return _callers_memo[0] tests = {} for m, d, q_ in res['test_near']: if m in g.sym and int(d) <= depth and int(d) < tests.get(m, (10 ** 9,))[0]: @@ -2427,6 +2435,18 @@ def main(argv): stubbed = sorted({x[0] for x in res.get('test_stub', []) if x[0] in g.sym and x[0] not in tests}, key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} + # ASKED AGAIN WITHOUT SOME ROWS (--drop , --exact; ax_evidence.py): the rows go, and with them what the + # answer reached ONLY through them -- the callables and tests no other dependent reaches. The rest is untouched. + _row = lambda x: {'id': x[0], 'at': x[4], 'certainty': x[3], 'reasons': direct_reasons.get((x[0], _grp(x[1])), [])} + _dropped = [x for x in D if ax_evidence.dropped(_row(x))] + if _dropped: + _keep = [x[0] for x in D if x not in _dropped and x[3] not in ('alongside', 'stubs it')] + [c for c, _ in contract] + _within = set(reached) | set(tests) + _lost = set().union(*(ax_evidence.only_through(callers_of(), _keep, x[0], _within) for x in _dropped)) + D = [x for x in D if x not in _dropped] + reached = {m: d for m, d in reached.items() if m not in _lost} + tests = {m: v for m, v in tests.items() if m not in _lost and not (v[1] and v[1] in _lost)} + byname_seeds -= {x[0] for x in _dropped} - set(_keep) # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: # they go in source order (file, line, then name), which only an edit changes. @@ -2668,7 +2688,7 @@ def main(argv): fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') if as_json: - print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], + doc = ({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is @@ -2689,11 +2709,19 @@ def main(argv): 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, - **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}, indent=1)) + **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}) + if ax_evidence.on() or _dropped: + if _dropped: doc['asked_again'] = {'note': ax_evidence.ask_again_note(), 'dropped': [g.loc(x[0]) if not x[4] else x[4] for x in _dropped]} + if ax_evidence.on(): + ax_evidence.impact_doc(doc, g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + if not ax_evidence.list_alongside() and doc.get('alongside') and any(not ax_evidence.is_exact(r['certainty']) for r in doc['direct']): + doc['alongside_count'] = len(doc.pop('alongside')) + print(json.dumps(doc, indent=1)) return 0 for i, (k, lab, _) in enumerate(targets): print(f"change: {lab} [{k}]") if want_why and i < len(target_why): print('\n'.join(g.why_lines(target_why[i]))) + if _dropped: print(ax_evidence.ask_again_note() + f" ({len(_dropped)} row(s))") # --tests-only: every section is still COMPUTED (the tests are derived from them) but only the tests section, # and the verified / bound lines that qualify it, are printed _stdout = sys.stdout @@ -2870,8 +2898,13 @@ def main(argv): if len(own) == 1 and own[0][0] == 'method': return f'grep -rn "\\.{own[0][1]}(" {P.grep_roots(g)}' return 'grep -rnw ' + ' '.join(f'-e "{n}"' for _, n in own) + f' {P.grep_roots(g)}' + _ev_view = ax_evidence.on() and not ax_evidence.list_alongside() and any(not ax_evidence.is_exact(x[3]) for x in D) for title, rows in groups: if not rows: continue + # with evidence on, in an answer that has an uncertain row, the `alongside` section (no call, no reference) is a + # count, not rows: --alongside lists them. An answer of exact rows alone is left exactly as it was. + if _ev_view and all(r[3] == 'alongside' for r in rows): + print(f"{title}: {len({c for c, *_ in rows})} callable(s) — --alongside lists them"); continue # PRODUCTION BEFORE TESTS. A test that reaches the target is a CONSEQUENCE of the change, not a place that # has to be edited, and in a well-tested tree the tests outnumber the production callers several times over. # Ranking by certainty alone therefore interleaved them, and since the window is finite the file that @@ -2940,6 +2973,14 @@ def main(argv): tbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tst if r[4]) where = (f" in {len(tbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in tbyf.most_common(4)) + (' …' if len(tbyf) > 4 else '')) if tbyf else '' print(f" +{len({c for c, *_ in tst})} test callable(s){where} also reach it, not listed here — `axiomcode test-impact` names them and the command that runs them (--tests lists them by file)") + if ax_evidence.on(): + # THE LINE THAT DECIDES EACH UNCERTAIN ROW (ax_evidence.py), for the five strongest, with what stands on it + _ed = ax_evidence.impact_doc({'targets': [{'label': lab} for k, lab, _ in targets], + 'direct': [{'id': c, 'display': g.disp(c), 'certainty': cert, 'at': loc} for c, role, why, cert, loc, n in D], + 'contract': [{'id': c} for c, _ in contract], + 'reached': [{'id': m} for m in reached], 'tests': [{'id': m} for m in tests]}, + g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + for l in ax_evidence.prose_block(_ed['direct'], _ed.get('evidence_more', 0)): print(l) nfiles = len({g.sym[m]['file'] for m in reached}) if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133a..3c28d0a15 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -2127,11 +2127,19 @@ if __name__ == '__main__': if e.code and UNRESOLVED and not as_json: sys.stdout.flush(); ax_text.emit(repo, UNRESOLVED, IN) raise - if not as_json: sys.exit(answer()) + import ax_evidence + if not as_json: + code = answer() + # the line that decides each hop that is not an exact edge (ax_evidence.py), after the chains it qualifies + if ax_evidence.on() and isinstance(RESULT.get('query'), dict): + ax_evidence.annotate('path', RESULT, RESULT['query'].get('repo') or '.') + for l in ax_evidence.prose_block([h for a in RESULT.get('answers', []) for h in a.get('hops', [])], RESULT.get('evidence_more', 0), 'hops'): print(l) + sys.exit(code) # the prose goes to a buffer, not to the caller: one code path produces both, and `prose` is # carried in the document so a machine answer is never LESS than the human one. buf = io.StringIO() with contextlib.redirect_stdout(buf): code = answer() RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('path', RESULT, (RESULT.get('query') or {}).get('repo') or '.') print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index d984ad6c6..d086a90de 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -2061,8 +2061,9 @@ def main(argv): replaced = concrete_test_classes(db, run_classes)[1] if lang in ('java', 'csharp') else {} cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db, repo) + import ax_evidence if as_json: - print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], + print(json.dumps(ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], @@ -2085,7 +2086,7 @@ def main(argv): 'range_note': changed.get('range_note'), 'baseline_note': changed.get('baseline_note'), 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' - 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, + 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, repo), indent=1)) return 0 @@ -2240,6 +2241,11 @@ def main(argv): for t, first in unreadable: print(f" {t} {(first or [''])[0][:100]}") print(" the selection above is incomplete — do not act on it as though it were the whole answer.") + if ax_evidence.on(): + # the line in each test that starts a route which is not an exact edge, and what decides it (ax_evidence.py) + _ed = ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol']} for t in targets], + 'tests': [dict(r) for r in tests.values()]}, repo) + for l in ax_evidence.prose_block(_ed['tests'], _ed.get('evidence_more', 0), 'test routes'): print(l) print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/case.json b/tests/cases/typescript/evidence-for-uncertain-rows/case.json new file mode 100644 index 000000000..c301fdb65 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/case.json @@ -0,0 +1,88 @@ +{ + "lang": "typescript,javascript,python,java,csharp", + "src": ".", + "checks": [ + { + "why": "a [by name] row carries the line that decides its receiver: a Map field and an `any` parameter show the name-match is another type; the resolved row gets no evidence", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--grep"], + "want": ["ts/callers.ts:16: return this.seen.get(key);", "decided L14: private readonly seen = new Map(); [field · type new Map(…)]", + "decided L20: export function viaAny(box: any, id: string): string { [param · type any]", "only through it:"], + "avoid": ["decided L7"] + }, + { + "why": "a dispatch-key row decides on where the key comes from, and a registration row on the constant it registers under, followed into the module that defines it", + "run": ["impact", "ts/bus.ts:16", "--evidence", "--grep"], + "want": ["decided L10: emit(topic: string, id: string): void { [dispatch key topic]", + "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]"] + }, + { + "why": "--json rows gain evidence {call, decider} and only_through {callables, tests}; alongside rows become a count", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--json"], + "stdout_json": true, + "want": ["\"evidence\"", "\"decider\"", "\"only_through\"", "\"callables\"", "\"alongside_count\""] + }, + { + "why": "the prose answer says the same, in one section after the rows it qualifies", + "run": ["impact", "ts/orders.ts:2", "--evidence"], + "want": ["evidence for the 2 strongest non-exact rows", "call L16: return this.seen.get(key);", "only through it: 1 callable(s), 0 test(s)", "--drop asks again without that row"] + }, + { + "why": "--exact asks again with exact edges only: the name-matches and what stands on them go, the resolved caller stays", + "run": ["impact", "ts/orders.ts:2", "--exact", "--grep"], + "want": ["return this.store.get(id);", "asked again with exact edges only"], + "avoid": ["box.get(id)", "this.seen.get(key)"] + }, + { + "why": "--drop asks again without that one row", + "run": ["impact", "ts/orders.ts:2", "--drop", "ts/callers.ts:21", "--grep"], + "want": ["this.seen.get(key)", "without ts/callers.ts:21"], + "avoid": ["box.get(id)"] + }, + { + "why": "control: an answer with only [resolved] rows is byte-identical with evidence on and off", + "run": ["impact", "ts/ledger.ts:2", "--evidence"], + "same_as": ["impact", "ts/ledger.ts:2", "--no-evidence"], + "want": ["[resolved] sum"] + }, + { + "why": "control: the same resolved-only answer as --json and as grep rows is byte-identical too", + "run": ["impact", "ts/ledger.ts:2", "--evidence", "--json"], + "same_as": ["impact", "ts/ledger.ts:2", "--json"], + "stdout_json": true + }, + { + "why": "control: evidence is off by default, so an answer with uncertain rows is the answer it was", + "run": ["impact", "ts/orders.ts:2", "--grep"], + "same_as": ["impact", "ts/orders.ts:2", "--no-evidence", "--grep"], + "avoid": ["decided ", "only through it"] + }, + { + "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", + "run": ["impact", "js/store.js:2", "--evidence", "--grep"], + "want": ["decided L11: cached(res) { [param]", "decided L16: export function remote(api) { [param]"], + "avoid": ["this.shelf = new Shelf()"] + }, + { + "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", + "run": ["impact", "py/gateway.py:2", "--evidence", "--grep"], + "want": ["py/billing.py:33: return self.gw.charge(-amount)", "decided L30: self.gw = gw [assigned]"], + "avoid": ["def __init__(self, gw: Gateway): [constructor parameter"] + }, + { + "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", + "run": ["impact", "java/app/Pipeline.java:7", "--evidence", "--grep"], + "want": ["decided L6: private final Function loader; [field · type Function]"] + }, + { + "why": "Java: path's hop through the same call carries the same decider", + "run": ["path", "Cache.load", "Pipeline.apply", "--evidence", "--grep"], + "want": ["private final Function loader;"] + }, + { + "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", + "run": ["impact", "cs/Stock.cs:5", "--evidence", "--grep"], + "want": ["decided L11: private readonly dynamic _meter; [field · type dynamic]"], + "avoid": ["decided L10"] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs new file mode 100644 index 000000000..bd7a4df74 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs @@ -0,0 +1,19 @@ +namespace App +{ + public class Stock + { + public int Count(string sku) { return sku.Length; } + } + + public class Counter + { + private readonly Stock _stock = new Stock(); + private readonly dynamic _meter; + + public Counter(dynamic meter) { _meter = meter; } + + public int Total(string sku) { return _stock.Count(sku); } + + public int Metered(string sku) { return _meter.Count(sku); } + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java new file mode 100644 index 000000000..9bd696b4b --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java @@ -0,0 +1,20 @@ +package app; + +import java.util.function.Function; + +public class Cache { + private final Function loader; + private final Store store = new Store(); + + public Cache(Function loader) { + this.loader = loader; + } + + public String read(String id) { + return store.lookup(id); + } + + public String load(String id) { + return loader.apply(id); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java new file mode 100644 index 000000000..dd33065e5 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java @@ -0,0 +1,10 @@ +package app; + +import java.util.function.Function; + +public class Pipeline implements Function { + @Override + public String apply(String s) { + return s.trim(); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java new file mode 100644 index 000000000..132c2dc8c --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java @@ -0,0 +1,7 @@ +package app; + +public class Store { + public String lookup(String id) { + return "store:" + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js new file mode 100644 index 000000000..99274be41 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js @@ -0,0 +1,18 @@ +import { Shelf } from './store.js'; + +export class Client { + constructor() { + this.cache = new Map(); + this.shelf = new Shelf(); + } + load(id) { + return this.shelf.fetch(id); + } + cached(res) { + return res.fetch('x'); + } +} + +export function remote(api) { + return api.fetch('/orders'); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js new file mode 100644 index 000000000..7721cbe8d --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js @@ -0,0 +1,5 @@ +export class Shelf { + fetch(id) { + return 'shelf:' + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py new file mode 100644 index 000000000..c423f8cb6 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py @@ -0,0 +1,33 @@ +from gateway import Gateway, Mailer + + +class Billing: + def __init__(self, gw: Gateway): + self.gw = gw + + def bill(self, amount): + return self.gw.charge(amount) + + +class Router: + def __init__(self, handlers): + self.handlers = handlers + + def on_refund(self, event): + return event + + def route(self, event): + kind = event["kind"] + return getattr(self, f"on_{kind}")(event) + + +def settle(m: Mailer): + return m.charge(5) + + +class Refunds: + def __init__(self, gw): + self.gw = gw + + def undo(self, amount): + return self.gw.charge(-amount) diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py new file mode 100644 index 000000000..f9f26732a --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py @@ -0,0 +1,8 @@ +class Gateway: + def charge(self, amount): + return amount + + +class Mailer: + def charge(self, amount): + return -amount diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts new file mode 100644 index 000000000..13fd6acef --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts @@ -0,0 +1,21 @@ +import { ORDER_PLACED } from './callers'; + +type Handler = (id: string) => void; + +export class Bus { + private readonly handlers: Record = {}; + on(topic: string, handler: Handler): void { + this.handlers[topic] = handler; + } + emit(topic: string, id: string): void { + this.handlers[topic](id); + } +} + +export function onPlaced(id: string): void { + console.log(id); +} + +export function wire(bus: Bus): void { + bus.on(ORDER_PLACED, onPlaced); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts new file mode 100644 index 000000000..e17afae37 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts @@ -0,0 +1,26 @@ +import { OrderStore } from './orders'; +import { Ledger } from './ledger'; + +export const ORDER_PLACED = 'order.placed'; + +export class Report { + constructor(private readonly store: OrderStore) {} + line(id: string): string { + return this.store.get(id); + } +} + +export class Memo { + private readonly seen = new Map(); + recall(key: string): string | undefined { + return this.seen.get(key); + } +} + +export function viaAny(box: any, id: string): string { + return box.get(id); +} + +export function sum(ledger: Ledger): number { + return ledger.total(3); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts new file mode 100644 index 000000000..04a5c1fad --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts @@ -0,0 +1,5 @@ +export class Ledger { + total(n: number): number { + return n * 2; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts new file mode 100644 index 000000000..5b1abdb7a --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts @@ -0,0 +1,5 @@ +export class OrderStore { + get(id: string): string { + return 'order:' + id; + } +} diff --git a/tests/run.py b/tests/run.py index 75fcd4566..a35f564a2 100755 --- a/tests/run.py +++ b/tests/run.py @@ -14,7 +14,9 @@ it. A check fails loudly with the line that was wrong, so a regression names itself. No corpus, no network, nothing outside the case directory. -Three other keys a check may carry: +Four other keys a check may carry: + + "same_as": [run …] STDOUT must be byte-identical to that other run's: a switch that must leave an answer alone. "stdout_json": true STDOUT ALONE must parse as one JSON document. `want` and `avoid` read stdout and stderr CONCATENATED, so no substring can express "this must not be inside the document" — which is @@ -70,6 +72,14 @@ # not do this yet", not "anything may happen here". A crash, a missing fixture or an unreadable answer under # a marker would otherwise be indistinguishable from the gap it names, and the marker becomes the hiding # place this mechanism exists to remove. + # "same_as": [run …] — STDOUT must be BYTE-IDENTICAL to another run's: a switch that must not change an answer + # (evidence on an answer with no uncertain row, a default that is off) is a comparison, not a substring + if ch.get('same_as'): + other = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['same_as']] + [path], capture_output=True, text=True) + if other.stdout != out.stdout: + a_, b_ = out.stdout.split('\n'), other.stdout.split('\n') + i = next((i for i, (x, y) in enumerate(zip(a_, b_)) if x != y), min(len(a_), len(b_))) + bad.append(f"stdout differs from {' '.join(ch['same_as'])} at line {i + 1}: {a_[i] if i < len(a_) else '(end)'!r} vs {b_[i] if i < len(b_) else '(end)'!r}") crashed = bool(out.returncode) and not ch.get('expect_error') if crashed: bad.append(f"(exit {out.returncode})") # "pending": "" — a case that states behaviour the tool does NOT have yet. It still RUNS, and the two