diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b424424..091ea04 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -34,6 +34,11 @@ "name": "gitea", "source": "./plugins/gitea", "description": "Bidirectional Gitea ticket integration: fetch context, create issues, post comments, list/search, close, and scaffold labels" + }, + { + "name": "orbit", + "source": "./plugins/orbit", + "description": "PROTOTYPE — orbit code-graph skill for reviewing Rust changes: blast radius, trait-impl coverage, crate boundaries, cfg-blindness preflight. Needs the orbit CLI." } ] } diff --git a/plugins/orbit/.claude-plugin/plugin.json b/plugins/orbit/.claude-plugin/plugin.json new file mode 100644 index 0000000..80e3ca3 --- /dev/null +++ b/plugins/orbit/.claude-plugin/plugin.json @@ -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" + } +} diff --git a/plugins/orbit/README.md b/plugins/orbit/README.md new file mode 100644 index 0000000..0d6ddb6 --- /dev/null +++ b/plugins/orbit/README.md @@ -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 `/.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). diff --git a/plugins/orbit/skills/orbit/SKILL.md b/plugins/orbit/skills/orbit/SKILL.md new file mode 100644 index 0000000..1bea156 --- /dev/null +++ b/plugins/orbit/skills/orbit/SKILL.md @@ -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 ` (no query) / `orbit repo-map class ` / `repo-map tree ` | +| 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 ` (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 -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 -> ^1.. +# 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 [$S/rules/.rules] > review.txt # ~30 s, ~8–16 KB +$S/review-teardown.sh # 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 `.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= $S/fb.sh "" "" [""] [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: `/skills/orbit/scripts`. First run `freshness.sh` (ad-hoc) or `review-pass.sh` (range `..`, 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. diff --git a/plugins/orbit/skills/orbit/references/cookbook.md b/plugins/orbit/skills/orbit/references/cookbook.md new file mode 100644 index 0000000..664da9d --- /dev/null +++ b/plugins/orbit/skills/orbit/references/cookbook.md @@ -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 -t 4 | grep -E '"definitions"|"skipped_files"|"errored_files"' # shared DB: main checkouts only +orbit index -t 4 --db ~/.cache/orbit-review/.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: # source + callers/callees (+ test callers section) +orbit sql -f q.sql [-F json|csv] [--repo ] [--db ] +``` + +## 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 ` plus `rm ` 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. diff --git a/plugins/orbit/skills/orbit/references/review-recipes.md b/plugins/orbit/skills/orbit/references/review-recipes.md new file mode 100644 index 0000000..69db9bc --- /dev/null +++ b/plugins/orbit/skills/orbit/references/review-recipes.md @@ -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=/skills/orbit/scripts; O=$ORBIT_REVIEW_HOME/out/ +$S/review-setup.sh > $O.env # 2 detached worktrees, both indexed -t 4 into .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/.rules [--audit] # recipe 4 +python3 $S/context.py $O.env $O.defdiff.json [--budget 8000] # recipe 6 +$S/review-teardown.sh +``` + +Both commits live in one DB. `orbit sql --repo --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 : ... # only these workspace crates may be imported +deny -> # 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 `). 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 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 ``. 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). diff --git a/plugins/orbit/skills/orbit/scripts/blast.py b/plugins/orbit/skills/orbit/scripts/blast.py new file mode 100755 index 0000000..2d62205 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/blast.py @@ -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 [--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])}") diff --git a/plugins/orbit/skills/orbit/scripts/boundary.py b/plugins/orbit/skills/orbit/scripts/boundary.py new file mode 100755 index 0000000..67ac090 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/boundary.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Recipe 4: module/crate boundary guard for a diff. + +Usage: boundary.py [--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)}") diff --git a/plugins/orbit/skills/orbit/scripts/context.py b/plugins/orbit/skills/orbit/scripts/context.py new file mode 100755 index 0000000..afead58 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/context.py @@ -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 [--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 ` (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)) diff --git a/plugins/orbit/skills/orbit/scripts/defdiff.py b/plugins/orbit/skills/orbit/scripts/defdiff.py new file mode 100755 index 0000000..328b9d4 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/defdiff.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Recipe 1: map `git diff base..tip` onto definitions of both indexed commits. + +Usage: defdiff.py [--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) diff --git a/plugins/orbit/skills/orbit/scripts/fb.sh b/plugins/orbit/skills/orbit/scripts/fb.sh new file mode 100755 index 0000000..5fecd5a --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/fb.sh @@ -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 "" "" [""] [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 diff --git a/plugins/orbit/skills/orbit/scripts/freshness.sh b/plugins/orbit/skills/orbit/scripts/freshness.sh new file mode 100755 index 0000000..09525e0 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/freshness.sh @@ -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 diff --git a/plugins/orbit/skills/orbit/scripts/ob.py b/plugins/orbit/skills/orbit/scripts/ob.py new file mode 100755 index 0000000..8a4f2b1 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/ob.py @@ -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 diff --git a/plugins/orbit/skills/orbit/scripts/preflight.py b/plugins/orbit/skills/orbit/scripts/preflight.py new file mode 100755 index 0000000..430308a --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/preflight.py @@ -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 +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 ; treat every 'no callers/no implementors' as UNKNOWN until grep agrees") diff --git a/plugins/orbit/skills/orbit/scripts/regions.py b/plugins/orbit/skills/orbit/scripts/regions.py new file mode 100755 index 0000000..5d44346 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/regions.py @@ -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 diff --git a/plugins/orbit/skills/orbit/scripts/review-pass.sh b/plugins/orbit/skills/orbit/scripts/review-pass.sh new file mode 100755 index 0000000..91d084c --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/review-pass.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# End-to-end orbit review pass. Usage: review-pass.sh [rules-file] +# Writes $H/out/.{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 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 diff --git a/plugins/orbit/skills/orbit/scripts/review-setup.sh b/plugins/orbit/skills/orbit/scripts/review-setup.sh new file mode 100755 index 0000000..318a340 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/review-setup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Usage: review-setup.sh +# Creates detached worktrees for base and tip under $ORBIT_REVIEW_HOME/worktrees, +# indexes both into ONE isolated DB ($ORBIT_REVIEW_HOME/.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" diff --git a/plugins/orbit/skills/orbit/scripts/review-teardown.sh b/plugins/orbit/skills/orbit/scripts/review-teardown.sh new file mode 100755 index 0000000..169c57c --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/review-teardown.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Usage: review-teardown.sh — 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 diff --git a/plugins/orbit/skills/orbit/scripts/rules/dirigent.rules b/plugins/orbit/skills/orbit/scripts/rules/dirigent.rules new file mode 100644 index 0000000..689a8ab --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/rules/dirigent.rules @@ -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 diff --git a/plugins/orbit/skills/orbit/scripts/rules/spacecraft.rules b/plugins/orbit/skills/orbit/scripts/rules/spacecraft.rules new file mode 100644 index 0000000..a399d22 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/rules/spacecraft.rules @@ -0,0 +1,20 @@ +# Boundary rules for spacecraft, derived from AGENTS.md "Dependency Graph" and crates/*/CLAUDE.md. +# Format: allow : ... (only these workspace crates may be imported; others flagged) +# deny -> # 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 diff --git a/plugins/orbit/skills/orbit/scripts/sql/blind-files.sql b/plugins/orbit/skills/orbit/scripts/sql/blind-files.sql new file mode 100644 index 0000000..ea38341 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/blind-files.sql @@ -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; diff --git a/plugins/orbit/skills/orbit/scripts/sql/crate-edges-delta.sql b/plugins/orbit/skills/orbit/scripts/sql/crate-edges-delta.sql new file mode 100644 index 0000000..36c0ef5 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/crate-edges-delta.sql @@ -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; diff --git a/plugins/orbit/skills/orbit/scripts/sql/dependents.sql b/plugins/orbit/skills/orbit/scripts/sql/dependents.sql new file mode 100644 index 0000000..b6f6a99 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/dependents.sql @@ -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; diff --git a/plugins/orbit/skills/orbit/scripts/sql/hunk-to-defs.sql b/plugins/orbit/skills/orbit/scripts/sql/hunk-to-defs.sql new file mode 100644 index 0000000..9f603c8 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/hunk-to-defs.sql @@ -0,0 +1,8 @@ +-- Innermost definitions covering a changed line range (one hunk). Replace FILE/START/END. +-- Run: orbit sql --repo --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; diff --git a/plugins/orbit/skills/orbit/scripts/sql/implementors.sql b/plugins/orbit/skills/orbit/scripts/sql/implementors.sql new file mode 100644 index 0000000..563d40b --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/implementors.sql @@ -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; diff --git a/plugins/orbit/skills/orbit/scripts/sql/importers.sql b/plugins/orbit/skills/orbit/scripts/sql/importers.sql new file mode 100644 index 0000000..f8471d2 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/sql/importers.sql @@ -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; diff --git a/plugins/orbit/skills/orbit/scripts/traitcov.py b/plugins/orbit/skills/orbit/scripts/traitcov.py new file mode 100755 index 0000000..3d350f6 --- /dev/null +++ b/plugins/orbit/skills/orbit/scripts/traitcov.py @@ -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 [--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 ` 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']})")