🤖 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
+9
View File
@@ -0,0 +1,9 @@
{
"name": "orbit",
"version": "0.0.1",
"description": "PROTOTYPE. Structural review of Rust changes with the GitLab orbit code graph (orbit CLI 0.133): changed definitions, blast radius, trait-impl coverage, crate-boundary guards, blindness preflight. Complements grep, does not replace it.",
"author": {
"name": "Gabor Körber",
"email": "gab@g4b.org"
}
}
+83
View File
@@ -0,0 +1,83 @@
# orbit (prototype)
This plugin is a skill for **structural review of Rust changes** with the GitLab [orbit](https://gitlab.com/gitlab-org/orbit/knowledge-graph) code graph. It reports changed definitions, blast radius, trait-impl coverage, crate and module boundary guards, and a cfg-blindness preflight that decides how far to trust the rest. It complements grep and does not replace it.
It is an early prototype built from the 2026-09-29 research in `docs/workpad/research/2026-09-29-17-orbit/`, for orbit CLI **0.133**. It expects `orbit`, `git` and `python3` on PATH. No compilation is involved.
## Contents
| Path | What |
|---|---|
| `skills/orbit/SKILL.md` | When to use orbit rather than grep, the mandatory preflight, the review pass, reporting rules, feedback, and how to brief a subagent |
| `skills/orbit/references/review-recipes.md` | Per-recipe detail, the gap-class table with grep fallbacks, and the rules-file format |
| `skills/orbit/references/cookbook.md` | Ad-hoc CLI and SQL for Rust, and gotchas |
| `skills/orbit/scripts/review-pass.sh` | End-to-end pass: setup, then preflight, defdiff, blast, traits, boundary and context (about 25 s) |
| `skills/orbit/scripts/review-setup.sh`, `review-teardown.sh` | Detached worktrees for base and tip in an **isolated** `--db`, and their cleanup |
| `skills/orbit/scripts/*.py` | The recipes (`ob.py` and `regions.py` are libraries) |
| `skills/orbit/scripts/freshness.sh` | Read-only index freshness check for ad-hoc questions |
| `skills/orbit/scripts/fb.sh` | Appends one feedback record |
| `skills/orbit/scripts/rules/*.rules` | Boundary rules for spacecraft and dirigent (hand-derived, may drift) |
| `skills/orbit/scripts/sql/*.sql` | Standalone SQL templates |
Review scratch (worktrees, DBs, outputs) goes to `$ORBIT_REVIEW_HOME` (default `~/.cache/orbit-review`), never `/tmp`. Always run `review-teardown.sh` afterwards: orbit has no command to delete an index entry, so nothing review-related may go into the shared `~/.gitlab/orbit/graph.duckdb`.
## Enabling locally (spacecraft, dirigent)
Both projects already declare the `reliquary` marketplace in their committed `.claude/settings.json` (`extraKnownMarketplaces`). That marketplace is fetched from **git** (`git@git.g4b.org:dirigence/reliquary.git`), so `orbit@reliquary` only resolves after this plugin is committed and pushed, and the marketplace has refreshed (`/plugin marketplace update reliquary`).
**Per project, not committed.** Run this in the project root:
```bash
claude plugin install orbit@reliquary --scope local
```
`--scope local` writes to `.claude/settings.local.json`, which is meant to stay uncommitted (confirm with `git check-ignore .claude/settings.local.json`). To do it by hand, add this to `<project>/.claude/settings.local.json` (create it if missing, and merge with any existing keys):
```json
{
"enabledPlugins": {
"orbit@reliquary": true
}
}
```
**Before pushing** (trying out this working copy), load the plugin for one session only:
```bash
cd ~/Projects/spacecraft # or ~/Projects/dirigent
claude --plugin-dir ~/Projects/reliquary/plugins/orbit
```
Check it with `/plugin` (orbit should be listed as enabled) or by asking "review this branch with orbit". Also useful:
- `claude plugin validate ~/Projects/reliquary/plugins/orbit`
- Rules files: `rules/spacecraft.rules` and `rules/dirigent.rules` ship with the plugin. Pass one as the 5th argument to `review-pass.sh`.
## Feedback log
While this is a prototype, the skill asks agents to append **one JSONL line per orbit result that informed a decision** (or should have, and turned out wrong):
```bash
ORBIT_AGENT=search skills/orbit/scripts/fb.sh blast grep-needed "callers of format_token_title" "blast.py dg-tokens" 0 "both call sites inside rsx!" rsx
```
- File: `~/.local/share/orbit-skill/feedback.jsonl`. Set `ORBIT_FEEDBACK` to override it. It is outside every repo and shared across projects.
- Fields: `ts, repo, commit, dirty, orbit` (automatic), `agent` (`$ORBIT_AGENT`), `recipe, purpose, cmd, rows, verdict`, and optionally `note, gap`. `verdict` is one of `useful | wrong | incomplete | grep-needed | noise | unused`.
To read or mine it:
```bash
F=~/.local/share/orbit-skill/feedback.jsonl
jq -s 'group_by(.recipe, .verdict) | map({recipe: .[0].recipe, verdict: .[0].verdict, n: length})' $F # value per recipe
jq -s '[.[] | select(.gap != "")] | group_by(.gap) | map({gap: .[0].gap, n: length}) | sort_by(-.n)' $F # which blind spots bite
jq -c 'select(.verdict == "wrong" or .verdict == "incomplete") | {repo, recipe, purpose, note}' $F # failures to learn from
jq -s 'group_by(.repo) | map({repo: .[0].repo, n: length, dirty: map(select(.dirty)) | length})' $F # per project, dirty-tree usage
```
Summaries of mined data (not raw logs) belong in `docs/workpad/research/…`. The open questions the log is meant to answer (whether the pass changes outcomes, the preflight thresholds, token budget, agent fit) are listed in §11 of `phase-2-review-workflows.md`.
## Known rough edges
- Tested only on orbit 0.133.0, spacecraft and dirigent. The preflight thresholds (25 % and 60 %) are guesses.
- Uncommitted review uses `git stash create`, so **untracked files are not reviewed**.
- `review-setup.sh` briefly registers two worktrees in the target repo's `.git` (removed by teardown). If teardown is skipped, they linger. Check with `git worktree list`.
- Rust only. The shell scripts need bash (they work when called from fish).
+95
View File
@@ -0,0 +1,95 @@
---
name: orbit
description: >
PROTOTYPE. Use the orbit code graph (GitLab `orbit` CLI) for STRUCTURAL questions about a Rust
codebase: reviewing a Rust change, branch, PR, merge or diff ("what did this diff touch
structurally", "review this branch against main"); "who depends on / calls / imports / uses X";
"who implements trait X", "did every impl get the new method"; blast radius of a change; "did this
cross a crate or module boundary"; "what does this crate/module/type look like". Also for
briefing a subagent (e.g. Spire's `search`) that should run these queries. NOT a grep
replacement: orbit is blind to #[cfg(test)], cargo-feature-gated code, macro args, rsx!, dyn
dispatch and Bevy add_systems, so every orbit answer is a lower bound that grep must close.
For literal text, counting occurrences, or "where is string X", use grep. Shell only, not MCP.
---
# Orbit (prototype)
Orbit indexes a Rust workspace into a DuckDB graph: definitions, CALLS, IMPORTS, EXTENDS. This skill decides **when orbit is worth asking**, **what must be checked before trusting it**, and **how to report what it says**. It was built from a probe of orbit 0.133 on spacecraft (Bevy) and dirigent (Tokio/Dioxus). The numbers below come from that probe.
Paths: `S=${CLAUDE_PLUGIN_ROOT}/skills/orbit/scripts`. If `CLAUDE_PLUGIN_ROOT` is unset (subagent shells), use the plugin's absolute path. Details are in `references/review-recipes.md` and `references/cookbook.md`. Read them only when you run a recipe or write ad-hoc SQL.
## Orbit or grep
| Question | Use |
|---|---|
| Which definitions did this diff add, remove, or change in signature? | **orbit** (`defdiff.py`, exact within visible code) |
| Who calls or imports X outside the diff? | **orbit first, then grep.** Orbit finds method callers of a type that grep can't. Grep finds the ~50 % orbit misses at definition level. |
| Who implements in-repo trait T? | **orbit plus grep.** EXTENDS only links same-file impls (`traitcov.py` does both). |
| New crate→crate edges, documented layering rules | **orbit** (`boundary.py`), plus its Cargo.toml and inline-path fallbacks |
| Outline of a file, module, or type | `orbit grep --path <file>` (no query) / `orbit repo-map class <Type>` / `repo-map tree <dir>` |
| Literal strings, counts, "every place that mentions X", test code, feature-gated code | **grep**. Orbit does not see gated code and its `grep` is ranked and capped. |
| Runtime behaviour, ordering, Bevy schedules, async | **Neither.** Read the code or run it. Orbit knows structure only. |
## Mandatory preflight (every time, before trusting a single row)
1. **Freshness.** Ad-hoc: `$S/freshness.sh <repo>` (read-only). For a review, `preflight.py` checks this itself.
- `STALE` means the index sha is not HEAD. `orbit sql` then **silently queries every repo and commit** (the note goes to stderr only), and `grep`/`context` give no warning. Re-index the **main checkout** (`orbit index <repo> -t 4`, which replaces that repo's entry in the shared DB), or stop.
- `DIRTY` / working-tree caveat: **orbit indexes the working tree, including uncommitted and untracked files, but labels the entry with HEAD's sha.** A matching sha proves nothing if the tree was dirty at index time or was edited since.
2. **Always `-t 4`.** With default threads, a per-file watchdog silently skips 4–7 large files per run. Check `processing.skipped_files == 0` and `errored_files == 0` in the index JSON. If either is not 0, re-index; if it persists, list those files as blind.
3. **Blind regions.** State them before any conclusion:
- `#[cfg(test)]` modules, and files gated by a `#[cfg(test)] mod x;`: no definitions, no CALLS, and no import rows for `use` inside test mods.
- **Cargo features are evaluated ALL OFF, defaults included.** dirigent's `server` feature hides most of `dirigent_core`; spacecraft's default `steam` feature is dropped. There is no flag to change this.
- CALLS gaps: `dyn Trait` receivers (0–14 % recall), macro arguments (`assert!`, `matches!`, `vec!`), `rsx!` bodies (Dioxus components ~0 %), fns passed by reference (Bevy `add_systems`, observers, callbacks), enum variant patterns, `Type::default()`.
- Traits: external traits (`Plugin`, `Serialize`) are not nodes and return empty **without a warning**. Cross-file `impl` blocks, blanket impls and impls on external types have no EXTENDS.
- Imports: inline paths (`other_crate::x::Y` without `use`) are not import rows. `pub use` re-exports are not followed.
## Review pass (Rust branch, PR, or uncommitted diff)
Run it in an **isolated `--db`**, never the shared DB. Worktree entries in the shared DB can't be removed, because **orbit has no delete command**.
```bash
# base..tip: branch -> base=$(git merge-base main B) tip=B; merged PR -> <merge>^1..<merge>
# uncommitted -> tip=$(git stash create) (a commit object of tracked changes; touches nothing; untracked files NOT included)
export ORBIT_REVIEW_HOME=~/.cache/orbit-review # default; worktrees + DBs + outputs live here, never /tmp
$S/review-pass.sh <repo> <base> <tip> <name> [$S/rules/<repo>.rules] > review.txt # ~30 s, ~8–16 KB
$S/review-teardown.sh <repo> <name> # always, even after failure
```
`review-setup.sh` adds two detached `git worktree`s to the repo (these are removed in teardown) and indexes both into `<name>.duckdb`. Afterwards, `git worktree list` and `pgrep -a orbit` should show nothing of yours.
Read the output in this order:
1. **Preflight.** Blind share of changed lines: **<25 %** → orbit-led review, grep the listed gaps. **25–60 %** → orbit for the visible part only; read the blind files. **>60 %** → stop using orbit except defdiff and boundary, and say so. Also stop if the index isn't fresh or clean, or if the plan's key items all sit in blind files.
2. **defdiff.** Compare with the plan: is every planned item present, and is anything unplanned present? Read every `S` (signature) row, every `-` row, and every type with member changes.
3. **blast.** For each OUTSIDE-diff dependent: did it need a change it didn't get? Then close the gaps with grep: an **added enum variant** → grep every `match` on the enum; a **changed Bevy system signature** → is it still registered? a **removed item** → is the old path re-exported, or does it dangle?
4. **traits.** Read every `[G]`-only or `!!` implementor. `[H]`-only may be a delegating facade; confirm it by reading.
5. **boundary.** Check new crate edges and Cargo.toml deps against the documented graph (AGENTS.md, crate CLAUDE.md). Doc drift is a real finding.
6. **context.** A compact API brief of the touched area to hand to the reviewer, together with the plan.
For ad-hoc questions, use the SQL in `references/cookbook.md` (callers, importers, implementors, blind files).
## Reporting
- **Fact**: orbit states it **and** grep or a file read confirmed it. Cite `file:line`.
- **Hypothesis**: any orbit count or absence ("no other callers", "no implementors", "no boundary crossing") that grep has not closed. Write it as **"≥ N (orbit lower bound; gaps: cfg-test, dyn, …)"**. Never write "unused" or "dead": most callables with no incoming CALLS are systems, handlers or trait impls.
- **Blind**: state the preflight blind files and blind share **once, at the top**.
## Feedback (cheap, required while this is a prototype)
After each orbit query or recipe whose result **informed a decision** (or should have, and was wrong), append one line:
```bash
ORBIT_AGENT=<search|reviewer|…> $S/fb.sh <recipe> <verdict> "<purpose>" "<cmd>" <rows> ["<note>"] [gap]
# recipe: defdiff blast traitcov boundary preflight context adhoc-sql grep repo-map context-cmd
# verdict: useful | wrong | incomplete | grep-needed | noise | unused
# gap: 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
```
Written to `~/.local/share/orbit-skill/feedback.jsonl` (override with `ORBIT_FEEDBACK`). One call per decision, not per query. Skip it for idle exploration.
## Briefing a subagent
Spire's `search` agent (Sonnet, Bash, read-only) is the right carrier. Put this in the brief:
> Use the orbit skill (`orbit:orbit`), from the shell only (the orbit MCP server has only unscoped SQL). Scripts: `<abs path>/skills/orbit/scripts`. First run `freshness.sh` (ad-hoc) or `review-pass.sh` (range `<base>..<tip>`, name `<name>`, isolated DB under ~/.cache/orbit-review), and **always** run `review-teardown.sh` at the end. Never run `orbit index` on a worktree without `--db`. Never use `/tmp`. Do not compile. Return: (1) blind share and blind files, (2) facts with file:line, (3) hypotheses labelled "orbit lower bound" with gap classes, (4) the grep commands you ran to close gaps. Log one `fb.sh` line per result that informed a finding (`ORBIT_AGENT=search`).
The receiving reviewer (Opus) decides severity. The search agent relays facts and hypotheses, not verdicts.
@@ -0,0 +1,77 @@
# Orbit 0.133 cookbook for Rust (ad-hoc questions)
Source: `phase-1-rust-capabilities.md`. Run these in the repo directory. First run `scripts/freshness.sh`: if the index is stale, `orbit sql` silently falls back to **all repos and all commits**.
## CLI
```bash
orbit index <repo> -t 4 | grep -E '"definitions"|"skipped_files"|"errored_files"' # shared DB: main checkouts only
orbit index <worktree> -t 4 --db ~/.cache/orbit-review/<name>.duckdb # anything else: isolated
orbit list # entries in the shared DB (add --db for isolated)
orbit repo-map # overview: crates, key abstractions, most-imported symbols
orbit repo-map tree crates/foo/src/backend # directory prefix only
orbit repo-map class JsonlBackend # fields + methods across impl files (signatures truncated)
orbit repo-map extends MyTrait # same-file in-repo implementors: LOWER BOUND
orbit repo-map imports themed_button # counts per import path (not file list)
orbit grep put_session --kind Method --path crates/foo # one query; 'a|b' for OR; ranked and capped
orbit grep --path crates/foo/src/x.rs # file outline
orbit context Definition:<id> # source + callers/callees (+ test callers section)
orbit sql -f q.sql [-F json|csv] [--repo <checkout>] [--db <file>]
```
## Gotchas
- **fqn root is the cargo target name** (lib, bin, or test file stem) and can collide (spacecraft lib and bin are both `warpspace`). Group by `file_path`.
- There is no Impl node. Trait impl methods look like inherent methods (`Type::m`); the fqn carries no trait.
- `start_line` includes doc comments and attributes.
- CALLS→Struct/Variant means **construction**, not usage. Type mentions in signatures or `Query<&T>` produce no edge. There are no CALLS edges to Constant or Static.
- Default scope deduplicates edges to one per (source, target). `--all` gives one per call site. Don't compare counts across the two.
- DuckDB: `target` is reserved, so alias it `tgt`. An empty result prints **nothing** and exits 0.
- Import paths are raw (`crate::`, `super::`, relative) and are not resolved to definitions. Match on `identifier_name`.
- MCP `run_sql` is always unscoped (like `--all`). Use the CLI.
- Orbit has **no command to delete an index entry**. Never index worktrees or scratch checkouts into the shared DB. `--db <file>` plus `rm <file>` is the only clean pattern.
## SQL
Callers of a definition:
```sql
SELECT s.fqn AS caller, s.file_path, s.start_line
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 = 'CALLS' AND t.fqn = 'dirigent_web::components::toast::show_toast'
ORDER BY s.file_path;
```
Files importing a name (includes top-level `use` in gated files; excludes `use` inside `#[cfg(test)] mod` and fn bodies, and inline paths):
```sql
SELECT file_path, import_type, import_path, start_line FROM gl_imported_symbol
WHERE identifier_name = 'TrashDir' ORDER BY file_path;
```
Crate import matrix:
```sql
SELECT split_part(i.file_path,'/',2) from_crate, split_part(i.import_path,'::',1) to_crate, count(*) n
FROM gl_imported_symbol i
WHERE i.file_path LIKE 'crates/%' AND split_part(i.file_path,'/',2) <> split_part(i.import_path,'::',1)
GROUP BY ALL ORDER BY n DESC;
```
Trait implementors via EXTENDS (lower bound) and method-name overlap (covers cross-file impls, but noisy):
```sql
WITH tm AS (SELECT m.name FROM gl_definition t
JOIN gl_definition m ON m.fqn LIKE t.fqn || '::%' AND m.definition_type='Method'
WHERE t.definition_type='Trait' AND t.name='ArchiveBackend')
SELECT regexp_replace(d.fqn,'::[^:]+$','') implementor, count(DISTINCT d.name) matched, (SELECT count(*) FROM tm) trait_methods
FROM gl_definition d WHERE d.definition_type='Method' AND d.name IN (SELECT name FROM tm)
GROUP BY 1 HAVING count(DISTINCT d.name) >= 3 ORDER BY matched DESC;
```
Always also run `git grep -nE 'impl\b.*\bArchiveBackend\b.*\bfor\b'`.
Blind files (Rust files with no non-macro definitions: cfg-off, tests, vendor):
```sql
SELECT f.path, f.size_bytes FROM gl_file f
WHERE f.language='rust'
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 LIMIT 20;
```
More templates (hunk→defs, dependents including members, crate-edge delta) are in `scripts/sql/`. Each has a placeholder name to replace.
@@ -0,0 +1,76 @@
# Review recipes: detail
Source: `docs/workpad/research/2026-09-29-17-orbit/phase-2-review-workflows.md` in reliquary (7 historical ranges in spacecraft and dirigent, orbit 0.133). Every script takes the env file that `review-setup.sh` prints (`DB BASE_WT TIP_WT BASE_SHA TIP_SHA REPO`). `review-pass.sh` runs all of them. You only need the individual commands to re-run one recipe with other flags.
```bash
S=<plugin>/skills/orbit/scripts; O=$ORBIT_REVIEW_HOME/out/<name>
$S/review-setup.sh <repo> <base> <tip> <name> > $O.env # 2 detached worktrees, both indexed -t 4 into <name>.duckdb
python3 $S/defdiff.py $O.env --json $O.defdiff.json # recipe 1
python3 $S/preflight.py $O.env $O.defdiff.json # recipe 5 (read first)
python3 $S/blast.py $O.env $O.defdiff.json [--measure] # recipe 2
python3 $S/traitcov.py $O.env $O.defdiff.json [--trait Name] [--all-traits] # recipe 3
python3 $S/boundary.py $O.env $O.defdiff.json $S/rules/<repo>.rules [--audit] # recipe 4
python3 $S/context.py $O.env $O.defdiff.json [--budget 8000] # recipe 6
$S/review-teardown.sh <repo> <name>
```
Both commits live in one DB. `orbit sql --repo <worktree> --db <db>` scopes to one commit. Definition ids differ between commits, so the scripts join on `(fqn, kind)`.
## 1. defdiff: changed definitions
Markers: `+` added, `-` removed, `>` moved (untested), `S` signature changed (with a word diff), `~` body touched (size and member changes), `?` relocated candidate.
- Within visible code: precision and recall were both 100 % on 7 ranges. Every miss was in cfg-test, cfg-feature, or a gated file.
- Blind: `impl` headers and where-clauses (there is no Impl node), attributes/derives (these count as touching the item), signatures longer than 12 lines (cut).
- The "unattributed lines" tally at the bottom feeds the preflight.
## 2. blast: dependents
For each `S`, `-`, `>` definition, and each `~` with member changes, it lists CALLS callers (for types: callers of methods, assoc fns and variants), importing files, and EXTENDS implementors, split into in-diff and **OUTSIDE diff**. For removed items it also checks whether a same-name item survives and whether the old path is kept alive by a `pub use` re-export or dangles. The re-export check was 8/8 correct.
Recall against grep: **49 % at definition level, 93 % at file level** (callers plus importers). The gaps and the grep that closes each:
| gap | share of misses | close with |
|---|---|---|
| `#[cfg(test)]` | 36/136 | `git grep -nw Name`, read the test module |
| enum variant literal / multi-line pattern | 32 | `git grep -nE 'Enum::\w+'`; for an added variant, find every `match` on the enum |
| fn passed by reference (Bevy system, callback) | 21 | `git grep -nw fn_name`; ask "still registered?" |
| file gated at parent `mod` | 17 | read the file / `git grep` |
| `Type::default()`, derive/trait assoc calls | 6 | `git grep -n 'Type::'` |
| struct literal, return type in signature | 12 | `git grep -nw Type` |
| `rsx!` body | 4 | `git grep -nw` |
| cfg-feature / cfg-other | 6 | read the region |
| dyn/field/closure receiver | 2 | `git grep -n '\.method('` |
Orbit-only dependents are real: they are callers of a type's methods, which a grep for the type name can't find.
## 3. traitcov: trait implementors
Sources: `E` = EXTENDS (same-file impls only), `G` = `git grep 'impl … Trait … for Type'` (text, so it also sees gated and test impls), `H` = at least 60 % method-name overlap (noisy: delegating facades such as `Archivist` also match). For implementors orbit can't see, it text-checks that the new method exists in the impl file. `defines 20/21` often just means a default method isn't overridden.
- Blind: external traits, blanket impls, impls on external types, multi-line `impl` headers (the `G` regex misses them).
## 4. boundary: crate and module edges
Reports new crate→crate import edges (tip minus base), new imports in changed files checked against the rules file, new intra-crate module edges, **inline qualified paths** in changed files (these are not import rows), and **Cargo.toml dependency changes**. `--audit` checks every tip import against the rules, which validates the rules file itself.
Rules format (`scripts/rules/*.rules`, one per repo; the spacecraft and dirigent files were derived from AGENTS.md and crate CLAUDE.md files on 2026-09-29 and may drift):
```
allow <crate> : <crate> <crate> ... # only these workspace crates may be imported
deny <src-crate-or-path-glob> -> <import-regex> # note
```
The compiler already enforces Cargo. This recipe's value is in documented rules beyond Cargo, and in doc drift (for example, a new edge not listed in AGENTS.md).
## 5. preflight: blindness
Checks index freshness (manifest sha = worktree HEAD = requested sha, clean tree, 0 skipped/errored), changed files with no definitions and the `mod` line that gates each one, the share of changed lines in cfg-test/cfg-feature/cfg-other/gated files, the Cargo features of touched crates, and CALLS-blind constructs in added lines. Measured blind share across the 7 ranges: **6 % to 71 %**.
## 6. context: compact brief
One line per type or callable in touched files, with change markers, within a byte budget (5–10 KB). Unchanged items in large files collapse to a pointer (`orbit grep --path <file>`). Prefer this to `orbit repo-map api`, which is about 200 KB for the same area, needs a **directory** prefix (a file path silently returns nothing), and shows `let` lines as signatures about 2 % of the time.
## Uncommitted changes
`tip=$(git -C <repo> stash create)` makes a dangling commit object from the tracked working-tree changes and index. It changes no refs, files or stash list. Pass it as `<tip>`. Untracked new files are **not** included: read them directly and list them as blind. Tested 2026-09-29 on a scratch clone. If `git stash create` prints nothing, the tree has no tracked changes.
## Stop conditions
Stop and fall back to grep plus reading if any of these hold: the blind share is over 60 %, the index is not fresh or clean, the plan's key items are all in blind files, or the question is behavioural (runtime ordering, Bevy schedule, async).
+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']})")