From 4e1a8ac3c95242b883e6e2d6db8d219e4a11b6d9 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:47:35 -0700 Subject: [PATCH 1/2] impact, path, context: when the graph has no answer, give the text matches, labelled [text] A name no graph declares (an environment variable, a message, a key in a YAML), a string, or a name matched only by name stopped at "nothing named X", and the agent then searched the tree by hand. The refusal now carries the lines that write it, each labelled [text] with the declaration that holds it (or "not indexed"), searched once per repository even with several language graphs; the exit status is unchanged. A message quoted in a context task is searched the same way. An --in no graph holds is answered at the repository root (or under the other scopes) and said once, instead of a refusal menu per language. A name the graph resolves answers from the graph only. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_langs.py | 22 ++ .../skills/axiomcode/scripts/ax_text.py | 245 ++++++++++++++++++ .../axiomcode/scripts/axiomcode-context | 45 +++- .../skills/axiomcode/scripts/axiomcode-impact | 29 ++- .../skills/axiomcode/scripts/axiomcode-path | 23 +- .../NOTES.md | 1 + .../case.json | 37 +++ .../deploy.yml | 2 + .../src/widgets.py | 16 ++ .../scope-spanning-two-roots/case.json | 11 +- .../.env.example | 1 + .../case.json | 13 + .../src/orders.ts | 10 + tests/multi_language.py | 7 +- 14 files changed, 448 insertions(+), 14 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_text.py create mode 100644 tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md create mode 100644 tests/cases/python/text-when-the-graph-has-no-answer/case.json create mode 100644 tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml create mode 100644 tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py create mode 100644 tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example create mode 100644 tests/cases/typescript/text-when-the-graph-has-no-answer/case.json create mode 100644 tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py index 7db30c957..6c33ad9ee 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py @@ -91,10 +91,26 @@ def main(argv): named = [n for n in named if n[2] != 3] named, outside = by_scope(named) named, others = by_landing(named) + # what each graph found nothing by (ax_text.py): searched once, below, and never shown as a graph's stderr + import ax_text + marks = [] + for i, n in enumerate(named): + err, got = ax_text.take(n[4]); marks.append(got); named[i] = (*n[:4], err) answered = [n for n in named if n[2] == 0] # a graph that could not answer at all keeps its `graph refresh:` lines (a corrupt graph moved aside and being rebuilt, # ax_contract.usable_graph): dropped with its refusal, the answer read as whole while a language was missing from it notes = ''.join(l + '\n' for n in named if n[2] != 0 and answered for l in n[4].splitlines() if l.startswith('graph refresh:')) + text = '' + quoted = [m for ms in marks for m in ms if m.get('why') != 'unresolved'] + if quoted: + # a string asked about, or quoted in a task, is searched once for the repository, whichever graphs answered + text = ax_text.block(repo, list(dict.fromkeys(a for m in quoted for a in m['asked'])), quoted[0].get('scope'), + quoted[0].get('why'), quoted[0].get('rows') or ax_text.ROWS) + elif not answered and marks and all(marks): + # every graph refused and every one of them found nothing by the name: ONE [text] block, not one per language. + # A graph that refused for another reason (a scope it does not hold, an ambiguous kind) withholds it + asked = [a for a in marks[0][0]['asked'] if all(any(a in m['asked'] for m in ms) for ms in marks)] + text = ax_text.block(repo, asked, marks[0][0].get('scope')) if asked else '' if not answered: # a graph that does not hold the --in scope says only that; the graph that holds it has the refusal that # matters (nothing under it matches, or the name is not there), so a scope in one language is judged by it @@ -119,12 +135,18 @@ def main(argv): show = answered or named if not answered and len({(n[3], n[4]) for n in named}) == 1: # the same refusal from every graph: once print(f"══ {', '.join(n[0] for n in named)} graph{'s' if len(named) > 1 else ''} ══"); sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]) + if text: sys.stdout.write('\n' + text) return named[0][2] + # an --in no graph holds is dropped by every graph for the root, and each says so: the line is said once, on top + gone = [o.split('\n', 1)[0] for _, _, _, o, _ in show if o.startswith(ax_text.SCOPE_GONE)] + if len(show) > 1 and gone: + print(gone[0]); show = [(*n[:3], n[3][len(n[3].split('\n', 1)[0]) + 1:] if n[3].startswith(ax_text.SCOPE_GONE) else n[3], n[4]) for n in show] for i, (lang, is_main, rc, out, err) in enumerate(show): if len(show) > 1 or not is_main: print(('' if i == 0 else '\n') + f"══ {lang} graph ══" + ('' if is_main else f" (.axiomcode/lang/{lang})"), flush=True) sys.stdout.write(out); sys.stdout.flush(); sys.stderr.write(err); sys.stderr.flush() if notes: sys.stderr.write(notes); sys.stderr.flush() + if text: sys.stdout.write('\n' + text) if answered and others: print(f"\nthe --from name is also declared in the {', '.join(others)} graph(s), where its flow reaches " "half as many of the task's words or fewer; --lang asks one of them") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py new file mode 100644 index 000000000..101f23132 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -0,0 +1,245 @@ +"""[text]: what a search by hand finds, for a question the graph has no declaration for. + +NEVER ANSWER WORSE THAN GREP. A name no graph declares (a message, an environment variable, a key written in a YAML, a +string an issue quotes) was refused with "nothing named X" and nothing else, and the agent asked it then searched the +tree by hand: most of the searches that bypassed the graph were exactly that. The search is cheap and the tool knows +more than grep does about each line it finds: which declaration holds it, or that no graph reads that file at all. + +So a refusal now carries the text matches, and every one of them is labelled `[text]`: a line that WRITES the name, +never a call edge, never counted as a caller, and never an exit status of 0 (the verb still refused). The containing +declaration is looked up in every graph of the repository, so a line in another language's file is placed too. + +In a repository in several languages every graph refuses on its own (ax_langs.py). A verb that is one of several +(AXIOMCODE_FANOUT) prints no text block: it writes MARK and what it would have searched for on stderr, and the +dispatcher prints ONE block when no graph answered, instead of the same lines once per language. +""" +import json, os, re, subprocess, sys + +MARK = 'axiomcode-text: ' +SCOPE_GONE = 'scope: no indexed file in any graph has ' # the line that says an --in was dropped for the root +ROWS = 12 # rows printed; the count of the rest is printed with them +PER_FILE = 3 # rows from one file before the next file gets a turn +_FILE_LIKE = re.compile(r'^[\w./-]*\.[A-Za-z][\w]{0,7}$|/') +_FILE_LINE = re.compile(r':\d+(:\d+)?$') + + +def graph_dbs(repo): + """every graph.sqlite of the repository, the main one first""" + import ax_langs + return [os.path.join(d or os.path.join(repo, '.axiomcode'), 'out', 'graph.sqlite') for _, d in ax_langs.graphs(repo)] + + +def _con(db): + import sqlite3 + try: return sqlite3.connect(f"file:{db}?mode=ro", uri=True) + except Exception: return None + + +def held(repo, scope): + """True when some graph of the repository holds a file with `scope` in its path""" + for db in graph_dbs(repo): + c = _con(db) + if not c: continue + try: + if c.execute("SELECT 1 FROM symbols WHERE file LIKE ? LIMIT 1", (f'%{scope}%',)).fetchone(): return True + except Exception: pass + finally: c.close() + return False + + +def declared(repo, name): + """True when some graph declares a symbol named `name`""" + for db in graph_dbs(repo): + c = _con(db) + if not c: continue + try: + if c.execute("SELECT 1 FROM symbols WHERE name = ? LIMIT 1", (name,)).fetchone(): return True + except Exception: pass + finally: c.close() + return False + + +def needles(asked): + """[(needle, whole word?)] to try in turn for a name as it was asked: the whole of it, then (for Owner.member) the + member alone. A quoted string is searched as written; a file:line is not searched (the graph answers a line)""" + s = asked.strip() + if len(s) > 1 and s[0] in '"\'`' and s[-1] == s[0]: return [(s[1:-1], False)] if s[1:-1] else [] + if _FILE_LINE.search(s) or s in ('*', ''): return [] + s = re.sub(r'\([^()]*\)$', '', s) # Owner.m(p): the name, not the signature + word = lambda x: bool(re.fullmatch(r'\w+', x)) + out = [(s, word(s))] + last = re.split(r'[.#:$]', s)[-1] + if last and last != s and word(last) and len(last) >= 3: out.append((last, True)) + return out + + +def _git(repo, args): + try: + r = subprocess.run(['git', '-C', repo] + args, capture_output=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): return None + return r.stdout.decode('utf-8', 'replace') if r.returncode in (0, 1) else None + + +def _walk(repo): + import ax_fresh + for root, dirs, files in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ax_fresh.PRUNE_ALL and not d.startswith('.')] + rel = os.path.relpath(root, repo).replace(os.sep, '/') + for fn in files: yield fn if rel == '.' else f"{rel}/{fn}" + + +def files(repo): + """every file of the repository a search by hand would look at: git's (tracked and untracked, not ignored), else + the refresher's pruned walk""" + out = _git(repo, ['ls-files', '-co', '--exclude-standard']) + fs = out.splitlines() if out is not None else list(_walk(repo)) + return [f for f in fs if f and not f.startswith('.axiomcode/')] + + +def grep(repo, needle, word): + """[(file, line, text)] of every line holding `needle` (as a whole word when `word`), outside .axiomcode""" + out = _git(repo, ['grep', '-n', '-I', '--no-color', '--untracked', '-F'] + (['-w'] if word else []) + + ['-e', needle, '--', '.', ':(exclude).axiomcode']) + hits = [] + if out is not None: + for l in out.splitlines(): + m = re.match(r'^(.*?):(\d+):(.*)$', l) + if m: hits.append((m.group(1), int(m.group(2)), m.group(3))) + return hits + pat = re.compile((r'(? 2 << 20: continue + with open(p, 'rb') as fh: data = fh.read() + except OSError: continue + if b'\0' in data[:4096]: continue + for i, l in enumerate(data.decode('utf-8', 'replace').splitlines(), 1): + if pat.search(l): hits.append((f, i, l)) + return hits + + +class Places: + """the declaration that holds a line, in whichever graph holds its file""" + def __init__(self, repo): + self.cons = [c for c in (_con(db) for db in graph_dbs(repo)) if c] + self.cache = {} + + def graph_file(self, rel): + """(connection, the path that graph stores for this file) or (None, None). A graph built with --src stores paths + under it, so a repository path is matched by its longest suffix that some graph holds""" + if rel in self.cache: return self.cache[rel] + parts = rel.split('/'); found = (None, None) + for i in range(len(parts)): + cand = '/'.join(parts[i:]) + for c in self.cons: + try: + if c.execute("SELECT 1 FROM symbols WHERE file = ? LIMIT 1", (cand,)).fetchone(): found = (c, cand); break + except Exception: pass + if found[0]: break + self.cache[rel] = found + return found + + def of(self, rel, line): + """'in ' for the innermost declaration around the line, 'not indexed' for a file no graph holds, + '' for a line in an indexed file outside every declaration""" + c, f = self.graph_file(rel) + if not c: return 'not indexed' + try: + r = c.execute("""SELECT display, kind FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? + AND kind NOT IN ('module', 'library', 'written', 'file') + ORDER BY line DESC, end_line ASC LIMIT 1""", (f, line, line)).fetchone() + except Exception: r = None + return f"in {r[0]}" if r else '' + + +def block(repo, asked, scope=None, why='unresolved', rows=ROWS): + """the text lines to print for names the graph could not answer (`asked`), searched under `scope` when some file + lies there, else at the root (and said so). '' when there is nothing to search for""" + lines = [] + places = None; rowed = False + for a in dict.fromkeys(asked): + tries = needles(a) + if not tries: continue + hits, used = [], None + for n, w in tries: + hits = grep(repo, n, w); used = (n, w) + if hits: break + if why == 'string' and hits: + # a STRING in a source file is written quoted; the same word bare there is an identifier (a field, a local) + # and another question. A file no graph reads keeps every mention: a YAML value is written bare + places = places or Places(repo) + quoted = re.compile(r'["\'`]' + re.escape(used[0]) + r'["\'`]') + hits = [h for h in hits if quoted.search(h[2]) or not places.graph_file(h[0])[0]] + under = '' + if scope: + inside = [h for h in hits if scope in h[0]] + if inside: hits = inside; under = f", under {scope}" + elif hits: under = f"; none under {scope}, so these are from the whole repository" + n, w = used + also = f" (as '{n}')" if n != tries[0][0] else '' + # a name that reads as a file: the files whose path holds it come first, which is what the search by hand was for + paths = [] + if _FILE_LIKE.search(tries[0][0]) and ' ' not in tries[0][0]: + want = tries[0][0].lstrip('./') + paths = [f for f in files(repo) if f == want or f.endswith('/' + want) or want in f][:rows] + q = tries[0][0] if not tries[0][1] and a.strip()[:1] in '"\'`' else a # a quoted string, shown quoted once + head = {'unresolved': f"the graph has no declaration for '{q}'", 'quoted': f"no declaration is named '{q}'", + 'string': f"'{q}' is a string, so beside the literal sites above", + 'undeclared': f"no graph declares '{q}': the rows above match it by name only, and"}.get(why, f"'{q}'") + if paths: + lines.append(f"[text] files whose path holds '{tries[0][0]}' ({len(paths)}{'+' if len(paths) == rows else ''}):") + lines += [f" [text] {f}" for f in paths] + if not hits: + if not paths and why not in ('string', 'undeclared'): + lines.append(f"[text] {head}, and no line of the repository's files writes it either" + + (f" (nor '{n}')" if len(tries) > 1 else '') + ": absent from the text, not only from the graph.") + continue + places = places or Places(repo) + nfiles = len({h[0] for h in hits}) + rowed = True + lines.append(f"[text] {head}; the lines that write it{also} — text, not call edges " + f"({len(hits)} line(s) in {nfiles} file(s){under}):") + shown, per = [], {} + for f, ln, t in sorted(hits, key=lambda h: (h[0], h[1])): + if per.get(f, 0) >= PER_FILE: continue + per[f] = per.get(f, 0) + 1; shown.append((f, ln, t)) + # one row per file first, so twelve rows name twelve files rather than one file twelve times + seen = set(); firsts = [] + for h in shown: + if h[0] not in seen: seen.add(h[0]); firsts.append(h) + order = firsts + [h for h in shown if h not in firsts] + for f, ln, t in order[:rows]: + where = places.of(f, ln) + t = t.strip() + t = t if len(t) <= 110 else t[:107] + '…' + lines.append(f" [text] {f}:{ln}" + (f" {where}" if where else '') + f" | {t}") + rest = len(hits) - min(len(order), rows) + if rest > 0: + lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'") + if not lines: return '' + if rowed and why == 'unresolved': lines.append("next: these [text] rows are leads, not resolved edges — open the one that fits; for code the graph " + "does hold, ask again by the name of the declaration a row sits in") + return '\n'.join(lines) + '\n' + + +def emit(repo, asked, scope=None, why='unresolved', rows=ROWS): + """print the block (a verb alone) or hand what it would search for to the dispatcher (a verb that is one of several)""" + asked = [a for a in asked if a] + if not asked: return + if os.environ.get('AXIOMCODE_FANOUT') == '1': + sys.stderr.write(MARK + json.dumps({'asked': asked, 'scope': scope, 'why': why, 'rows': rows}) + '\n'); sys.stderr.flush() + return + b = block(repo, asked, scope, why, rows) + if b: sys.stdout.write('\n' + b); sys.stdout.flush() + + +def take(err): + """(stderr without the markers, [the marker objects])""" + keep, got = [], [] + for l in err.splitlines(keepends=True): + if l.startswith(MARK): + try: got.append(json.loads(l[len(MARK):])) + except ValueError: pass + else: keep.append(l) + return ''.join(keep), got diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 6be6f0b29..81b1858d7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -33,7 +33,7 @@ import importlib.machinery, importlib.util, math, os, re, sys, collections HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) -import ax_edges +import ax_edges, ax_text from ax_contract import (subtokens, TESTY, SPLIT, CAMEL, require_scope, sole_scope, best_scope, budgeted, BOUND, scope_spec, under_scope, scope_sql, dirs_with_counts, drop_ancestors, offer, task_text, task_terms, winnow, headline, is_synthetic, code_terms, @@ -939,6 +939,19 @@ def main(argv): if notice: print() if text_dirs: scopes = [x for x in scopes if x.strip('/').lstrip('./') not in text_dirs] + # AN --in NO GRAPH HOLDS AND NO DIRECTORY IS was refused with a menu, once per language graph, and no answer: the + # agent then searched by hand. A typo is not a question about nothing; the task is answered at the root (or under + # the scopes that are real) and the line says what was dropped and what is close. A scope another language's graph + # holds is left alone: this graph refuses it and that one answers; the line is printed once (ax_langs.py) + if scopes and not scope_offered: + gone = [x for x in scopes if x in unknown_scopes and not ax_text.held(g.repo, x)] + if gone: + scopes = [x for x in scopes if x not in gone] + import difflib + near = difflib.get_close_matches(gone[0], [d for d, _ in dirs_with_counts(g).items()], n=3, cutoff=0.4) + print(f"{ax_text.SCOPE_GONE}{', '.join(repr(x) for x in gone)} in its path — answering " + + (f"under {', '.join(scopes)}" if scopes else "at the repository root") + " instead" + + (f" (close: {', '.join(near)})" if near else '') + "\n") # the terms the report itself marked as code take the budget first (see winnow) terms = winnow(g, terms, strong=code_terms(body)) scored = score_symbols(g, terms) @@ -1251,11 +1264,39 @@ def main(argv): return 0 +# A QUOTED STRING IN THE TASK is usually a message, a key or a value the reader saw somewhere ("nothing named X", +# `AXIOMCODE_NO_REFRESH`): the graph holds declarations, not what they print, so its words scored against names and the +# agent then searched for the string by hand. Where it is written is a text search, so it is one, labelled [text] and +# printed after the answer; a quoted name some graph declares is the graph's question and is left to it +QUOTED = re.compile(r'"([^"\n]{4,120})"|(? 1 else '.') + asked = [] + for m in QUOTED.finditer(task_text(task)): + s = next(x for x in m.groups() if x is not None).strip() + if len(s) < 4 or s.startswith('/') or s in asked: continue # a route: the graph's route seeds answer it + if ax_text.declared(repo, re.split(r'[.#:$]', re.sub(r'\(.*\)$', '', s))[-1]): continue + asked.append(s) + ax_text.emit(repo, asked[:3], why='quoted', rows=4) + + 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.""" - if '--json' not in argv: sys.exit(main(argv)) + if '--json' not in argv: + code = main(argv) + 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) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index eac4612c6..10767207c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -58,7 +58,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 +sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text _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 @@ -1711,6 +1711,9 @@ def tests_outside_src(g, cap=20000): return rel.replace(os.sep, '/'), sorted(out) +STRING_TEXT = [] # (repo, target, --in, why): targets answered by text too (ax_text.py) + + def main(argv): args = list(argv); depth = 40; limit = 25; IN = None; as_json = '--json' in args; kind = None # --tests lists the tests grouped by rung and file; --tests-only prints that section and nothing else; @@ -1766,7 +1769,15 @@ def main(argv): if not args: die("usage: axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--json] [--kind k] [--delete]\n" " axiomcode impact --from-text [] [--top N] — an issue report in, the candidate declarations out\n" " axiomcode impact --warm [] — export the graph's facts now so the first query need not") + # AN --in NO GRAPH HOLDS narrowed the answer to nothing and said only "0 more callable(s) under it": a typo read as an + # answer. It is answered at the root and says so; a scope another language's graph holds is left alone (that graph + # answers under it); in a repository in several languages ax_langs.py prints the line once + if IN and not ax_text.held(repo, IN): + print(f"{ax_text.SCOPE_GONE}'{IN}' in its path — answering at the repository root instead", + file=sys.stderr if as_json else sys.stdout) + IN = None g = G(repo); g.IN = IN; I = Impact(g); prof('graph loaded, path facts exported') + I.kind_arg(kind) # a bad --kind is a usage error, not a name to search the text for if IN: cond, params = P.ax_contract.scope_sql(P.ax_contract.scope_spec(IN, g.repo)) if not g.q(f"SELECT 1 FROM symbols WHERE {cond} LIMIT 1", *params): @@ -1775,10 +1786,20 @@ def main(argv): die(f"no indexed file has '{IN}' in its path.") targets = []; scoped_in = False for a in args: - rs = I.resolve(a, kind) + try: rs = I.resolve(a, kind) + except SystemExit as e: + # nothing in the graph answers for it: say what a search by hand would find, labelled [text] (ax_text.py) + if e.code and not as_json: sys.stdout.flush(); ax_text.emit(repo, [a], IN) + raise if IN: rs, narrowed = scope_targets(g, rs) scoped_in = scoped_in or narrowed + # a string is text before it is anything else: its literal sites below, and after them where it is WRITTEN + # and a name nothing declares, matched only where it is referenced by name (type_use), is answered by name + # alone: the lines that write it are what a search by hand was for. A library method is a resolved edge: not this + if not as_json and any(k == 'string' for k, _, _ in rs): STRING_TEXT.append((repo, a, IN, 'string')) + elif not as_json and any('not declared here' in lab and 'library method' not in lab for _, lab, _ in rs): + STRING_TEXT.append((repo, a, IN, 'undeclared')) kinds = {k for k, _, _ in rs} if kinds == {'field', 'method'} and not kind: # a fluent accessor, a record component, a Python @property: one concept written one way. Answering for @@ -2915,4 +2936,6 @@ def main(argv): if __name__ == '__main__': if not sys.argv[1:] or sys.argv[1] in ('-h', '--help'): print(__doc__); sys.exit(0) import ax_pages; ax_pages.install('impact') # --page / --budget, and the answer printed in pages (#1198) - sys.exit(main(sys.argv[1:])) + code = main(sys.argv[1:]) + for repo, a, scope, why in STRING_TEXT: sys.stdout.flush(); ax_text.emit(repo, [a], scope, why=why) + sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 1a9ebafe0..fdcf9c21b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -33,7 +33,7 @@ look at, never as a path. import contextlib, io, json, os, re, sys, sqlite3, subprocess, tempfile, shutil, collections, glob, time HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract +sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract, ax_text # The certainty table used to live here with SEVEN entries and a default of 5 for everything else. The # engine emits ELEVEN tiers, and the five it did not list all fell BELOW `defines` — which is not a call # at all — so on a JavaScript graph, where 8,916 of 25,048 traversable edges are `callback_registered`, @@ -42,6 +42,7 @@ sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract TIER_RANK = ax_edges.TIER_RANK def die(msg, code=2): print(msg); sys.exit(code) +UNRESOLVED = [] # endpoints G.resolve found nothing by, in the order refused # ── --json: the same answer, for a program ──────────────────────────────────────────────────────── # Filled as the human output is produced, from the SAME chain objects, so the two cannot disagree: @@ -496,6 +497,7 @@ class G: shown = ', '.join(r['display'] for r in hits[:4]) + (' …' if len(hits) > 4 else '') return f"{len(ids)} declaration(s) containing '{s}' ({shown})", ids near = self.q("SELECT DISTINCT display FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL) AND (lower(display) = lower(?) OR lower(name) = lower(?) OR display LIKE ?) LIMIT 6", s, s.split('.')[-1], f"%{s.split('.')[-1]}%") + UNRESOLVED.append(s) # each refusal below: the [text] block searches for it (ax_text.py) # with candidates the advice is "pick one"; with none it has to be something else, or the line reads # "use one of them" pointing at an empty list # a word the code WRITES but nothing declares (a local, a parameter, a property) is not a target: the methods that @@ -1630,11 +1632,22 @@ if __name__ == '__main__': if args[0] == '*' and args[1] == '*': die("one endpoint must be named: path '*' X (everything that reaches X) or path X '*' (everything X reaches)") def answer(): - g = G(args[2] if len(args) > 2 else '.'); g.IN = IN + global IN + repo = args[2] if len(args) > 2 else '.' + # an --in no graph holds is answered at the root, and says so (the same rule as impact's) + if IN and not ax_text.held(repo, IN): + print(f"{ax_text.SCOPE_GONE}'{IN}' in its path — answering at the repository root instead") + IN = None + g = G(repo); g.IN = IN RESULT['query'] = {'from': args[0], 'to': args[1], 'repo': g.repo} - if args[0] == '*': return closure(g, args[1], upstream=True, limit=limit if limit != 10 else 40, depth=depth) - if args[1] == '*': return closure(g, args[0], upstream=False, limit=limit if limit != 10 else 40, depth=depth) - return path(g, args[0], args[1], show_all, limit, every, max_paths) + try: + if args[0] == '*': return closure(g, args[1], upstream=True, limit=limit if limit != 10 else 40, depth=depth) + if args[1] == '*': return closure(g, args[0], upstream=False, limit=limit if limit != 10 else 40, depth=depth) + return path(g, args[0], args[1], show_all, limit, every, max_paths) + except SystemExit as e: + # an endpoint nothing in the graph declares: what a search by hand would find, labelled [text] (ax_text.py) + 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()) # the prose goes to a buffer, not to the caller: one code path produces both, and `prose` is diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md b/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md new file mode 100644 index 000000000..c0006293d --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md @@ -0,0 +1 @@ +Set WIDGET_SECRET_KEY before starting; a missing widget prints "widget went missing". diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/case.json b/tests/cases/python/text-when-the-graph-has-no-answer/case.json new file mode 100644 index 000000000..0d1a97502 --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/case.json @@ -0,0 +1,37 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a name no graph declares (an environment variable) is answered with the lines that write it, each labelled [text], the one in code placed in its declaration and the others marked not indexed", + "run": ["impact", "WIDGET_SECRET_KEY"], "expect_error": true, + "want": ["nothing named 'WIDGET_SECRET_KEY'", "[text] the graph has no declaration for 'WIDGET_SECRET_KEY'", "[text] deploy.yml:2 not indexed", "[text] src/widgets.py:5 in load_settings", "[text] NOTES.md:1", "not call edges"], + "avoid": ["[resolved]", "reads or uses it"]}, + {"why": "a name written in no source file is searched all the same: here only the case file itself holds it, and says so as text", + "run": ["path", "noSuchWidgetThing", "*"], "expect_error": true, + "want": ["no declaration in the graph contains 'noSuchWidgetThing'", "[text] case.json:"], + "avoid": ["[text] src/", "[text] NOTES.md"]}, + {"why": "path with an undeclared endpoint gets the same [text] rows", + "run": ["path", "handler", "WIDGET_SECRET_KEY"], "expect_error": true, + "want": ["[text] src/widgets.py:5 in load_settings"]}, + {"why": "a message the task quotes is searched as text and placed in the declaration that raises it", + "run": ["context", "why does it raise \"widget went missing\" for a known id"], + "want": ["[text] no declaration is named 'widget went missing'", "[text] src/widgets.py:10 in find_widget", "[text] NOTES.md:1"]}, + {"why": "a quoted string asked of impact is answered from its literal sites and then from the lines that write it", + "run": ["impact", "\"widget went missing\""], + "want": ["[string]", "[text] 'widget went missing' is a string", "[text] NOTES.md:1 not indexed"]}, + {"why": "an --in that no graph holds is dropped for the repository root and said once, not refused with a menu", + "run": ["context", "where is the widget found", "--in", "srcc/widgts"], + "want": ["scope: no indexed file in any graph has 'srcc/widgts' in its path — answering at the repository root instead", "find_widget"], + "avoid": ["re-run with one of these"]}, + {"why": "impact under an --in no graph holds answers at the root and says so, instead of an empty answer", + "run": ["impact", "find_widget", "--in", "srcc"], + "want": ["scope: no indexed file in any graph has 'srcc'", "[resolved] handler"]}, + {"why": "control: a name the graph resolves answers from the graph only, with no [text] block", + "run": ["impact", "find_widget"], + "want": ["[resolved] handler"], + "avoid": ["[text] the graph has no declaration", "not call edges"]}, + {"why": "control: a task that quotes a name the graph declares leaves it to the graph", + "run": ["context", "how does 'find_widget' look a widget up"], + "avoid": ["no declaration is named"]}, + {"why": "control: a real --in narrows as before and prints no scope line", + "run": ["impact", "find_widget", "--in", "src"], + "want": ["[resolved] handler"], + "avoid": ["scope: no indexed file"]}]} diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml b/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml new file mode 100644 index 000000000..67b766f28 --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml @@ -0,0 +1,2 @@ +env: + WIDGET_SECRET_KEY: change-me diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py b/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py new file mode 100644 index 000000000..574f4ec5f --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py @@ -0,0 +1,16 @@ +import os + + +def load_settings(): + return {'key': os.environ.get('WIDGET_SECRET_KEY', '')} + + +def find_widget(store, wid): + if wid not in store: + raise LookupError("widget went missing") + return store[wid] + + +def handler(store, wid): + load_settings() + return find_widget(store, wid) diff --git a/tests/cases/typescript/scope-spanning-two-roots/case.json b/tests/cases/typescript/scope-spanning-two-roots/case.json index 953b780ad..97652efa0 100644 --- a/tests/cases/typescript/scope-spanning-two-roots/case.json +++ b/tests/cases/typescript/scope-spanning-two-roots/case.json @@ -70,7 +70,7 @@ ] }, { - "why": "a misspelled root names ITSELF rather than failing the whole call behind a good one", + "why": "a misspelled root names ITSELF and the root it is close to, and the call is answered under the good one rather than failing behind it", "run": [ "context", "stop formatting the amount on the subtotal", @@ -80,9 +80,14 @@ "packages/ordrs" ], "want": [ - "no indexed file has 'packages/ordrs' in its path" + "no indexed file in any graph has 'packages/ordrs' in its path — answering under packages/core instead", + "close: packages/orders", + "packages/core/src/format.ts" ], - "expect_error": true + "avoid": [ + "packages/orders/src/invoice.ts", + "re-run with one of these" + ] } ] } diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example b/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example new file mode 100644 index 000000000..df1b10cee --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example @@ -0,0 +1 @@ +ORDER_BATCH_LIMIT=25 diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json new file mode 100644 index 000000000..7ea69fc31 --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json @@ -0,0 +1,13 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an environment variable no declaration names, matched only by name, also gets the lines that write it, as [text], placed in its function", + "run": ["impact", "ORDER_BATCH_LIMIT"], + "want": ["[text] no graph declares 'ORDER_BATCH_LIMIT'", "[text] src/orders.ts:2 in readLimit", "not call edges"], + "avoid": ["[resolved]"]}, + {"why": "a quoted error message in a task is found as text in the function that throws it", + "run": ["context", "placing an order fails with 'order exceeds the batch limit'"], + "want": ["[text] no declaration is named 'order exceeds the batch limit'", "[text] src/orders.ts:7 in placeOrder"]}, + {"why": "control: a declared function answers from the graph alone", + "run": ["impact", "readLimit"], + "want": ["placeOrder"], + "avoid": ["[text] the graph has no declaration"]}]} diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts b/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts new file mode 100644 index 000000000..0202c0599 --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts @@ -0,0 +1,10 @@ +export function readLimit(): number { + return Number(process.env.ORDER_BATCH_LIMIT ?? '10'); +} + +export function placeOrder(qty: number): number { + if (qty > readLimit()) { + throw new Error("order exceeds the batch limit"); + } + return qty; +} diff --git a/tests/multi_language.py b/tests/multi_language.py index 5973c29e0..7f0f644d1 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -181,7 +181,12 @@ def check(ok, why, detail=''): check(s.returncode == 0 and '══' not in s.stdout and 'src/' in s.stdout, 'scope (control): --in the main graph\'s directory answers as the main graph alone', s.stdout + s.stderr) s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) - check(s.returncode != 0 and 'no indexed file' in s.stdout, 'scope (control): a directory no graph holds is still refused', s.stdout + s.stderr) + # a directory no graph holds is a typo, not a question about nothing: answered at the root, said ONCE, never one + # refusal menu per language (the scope another graph holds, above, is still answered from that graph) + check(s.returncode == 0 and s.stdout.count("no indexed file in any graph has 'tools/nosuch'") == 1 + and 'answering at the repository root' in s.stdout and 'tools/pkg/calc.py' in s.stdout + and 're-run with one of these' not in s.stdout, + 'scope: a directory no graph holds is answered at the repository root, and said once', s.stdout + s.stderr) s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode != 0 and 'none of these words appear under it' in s.stdout and 'no indexed file' not in s.stdout, 'scope (control): a scope one graph holds, with nothing under it matching, is refused by that graph alone', s.stdout + s.stderr) From 007325f70ea7f5b873bacb04856a118b2b2fae5c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:35:28 -0700 Subject: [PATCH 2/2] tests: a case's own case.json quoted back as [text] is not the answer its avoid checks The [text] fallback searches the whole case directory, so a refusal quoted the case file, and each check's avoid strings matched their own spec line. Avoid is now checked without the `[text] case.json:` rows; want still sees them. An undeclared environment variable is refused (exit 2) since #1602; its case now expects that refusal with the [text] rows placed in their function. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../typescript/text-when-the-graph-has-no-answer/case.json | 5 +++-- tests/run.py | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json index 7ea69fc31..7bd327e6f 100644 --- a/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json @@ -1,8 +1,9 @@ {"lang": "typescript", "src": "src", "checks": [ {"why": "an environment variable no declaration names, matched only by name, also gets the lines that write it, as [text], placed in its function", - "run": ["impact", "ORDER_BATCH_LIMIT"], - "want": ["[text] no graph declares 'ORDER_BATCH_LIMIT'", "[text] src/orders.ts:2 in readLimit", "not call edges"], + "run": ["impact", "ORDER_BATCH_LIMIT"], "expect_error": true, + "want": ["nothing named 'ORDER_BATCH_LIMIT' is declared", "[by name] readLimit", + "[text] the graph has no declaration for 'ORDER_BATCH_LIMIT'", "[text] src/orders.ts:2 in readLimit", "[text] .env.example:1 not indexed", "not call edges"], "avoid": ["[resolved]"]}, {"why": "a quoted error message in a task is found as text in the function that throws it", "run": ["context", "placing an order fails with 'order exceeds the batch limit'"], diff --git a/tests/run.py b/tests/run.py index a4bba1911..dbc733636 100755 --- a/tests/run.py +++ b/tests/run.py @@ -55,7 +55,10 @@ tot += 1 out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True) text = out.stdout + out.stderr - bad = [w for w in ch.get('want', []) if w not in text] + [f"(present) {w}" for w in ch.get('avoid', []) if w in text] + # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the + # case file lies in the searched tree): its own `avoid` strings there are not the tool's answer + said = re.sub(r'(?m)^\s*\[text\] case\.json:\d+.*\n?', '', text) + bad = [w for w in ch.get('want', []) if w not in text] + [f"(present) {w}" for w in ch.get('avoid', []) if w in said] # "stdout_json": true — STDOUT alone must parse as one JSON document. A diagnostic printed beside the answer # is invisible to a substring check (want/avoid read both streams together) and fatal to a consumer, which is # how a `note:` line sat inside --json for every name declared as both a field and a method.