diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 428560453..26ccd9428 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -176,7 +176,8 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} moved = _graphline.base_moved_line(cwd, st) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) - since = f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) else f"the graph's commit {(j.get('built_at') or '')[:10]}" + since = (f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) + else "the files the graph was indexed from" if j.get('against_index') else f"the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against {since}) —") st['reported'] = list(seen | {key(d) for d in new}) @@ -189,7 +190,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) since = (f"against {head_at} (the commits that came in are not counted)" if (AGAINST or j.get('base_moved')) - else f"since the graph's commit {(j.get('built_at') or '')[:10]}") + else "since the graph was indexed" if j.get('against_index') else f"since the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree {since} and were not reported yet —") st['reported'] = list(seen | {key(d) for d in new}) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 0b49ea840..036e7f4ac 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -207,7 +207,7 @@ def impact(d): except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) - base = (ch.get('built_at') or '')[:10] + base = '' if ch.get('against_index') else (ch.get('built_at') or '')[:10] # no commit recorded: nothing to name if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base and before is None else '') + " —") for d, j in results: head = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') @@ -263,6 +263,10 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' try: against = open(os.path.join(cwd, '.axiomcode', 'out', 'indexed-tree')).read().strip() or built except OSError: against = built if against != 'nogit' and subprocess.run(['git', 'diff', '--quiet', against, '--', rel], cwd=cwd, capture_output=True).returncode == 1: stale = f" — this file changed since the graph was built at {built[:10]}: lines are the graph's, not the file's" + elif against == 'nogit': # no commit recorded: the hash the index read it with + import ax_fresh + rec = ((ax_fresh.load_table(cwd) or {}).get('files') or {}).get(rel) + if rec and ax_fresh.digest(os.path.join(cwd, rel)) != rec[2]: stale = " — this file changed since the graph was indexed: lines are the graph's, not the file's" except Exception: pass if rows: st = load_state(); ctx = set(context_ids()) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 415c183da..cce1ea3cf 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -504,15 +504,34 @@ def _vnum(v): except (TypeError, ValueError): return None def _newer(old, impact, now_e): - """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id)""" + """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id). + AHEAD ON EITHER IS NEWER. A lower IMPACT_VERSION returned '' before the engines were compared, so a graph a 0.1.8 + engine built, read by these scripts with AXIOMCODE_ENGINE naming a 0.1.3 checkout, was called "built by an older + axiomcode (0.1.8 -> 0.1.3)" and rebuilt with the older engine""" oi, ni = _vnum(old.get('impact')), _vnum(impact) - if oi and ni and oi != ni: - return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" if oi > ni else '' + if oi and ni and oi > ni: + return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" ov, nv = old.get('engine_version'), (now_e[0] if now_e else None) if _vnum(ov) and _vnum(nv) and _vnum(ov) > _vnum(nv): return f"graph built by a newer axiomcode (engine {ov}, this one is {nv})" return '' +def _answering(): + """the engine checkout these scripts sit in, the one `axiomcode --version` reports; '' when they sit in none""" + w = H + while os.path.dirname(w) != w and not os.path.isfile(os.path.join(w, 'bin', 'axiomcode')): w = os.path.dirname(w) + return w if engine_ok(w) else '' + +def _whose(eng): + """WHICH ENGINE "THIS ONE" IS, when it is not the one answering: AXIOMCODE_ENGINE names another checkout, and a + rebuild here uses that one. Unsaid, "0.1.8 -> 0.1.3" read as a version going backwards under `axiomcode --version` + printing 0.1.8. '' when the engine compared is the checkout answering""" + a = _answering() + if not eng or not a or os.environ.get('AXIOMCODE_ENGINE') != eng or os.path.realpath(a) == os.path.realpath(eng): return '' + try: v = json.load(open(os.path.join(a, 'package.json'))).get('version', '?') + except (OSError, ValueError): v = '?' + return f"; compared with AXIOMCODE_ENGINE={eng}, which a rebuild here uses, not the axiomcode answering ({v} at {a})" + def built_by_state(repo, t=None): """(older, newer): what engine_change and newer_build return, found with one look at the engine. NEVER A DOWNGRADE comes first: a graph a newer axiomcode built is newer whatever else differs, and only a graph that is not is judged @@ -532,7 +551,7 @@ def built_by_state(repo, t=None): langs = old.get('engine_langs') now_e = engine_id(eng, langs) if eng else None newer = _newer(old, impact, now_e) - if newer: return '', newer + if newer: return '', newer + _whose(eng) diff = [] if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: h = _seen_hash(repo, now_e) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index e393ae737..60068b7bc 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -219,6 +219,15 @@ set_base(){ # the commit the baseline follows, written LAST: `changed` waits while it differs from HEAD, so it must not say "moved" # before the baseline's graph exists (keep_base_graph), or `changed` reads a baseline with no graph for it commit_base(){ [ -n "$GD" ] && echo "$HEAD_SHA" > "$OUT/base-commit"; return 0; } +# THE BASELINE WITHOUT GIT (a mirror synced without .git, an export): no tree to record, so `changed` reads an edit as a +# file whose hash differs from the table an explicit index wrote (axiomcode-changed, since_index). Kept as base-files.json, +# since every build, a background refresh too, rewrites files.json: read from it, an edit left `changed` a minute after it +# was made. An explicit index moves it; a refresh keeps it; a build with git has a tree and drops it. +set_base_files(){ + if [ -n "$GD" ]; then rm -f "$OUT/base-files.json" + elif [ -z "$KEEP_BASE" ] || [ ! -f "$OUT/base-files.json" ]; then [ -f "$OUT/files.json" ] && cp "$OUT/files.json" "$OUT/base-files.json"; fi + return 0 +} # THE BASELINE GRAPH. `changed`, `test-impact` and the change reports ask about an EDIT: which declarations it touched and # who depended on them before it, a removed method's callers included. After a background refresh the graph no longer # has the removed method, and its spans are in the new text. So the graph that describes the baseline is kept, moved @@ -287,6 +296,7 @@ if [ -z "${AXIOMCODE_REINDEX:-}" ] && [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/grap fi if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi + set_base_files has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 @@ -457,6 +467,7 @@ publish_main(){ if [ -n "$OTHER_LANGS" ]; then S="${SOLVE#*,}"; for l in ${S//,/ }; do echo "$l $MODE"; done > "$PENDING"; fi # before the pointer: the first query may follow at once point "$DB" || { restore; exit 1; }; rm -f "$OUT/.live.sqlite" if [ -f "$OUT/.files.json.new" ]; then mv "$OUT/.files.json.new" "$OUT/files.json"; else rm -f "$OUT/files.json"; fi + set_base_files # the graph now matches the files: record how long that took, and whether it included compiling the engine's rules (a # one-time cost a refresh does not pay, so such a duration predicts nothing). With other languages still to come this # is the time to the MAIN graph, which is what a query waits for. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index c97b818d3..45b023f05 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -42,6 +42,14 @@ CODE_EXT = r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$' def is_git(repo): return sh('git', 'rev-parse', '--git-dir', cwd=repo) is not None +def base_table(repo): + """the file table a graph with no commit is diffed against: the one the last explicit index kept (base-files.json, + which a background refresh leaves as it is), else the current one""" + try: return json.load(open(os.path.join(repo, '.axiomcode', 'out', 'base-files.json'))) + except (OSError, ValueError): + sys.path.insert(0, HERE); import ax_fresh + return ax_fresh.load_table(repo) + def resolve_range(repo, rng): """(old rev, new rev, note) for `--range`. `a..b` means what an agent asking about ITS commits means: what b added since it forked from a, so the old side is `git merge-base a b`, as `a...b` already is. Diffing the two TIPS, as this did, @@ -202,6 +210,20 @@ class Changed: b = os.path.join(self.repo, '.axiomcode', 'out', 'base-tree') b = open(b).read().strip() if os.path.exists(b) else '' self.base_tree = b if b and sh('git', 'cat-file', '-e', b, cwd=self.repo) is not None else self.indexed_tree + # NO COMMIT RECORDED: a graph built where there was no git (a mirror synced without .git, an export) stamps 'nogit' + # and writes no tree. Read against HEAD, every uncommitted edit the index had READ came back as changed right after + # it (a fresh index of a dirty mirror: 208 declarations), and in a copy without git nothing could be answered. The + # file table the build wrote holds each file's hash as the parser read it: a file whose bytes still match is + # unchanged against the graph, whatever git says; one that differs, is new or is gone is the edit (since_index). + self.since_index = None + if (not self.built_at or self.built_at == 'nogit') and not self.base_tree: + sys.path.insert(0, HERE); import ax_fresh + t = base_table(self.repo) + if t and t.get('files'): + c = ax_fresh.changes(self.repo, t) + if c is not None: + self.since_index = set(c[0]) | set(c[1]) | set(c[2]); self.indexed_files = set(t['files']) + self.index_built = float(t.get('built') or 0) # THE BASE MOVED UNDER THE BASELINE: a rebase, a pull, a checkout, a reset or a commit since it was set, and the # refresher has not caught up (it builds HEAD's text first, which takes minutes on a dirty tree, and never runs # with refresh off). Read against the old baseline, every change the new commits brought in (upstream's) came @@ -419,6 +441,13 @@ class Changed: # dropped here, an edit to them answered "no change" and test-impact "no test can be selected", which reads as # "nothing to test". They are kept apart, named, and test-impact looks for the tests that load them. lines = [l for l in (out or '').split('\n') if l.strip()] + if self.since_index is not None and mode == 'worktree': + # no commit to diff against: the files the table says changed since the index, and a file the table does not + # watch (a fixture, a schema) that git lists and that was written after the index + def later(l): + try: return os.path.getmtime(os.path.join(self.repo, l)) > self.index_built + except OSError: return False + lines = sorted(self.since_index | {l for l in lines if l not in self.indexed_files and not self.is_code(l) and later(l)}) self.outside = [l for l in lines if not self.is_code(l)] return [l for l in lines if self.is_code(l)] def is_code(self, rel): @@ -1381,7 +1410,9 @@ def main(argv): # function as added at line 1 and docstring words as methods. Without named files there is no answer to give: say so # and say what to pass. With named files, each counts whole (below). git = is_git(rrepo) - if not git and not (old_f or new_f) and not a: + sys.path.insert(0, HERE); import ax_fresh + # ...unless the graph's file table holds what the index read: an edit is a file that differs from it (Changed.since_index) + if not git and not (old_f or new_f) and not a and mode == 'worktree' and not (base_table(rrepo) or {}).get('files'): msg = NO_GIT.format(repo=rrepo) if as_json: print(json.dumps({'built_at': None, 'changed': [], 'notes': [], 'refused': msg, 'no_git': True}, indent=1)) else: print(msg) @@ -1392,7 +1423,6 @@ def main(argv): # A caller that already chose it (test-impact, the change hook) passes it in AXIOMCODE_GRAPH; either way it is the # baseline's graph, and its spans are not mapped again. baseline = None; fanout_empty = False - sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): bg = ax_fresh.baseline_graph(rrepo); given = os.environ.get('AXIOMCODE_GRAPH') # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a @@ -1439,9 +1469,13 @@ def main(argv): for rel in files: if a and not git: # a named file, no git: every declaration in it results += C.whole_file(rel, 'no git base to diff against'); continue + since_index = C.since_index is not None and mode == 'worktree' and rel in C.since_index + if since_index and not git: # changed since the index, no text of it before + results += C.whole_file(rel, 'changed since the index, and no git to diff its declarations against'); continue old, new = C.texts(rel, mode, rng) if old == new: if a and whole: results += C.whole_file(rel, f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + elif since_index: results += C.whole_file(rel, 'changed since the index, back to the text HEAD has: which declarations changed cannot be told') continue if flipped and not old.strip() and new.strip(): # a file the range adds: one line, not its decls reversed results += C.new_file(rel, new, C.decl_spans(rel, mode), rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython', new))) @@ -1470,14 +1504,16 @@ def main(argv): # A BASELINE THAT HOLDS EDITS (an explicit index of an edited tree, or a rebuild run as one) hides them: said, with how # to count them, rather than a bare "no change" over a real edit. Only when the question is the working tree as a whole base_note = C.baseline_note() if mode == 'worktree' and not a and not (old_f or new_f) else '' - base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else + by_index = C.since_index is not None and mode == 'worktree' and not (old_f or new_f) + base_desc = ("working tree against the files the graph was indexed from (no commit recorded)" if by_index else + f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None mv = C.moved if mode in ('worktree', 'head') and not (old_f or new_f) else None moved_json = dict(mv, note=moved_note(mv)) if mv else None if as_json: - print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), + print(json.dumps({'built_at': C.built_at, 'against_index': by_index, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, 'baseline_note': base_note, 'suggest_range': suggest, 'suggest_note': sugg_line, 'base_moved': moved_json}, indent=1)) return 3 if fanout_empty else 0 @@ -1489,6 +1525,8 @@ def main(argv): if sugg_line: print(f"next: {sugg_line}") return 3 if fanout_empty else 0 against = (f" — against HEAD {mv['new'][:10]} (the base moved: see the note)" if mv else + " — against the files the graph was indexed from (it recorded no commit): a file unchanged since the index is " + "not an edit; a changed one is read " + ("against HEAD" if git else "whole") if by_index else f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] diff --git a/tests/changed_range.py b/tests/changed_range.py index 5b661576a..d82a02037 100644 --- a/tests/changed_range.py +++ b/tests/changed_range.py @@ -10,8 +10,10 @@ control on the base branch itself: no suggestion a new module one `added ` line; no docstring word, no parameter, nothing at line 1 control a function added to an existing file keeps its own line - a copy without git a refusal naming what to pass, not invented "added" declarations - control the same copy with a named file: every declaration in it counts, and its tests are named + a copy without git against the file table its index kept: unchanged is "no change", an edited file counts + whole; read later from a git checkout, a file as the index read it is not an edit + control an edit after that index is reported; with no table, a refusal naming what to pass; a + named file: every declaration in it counts, and its tests are named a changed fixture named as outside the index; it lies in a tree a test reads by path, so it is case data for that test, with that test's own pytest line; files inside the tree are its data control a data file no test names: said so, never "no change" @@ -200,11 +202,32 @@ def ax(repo, *a): shutil.copytree(repo, copy, ignore=shutil.ignore_patterns('.git', '.axiomcode')) built = sh(copy, AX, 'index', '.', '--lang', 'python', env=env) check(built.returncode == 0, 'the copy builds', built.stdout + built.stderr) + # no commit recorded: the baseline is the file table the index wrote (each file's hash as it was read) + rc, out = ax(copy, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'indexed from' in out, 'no git: an unchanged copy right after its index is no change', out) write(copy, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 3')) rc, out = ax(copy, 'changed', '.') - check(rc != 0 and 'no git base' in out and 'added' not in out, 'no git: changed refuses, naming what to pass', out) + check(rc == 0 and 'named' in out and 'price' in out and 'discount' in out and 'level' not in out and 'added' not in out, + 'no git: the file that differs from the index counts whole, the others not at all', out) + rc, out = ax(copy, 'test-impact', '.') + check(rc == 0 and 'test_pricing' in out and 'test_stock' not in out and 'page 1 of' not in out, 'no git: test-impact selects the edited file\'s tests only', out) + # a graph with no commit read from a git checkout (a mirror synced without .git): HEAD is not what was indexed + copy2 = os.path.join(work, 'copy2') + shutil.copytree(copy, copy2, symlinks=True) + write(copy2, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 5')) + for c in (['init', '-q'], ['add', 'app', 'tests'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'older']): sh(copy2, 'git', *c) + write(copy2, 'app/pricing.py', FILES['app/pricing.py']) # the text the copy's index read + rc, out = ax(copy2, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'price' not in out, + 'no commit recorded, read in a git checkout: a file as the index read it is not an edit against HEAD', out) + write(copy2, 'app/stock.py', FILES['app/stock.py'].replace('return n', 'return n + 0')) + rc, out = ax(copy2, 'changed', '.') + check('level' in out and 'price' not in out, 'control: an edit made after that index is still reported, and only it', out) + for t in ('files.json', 'base-files.json'): os.remove(os.path.join(copy, '.axiomcode', 'out', t)) + rc, out = ax(copy, 'changed', '.') + check(rc != 0 and 'no git base' in out and 'added' not in out, 'control: no git and no file table: changed refuses, naming what to pass', out) rc, out = ax(copy, 'test-impact', '.') - check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'no git: test-impact refuses, with no page footer', out) + check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'control: no git and no file table: test-impact refuses, with no page footer', out) rc, out = ax(copy, 'test-impact', '.', 'app/pricing.py') check(rc == 0 and 'tests/test_pricing.py' in out and 'test_stock' not in out, 'control: test-impact on the copy names that file\'s tests', out) rc, out = ax(copy, 'changed', '.', 'app/pricing.py') diff --git a/tests/freshness.py b/tests/freshness.py index c106676f8..08bcd425b 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -470,7 +470,7 @@ def query(repo): repo = repo_by('newer', impact=up) s = ax_fresh.status(repo); want = f"graph built by a newer axiomcode (IMPACT_VERSION {up}, this one has {mine})" check("newer: a higher IMPACT_VERSION is a newer build, not an older one to rebuild; with no edit the graph is fresh", - s.get('state') == 'fresh' and s.get('newer') == want and ax_fresh.engine_change(repo) == '', s) + s.get('state') == 'fresh' and s.get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '', s) out, err, took = query(repo) check(f"newer: the answer comes from it at once ({took:.1f}s), unmarked, and says a newer axiomcode built it and it is not rebuilt", out.strip() == ROWS.strip() and want in err and 'not rebuild' in err and 'rebuilding' not in err and took < 10, (out, err)) @@ -482,24 +482,33 @@ def query(repo): # an edit: still never rebuilt; the answer marks the edited file's rows, waits for nothing, and says why no refresh comes open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') s = ax_fresh.status(repo); out, err, took = query(repo) - check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer') == want, s) + check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer', '').startswith(want), s) check("newer: with a file edited the refresher still does not rebuild it", not worker(repo), '') check(f"newer: with a file edited the answer marks that file's rows, does not wait ({took:.1f}s), and names no rebuild", 'shop/api.py:5 - calls it' + ax_fresh.MARK in out and want in err and 'predates edits to shop/api.py' in err and 'queued' not in err and 'rebuilding' not in err and 'waiting' not in err and took < 10, (out, err)) w = ax_fresh.wait(repo, 5) - check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", w.get('newer') == want, w) + check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", (w.get('newer') or '').startswith(want), w) # the same IMPACT_VERSION and a later engine is newer too repo = repo_by('newer-engine', engine_version='1.0.1') check("newer: the same IMPACT_VERSION and a later engine version is a newer build", - ax_fresh.status(repo).get('newer') == "graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)" and not worker(repo), + ax_fresh.status(repo).get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") and not worker(repo), ax_fresh.status(repo)) + # AHEAD ON EITHER IS NEWER: a later engine whose IMPACT_VERSION is lower (these scripts newer than the engine that + # AXIOMCODE_ENGINE names) was "built by an older axiomcode (engine 1.0.1 -> 1.0.0)" and rebuilt with the older engine + repo = repo_by('newer-engine-older-export', impact=down, engine_version='1.0.1', engine_hash='0' * 40, engine_stat='0' * 40) + s = ax_fresh.status(repo) + check("newer: a later engine is a newer build even with a lower IMPACT_VERSION, not an older one to rebuild", + s.get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") + and ax_fresh.engine_change(repo) == '' and not worker(repo), s) + check("newer: the note says the engine compared is AXIOMCODE_ENGINE, not the axiomcode answering", + f"AXIOMCODE_ENGINE={e1}" in s.get('newer', '') and 'not the axiomcode answering' in s.get('newer', ''), s) # ── composed with the per-language key: NEVER A DOWNGRADE is decided first ── # a newer graph whose own language's rules differ from this engine's is still not rebuilt: that difference is # the newer axiomcode's, not a staleness this one can fix repo = repo_by('newer-own-rules', impact=up, engine_hash='0' * 40, engine_stat='0' * 40) check("newer: a newer build whose own language's engine files differ is still a newer build, not an older one", - ax_fresh.status(repo).get('newer') == want and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) + ax_fresh.status(repo).get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) # a higher IMPACT_VERSION is not re-exported either (rewarm): that would record this older version over it worker(repo) check("newer: the refresher does not re-export a newer build's facts; the table keeps the newer IMPACT_VERSION",