From c31ccf2ac5910891da8cc7206f03397cb6e7b275 Mon Sep 17 00:00:00 2001 From: swapnil Date: Tue, 29 Sep 2026 23:27:32 -0700 Subject: [PATCH] impact, context: a name the code calls and nothing declares lists its call sites Asked about a function that is called but not yet written, impact counted the sites ("`scale` as written at 1 unresolved call site(s)") and then answered "directly touches it: nothing" and "the change is local"; --grep printed no rows; context dropped the word, since only declared names reach its ranking. Writing such a function starts from exactly those sites, and a text search finds them in one call. - impact: each unresolved call site of a name-matched (written) target is a direct row, [by name], "calls it (no declaration of this name)" at its file:line, so the prose, --json and --grep answers carry them. - context: identifiers the task writes that the code calls and nothing declares are listed first, with their call sites and code lines (called_undeclared in --json), before anything that can stop the answer early. - ax_grep: those sites lead the grep-shaped context answer the MCP tools return by default. tests/cases/python/called-but-undeclared: 4/4 here, 2/4 on the base (the two controls, a declared function, pass on both). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_grep.py | 3 ++ .../axiomcode/scripts/axiomcode-context | 33 +++++++++++++++++++ .../skills/axiomcode/scripts/axiomcode-impact | 11 +++++++ .../python/called-but-undeclared/case.json | 16 +++++++++ .../src/shop/__init__.py | 0 .../called-but-undeclared/src/shop/pricing.py | 10 ++++++ .../called-but-undeclared/src/shop/rates.py | 2 ++ 7 files changed, 75 insertions(+) create mode 100644 tests/cases/python/called-but-undeclared/case.json create mode 100644 tests/cases/python/called-but-undeclared/src/shop/__init__.py create mode 100644 tests/cases/python/called-but-undeclared/src/shop/pricing.py create mode 100644 tests/cases/python/called-but-undeclared/src/shop/rates.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d163..4bd426ee6 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -163,6 +163,9 @@ def path(d, code): def context(d, code): rows = [] listed = set() + # first: where a name the task writes is called though nothing declares it — the code to write is used there + for u in d.get('called_undeclared', []): + rows.append(('called', site(code, u['at'], f"calls {u['name']}, declared nowhere" + (f" · in {u['in']}" if u.get('in') else '')))); listed.add(u['at']) for s in d.get('flow', []): if s.get('repeat_of'): continue c = s.get('certainty') or 'entry' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c8..e7dc81f2b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -64,6 +64,30 @@ TEST_PENALTY = 0.05 # demoted by 20x, never dropped: sometimes the test COMMON_PATH = 0.25 # a word in more than a quarter of the file paths earns no path bonus + +def called_undeclared(g, body, cap=8): + """{name: [(file, line, caller, code)]} for identifiers the task writes that the code CALLS but declares nowhere. + The IDF ranker only knows declared names, so a task about a function the code already calls and nobody has written + yet ("implement numpy_to_votable_dtype") lost that word before ranking. The call sites are where the new function + is used: the one thing a search would find and the graph did not say.""" + if not (g.has('call_sites') and g.has('unresolved_sites')): return {} + ids = sorted({w for w in re.findall(r'[A-Za-z_][A-Za-z0-9_]{3,}', body)}) + if not ids: return {} + ph = ','.join('?' * len(ids)) + declared = {r[0] for r in g.q(f"SELECT DISTINCT name FROM symbols WHERE name IN ({ph}) AND kind NOT IN ('library', 'written')", *ids)} + rows = g.q(f"SELECT cs.callee_name n, cs.file_path f, cs.start_line l, cs.caller_id c FROM call_sites cs " + f"JOIN unresolved_sites u ON u.call_site_id = cs.id WHERE cs.callee_name IN ({ph}) ORDER BY cs.file_path, cs.start_line", *ids) + out = {}; lines = {} + for r in rows: + if r['n'] in declared or len(out.get(r['n'], [])) >= cap: continue + f = g.site_file(r['f']) if hasattr(g, 'site_file') else r['f'] + if f not in lines: + try: lines[f] = open(os.path.join(g.repo, f), errors='replace').read().split('\n') + except OSError: lines[f] = [] + L = lines[f]; code = L[r['l'] - 1].strip() if r['l'] and r['l'] <= len(L) else '' + out.setdefault(r['n'], []).append((f, r['l'], g.disp(r['c']) if r['c'] in g.sym else '', code[:160])) + return out + def score_symbols(g, terms, use_path=True): """{symbol id: (score, matched terms)} — the lexical tier, IDF-weighted. @@ -934,6 +958,15 @@ def main(argv): RESULT['task'], RESULT['repo'] = task, g.repo body = task_text(task) + # before anything that can stop the answer (no terms, no scope): a name the code calls and nobody declares is an + # answer on its own, and "implement " is often all the task says + undecl = called_undeclared(g, body) + RESULT['called_undeclared'] = [{'name': n, 'at': f"{f}:{l}", 'in': c, 'code': code} for n, rs in undecl.items() for f, l, c, code in rs] + if undecl: + print("\ncalled but declared nowhere (the task names them; the code already calls them — this is where they are used):") + for n, rs in undecl.items(): + print(f" {n} — {len(rs)} call site(s)") + for f, l, c, code in rs: print(f" {f}:{l}: {code}" + (f" [in {c}]" if c else '')) terms = task_terms(body) if not terms: die("nothing to search for in that task description") # what the question names that no graph here holds is said FIRST (#1571), and a scope that exists on disk but holds diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fbd91552f..11451c986 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2295,6 +2295,17 @@ def main(argv): print(f"axiomcode-scope-declared: {int(scoped_in)}", file=sys.stderr) targets_files =[t for k, _, pay in targets for t in (pay if isinstance(pay, list) else []) if isinstance(t, str) and t in g.sym] prof('targets resolved'); res = I.run(targets); prof('souffle done') + # A NAME THAT IS CALLED BUT DECLARED NOWHERE. The path finder's resolver takes such a name to the call sites written + # with it (a node `w:`, its rows in g.SITES), but the rules cannot seed on a node the graph does not hold, so + # the answer said "directly touches it: nothing" and "the change is local" about a name with callers. That is the + # question asked when writing a function the code already calls: each of those sites is a direct row, by name. + for q_, lab in res['_targets'].items(): + for k, klab, ids in targets: + if k != 'name match' or klab != lab: continue + for i in ids: + for r in getattr(g, 'SITES', {}).get(i, []): + res['direct'].append([r['caller_id'], 'uses', 'calls it (no declaration of this name)', 'by name', + g.site_file(r['file_path']) or '', str(r['start_line'] or 0), q_]) LAB = res['_targets']; multi = len(LAB) > 1 contract_of = collections.defaultdict(set) for c, why, q_ in res['contract']: contract_of[(c, why)].add(LAB[q_]) diff --git a/tests/cases/python/called-but-undeclared/case.json b/tests/cases/python/called-but-undeclared/case.json new file mode 100644 index 000000000..01e092202 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a function the code calls and nobody has written yet: impact lists every call site, where the new function is used", + "run": ["impact", "apply_discount"], + "want": ["[by name] total", "pricing.py:6", "[by name] invoice", "pricing.py:10", "calls it (no declaration of this name)"], + "avoid": ["directly touches it: nothing the graph can see", "the change is local"]}, + {"why": "the same answer from a task that names the missing function, even when it is the task's only word the graph lacks", + "run": ["context", "implement apply_discount"], "expect_error": true, + "want": ["called but declared nowhere", "apply_discount — 2 call site(s)", "pricing.py:6: return apply_discount(net) * (1 + vat_rate()) [in total]"]}, + {"why": "control: a declared function is never listed as called-but-undeclared", + "run": ["context", "how is vat_rate used in total"], + "avoid": ["called but declared nowhere"]}, + {"why": "control: a declared function's impact answers from the graph, not by name", + "run": ["impact", "vat_rate"], + "want": ["[resolved] total"], + "avoid": ["no declaration of this name"]}]} diff --git a/tests/cases/python/called-but-undeclared/src/shop/__init__.py b/tests/cases/python/called-but-undeclared/src/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/called-but-undeclared/src/shop/pricing.py b/tests/cases/python/called-but-undeclared/src/shop/pricing.py new file mode 100644 index 000000000..6efb21dc0 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/src/shop/pricing.py @@ -0,0 +1,10 @@ +from shop.rates import vat_rate + + +def total(items): + net = sum(i.price for i in items) + return apply_discount(net) * (1 + vat_rate()) + + +def invoice(items): + return {"total": total(items), "net": apply_discount(sum(i.price for i in items))} diff --git a/tests/cases/python/called-but-undeclared/src/shop/rates.py b/tests/cases/python/called-but-undeclared/src/shop/rates.py new file mode 100644 index 000000000..12d9c1463 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/src/shop/rates.py @@ -0,0 +1,2 @@ +def vat_rate(): + return 0.2