🤖 Add orbit prototype plugin for Rust code review

Standalone plugin with one skill: when to use orbit and when grep, a
mandatory preflight for stale indexes and blind regions (cfg(test),
features, macros, dyn dispatch), a scripted review pass over isolated
worktree DBs, and a JSONL feedback log for mining real usage later.
Registered in the marketplace as a prototype.
This commit is contained in:
2026-09-29 19:52:59 +02:00
parent 0dee6c415b
commit f1de89e869
27 changed files with 1482 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Recipe 2: blast radius of changed definitions, with a grep cross-check that closes orbit's gaps.
Usage: blast.py <env-file> <defdiff.json> [--ops S-~>] [--kinds ...] [--measure] [--top N]
For each changed definition (default ops: S signature, - removed, > moved, ~ body with member changes):
orbit: CALLS callers (tip; base for removed), construction sites (CALLS->Variant for enums),
importing files (gl_imported_symbol.identifier_name), EXTENDS implementors (traits)
split: dependents inside the diff vs OUTSIDE the diff (the review-relevant ones)
--measure: git grep -w at the same commit, attribute hits to enclosing orbit definitions,
report recall and classify every grep-only site (the gap list).
"""
import collections, json, re, subprocess, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q, git
from regions import cfg_regions, region_of
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
arg = lambda k, d: sys.argv[sys.argv.index(k) + 1] if k in sys.argv else d
OPS = set(arg('--ops', 'S->~'))
MEASURE = '--measure' in sys.argv
TOP = int(arg('--top', '6'))
GENERIC = {'new', 'default', 'build', 'from', 'fmt', 'run', 'drop', 'clone', 'eq', 'deref', 'into', 'len',
'get', 'set', 'id', 'name', 'start', 'update', 'apply', 'is_empty', 'as_str', 'to_string', 'hash', 'serialize'}
CALLABLE = {'Function', 'Method', 'AssociatedFunction'}
def defs_at(wt):
ds = q("SELECT id, file_path, fqn, name, definition_type k, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=wt, db=DB)
by = collections.defaultdict(list)
for d in ds: by[d['file_path']].append(d)
return {d['id']: d for d in ds}, by
def edges_at(wt, kind):
return q(f"SELECT e.source_id s, e.target_id t FROM gl_edge e JOIN gl_definition d ON d.id=e.target_id "
f"WHERE e.relationship_kind='{kind}'", repo=wt, db=DB)
SIDE = {}
for side, wt in (('tip', TW), ('base', BW)):
byid, byfile = defs_at(wt)
calls = collections.defaultdict(set); ext = collections.defaultdict(set)
for e in edges_at(wt, 'CALLS'): calls[e['t']].add(e['s'])
for e in edges_at(wt, 'EXTENDS'): ext[e['t']].add(e['s'])
imps = collections.defaultdict(set); globs = collections.defaultdict(set)
for r in q("SELECT DISTINCT file_path, identifier_name n, import_path p, import_type it FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=wt, db=DB):
if r['n']: imps[r['n']].add(r['file_path'])
if r['it'] in ('GlobUse', 'ReExportGlob'): globs[r['p'].split('::')[-1]].add(r['file_path'])
children = collections.defaultdict(list)
for d in byid.values():
children[d['fqn'].rsplit('::', 1)[0]].append(d)
SIDE[side] = dict(byid=byid, byfile=byfile, calls=calls, ext=ext, imps=imps, globs=globs, children=children, wt=wt,
rev=T if side == 'tip' else B)
changed_files = set(dd['changed_files'])
TIP_REEXPORTS = q("SELECT file_path, identifier_name n, import_path p, import_type it, start_line s FROM gl_imported_symbol "
"WHERE import_type LIKE 'ReExport%' OR import_type = 'PubUseGroup'", repo=TW, db=DB)
TIP_IMPORTS = collections.defaultdict(list)
for x in q("SELECT file_path, identifier_name n, import_path p FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=TW, db=DB):
TIP_IMPORTS[x['n']].append(x)
touched = {'tip': set(dd['touched_tip']), 'base': set(dd['touched_base'])}
def innermost(byfile, f, ln):
cov = [d for d in byfile.get(f, []) if d['s'] <= ln <= d['e'] and d['k'] not in ('Module', 'Field', 'Variant')]
return min(cov, key=lambda d: d['e'] - d['s']) if cov else None
def grep_sites(S, row):
n = row['name']
if row['k'] == 'Method': pat = rf'(\.|::){n}\b'
elif row['k'] == 'AssociatedFunction': pat = rf'{row["fqn"].split("::")[-2]}::{n}\b'
else: pat = rf'\b{n}\b'
try: out = git(REPO, 'grep', '-nE', pat, S['rev'], '--', '*.rs')
except subprocess.CalledProcessError: return []
res = []
parts = row['fqn'].split('::')
owner = parts[-2] if len(parts) > 1 else ''
# Rust name-resolution approximation: the site's file must be able to see the name
vis = {row['file']} | S['imps'].get(n, set()) | S['globs'].get(owner, set())
if row['k'] in ('Method', 'AssociatedFunction'):
vis = None # methods resolve through the receiver type, not imports
for l in out.splitlines():
_, f, ln, txt = l.split(':', 3); ln = int(ln); t = txt.strip()
if vis is not None and f not in vis and not re.search(rf'\b{owner}::{n}\b', t): continue
if t.startswith(('//', '///', '//!')): continue
if re.search(rf'\b(fn|struct|enum|trait|const|static|type|mod)\s+{n}\b', t): continue
res.append((f, ln, t))
return res
_src = {}
def lines_of(S, f):
if (S['wt'], f) not in _src:
try: _src[(S['wt'], f)] = open(f"{S['wt']}/{f}", encoding='utf-8', errors='replace').read().split('\n')
except FileNotFoundError: _src[(S['wt'], f)] = []
return _src[(S['wt'], f)]
def enclosing_macro(L, ln, limit=400):
depth = 0
for i in range(ln - 2, max(-1, ln - 2 - limit), -1):
x = re.sub(r'"(\\.|[^"\\])*"', '""', re.sub(r'//.*', '', L[i]))
depth += x.count('}') + x.count(')') + x.count(']') - x.count('{') - x.count('(') - x.count('[')
if depth < 0:
m = re.search(r'(\w+)!\s*[\{\(\[]', x)
if m: return m.group(1)
if re.match(r'\s*((pub|async|unsafe|const)\s+)*fn\b|\s*impl\b', x): return None
depth = 0
return None
def classify_gap(S, row, f, ln, t, imported_files):
if re.match(r'(pub(\([^)]*\))?\s+)?use\b', t) or f in imported_files and t.endswith((',', '};', '{')) and '(' not in t:
return 'import-line'
real = [d for d in S['byfile'].get(f, []) if d['k'] != 'Macro']
if not real: return 'file-no-defs(gated/test)'
reg = region_of(cfg_regions(lines_of(S, f)), ln)
if reg: return f"cfg-{reg[2]}"
n = row['name']
if re.match(rf'(unsafe\s+)?impl\b.*\b{n}\b', t): return 'impl-header(no edge by design)'
if row['k'] in ('Enum', 'Struct') and (re.search(rf'\b{n}(::\w+)?\s*(\(.*\)|\{{.*\}}|)\s*(=>|\|)', t) or re.search(r'\b(if|while) let\b|matches!', t)):
return 'pattern-match(no edge by design)'
if row['k'] in ('Constant', 'Static'): return 'const-read(no edge by design)'
if row['k'] == 'Enum' and re.search(rf'\b{n}::[A-Z]\w*\s*(\{{|\(|$)', t): return 'variant-literal-or-multiline-pattern'
if row['k'] in ('Enum', 'Struct') and re.search(rf'\b{n}::\w+\s*\(', t): return 'derived/trait-assoc-call(Type::default() etc.)'
if row['k'] in CALLABLE and (re.search(r'add_systems|add_observer|\.observe\(|run_if|in_set|\.after\(|\.before\(', t)
or re.search(rf'\b{n}\b(?!\s*(\(|::|<))', t)):
return 'fn-ref(bevy system/callback)'
if re.search(rf'\w+!\s*[\(\[{{][^;]*\b{row["name"]}\b', t): return 'macro-arg'
if row['k'] in ('Struct', 'Enum', 'Trait', 'TypeAlias') and not re.search(rf'\b{row["name"]}\s*(\{{|\(|::)', t):
return 'type-mention(no edge by design)'
m = enclosing_macro(lines_of(S, f), ln)
if m: return f'inside-macro-body({m}!)'
if row['k'] == 'Method' and re.search(rf'\.{row["name"]}\b', t): return 'method-on-unresolved-receiver(dyn/field/closure)'
if not innermost(S['byfile'], f, ln): return 'outside-any-def(static/const init?)'
return 'other'
def dependents(row):
side = 'base' if row['op'] == '-' else 'tip'
S = SIDE[side]; did = row['bid'] if side == 'base' else row['tid']
tgt = {did}
if row['k'] in ('Enum', 'Struct'): # construction sites hit variants / the struct itself
tgt |= {c['id'] for c in S['children'].get(row['fqn'], []) if c['k'] in ('Variant', 'Method', 'AssociatedFunction')}
callers = set().union(*[S['calls'].get(t, set()) for t in tgt]) - tgt
impls = S['ext'].get(did, set())
importers = S['imps'].get(row['name'], set()) - {row['file']}
return side, S, callers, impls, importers
def tgt_ids(row, S):
did = row['bid'] if row['op'] == '-' else row['tid']
return {did} | {c['id'] for c in S['children'].get(row['fqn'], [])}
def fmt_def(S, i):
d = S['byid'][i]; return f"{d['file_path']}:{d['s']} {d['fqn'].split('::')[-1]}"
rows = [r for r in dd['rows'] if r['op'] in OPS and (r['op'] != '~' or r.get('members'))
and r['k'] not in ('Module',) and not r.get('collapsed')]
tot = collections.Counter(); gaps_all = collections.Counter(); gap_ex = collections.defaultdict(list)
print(f"# blast radius {B[:8]}..{T[:8]} targets={len(rows)} (callers split: in-diff / OUTSIDE diff)")
for r in rows:
side, S, callers, impls, importers = dependents(r)
tset = touched[side]
outside = sorted(c for c in callers if c not in tset)
imp_out = sorted(f for f in importers if f not in changed_files)
line = (f"{r['op']} {r['k']} {r['fqn'].split('::',1)[-1]} [{r['file']}] callers={len(callers)} outside={len(outside)}"
f" importers={len(importers)} outside={len(imp_out)}" + (f" implementors={len(impls)}" if r['k'] == 'Trait' else ''))
print(line)
for c in outside[:TOP]: print(f" <- {fmt_def(S, c)}")
if len(outside) > TOP: print(f" … {len(outside)-TOP} more outside callers")
if imp_out and not outside: print(f" imports in: {', '.join(imp_out[:TOP])}{' …' if len(imp_out)>TOP else ''}")
if r['op'] == '-':
tail = '::'.join(r['fqn'].split('::')[-2:]) # parent_module::Name
stale = sorted({x['file_path'] for x in TIP_IMPORTS.get(r['name'], []) if x['p'].endswith(tail)})
surv = sorted({d['file_path'] for d in SIDE['tip']['byid'].values() if d['name'] == r['name'] and d['k'] == r['k']})
if surv: print(f" = same-name {r['k']} survives at tip in: {', '.join(surv[:3])}{' …' if len(surv)>3 else ''}")
if stale:
par = r['fqn'].split('::')[-2]
mods = [d for d in SIDE['tip']['byid'].values() if d['k'] == 'Module' and d['name'] == par]
rex = [x for x in TIP_REEXPORTS if (x['n'] == r['name'] or x['it'] in ('ReExportGlob',)) and
(x['file_path'] == r['file'] or any(m['file_path'] == x['file_path'] and m['s'] <= x['s'] <= m['e'] for m in mods))]
if rex: print(f" = old path '…{tail}' kept alive by re-export at {rex[0]['file_path']}:{rex[0]['s']} ({len(stale)} importers use it)")
else: print(f" ! tip imports via old path '…{tail}' with NO re-export found (dangling unless cfg-gated): {', '.join(stale[:TOP])}")
same_name = sum(1 for d in S['byid'].values() if d['name'] == r['name'] and d['k'] not in ('Field', 'Module'))
if MEASURE and (r['name'] in GENERIC or same_name > 1):
tot['skipped_ambiguous'] += 1; print(f" measure: skipped (name defined {same_name}x at {side})")
elif MEASURE:
sites = grep_sites(S, r)
me = S['byid'][r.get('tid') if side == 'tip' else r.get('bid')]
found, missed = set(), {}; skipped = collections.Counter()
for f, ln, t in sites:
if f == me['file_path'] and me['s'] <= ln <= me['e']: continue
g = classify_gap(S, r, f, ln, t, importers)
if g in ('import-line',) or 'by design' in g: skipped[g] += 1; continue
enc = innermost(S['byfile'], f, ln)
if enc and enc['id'] in callers: found.add(enc['id']); continue
if enc and enc['id'] in tgt_ids(r, S): continue
key = enc['id'] if enc else (f, g)
if key in missed: continue
missed[key] = g
if len(gap_ex[g]) < 3: gap_ex[g].append(f"{f}:{ln} {t[:60]}")
gaps = collections.Counter(missed.values())
dep_files = {k[0] if isinstance(k, tuple) else S['byid'][k]['file_path'] for k in missed} | {S['byid'][c]['file_path'] for c in found}
cover = importers | {S['byid'][c]['file_path'] for c in callers} | {r['file']}
tot['dep_files'] += len(dep_files); tot['dep_files_covered'] += len(dep_files & cover)
n_true = len(found) + len(missed)
for c in list(callers - found)[:2]:
if len(gap_ex['orbit-only']) < 6: gap_ex['orbit-only'].append(f"{r['name']} <- {fmt_def(S, c)}")
tot['true'] += n_true; tot['hit'] += len(found); tot['orbit_only'] += len(callers - found)
for g, c in gaps.items(): gaps_all[g] += c
print(f" measure: dependents(grep∪orbit)={n_true} orbit={len(found)} recall={len(found)/n_true if n_true else float('nan'):.0%} "
f"orbit_only={len(callers-found)} files_covered_by_callers∪importers={len(dep_files & cover)}/{len(dep_files)} missed_by={dict(gaps)} not_counted={dict(skipped)}")
if MEASURE:
print(f"# TOTAL dependents={tot['true']} orbit_found={tot['hit']} recall={tot['hit']/max(1,tot['true']):.0%} orbit_only={tot['orbit_only']} "
f"ambiguous_skipped={tot['skipped_ambiguous']} file-level(callers∪importers)={tot['dep_files_covered']}/{tot['dep_files']}={tot['dep_files_covered']/max(1,tot['dep_files']):.0%}")
print(f"# orbit-only examples: {' | '.join(gap_ex['orbit-only'])}")
for g, c in gaps_all.most_common(): print(f"# gap {c:4d} {g:40s} e.g. {' | '.join(gap_ex[g][:2])}")
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Recipe 4: module/crate boundary guard for a diff.
Usage: boundary.py <env-file> <defdiff.json> <rules-file> [--audit] [--measure]
- NEW crate->crate import edges (tip minus base, whole repo) with an example file
- NEW import rows in changed files, checked against allow/deny rules
- NEW intra-crate module edges (crate::a -> crate::b) introduced by changed files
--audit also check every tip import against the rules (validates the rules themselves)
--measure compare files with new imports (orbit) against files whose diff adds a `use` line (text)
Imports are statement-level and kept even in cfg-gated/test files (unlike definitions).
Raw paths: `super::`/relative paths are attributed to the file's own crate; re-exports are not followed.
"""
import collections, fnmatch, json, os, re, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q, git
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2])); RULES = sys.argv[3]
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
def root_pkg():
m = re.search(r'\[package\][^\[]*?name\s*=\s*"([^"]+)"', open(f"{TW}/Cargo.toml").read(), re.S)
return m.group(1).replace('-', '_') if m else '_root'
ROOT = root_pkg()
CRATES = {d.replace('-', '_') for d in os.listdir(f"{TW}/crates") if os.path.isdir(f"{TW}/crates/{d}")} | {ROOT}
def crate_of(f):
p = f.split('/')
return p[1].replace('-', '_') if p[0] == 'crates' and len(p) > 2 else ROOT
def dst_of(f, path):
head = path.split('::')[0]
if head in ('crate', 'self', 'super', ''): return crate_of(f)
return head.replace('-', '_')
allow, deny = {}, []
for line in open(RULES):
line = line.split('#', 1)[0].strip() if not line.lstrip().startswith('deny') else line.strip()
if line.startswith('allow'):
a, b = line[5:].split(':', 1); allow[a.strip()] = set(b.split())
elif line.startswith('deny'):
body, _, note = line[4:].partition('#')
src, _, rx = body.partition('->'); deny.append((src.strip(), re.compile(rx.strip()), note.strip()))
def rows(wt):
return q("SELECT file_path f, import_path p, identifier_name n, import_type it, start_line s FROM gl_imported_symbol "
"WHERE file_path LIKE '%.rs' AND import_type <> 'ModDeclaration'", repo=wt, db=DB)
R = {'base': rows(BW), 'tip': rows(TW)}
renames = dd['renames']
def key(r, side):
f = renames.get(r['f'], r['f']) if side == 'base' else r['f']
return (f, r['p'], r['n'])
def violations(r):
src, dst = crate_of(r['f']), dst_of(r['f'], r['p'])
out = []
if src in allow and dst in CRATES and dst != src and dst not in allow[src]:
out.append(f"allow-list: {src} may not import {dst}")
for g, rx, note in deny:
if fnmatch.fnmatch(r['f'], g) and rx.search(r['p']) and dst != src: out.append(f"deny: {note or rx.pattern}")
return out
# crate edges
def crate_edges(side):
e = collections.defaultdict(list)
for r in R[side]:
s, d = crate_of(r['f']), dst_of(r['f'], r['p'])
if d in CRATES and d != s: e[(s, d)].append(r['f'])
return e
be, te = crate_edges('base'), crate_edges('tip')
print(f"# boundary {B[:8]}..{T[:8]} root_pkg={ROOT} workspace_crates={len(CRATES)} rules: allow={len(allow)} deny={len(deny)}")
new_edges = sorted(set(te) - set(be)); gone = sorted(set(be) - set(te))
print(f"new crate edges: {len(new_edges)}" + ''.join(f"\n + {s} -> {d} ({len(te[(s,d)])} imports, e.g. {te[(s,d)][0]})" for s, d in new_edges))
if gone: print(f"removed crate edges: " + ', '.join(f"{s}->{d}" for s, d in gone))
changed = set(dd['changed_files'])
# grep fallback 1: inline qualified paths (`other_crate::x::Y` without a `use`) are NOT import rows
cre = re.compile(r'\b(' + '|'.join(sorted(CRATES, key=len, reverse=True)) + r')::')
def inline_edges(wt, files):
te_ = collections.defaultdict(set)
for f in files:
try: src = open(f"{wt}/{f}", errors='replace').read().split('\n')
except FileNotFoundError: continue
for i, l in enumerate(src):
t = l.strip()
if t.startswith(('//', 'use ', 'pub use ')): continue
for m in cre.finditer(l):
if m.group(1) != crate_of(f): te_[(crate_of(f), m.group(1))].add(f"{f}:{i+1}")
return te_
inv_ren = {v: k for k, v in renames.items()}
text_edges = inline_edges(TW, changed)
base_text = inline_edges(BW, {inv_ren.get(f, f) for f in changed})
imp_pairs = set(te)
extra = {k: v for k, v in text_edges.items() if k not in imp_pairs}
if extra: print("crate edges seen only as inline paths in changed files (no import row):" + ''.join(
f"\n ~ {s} -> {d} e.g. {sorted(v)[0]}{' [NEW: not in base imports or base inline paths]' if (s, d) not in be and (s, d) not in base_text else ''}" for (s, d), v in sorted(extra.items())))
# grep fallback 2: Cargo.toml dependency changes (the compiler-enforced graph)
cd = git(REPO, 'diff', '-U0', B, T, '--', '*Cargo.toml'); cf = None; deps = []
for l in cd.split('\n'):
if l.startswith('+++ '): cf = l[6:]
m = re.match(r'^([-+])\s*([\w-]+)\s*=', l)
if m and cf and not l.startswith(('+++', '---')) and m.group(2).replace('-', '_') in CRATES | {'dioxus', 'opencode_client'}:
deps.append(f"{m.group(1)}{m.group(2)} in {cf}")
print(f"Cargo.toml workspace-dep changes: {deps if deps else 'none'}")
for dline in deps:
sign, rest = dline[0], dline[1:]; dname, _, cf2 = rest.partition(' in ')
sc = cf2.split('/')[1].replace('-', '_') if cf2.startswith('crates/') else ROOT
if sign == '+' and sc in allow and dname.replace('-', '_') not in allow[sc]:
print(f" !! {sc} gains dependency {dname}, not in documented allow-list")
bkeys = {key(r, 'base') for r in R['base']}
new_rows = [r for r in R['tip'] if r['f'] in changed and key(r, 'tip') not in bkeys]
tkeys = {key(r, 'tip') for r in R['tip']}
gone_rows = [r for r in R['base'] if renames.get(r['f'], r['f']) in changed and key(r, 'base') not in tkeys]
print(f"new import rows in changed files: {len(new_rows)} (in {len({r['f'] for r in new_rows})} files); removed rows: {len(gone_rows)}")
viol = [(r, v) for r in new_rows for v in violations(r)]
print(f"rule violations among NEW imports: {len(viol)}" + ''.join(f"\n !! {r['f']}:{r['s']} use {r['p']} [{v}]" for r, v in viol[:20]))
# intra-crate module edges introduced by changed files
def mod_edges(rs):
e = set()
for r in rs:
p = r['p'].split('::')
if p[0] == 'crate' and len(p) > 2:
f = r['f']; parts = f.split('/')
src_mod = (parts[3] if parts[0] == 'crates' and len(parts) > 4 else parts[1] if parts[0] == 'src' and len(parts) > 2 else '(root)').replace('.rs', '')
if src_mod != p[1]: e.add((crate_of(f), src_mod, p[1]))
return e
allbase = mod_edges(R['base'])
nm = sorted(mod_edges(new_rows) - allbase)
print(f"new intra-crate module edges: {len(nm)}" + ''.join(f"\n + {c}: {a} -> {b}" for c, a, b in nm[:15]) + (f"\n … {len(nm)-15} more" if len(nm) > 15 else ''))
if '--audit' in sys.argv:
allv = collections.Counter(); ex = {}
for r in R['tip']:
for v in violations(r):
k = (crate_of(r['f']), dst_of(r['f'], r['p']), v); allv[k] += 1; ex.setdefault(k, f"{r['f']}:{r['s']} {r['p']}")
print(f"# audit: existing violations at tip: {sum(allv.values())}")
for k, n in allv.most_common(12): print(f" {n:4d} {k[0]} -> {k[1]} [{k[2]}] e.g. {ex[k]}")
if '--measure' in sys.argv:
diff = git(REPO, 'diff', '-U0', B, T, '--', '*.rs'); f = None; text_files = set(); text_del = set()
for l in diff.split('\n'):
if l.startswith('+++ '): f = l[6:]
elif l.startswith('--- '): of = l[6:]
elif re.match(r'^\+\s*(pub(\([^)]*\))?\s+)?use\s', l): text_files.add(f)
elif re.match(r'^-\s*(pub(\([^)]*\))?\s+)?use\s', l): text_del.add(renames.get(of, of))
ofiles = {r['f'] for r in new_rows}; ogone = {renames.get(r['f'], r['f']) for r in gone_rows}
print(f"# measure: files with added `use` line (text)={len(text_files)} orbit-new-import files={len(ofiles)} both={len(text_files & ofiles)} "
f"recall={len(text_files & ofiles)/max(1,len(text_files)):.0%}; text-missed={sorted(text_files - ofiles)[:5]}; orbit-only={len(ofiles - text_files)}")
print(f"# measure: files with removed `use` line (text)={len(text_del)} orbit-removed-import files={len(ogone)} both={len(text_del & ogone)}")
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Recipe 6: compact API context of the touched area, sized for an LLM reviewer brief.
Usage: context.py <env-file> <defdiff.json> [--budget BYTES] [--all-files]
Per changed file (visible to orbit), one line per type/callable at tip with its declaration text
read from the source (more reliable than repo-map's extractor), change markers from defdiff
(+ added, S signature, ~ body, > moved), untouched items collapsed when over budget.
Compare: `orbit repo-map api <dir>` (directory prefix only; a file path returns nothing).
"""
import collections, json, re, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
DB, TW = env['DB'], env['TIP_WT']
BUDGET = int(sys.argv[sys.argv.index('--budget') + 1]) if '--budget' in sys.argv else 8000
SHOW = {'Struct', 'Enum', 'Trait', 'TypeAlias', 'Function', 'Method', 'AssociatedFunction', 'Constant', 'Static', 'Macro'}
ops = {r.get('tid'): r['op'] for r in dd['rows'] if r.get('tid')}
changed = [f for f in dd['changed_files'] if f.endswith('.rs')]
defs = collections.defaultdict(list)
for d in q("SELECT id, file_path f, fqn, name, definition_type k, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=TW, db=DB):
if d['f'] in changed and d['k'] in SHOW: defs[d['f']].append(d)
def decl(lines, d):
out = []
for l in lines[d['s'] - 1:d['e']]:
t = l.strip()
if not out and (not t or t.startswith(('///', '//', '#[', '#!'))): continue
out.append(t)
if '{' in t or t.endswith(';') or len(out) > 8: break
s = re.sub(r'\s+', ' ', ' '.join(out)).split('{')[0].strip()
s = re.sub(r'\(\s+', '(', s); s = re.sub(r',\s*\)', ')', s)
return s[:160]
blocks = []
for f in changed:
ds = sorted(defs.get(f, []), key=lambda d: d['s'])
if not ds: continue
try: lines = open(f"{TW}/{f}", errors='replace').read().split('\n')
except FileNotFoundError: continue
body = []
nchg = 0
for d in ds:
op = ops.get(d['id'], ' ')
nchg += op != ' '
ind = ' ' if d['k'] in ('Method', 'AssociatedFunction') else ''
body.append((op, f"{op} {ind}{decl(lines, d)} L{d['s']}"))
blocks.append((f, nchg, body))
out = []; size = 0
is_test = lambda f: '/tests/' in f or f.endswith('_test.rs') or f.endswith('tests.rs')
blocks.sort(key=lambda b: (is_test(b[0]), -b[1]))
for f, nchg, body in blocks:
head = f"## {f} ({nchg} changed / {len(body)} items)"
full = '\n'.join([head] + [x for _, x in body])
unchanged = sum(1 for op, _ in body if op == ' ')
slim = '\n'.join([head] + [x for op, x in body if op != ' '] + ([f" … {unchanged} unchanged items (orbit grep --path {f})"] if unchanged else []))
txt = full if len(full) <= 2000 and size + len(full) <= BUDGET else slim
if size + len(txt) > BUDGET:
out.append(f"## {f} ({nchg} changed) — omitted (budget)"); continue
out.append(txt); size += len(txt)
print(f"# context: {len(blocks)} visible changed files, budget {BUDGET}B")
print('\n'.join(out))
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Recipe 1: map `git diff base..tip` onto definitions of both indexed commits.
Usage: defdiff.py <env-file> [--json out.json] [--all-kinds]
env-file: output of review-setup.sh (DB, BASE_WT, TIP_WT, BASE_SHA, TIP_SHA, REPO)
Prints a compact per-file report:
+ added - removed > moved (same fqn, other file)
S signature changed (declaration text differs)
~ body touched (a diff hunk hits the definition; size shown)
? relocated candidate (removed+added pair with same name+kind, different fqn)
and an 'unattributed' tally of changed lines no definition covers (the blindness input).
"""
import collections, json, re, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q, git
from regions import cfg_regions, region_of
env = load_env(sys.argv[1])
DB, BW, TW, REPO = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO']
B, T = env['BASE_SHA'], env['TIP_SHA']
JSON_OUT = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None
SQL = """SELECT id, file_path, fqn, name, definition_type k, start_line s, end_line e
FROM gl_definition WHERE file_path LIKE '%.rs'"""
ROLLUP = {'Field', 'Variant'} # reported through their parent
CONTAINER = {'Module'} # only used if nothing smaller covers a line
def load(wt):
defs = q(SQL, repo=wt, db=DB)
byfile = collections.defaultdict(list)
for d in defs: byfile[d['file_path']].append(d)
return defs, byfile
bdefs, bfile = load(BW)
tdefs, tfile = load(TW)
# ---- hunks (-U0, rename detection so moved files map old->new path)
diff = git(REPO, 'diff', '-U0', '-M', '--no-color', B, T, '--', '*.rs')
old_lines = collections.defaultdict(set); new_lines = collections.defaultdict(set)
old_text = collections.defaultdict(dict); new_text = collections.defaultdict(dict)
renames = {}
of = nf = None
for line in diff.split('\n'):
if line.startswith('--- '):
of = None if line[4:] == '/dev/null' else line[6:]
elif line.startswith('+++ '):
nf = None if line[4:] == '/dev/null' else line[6:]
if of and nf and of != nf: renames[of] = nf
elif line.startswith('@@'):
m = re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
os_, oc, ns, nc = int(m[1]), int(m[2] or 1), int(m[3]), int(m[4] or 1)
oi, ni = os_, ns
elif line.startswith('-') and of:
old_lines[of].add(oi); old_text[of][oi] = line[1:]; oi += 1
elif line.startswith('+') and nf:
new_lines[nf].add(ni); new_text[nf][ni] = line[1:]; ni += 1
def innermost(defs, ln):
cov = [d for d in defs if d['s'] <= ln <= d['e']]
if not cov: return None
non_container = [d for d in cov if d['k'] not in CONTAINER]
cov = non_container or cov
cov.sort(key=lambda d: d['e'] - d['s'])
d = cov[0]
if d['k'] in ROLLUP: # roll fields/variants up to their parent type
parents = [p for p in cov if p['k'] not in ROLLUP]
if parents: d = parents[0]
return d
def imports(wt):
r = collections.defaultdict(list)
for d in q("SELECT DISTINCT file_path, start_line s, end_line e FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=wt, db=DB):
r[d['file_path']].append((d['s'], d['e']))
return r
bimp, timp = imports(BW), imports(TW)
def attribute(lines, byfile, texts, wt, imp):
hit = collections.defaultdict(set); unattr = collections.Counter(); samples = collections.defaultdict(list)
for f, ls in lines.items():
defs = byfile.get(f, [])
real = [d for d in defs if d['k'] != 'Macro'] # macro_rules! are indexed even in unreachable files
for ln in ls:
d = innermost(defs, ln)
if d: hit[d['id']].add(ln); continue
t = texts[f].get(ln, '').strip()
reg = region_of(cfg_regions(src(wt, f)), ln)
cat = (f"cfg-{reg[2]}" if reg else
'file-has-no-defs' if not real else
'import' if any(a <= ln <= b for a, b in imp.get(f, [])) else
'impl-header/brace' if re.match(r'(unsafe\s+)?impl\b', t) or t in ('}', '};', '});') else
'blank/comment' if not t or t.startswith('//') else
'use/mod-decl' if re.match(r'(pub(\(.*?\))?\s+)?(use|mod)\s', t) else
'attribute' if t.startswith('#') else 'other')
unattr[(f, cat)] += 1
if len(samples[(f, cat)]) < 2: samples[(f, cat)].append(f"L{ln}: {t[:70]}")
return hit, unattr, samples
# ---- declaration text (signature) from the worktree sources
_src = {}
def src(wt, f):
if (wt, f) not in _src:
try: _src[(wt, f)] = open(f"{wt}/{f}", encoding='utf-8', errors='replace').read().split('\n')
except FileNotFoundError: _src[(wt, f)] = []
return _src[(wt, f)]
def decl(wt, d):
lines = src(wt, d['file_path'])[d['s'] - 1:d['e']]
out = []
for l in lines:
t = l.strip()
if not out and (t.startswith(('///', '//', '#[', '#!')) or not t): continue
out.append(t)
joined = ' '.join(out)
if d['k'] in ROLLUP: return joined.rstrip(',')
if '{' in t or t.endswith(';') or len(out) > 12: break
s = ' '.join(out)
s = s.split('{')[0] if '{' in s else s
return re.sub(r'\s+', ' ', s).strip()
bhit, bun, bsam = attribute(old_lines, bfile, old_text, BW, bimp)
thit, tun, tsam = attribute(new_lines, tfile, new_text, TW, timp)
# ---- match definitions across commits on (fqn, kind), tie-break by file then order
def key(d): return (d['fqn'], d['k'])
bidx = collections.defaultdict(list); tidx = collections.defaultdict(list)
for d in bdefs: bidx[key(d)].append(d)
for d in tdefs: tidx[key(d)].append(d)
pairs, added, removed = [], [], []
for k in set(bidx) | set(tidx):
bl, tl = sorted(bidx.get(k, []), key=lambda d: (d['file_path'], d['s'])), sorted(tidx.get(k, []), key=lambda d: (d['file_path'], d['s']))
# pair same-file (after rename mapping) first
rest_t = list(tl)
for bd in list(bl):
nfp = renames.get(bd['file_path'], bd['file_path'])
m = next((td for td in rest_t if td['file_path'] == nfp), None)
if m: pairs.append((bd, m)); rest_t.remove(m); bl.remove(bd)
for bd, td in zip(bl, rest_t): pairs.append((bd, td))
removed += bl[len(rest_t):]; added += rest_t[len(bl):]
# fields/variants: roll into parent change notes
def parent_fqn(d): return d['fqn'].rsplit('::', 1)[0]
member_notes = collections.defaultdict(list)
variants = {d['fqn'] for d in bdefs + tdefs if d['k'] == 'Variant'}
def owner(d): # Enum::Variant::field -> (Enum, 'Variant.field')
p = parent_fqn(d)
if p in variants: return parent_fqn({'fqn': p}), p.rsplit('::', 1)[1] + '.' + d['name']
return p, d['name']
for side, lst, sign in (('t', added, '+'), ('b', removed, '-')):
for d in lst:
if d['k'] in ROLLUP:
o, n = owner(d); member_notes[(side, o)].append(sign + n)
rows = []
for d in added:
if d['k'] not in ROLLUP: rows.append(dict(op='+', tid=d['id'], file=d['file_path'], k=d['k'], fqn=d['fqn'], name=d['name'], lines=f"L{d['s']}-{d['e']}", new_len=d['e']-d['s']+1))
for d in removed:
if d['k'] not in ROLLUP: rows.append(dict(op='-', bid=d['id'], file=d['file_path'], k=d['k'], fqn=d['fqn'], name=d['name'], lines=f"L{d['s']}-{d['e']}", old_len=d['e']-d['s']+1))
for bd, td in pairs:
if td['k'] in ROLLUP: continue
moved = renames.get(bd['file_path'], bd['file_path']) != td['file_path']
touched = td['id'] in thit or bd['id'] in bhit
mem = member_notes.get(('t', td['fqn']), []) + member_notes.get(('b', bd['fqn']), [])
ol, nl = bd['e'] - bd['s'] + 1, td['e'] - td['s'] + 1
if not (moved or touched or mem or ol != nl): continue
sb, st = decl(BW, bd), decl(TW, td)
op = '>' if moved else ('S' if sb != st else '~')
r = dict(op=op, bid=bd['id'], tid=td['id'], file=td['file_path'], k=td['k'], fqn=td['fqn'], name=td['name'], lines=f"L{td['s']}-{td['e']}", old_len=ol, new_len=nl)
if moved: r['from'] = bd['file_path']
if sb != st: r['sig_old'], r['sig_new'] = sb, st
if mem: r['members'] = mem
rows.append(r)
# relocation candidates: removed + added with same (name, kind), different fqn
rem_by = collections.defaultdict(list)
for r in rows:
if r['op'] == '-': rem_by[(r['name'], r['k'])].append(r)
GENERIC = {'new', 'default', 'build', 'from', 'fmt', 'run', 'plugin', 'tests', 'drop', 'clone', 'eq', 'deref'}
for r in rows:
if r['op'] == '+' and (r['name'], r['k']) in rem_by and r['name'] not in GENERIC:
r['relocated_from'] = sorted({x['file'] for x in rem_by[(r['name'], r['k'])]})[:3]
# ---- collapse children of added/removed types into their parent row (keeps JSON complete)
TYPES = {'Struct', 'Enum', 'Trait', 'Module'}
whole = {(r['op'], r['fqn']) for r in rows if r['op'] in '+-' and r['k'] in TYPES - {'Module'}}
for r in rows:
par = r['fqn'].rsplit('::', 1)[0]
if r['op'] in '+-' and (r['op'], par) in whole:
r['collapsed'] = True
nchild = collections.Counter((r['op'], r['fqn'].rsplit('::', 1)[0]) for r in rows if r.get('collapsed'))
for r in rows:
if (r['op'], r['fqn']) in nchild: r['children'] = nchild[(r['op'], r['fqn'])]
# ---- print
def sigdiff(a, b, ctx=25):
i = 0
while i < min(len(a), len(b)) and a[i] == b[i]: i += 1
j = 0
while j < min(len(a), len(b)) - i and a[-1 - j] == b[-1 - j]: j += 1
pre = ('…' + a[max(0, i - ctx):i]) if i > ctx else a[:i]
sa, sb = a[i:len(a) - j], b[i:len(b) - j]
post = a[len(a) - j:][:ctx] + ('…' if j > ctx else '')
return f" sig: {pre}[-{sa}-]{{+{sb}+}}{post}"
order = {'-': 0, '>': 1, 'S': 2, '+': 3, '~': 4}
byf = collections.defaultdict(list)
for r in rows:
if not r.get('collapsed'): byf[r['file']].append(r)
cnt = collections.Counter(r['op'] for r in rows)
print(f"# defdiff {B[:8]}..{T[:8]} files_with_changes={len(set(new_lines)|set(old_lines))} "
f"removed={cnt['-']} moved={cnt['>']} sig={cnt['S']} added={cnt['+']} body={cnt['~']}")
for f in sorted(byf):
print(f)
for r in sorted(byf[f], key=lambda r: (order[r['op']], r['lines'])):
extra = ''
if r['op'] == '~': extra = f" {r['old_len']}->{r['new_len']} lines"
if r['op'] == '>': extra = f" from {r['from']}"
if r.get('relocated_from'): extra += f" (same name removed in {', '.join(r['relocated_from'])})"
if r.get('children'): extra += f" (+{r['children']} members)" if r['op'] == '+' else f" (-{r['children']} members)"
if r.get('members'): extra += f" members {' '.join(r['members'][:8])}{' …' if len(r['members'])>8 else ''}"
print(f" {r['op']} {r['k']} {r['fqn'].split('::',1)[-1]} {r['lines']}{extra}")
if 'sig_old' in r and r['op'] in 'S>':
print(sigdiff(r['sig_old'], r['sig_new']))
un = collections.Counter()
for (f, c), n in list(bun.items()) + list(tun.items()): un[c] += n
tot = sum(len(v) for v in old_lines.values()) + sum(len(v) for v in new_lines.values())
print(f"# changed lines: {tot}; unattributed: {sum(un.values())} " + ' '.join(f"{c}={n}" for c, n in un.most_common()))
perfile = collections.Counter()
for (f, c), n in list(bun.items()) + list(tun.items()):
if c != 'blank/comment': perfile[(f, c)] += n
for (f, c), n in perfile.most_common(12): print(f"# {n:4d} {c:16s} {f}")
if JSON_OUT:
allun = collections.Counter()
for (f, c), n in list(bun.items()) + list(tun.items()): allun[f"{f}|{c}"] += n
json.dump(dict(touched_tip=sorted(thit), touched_base=sorted(bhit), rows=rows, unattributed=dict(allun),
samples={f"{f}|{c}": s for (f, c), s in list(bsam.items()) + list(tsam.items())},
changed_files=sorted(set(new_lines) | set(old_lines)), renames=renames), open(JSON_OUT, 'w'), indent=1)
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Append one orbit-usage feedback record (JSONL). Cheap: one call per orbit query that informed a decision.
# Usage: fb.sh <recipe|adhoc> <verdict> "<purpose>" "<orbit command>" <result_rows> ["<note>"] [gap]
# verdict: useful | wrong | incomplete | grep-needed | noise | unused
# gap (optional): cfg-test | cfg-feature | gated-file | dyn | fn-ref | macro | rsx | cross-file-impl |
# external-trait | inline-path | pattern-match | stale-index | sig-truncated | other
# Env: ORBIT_FEEDBACK (default ~/.local/share/orbit-skill/feedback.jsonl), ORBIT_AGENT (e.g. search, reviewer)
set -euo pipefail
F=${ORBIT_FEEDBACK:-$HOME/.local/share/orbit-skill/feedback.jsonl}; mkdir -p "$(dirname "$F")"
top=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "$top/.git")
python3 - "$F" "$top" "$common" "$@" <<'PY'
import json, sys, subprocess, datetime, os
F, top, common, recipe, verdict, purpose, cmd, rows = sys.argv[1:9]
note = sys.argv[9] if len(sys.argv) > 9 else ''
gap = sys.argv[10] if len(sys.argv) > 10 else ''
g = lambda *a: subprocess.run(['git', '-C', top, *a], capture_output=True, text=True).stdout.strip()
rec = dict(ts=datetime.datetime.now().astimezone().isoformat(timespec='seconds'),
repo=os.path.basename(os.path.dirname(common)) if common.endswith('.git') else os.path.basename(top),
commit=g('rev-parse', '--short=8', 'HEAD'), dirty=bool(g('status', '--porcelain')),
orbit=subprocess.run(['orbit', '--version'], capture_output=True, text=True).stdout.split()[-1],
agent=os.environ.get('ORBIT_AGENT', ''), recipe=recipe, purpose=purpose, cmd=cmd,
rows=int(rows) if rows.isdigit() else rows, verdict=verdict, gap=gap, note=note)
open(F, 'a').write(json.dumps(rec, ensure_ascii=False) + '\n')
PY
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Preflight for ad-hoc orbit questions (not a full review): is the index for this checkout fresh?
# Usage: freshness.sh [repo-dir] [--db PATH] (read-only; never indexes)
# Prints OK / STALE / MISSING plus the .rs files whose indexed state may differ from what you see on disk.
# Caveat: orbit indexes the WORKING TREE but labels the entry with HEAD's sha. A matching sha is not proof
# the index matches the files if the tree was dirty at index time or has been edited since.
set -euo pipefail
REPO=$(realpath "${1:-.}"); shift || true
DBARG=(); [ "${1:-}" = "--db" ] && DBARG=(--db "$2")
TOP=$(git -C "$REPO" rev-parse --show-toplevel)
HEAD=$(git -C "$TOP" rev-parse HEAD)
row=$(orbit sql --all "${DBARG[@]}" -F csv \
"SELECT commit_sha, status, last_indexed_at FROM _orbit_manifest WHERE repo_path = '$TOP'" 2>/dev/null | tail -n +2 | head -1)
if [ -z "$row" ]; then
echo "[MISSING] $TOP has no orbit index${DBARG:+ in ${DBARG[1]}}. Index with: orbit index '$TOP' -t 4 (shared DB: other agents see it) or use an isolated --db."
exit 2
fi
IFS=, read -r SHA STATUS AT <<<"$row"
dirty=$(git -C "$TOP" status --porcelain --untracked-files=normal -- '*.rs' | wc -l)
since=$(git -C "$TOP" diff --name-only "$SHA" -- '*.rs' 2>/dev/null | wc -l)
verdict=OK
[ "$STATUS" = indexed ] || verdict=BROKEN
[ "$SHA" = "$HEAD" ] || verdict=STALE
[ "$verdict" = OK ] && [ "$dirty" -gt 0 ] && verdict=DIRTY
echo "[$verdict] $TOP index=${SHA:0:8} ($STATUS, indexed_at ${AT} UTC) head=${HEAD:0:8} dirty_rs=$dirty rs_changed_since_index_sha=$since"
case $verdict in
STALE) echo " orbit sql will silently fall back to --all (all repos, all commits) -- note on stderr only. grep/context do not warn."
echo " Re-index (orbit index '$TOP' -t 4, check skipped_files==0) or treat every orbit answer as possibly outdated for these files:"
git -C "$TOP" diff --name-only "$SHA" -- '*.rs' | head -15 | sed 's/^/ /'
[ "$since" = 0 ] && echo " (none: .rs content likely unchanged, but orbit sql still mixes all repos unless you add WHERE commit_sha='$SHA' or re-index)"
exit 1 ;;
DIRTY) echo " sha matches, but .rs files are modified/untracked now; the index holds whatever was on disk at index time:"
git -C "$TOP" status --porcelain --untracked-files=normal -- '*.rs' | head -15 | sed 's/^/ /'
exit 1 ;;
esac
+26
View File
@@ -0,0 +1,26 @@
"""Tiny helper: run orbit sql against an isolated DB, scoped to a worktree's commit."""
import json, os, subprocess, sys
def load_env(path):
env = {}
for line in open(path):
line = line.strip()
if '=' in line and not line.startswith('#'):
k, v = line.split('=', 1)
env[k] = v
return env
def q(sql, repo=None, db=None, all_=False):
cmd = ['orbit', 'sql', sql, '-F', 'json']
if db: cmd += ['--db', db]
if all_: cmd += ['--all']
elif repo: cmd += ['--repo', repo]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"orbit sql failed: {r.stderr.strip()}\n{sql}")
if 'not indexed' in r.stderr:
sys.exit(f"STALE/UNINDEXED scope: {r.stderr.strip()}")
return json.loads(r.stdout or '[]')
def git(repo, *args):
return subprocess.run(['git', '-C', repo, *args], capture_output=True, text=True, check=True).stdout
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Recipe 5: blindness preflight — what orbit cannot see for THIS diff. Run before trusting any orbit answer.
Usage: preflight.py <env-file> <defdiff.json>
Checks (all cheap, no compilation):
1. index freshness: manifest commit == worktree HEAD, worktree clean (orbit indexes the WORKING TREE
but labels rows with HEAD's sha), skipped/errored files from the index JSON
2. changed files orbit has no definitions for (gated at a parent `mod`, tests/ dirs, outside module tree)
3. changed lines inside #[cfg(test)] / #[cfg(feature)] / other cfg regions (from defdiff attribution)
4. Cargo features of touched crates (orbit evaluates ALL features off, default ones too)
5. constructs in changed hunks that CALLS edges miss: add_systems/observers, rsx!, dyn, macro args
Prints a verdict line per area: OK / PARTIAL / BLIND, and the grep fallbacks to use.
"""
import collections, json, os, re, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q, git
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
H = os.path.dirname(DB); NAME = os.path.basename(DB).rsplit('.', 1)[0]
out = []
# 1. freshness
man = {m['repo_path']: m for m in q("SELECT repo_path, commit_sha, status FROM _orbit_manifest", db=DB, all_=True)}
for side, wt, sha in (('base', BW, B), ('tip', TW, T)):
m = man.get(wt); head = git(wt, 'rev-parse', 'HEAD').strip(); dirty = git(wt, 'status', '--porcelain', '--untracked-files=normal').strip()
js = f"{H}/out/{NAME}-{side}.index.json"
sk = json.load(open(js))['processing'] if os.path.exists(js) else {}
ok = m and m['commit_sha'] == head == sha and m['status'] == 'indexed' and not dirty and not sk.get('skipped_files') and not sk.get('errored_files')
out.append(f"[{'OK' if ok else 'STALE/BLIND'}] index {side}: manifest={m['commit_sha'][:8] if m else 'MISSING'} head={head[:8]} "
f"dirty={len(dirty.splitlines())} skipped={sk.get('skipped_files','?')} errored={sk.get('errored_files','?')}")
# 2. changed files without definitions
defs_by_file = collections.Counter()
for r in q("SELECT file_path, count(*) n FROM gl_definition WHERE definition_type <> 'Macro' GROUP BY 1", repo=TW, db=DB):
defs_by_file[r['file_path']] = r['n']
files = q("SELECT path, size_bytes FROM gl_file WHERE language = 'rust'", repo=TW, db=DB)
size = {f['path']: f['size_bytes'] for f in files}
changed = [f for f in dd['changed_files'] if f.endswith('.rs')]
blind = [f for f in changed if f in size and defs_by_file[f] == 0]
gone = [f for f in changed if f not in size]
def why(f, depth=0):
p = f.split('/')
if '/tests/' in f and p[2] == 'tests': return 'integration test w/ #![cfg] or not a target'
if re.search(r'(^|/)(tests?|test_\w+|\w+_tests?)(\.rs|/)', f): return 'test module (cfg(test) at parent mod)'
# find the gating `mod` decl for this file: parent dir mod.rs/lib.rs or sibling-named file
stem = p[-1][:-3] if p[-1] not in ('mod.rs', 'lib.rs', 'main.rs') else p[-2]
for cand in ('/'.join(p[:-1] + ['mod.rs']), '/'.join(p[:-1]) + '.rs', '/'.join(p[:-1] + ['lib.rs']), '/'.join(p[:-1] + ['main.rs']),
'/'.join(p[:-2] + ['mod.rs']), '/'.join(p[:-2]) + '.rs', '/'.join(p[:-2] + ['lib.rs'])):
try: L = open(f"{TW}/{cand}", errors='replace').read().split('\n')
except (FileNotFoundError, IsADirectoryError): continue
for i, l in enumerate(L):
if re.match(rf'\s*(pub(\([^)]*\))?\s+)?mod\s+{re.escape(stem)}\s*;', l):
for j in range(i - 1, max(-1, i - 4), -1):
m = re.match(r'\s*#\[cfg\((.*)\)\]', L[j])
if m: return f"gated at {cand}:{i+1} by cfg({m.group(1)})"
if depth < 6 and cand != f:
up = why(cand, depth + 1)
if up.startswith('gated'): return up
return f"declared at {cand}:{i+1}, no cfg found up the mod chain"
return 'not reachable from a crate root / gated higher up'
tot = len(changed)
out.append(f"[{'OK' if not blind else 'PARTIAL'}] {len(blind)}/{tot} changed .rs files have NO definitions in orbit (read/grep them):")
for f in sorted(blind, key=lambda f: -size[f])[:12]:
out.append(f" {f} ({size[f]//1024} KB) — {why(f)}")
if len(blind) > 12: out.append(f" … {len(blind)-12} more")
if gone: out.append(f" (deleted at tip: {len(gone)} files)")
# 3. changed lines by visibility
un = collections.Counter()
for k, n in dd['unattributed'].items(): un[k.split('|')[1]] += n
dstat = git(REPO, 'diff', '--numstat', B, T, '--', '*.rs')
nchanged = sum(int(a) + int(b) for a, b, *_ in (l.split('\t') for l in dstat.splitlines()) if a != '-')
blindl = un['cfg-test'] + un['cfg-feature'] + un['cfg-other'] + un['cfg-debug'] + un['file-has-no-defs']
out.append(f"[{'OK' if not blindl else 'PARTIAL'}] {blindl}/{nchanged} changed lines ({blindl/max(1,nchanged):.0%}) are in code orbit cannot see; unattributed: "
+ ', '.join(f"{k}={v}" for k, v in un.most_common() if k not in ('blank/comment',)))
# 4. features of touched crates
crates = sorted({f.split('/')[1] for f in changed if f.startswith('crates/')} | ({'.'} if any(not f.startswith('crates/') for f in changed) else set()))
feats = []
for c in crates:
path = f"{TW}/Cargo.toml" if c == '.' else f"{TW}/crates/{c}/Cargo.toml"
try: txt = open(path).read()
except FileNotFoundError: continue
m = re.search(r'^\[features\]\s*\n(.*?)(^\[|\Z)', txt, re.S | re.M)
if not m: continue
names = [x.group(1) for x in re.finditer(r'^([\w-]+)\s*=', m.group(1), re.M)]
d = re.search(r'^default\s*=\s*\[(.*?)\]', m.group(1), re.M | re.S)
dflt = re.findall(r'"([\w-]+)"', d.group(1)) if d else []
feats.append(f"{c if c != '.' else '(root)'}: {len([n for n in names if n != 'default'])} features" + (f", default={dflt}" if dflt else ''))
out.append(f"[INFO] orbit indexes with ALL cargo features OFF (defaults too). Touched crates with features: " + ('; '.join(feats) if feats else 'none'))
# 5. constructs CALLS misses, counted on added lines
diff = git(REPO, 'diff', '-U0', B, T, '--', '*.rs')
pat = {'add_systems/observer (fn refs, no CALLS)': r'add_systems|add_observer|\.observe\(', 'rsx! (components, no CALLS)': r'rsx!',
'dyn Trait (dispatch, few CALLS)': r'\bdyn\s+\w', 'assert!/matches!/vec! args (missed calls)': r'\b(assert\w*|matches|vec|format|debug_assert\w*)!'}
cnt = collections.Counter()
for l in diff.split('\n'):
if l.startswith('+') and not l.startswith('+++'):
for k, rx in pat.items():
if re.search(rx, l): cnt[k] += 1
out.append("[INFO] added lines with CALLS-blind constructs: " + (', '.join(f"{k}: {v}" for k, v in cnt.most_common()) or 'none'))
print(f"# preflight {B[:8]}..{T[:8]} ({os.path.basename(REPO)})")
print('\n'.join(out))
print("# fallbacks: blind files -> read them / git grep; cfg-test lines -> read the test module; "
"callers of fn refs/rsx/dyn -> git grep -nw <name>; treat every 'no callers/no implementors' as UNKNOWN until grep agrees")
+50
View File
@@ -0,0 +1,50 @@
"""cfg-region scanner: which line ranges of a Rust file sit under #[cfg(...)] / #![cfg(...)].
Text heuristic, no compilation. For each `#[cfg(COND)]` line, the gated item is the next
non-attribute line; its extent is brace-balanced (or up to `;`). `#![cfg(..)]` gates the whole file.
Returns list of (start, end, kind, cond) with kind in test|feature|debug|other.
"""
import re
CFG = re.compile(r'^\s*#(!?)\[cfg\((.*)\)\]\s*$')
def kind_of(cond):
c = cond.replace(' ', '')
if c == 'test' or c.startswith('all(test') or 'test,' in c and c.startswith('any('): return 'test'
if 'feature' in c: return 'feature'
if 'debug_assertions' in c: return 'debug'
return 'other'
def cfg_regions(lines):
out = []
n = len(lines)
for i, l in enumerate(lines):
m = CFG.match(l)
if not m: continue
inner, cond = m.group(1), m.group(2)
if 'not(' in cond.replace(' ', '') and kind_of(cond) != 'test':
# cfg(not(feature=..)) items are KEPT by orbit (features off) — not a blind spot
continue
if inner:
out.append((1, n, kind_of(cond), cond)); continue
j = i + 1
while j < n and (lines[j].strip().startswith(('#[', '///', '//')) or not lines[j].strip()): j += 1
if j >= n: continue
is_item = re.match(r'\s*(pub(\([^)]*\))?\s+)?(async\s+|unsafe\s+|const\s+|extern\s+"[^"]*"\s+)*(fn|struct|enum|trait|impl|mod|use|static|const|type|macro_rules!)\b', lines[j])
depth, k, opened = 0, j, False
while k < n:
s = re.sub(r'//.*', '', lines[k])
s = re.sub(r'"(\\.|[^"\\])*"', '""', s)
depth += s.count('{') - s.count('}')
if s.count('{'): opened = True
if (opened and depth <= 0) or (not opened and s.rstrip().endswith((';',) if is_item else (';', ','))): break
if not opened and depth < 0: k -= 1; break # field/arm at end of enclosing block
k += 1
out.append((i + 1, k + 1, kind_of(cond), cond))
return out
def region_of(regions, ln):
best = None
for s, e, k, c in regions:
if s <= ln <= e and (best is None or e - s < best[1] - best[0]): best = (s, e, k, c)
return best
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# End-to-end orbit review pass. Usage: review-pass.sh <repo> <base> <tip> <name> [rules-file]
# Writes $H/out/<name>.{env,defdiff.txt,defdiff.json,preflight.txt,blast.txt,traits.txt,boundary.txt,context.txt}
# and prints them in reading order. No compilation. Isolated DB. Run review-teardown.sh <repo> <name> afterwards.
set -euo pipefail
REPO=$1; BASE=$2; TIP=$3; NAME=$4; RULES=${5:-}
H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}; R=$(dirname "$(realpath "$0")"); O=$H/out/$NAME; mkdir -p "$H/out"
"$R/review-setup.sh" "$REPO" "$BASE" "$TIP" "$NAME" > "$O.env"
python3 "$R/defdiff.py" "$O.env" --json "$O.defdiff.json" > "$O.defdiff.txt"
python3 "$R/preflight.py" "$O.env" "$O.defdiff.json" > "$O.preflight.txt"
python3 "$R/blast.py" "$O.env" "$O.defdiff.json" > "$O.blast.txt"
python3 "$R/traitcov.py" "$O.env" "$O.defdiff.json" > "$O.traits.txt"
if [ -n "$RULES" ]; then python3 "$R/boundary.py" "$O.env" "$O.defdiff.json" "$RULES" > "$O.boundary.txt"; else echo "# boundary: no rules file" > "$O.boundary.txt"; fi
python3 "$R/context.py" "$O.env" "$O.defdiff.json" > "$O.context.txt"
for s in preflight defdiff blast traits boundary context; do echo; cat "$O.$s.txt"; done
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Usage: review-setup.sh <repo> <base-rev> <tip-rev> <review-name>
# Creates detached worktrees for base and tip under $ORBIT_REVIEW_HOME/worktrees,
# indexes both into ONE isolated DB ($ORBIT_REVIEW_HOME/<name>.duckdb) with -t 4,
# and prints env lines to eval. Never touches the shared ~/.gitlab/orbit DB.
set -euo pipefail
REPO=$(realpath "$1"); BASE=$2; TIP=$3; NAME=$4
H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}
DB=$H/$NAME.duckdb; mkdir -p "$H/worktrees" "$H/out"
B=$(git -C "$REPO" rev-parse "$BASE^{commit}"); T=$(git -C "$REPO" rev-parse "$TIP^{commit}")
for side in base tip; do
sha=$([ $side = base ] && echo $B || echo $T)
wt=$H/worktrees/$NAME-$side
[ -d "$wt" ] || git -C "$REPO" worktree add --detach --quiet "$wt" "$sha"
out=$H/out/$NAME-$side.index.json
orbit index "$wt" -t 4 --db "$DB" > "$out" 2> "$out.log"
skipped=$(python3 -c "import json,sys;d=json.load(open('$out'));p=d['processing'];print(p['skipped_files']+p.get('errored_files',0))")
echo "# $side $sha indexed: skipped_files=$skipped ($out)" >&2
[ "$skipped" = 0 ] || echo "WARNING: $side skipped files; re-run or read them by hand" >&2
done
echo "DB=$DB"; echo "BASE_WT=$H/worktrees/$NAME-base"; echo "TIP_WT=$H/worktrees/$NAME-tip"
echo "BASE_SHA=$B"; echo "TIP_SHA=$T"; echo "REPO=$REPO"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Usage: review-teardown.sh <repo> <name> — removes the two detached worktrees and the isolated DB.
set -euo pipefail
REPO=$1; NAME=$2; H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}
for side in base tip; do
wt=$H/worktrees/$NAME-$side
[ -d "$wt" ] && git -C "$REPO" worktree remove --force "$wt"
done
git -C "$REPO" worktree prune
rm -f "$H/$NAME.duckdb" "$H/$NAME.duckdb.wal"
echo "removed worktrees + $NAME.duckdb; remaining worktrees:"; git -C "$REPO" worktree list
@@ -0,0 +1,6 @@
# Boundary rules for dirigent, from crates/*/CLAUDE.md and docs/architecture/rules.md.
deny crates/dirigent_acp_api/src/** -> ^(dirigent_core|dirigent_archivist)\b # acp_api CLAUDE.md: no dependency on core/archivist (archivist dev-dep only)
deny crates/dirigent_logs/** -> ^dirigent_\w+ # logs CLAUDE.md: depends on no other dirigent crate
deny crates/dirigent_diff/** -> ^dioxus\b # diff CLAUDE.md: no Dioxus dependency
deny crates/dirigent_web/** -> ^opencode_client\b.*OpenCodeClient|^opencode_client::OpenCodeClient # rules.md: web must not import OpenCodeClient
deny crates/dirigent_appservice/** -> ^(dirigent_|opencode_client)\w* # appservice CLAUDE.md: no workspace path deps
@@ -0,0 +1,20 @@
# Boundary rules for spacecraft, derived from AGENTS.md "Dependency Graph" and crates/*/CLAUDE.md.
# Format: allow <crate> : <crate> <crate> ... (only these workspace crates may be imported; others flagged)
# deny <src-crate-or-path-glob> -> <import_path-regex> # note
# 'warpspace' is the root package (src/). Leaf = allow with empty list.
allow warpspace_engine :
allow warpspace_protocol : warpspace_engine
allow warpspace_core : warpspace_engine warpspace_protocol warpspace_squad
allow warpspace_sync_core : warpspace_engine warpspace_protocol
allow warpspace_domains : warpspace_core warpspace_sync_core warpspace_engine warpspace_protocol warpspace_squad
allow warpspace_combat : warpspace_core warpspace_sync_core warpspace_domains warpspace_engine warpspace_protocol warpspace_squad warpspace_meshgen warpspace_billboard
allow warpspace_hero : warpspace_core warpspace_domains warpspace_combat warpspace_sync_core warpspace_engine warpspace_protocol
allow warpspace_client : warpspace_core warpspace_domains warpspace_combat warpspace_hero warpspace_sync_core warpspace_engine warpspace_protocol warpspace_billboard warpspace_profile warpspace_skybox
allow warpspace_profile : warpspace_engine warpspace_protocol
allow warpspace_meshgen :
allow warpspace_skybox :
allow warpspace_universe :
allow warpspace_billboard :
deny crates/warpspace_core/** -> ^(warpspace_domains|warpspace_combat|warpspace_hero|warpspace_client)\b # core CLAUDE.md: dependency floor
deny crates/warpspace_combat/** -> ^(warpspace_hero|warpspace_client)\b # combat CLAUDE.md
allow warpspace_replay : warpspace warpspace_domains warpspace_combat warpspace_client warpspace_engine
@@ -0,0 +1,6 @@
-- Changed files that orbit has no (non-macro) definitions for. Paste the diff's file list into the IN (...).
SELECT f.path, f.size_bytes FROM gl_file f
WHERE f.path IN ('crates/dirigent_web/src/server.rs', 'crates/dirigent_archivist/src/storage/trash.rs',
'crates/dirigent_archivist/src/coordinator/tests.rs')
AND NOT EXISTS (SELECT 1 FROM gl_definition d WHERE d.file_path = f.path AND d.definition_type <> 'Macro')
ORDER BY f.size_bytes DESC;
@@ -0,0 +1,12 @@
-- Crate->crate import edges present at TIP but not at BASE (both commits in one --db; run with --all).
-- Crate = 2nd path segment under crates/; import crate = first segment of import_path (crate/self/super = own).
WITH e AS (
SELECT commit_sha, split_part(file_path, '/', 2) AS src,
CASE WHEN split_part(import_path, '::', 1) IN ('crate', 'self', 'super') THEN split_part(file_path, '/', 2)
ELSE split_part(import_path, '::', 1) END AS dst, file_path
FROM gl_imported_symbol WHERE file_path LIKE 'crates/%' AND import_type <> 'ModDeclaration')
SELECT src, dst, count(*) AS n, min(file_path) AS example FROM e
WHERE commit_sha = 'bfbbcd962e8fc176c0612972136a052811b18bf9' AND src <> dst
AND dst IN (SELECT DISTINCT split_part(path, '/', 2) FROM gl_file WHERE path LIKE 'crates/%')
AND (src, dst) NOT IN (SELECT src, dst FROM e WHERE commit_sha = '21cdd1fdd55806cafcee0391065e005ba845f169')
GROUP BY 1, 2 ORDER BY 1, 2;
@@ -0,0 +1,12 @@
-- Callers of a definition AND of its members (methods/assoc fns/variants), grouped by file.
-- For types this catches `x.method()` users that a grep for the type name misses. Replace the fqn.
WITH tgt AS (
SELECT id FROM gl_definition WHERE fqn = 'dirigent_archivist::storage::trash::TrashDir'
UNION
SELECT id FROM gl_definition
WHERE fqn LIKE 'dirigent_archivist::storage::trash::TrashDir::%'
AND definition_type IN ('Method', 'AssociatedFunction', 'Variant'))
SELECT s.file_path, string_agg(DISTINCT s.name, ', ') AS callers, count(*) AS edges
FROM gl_edge e JOIN gl_definition s ON s.id = e.source_id
WHERE e.relationship_kind = 'CALLS' AND e.target_id IN (SELECT id FROM tgt)
GROUP BY 1 ORDER BY 1;
@@ -0,0 +1,8 @@
-- Innermost definitions covering a changed line range (one hunk). Replace FILE/START/END.
-- Run: orbit sql --repo <tip-worktree> --db <db> -f hunk-to-defs.sql
SELECT definition_type, fqn, start_line, end_line
FROM gl_definition
WHERE file_path = 'crates/dirigent_archivist/src/storage/backup.rs'
AND start_line <= 450 AND end_line >= 438 -- hunk END / START
AND definition_type NOT IN ('Module', 'Field', 'Variant')
ORDER BY end_line - start_line LIMIT 3;
@@ -0,0 +1,12 @@
-- Implementors of an in-repo trait: EXTENDS (same-file impls only) UNION method-name overlap (cross-file,
-- noisy: facades that delegate also match). Replace trait name; tune the 0.6 threshold.
WITH t AS (SELECT fqn FROM gl_definition WHERE definition_type = 'Trait' AND name = 'ArchiveBackend'),
tm AS (SELECT m.name FROM gl_definition m, t WHERE m.fqn LIKE t.fqn || '::%' AND m.definition_type = 'Method'),
ext AS (SELECT s.fqn AS implementor, 'EXTENDS' AS via FROM gl_edge e
JOIN gl_definition s ON s.id = e.source_id JOIN gl_definition d ON d.id = e.target_id, t
WHERE e.relationship_kind = 'EXTENDS' AND d.fqn = t.fqn),
ovl AS (SELECT regexp_replace(d.fqn, '::[^:]+$', '') AS implementor, 'overlap ' || count(DISTINCT d.name) || '/' || (SELECT count(*) FROM tm) AS via
FROM gl_definition d, t WHERE d.definition_type = 'Method' AND d.name IN (SELECT name FROM tm)
AND d.fqn NOT LIKE t.fqn || '::%'
GROUP BY 1 HAVING count(DISTINCT d.name) >= 0.6 * (SELECT count(*) FROM tm))
SELECT implementor, string_agg(via, ' + ') AS via FROM (SELECT * FROM ext UNION ALL SELECT * FROM ovl) GROUP BY 1 ORDER BY 1;
@@ -0,0 +1,4 @@
-- Files importing a name (statement-level `use`; includes files whose defs are cfg-gated, but NOT
-- `use` inside #[cfg(test)] mod blocks or fn bodies; NOT inline paths like `krate::m::Name`).
SELECT file_path, import_type, import_path, start_line
FROM gl_imported_symbol WHERE identifier_name = 'TrashDir' ORDER BY file_path;
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Recipe 3: trait-impl coverage for traits touched by a diff (or named with --trait).
Usage: traitcov.py <env-file> <defdiff.json> [--trait Name ...] [--all-traits]
For each trait: its methods at tip (+/- vs base), implementors from three sources, and per
implementor which trait methods it defines (and whether the diff touched them):
E = EXTENDS edge (orbit; only when `impl Trait for Type` sits in the type's own file)
G = `git grep 'impl … Trait … for Type'` at tip (text; sees cfg-gated/test impls too)
H = method-name overlap heuristic (orbit; >= 60% of trait methods defined on the type)
Implementors only in G are cross-file impls, impls on external types, or cfg-gated/test impls.
"""
import collections, json, re, subprocess, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from ob import load_env, q, git
from regions import cfg_regions, region_of
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
names = [sys.argv[i + 1] for i, a in enumerate(sys.argv) if a == '--trait']
D = {}
for side, wt in (('tip', TW), ('base', BW)):
D[side] = q("SELECT id, fqn, name, definition_type k, file_path f, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=wt, db=DB)
traits = {d['fqn']: d for d in D['tip'] if d['k'] == 'Trait'}
btraits = {d['fqn']: d for d in D['base'] if d['k'] == 'Trait'}
touched = set(dd['touched_tip'])
if '--all-traits' in sys.argv: sel = set(traits)
elif names: sel = {f for f, d in traits.items() if d['name'] in names}
else:
sel = set()
for r in dd['rows']:
par = r['fqn'].rsplit('::', 1)[0]
if r['k'] == 'Trait' and r['fqn'] in traits: sel.add(r['fqn'])
if par in traits and r['op'] != '~' or par in traits and r['op'] == '~': sel.add(par)
for r in dd['rows']:
if r['k'] == 'Trait' and r['op'] == '-': print(f"- Trait {r['fqn']} REMOVED — check leftover impls with: git grep -n 'impl .*{r['name']}' {T[:8]}")
ext = collections.defaultdict(set)
for e in q("SELECT s.fqn src, t.fqn tgt FROM gl_edge e JOIN gl_definition s ON s.id=e.source_id JOIN gl_definition t ON t.id=e.target_id WHERE e.relationship_kind='EXTENDS'", repo=TW, db=DB):
ext[e['tgt']].add(e['src'])
methods_of = collections.defaultdict(dict)
for d in D['tip']:
if d['k'] in ('Method', 'AssociatedFunction', 'Constant', 'TypeAlias'):
methods_of[d['fqn'].rsplit('::', 1)[0]][d['name']] = d
bmethods_of = collections.defaultdict(dict)
for d in D['base']:
if d['k'] in ('Method', 'AssociatedFunction'):
bmethods_of[d['fqn'].rsplit('::', 1)[0]][d['name']] = d
types_by_name = collections.defaultdict(list)
for d in D['tip']:
if d['k'] in ('Struct', 'Enum', 'TypeAlias', 'Trait'): types_by_name[d['name']].append(d)
_src = {}
def regions(f):
if f not in _src:
try: _src[f] = cfg_regions(open(f"{TW}/{f}", encoding='utf-8', errors='replace').read().split('\n'))
except FileNotFoundError: _src[f] = []
return _src[f]
stats = collections.Counter()
for tf in sorted(sel):
t = traits[tf]; tm = methods_of.get(tf, {}); bm = bmethods_of.get(tf, {})
added = sorted(set(tm) - set(bm)); removed = sorted(set(bm) - set(tm))
sigchg = sorted(r['name'] for r in dd['rows'] if r['op'] == 'S' and r['fqn'].rsplit('::', 1)[0] == tf)
print(f"Trait {tf} [{t['f']}:{t['s']}] methods={len(tm)}" + (f" +{added}" if added else '') + (f" -{removed}" if removed else '') + (f" sig~{sigchg}" if sigchg else ''))
# G: text impls at tip
try: g = git(REPO, 'grep', '-nE', rf'^\s*(unsafe\s+)?impl\b.*\b{t["name"]}\b(<[^>]*>)?\s+for\s+', T, '--', '*.rs')
except subprocess.CalledProcessError: g = ''
G = {}
for l in g.splitlines():
_, f, ln, txt = l.split(':', 3)
m = re.search(rf'\bfor\s+&?(\'\w+\s+)?(mut\s+)?(dyn\s+)?([\w:]+)', txt)
if not m: continue
ty = m.group(4).split('::')[-1]; reg = region_of(regions(f), int(ln))
G[(ty, f)] = f"cfg-{reg[2]}" if reg else ''
E = {x.split('::')[-1]: x for x in ext.get(tf, set())}
# H: overlap heuristic
H = {}
if tm:
for owner, ms in methods_of.items():
if owner == tf: continue
ov = len(set(ms) & set(tm))
if ov >= max(2, 0.6 * len(tm)): H[owner.split('::')[-1]] = owner
impl_types = sorted(set(E) | {ty for ty, _ in G} | set(H))
for ty in impl_types:
src = ''.join(c for c, ok in (('E', ty in E), ('G', any(k[0] == ty for k in G)), ('H', ty in H)) if ok)
gfiles = [f for (tt, f) in G if tt == ty]; gates = {G[(ty, f)] for f in gfiles if G[(ty, f)]}
owners = [E.get(ty)] if ty in E else ([H[ty]] if ty in H else [d['fqn'] for d in types_by_name.get(ty, [])])
owners = [o for o in owners if o]
have = set(); tch = set()
for o in owners:
for n, d in methods_of.get(o, {}).items():
if n in tm: have.add(n); tch |= {n} if d['id'] in touched else set()
missing = sorted(set(added) - have) if added else []
cover = f"defines {len(have)}/{len(tm)}" if owners else "type not indexed (external/gated)"
flag = ''
if missing:
# grep fallback: look for `fn <m>` in the impl files (text; sees gated code)
txt = {m: any(re.search(rf'\bfn\s+{m}\b', open(f"{TW}/{f}", errors='replace').read()) for f in gfiles) for m in missing}
ok = [m for m, v in txt.items() if v]; bad = [m for m, v in txt.items() if not v]
if ok: flag += f" new method(s) {ok} found by text in impl file (orbit can't see it)"
if bad: flag += f" !! new method(s) {bad} NOT found in impl file (default impl? not updated?)"
print(f" [{src:3s}] {ty} {cover}{' touched:'+','.join(sorted(tch)) if tch else ''}"
f"{' impl-in: '+','.join(sorted(set(gfiles)))[:120] if gfiles and 'E' not in src else ''}{' '+','.join(gates) if gates else ''}{flag}")
stats['G'] += any(k[0] == ty for k in G); stats['E'] += ty in E; stats['H'] += ty in H
stats['EG'] += (ty in E and any(k[0] == ty for k in G)); stats['HG'] += (ty in H and any(k[0] == ty for k in G))
stats['H_only'] += (ty in H and not any(k[0] == ty for k in G))
print(f"# implementors: grep={stats['G']} extends={stats['E']} (∩grep {stats['EG']}) heuristic={stats['H']} (∩grep {stats['HG']}, heuristic-only {stats['H_only']})")