diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d16..4bd426ee 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 7052d34c..e7dc81f2 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 fbd91552..11451c98 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 00000000..01e09220 --- /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 00000000..e69de29b 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 00000000..6efb21dc --- /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 00000000..12d9c146 --- /dev/null +++ b/tests/cases/python/called-but-undeclared/src/shop/rates.py @@ -0,0 +1,2 @@ +def vat_rate(): + return 0.2