🤖 Add orbit prototype plugin for Rust code review
Standalone plugin with one skill: when to use orbit and when grep, a mandatory preflight for stale indexes and blind regions (cfg(test), features, macros, dyn dispatch), a scripted review pass over isolated worktree DBs, and a JSONL feedback log for mining real usage later. Registered in the marketplace as a prototype.
This commit is contained in:
@@ -34,6 +34,11 @@
|
|||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"source": "./plugins/gitea",
|
"source": "./plugins/gitea",
|
||||||
"description": "Bidirectional Gitea ticket integration: fetch context, create issues, post comments, list/search, close, and scaffold labels"
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# orbit (prototype)
|
||||||
|
|
||||||
|
This plugin is a skill for **structural review of Rust changes** with the GitLab [orbit](https://gitlab.com/gitlab-org/orbit/knowledge-graph) code graph. It reports changed definitions, blast radius, trait-impl coverage, crate and module boundary guards, and a cfg-blindness preflight that decides how far to trust the rest. It complements grep and does not replace it.
|
||||||
|
|
||||||
|
It is an early prototype built from the 2026-09-29 research in `docs/workpad/research/2026-09-29-17-orbit/`, for orbit CLI **0.133**. It expects `orbit`, `git` and `python3` on PATH. No compilation is involved.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `skills/orbit/SKILL.md` | When to use orbit rather than grep, the mandatory preflight, the review pass, reporting rules, feedback, and how to brief a subagent |
|
||||||
|
| `skills/orbit/references/review-recipes.md` | Per-recipe detail, the gap-class table with grep fallbacks, and the rules-file format |
|
||||||
|
| `skills/orbit/references/cookbook.md` | Ad-hoc CLI and SQL for Rust, and gotchas |
|
||||||
|
| `skills/orbit/scripts/review-pass.sh` | End-to-end pass: setup, then preflight, defdiff, blast, traits, boundary and context (about 25 s) |
|
||||||
|
| `skills/orbit/scripts/review-setup.sh`, `review-teardown.sh` | Detached worktrees for base and tip in an **isolated** `--db`, and their cleanup |
|
||||||
|
| `skills/orbit/scripts/*.py` | The recipes (`ob.py` and `regions.py` are libraries) |
|
||||||
|
| `skills/orbit/scripts/freshness.sh` | Read-only index freshness check for ad-hoc questions |
|
||||||
|
| `skills/orbit/scripts/fb.sh` | Appends one feedback record |
|
||||||
|
| `skills/orbit/scripts/rules/*.rules` | Boundary rules for spacecraft and dirigent (hand-derived, may drift) |
|
||||||
|
| `skills/orbit/scripts/sql/*.sql` | Standalone SQL templates |
|
||||||
|
|
||||||
|
Review scratch (worktrees, DBs, outputs) goes to `$ORBIT_REVIEW_HOME` (default `~/.cache/orbit-review`), never `/tmp`. Always run `review-teardown.sh` afterwards: orbit has no command to delete an index entry, so nothing review-related may go into the shared `~/.gitlab/orbit/graph.duckdb`.
|
||||||
|
|
||||||
|
## Enabling locally (spacecraft, dirigent)
|
||||||
|
|
||||||
|
Both projects already declare the `reliquary` marketplace in their committed `.claude/settings.json` (`extraKnownMarketplaces`). That marketplace is fetched from **git** (`git@git.g4b.org:dirigence/reliquary.git`), so `orbit@reliquary` only resolves after this plugin is committed and pushed, and the marketplace has refreshed (`/plugin marketplace update reliquary`).
|
||||||
|
|
||||||
|
**Per project, not committed.** Run this in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin install orbit@reliquary --scope local
|
||||||
|
```
|
||||||
|
|
||||||
|
`--scope local` writes to `.claude/settings.local.json`, which is meant to stay uncommitted (confirm with `git check-ignore .claude/settings.local.json`). To do it by hand, add this to `<project>/.claude/settings.local.json` (create it if missing, and merge with any existing keys):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"orbit@reliquary": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Before pushing** (trying out this working copy), load the plugin for one session only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/spacecraft # or ~/Projects/dirigent
|
||||||
|
claude --plugin-dir ~/Projects/reliquary/plugins/orbit
|
||||||
|
```
|
||||||
|
|
||||||
|
Check it with `/plugin` (orbit should be listed as enabled) or by asking "review this branch with orbit". Also useful:
|
||||||
|
- `claude plugin validate ~/Projects/reliquary/plugins/orbit`
|
||||||
|
- Rules files: `rules/spacecraft.rules` and `rules/dirigent.rules` ship with the plugin. Pass one as the 5th argument to `review-pass.sh`.
|
||||||
|
|
||||||
|
## Feedback log
|
||||||
|
|
||||||
|
While this is a prototype, the skill asks agents to append **one JSONL line per orbit result that informed a decision** (or should have, and turned out wrong):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ORBIT_AGENT=search skills/orbit/scripts/fb.sh blast grep-needed "callers of format_token_title" "blast.py dg-tokens" 0 "both call sites inside rsx!" rsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- File: `~/.local/share/orbit-skill/feedback.jsonl`. Set `ORBIT_FEEDBACK` to override it. It is outside every repo and shared across projects.
|
||||||
|
- Fields: `ts, repo, commit, dirty, orbit` (automatic), `agent` (`$ORBIT_AGENT`), `recipe, purpose, cmd, rows, verdict`, and optionally `note, gap`. `verdict` is one of `useful | wrong | incomplete | grep-needed | noise | unused`.
|
||||||
|
|
||||||
|
To read or mine it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
F=~/.local/share/orbit-skill/feedback.jsonl
|
||||||
|
jq -s 'group_by(.recipe, .verdict) | map({recipe: .[0].recipe, verdict: .[0].verdict, n: length})' $F # value per recipe
|
||||||
|
jq -s '[.[] | select(.gap != "")] | group_by(.gap) | map({gap: .[0].gap, n: length}) | sort_by(-.n)' $F # which blind spots bite
|
||||||
|
jq -c 'select(.verdict == "wrong" or .verdict == "incomplete") | {repo, recipe, purpose, note}' $F # failures to learn from
|
||||||
|
jq -s 'group_by(.repo) | map({repo: .[0].repo, n: length, dirty: map(select(.dirty)) | length})' $F # per project, dirty-tree usage
|
||||||
|
```
|
||||||
|
|
||||||
|
Summaries of mined data (not raw logs) belong in `docs/workpad/research/…`. The open questions the log is meant to answer (whether the pass changes outcomes, the preflight thresholds, token budget, agent fit) are listed in §11 of `phase-2-review-workflows.md`.
|
||||||
|
|
||||||
|
## Known rough edges
|
||||||
|
|
||||||
|
- Tested only on orbit 0.133.0, spacecraft and dirigent. The preflight thresholds (25 % and 60 %) are guesses.
|
||||||
|
- Uncommitted review uses `git stash create`, so **untracked files are not reviewed**.
|
||||||
|
- `review-setup.sh` briefly registers two worktrees in the target repo's `.git` (removed by teardown). If teardown is skipped, they linger. Check with `git worktree list`.
|
||||||
|
- Rust only. The shell scripts need bash (they work when called from fish).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: orbit
|
||||||
|
description: >
|
||||||
|
PROTOTYPE. Use the orbit code graph (GitLab `orbit` CLI) for STRUCTURAL questions about a Rust
|
||||||
|
codebase: reviewing a Rust change, branch, PR, merge or diff ("what did this diff touch
|
||||||
|
structurally", "review this branch against main"); "who depends on / calls / imports / uses X";
|
||||||
|
"who implements trait X", "did every impl get the new method"; blast radius of a change; "did this
|
||||||
|
cross a crate or module boundary"; "what does this crate/module/type look like". Also for
|
||||||
|
briefing a subagent (e.g. Spire's `search`) that should run these queries. NOT a grep
|
||||||
|
replacement: orbit is blind to #[cfg(test)], cargo-feature-gated code, macro args, rsx!, dyn
|
||||||
|
dispatch and Bevy add_systems, so every orbit answer is a lower bound that grep must close.
|
||||||
|
For literal text, counting occurrences, or "where is string X", use grep. Shell only, not MCP.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Orbit (prototype)
|
||||||
|
|
||||||
|
Orbit indexes a Rust workspace into a DuckDB graph: definitions, CALLS, IMPORTS, EXTENDS. This skill decides **when orbit is worth asking**, **what must be checked before trusting it**, and **how to report what it says**. It was built from a probe of orbit 0.133 on spacecraft (Bevy) and dirigent (Tokio/Dioxus). The numbers below come from that probe.
|
||||||
|
|
||||||
|
Paths: `S=${CLAUDE_PLUGIN_ROOT}/skills/orbit/scripts`. If `CLAUDE_PLUGIN_ROOT` is unset (subagent shells), use the plugin's absolute path. Details are in `references/review-recipes.md` and `references/cookbook.md`. Read them only when you run a recipe or write ad-hoc SQL.
|
||||||
|
|
||||||
|
## Orbit or grep
|
||||||
|
|
||||||
|
| Question | Use |
|
||||||
|
|---|---|
|
||||||
|
| Which definitions did this diff add, remove, or change in signature? | **orbit** (`defdiff.py`, exact within visible code) |
|
||||||
|
| Who calls or imports X outside the diff? | **orbit first, then grep.** Orbit finds method callers of a type that grep can't. Grep finds the ~50 % orbit misses at definition level. |
|
||||||
|
| Who implements in-repo trait T? | **orbit plus grep.** EXTENDS only links same-file impls (`traitcov.py` does both). |
|
||||||
|
| New crate→crate edges, documented layering rules | **orbit** (`boundary.py`), plus its Cargo.toml and inline-path fallbacks |
|
||||||
|
| Outline of a file, module, or type | `orbit grep --path <file>` (no query) / `orbit repo-map class <Type>` / `repo-map tree <dir>` |
|
||||||
|
| Literal strings, counts, "every place that mentions X", test code, feature-gated code | **grep**. Orbit does not see gated code and its `grep` is ranked and capped. |
|
||||||
|
| Runtime behaviour, ordering, Bevy schedules, async | **Neither.** Read the code or run it. Orbit knows structure only. |
|
||||||
|
|
||||||
|
## Mandatory preflight (every time, before trusting a single row)
|
||||||
|
|
||||||
|
1. **Freshness.** Ad-hoc: `$S/freshness.sh <repo>` (read-only). For a review, `preflight.py` checks this itself.
|
||||||
|
- `STALE` means the index sha is not HEAD. `orbit sql` then **silently queries every repo and commit** (the note goes to stderr only), and `grep`/`context` give no warning. Re-index the **main checkout** (`orbit index <repo> -t 4`, which replaces that repo's entry in the shared DB), or stop.
|
||||||
|
- `DIRTY` / working-tree caveat: **orbit indexes the working tree, including uncommitted and untracked files, but labels the entry with HEAD's sha.** A matching sha proves nothing if the tree was dirty at index time or was edited since.
|
||||||
|
2. **Always `-t 4`.** With default threads, a per-file watchdog silently skips 4–7 large files per run. Check `processing.skipped_files == 0` and `errored_files == 0` in the index JSON. If either is not 0, re-index; if it persists, list those files as blind.
|
||||||
|
3. **Blind regions.** State them before any conclusion:
|
||||||
|
- `#[cfg(test)]` modules, and files gated by a `#[cfg(test)] mod x;`: no definitions, no CALLS, and no import rows for `use` inside test mods.
|
||||||
|
- **Cargo features are evaluated ALL OFF, defaults included.** dirigent's `server` feature hides most of `dirigent_core`; spacecraft's default `steam` feature is dropped. There is no flag to change this.
|
||||||
|
- CALLS gaps: `dyn Trait` receivers (0–14 % recall), macro arguments (`assert!`, `matches!`, `vec!`), `rsx!` bodies (Dioxus components ~0 %), fns passed by reference (Bevy `add_systems`, observers, callbacks), enum variant patterns, `Type::default()`.
|
||||||
|
- Traits: external traits (`Plugin`, `Serialize`) are not nodes and return empty **without a warning**. Cross-file `impl` blocks, blanket impls and impls on external types have no EXTENDS.
|
||||||
|
- Imports: inline paths (`other_crate::x::Y` without `use`) are not import rows. `pub use` re-exports are not followed.
|
||||||
|
|
||||||
|
## Review pass (Rust branch, PR, or uncommitted diff)
|
||||||
|
|
||||||
|
Run it in an **isolated `--db`**, never the shared DB. Worktree entries in the shared DB can't be removed, because **orbit has no delete command**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# base..tip: branch -> base=$(git merge-base main B) tip=B; merged PR -> <merge>^1..<merge>
|
||||||
|
# uncommitted -> tip=$(git stash create) (a commit object of tracked changes; touches nothing; untracked files NOT included)
|
||||||
|
export ORBIT_REVIEW_HOME=~/.cache/orbit-review # default; worktrees + DBs + outputs live here, never /tmp
|
||||||
|
$S/review-pass.sh <repo> <base> <tip> <name> [$S/rules/<repo>.rules] > review.txt # ~30 s, ~8–16 KB
|
||||||
|
$S/review-teardown.sh <repo> <name> # always, even after failure
|
||||||
|
```
|
||||||
|
|
||||||
|
`review-setup.sh` adds two detached `git worktree`s to the repo (these are removed in teardown) and indexes both into `<name>.duckdb`. Afterwards, `git worktree list` and `pgrep -a orbit` should show nothing of yours.
|
||||||
|
|
||||||
|
Read the output in this order:
|
||||||
|
1. **Preflight.** Blind share of changed lines: **<25 %** → orbit-led review, grep the listed gaps. **25–60 %** → orbit for the visible part only; read the blind files. **>60 %** → stop using orbit except defdiff and boundary, and say so. Also stop if the index isn't fresh or clean, or if the plan's key items all sit in blind files.
|
||||||
|
2. **defdiff.** Compare with the plan: is every planned item present, and is anything unplanned present? Read every `S` (signature) row, every `-` row, and every type with member changes.
|
||||||
|
3. **blast.** For each OUTSIDE-diff dependent: did it need a change it didn't get? Then close the gaps with grep: an **added enum variant** → grep every `match` on the enum; a **changed Bevy system signature** → is it still registered? a **removed item** → is the old path re-exported, or does it dangle?
|
||||||
|
4. **traits.** Read every `[G]`-only or `!!` implementor. `[H]`-only may be a delegating facade; confirm it by reading.
|
||||||
|
5. **boundary.** Check new crate edges and Cargo.toml deps against the documented graph (AGENTS.md, crate CLAUDE.md). Doc drift is a real finding.
|
||||||
|
6. **context.** A compact API brief of the touched area to hand to the reviewer, together with the plan.
|
||||||
|
|
||||||
|
For ad-hoc questions, use the SQL in `references/cookbook.md` (callers, importers, implementors, blind files).
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
- **Fact**: orbit states it **and** grep or a file read confirmed it. Cite `file:line`.
|
||||||
|
- **Hypothesis**: any orbit count or absence ("no other callers", "no implementors", "no boundary crossing") that grep has not closed. Write it as **"≥ N (orbit lower bound; gaps: cfg-test, dyn, …)"**. Never write "unused" or "dead": most callables with no incoming CALLS are systems, handlers or trait impls.
|
||||||
|
- **Blind**: state the preflight blind files and blind share **once, at the top**.
|
||||||
|
|
||||||
|
## Feedback (cheap, required while this is a prototype)
|
||||||
|
|
||||||
|
After each orbit query or recipe whose result **informed a decision** (or should have, and was wrong), append one line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ORBIT_AGENT=<search|reviewer|…> $S/fb.sh <recipe> <verdict> "<purpose>" "<cmd>" <rows> ["<note>"] [gap]
|
||||||
|
# recipe: defdiff blast traitcov boundary preflight context adhoc-sql grep repo-map context-cmd
|
||||||
|
# verdict: useful | wrong | incomplete | grep-needed | noise | unused
|
||||||
|
# gap: cfg-test cfg-feature gated-file dyn fn-ref macro rsx cross-file-impl external-trait inline-path pattern-match stale-index sig-truncated other
|
||||||
|
```
|
||||||
|
|
||||||
|
Written to `~/.local/share/orbit-skill/feedback.jsonl` (override with `ORBIT_FEEDBACK`). One call per decision, not per query. Skip it for idle exploration.
|
||||||
|
|
||||||
|
## Briefing a subagent
|
||||||
|
|
||||||
|
Spire's `search` agent (Sonnet, Bash, read-only) is the right carrier. Put this in the brief:
|
||||||
|
|
||||||
|
> Use the orbit skill (`orbit:orbit`), from the shell only (the orbit MCP server has only unscoped SQL). Scripts: `<abs path>/skills/orbit/scripts`. First run `freshness.sh` (ad-hoc) or `review-pass.sh` (range `<base>..<tip>`, name `<name>`, isolated DB under ~/.cache/orbit-review), and **always** run `review-teardown.sh` at the end. Never run `orbit index` on a worktree without `--db`. Never use `/tmp`. Do not compile. Return: (1) blind share and blind files, (2) facts with file:line, (3) hypotheses labelled "orbit lower bound" with gap classes, (4) the grep commands you ran to close gaps. Log one `fb.sh` line per result that informed a finding (`ORBIT_AGENT=search`).
|
||||||
|
|
||||||
|
The receiving reviewer (Opus) decides severity. The search agent relays facts and hypotheses, not verdicts.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Orbit 0.133 cookbook for Rust (ad-hoc questions)
|
||||||
|
|
||||||
|
Source: `phase-1-rust-capabilities.md`. Run these in the repo directory. First run `scripts/freshness.sh`: if the index is stale, `orbit sql` silently falls back to **all repos and all commits**.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orbit index <repo> -t 4 | grep -E '"definitions"|"skipped_files"|"errored_files"' # shared DB: main checkouts only
|
||||||
|
orbit index <worktree> -t 4 --db ~/.cache/orbit-review/<name>.duckdb # anything else: isolated
|
||||||
|
orbit list # entries in the shared DB (add --db for isolated)
|
||||||
|
orbit repo-map # overview: crates, key abstractions, most-imported symbols
|
||||||
|
orbit repo-map tree crates/foo/src/backend # directory prefix only
|
||||||
|
orbit repo-map class JsonlBackend # fields + methods across impl files (signatures truncated)
|
||||||
|
orbit repo-map extends MyTrait # same-file in-repo implementors: LOWER BOUND
|
||||||
|
orbit repo-map imports themed_button # counts per import path (not file list)
|
||||||
|
orbit grep put_session --kind Method --path crates/foo # one query; 'a|b' for OR; ranked and capped
|
||||||
|
orbit grep --path crates/foo/src/x.rs # file outline
|
||||||
|
orbit context Definition:<id> # source + callers/callees (+ test callers section)
|
||||||
|
orbit sql -f q.sql [-F json|csv] [--repo <checkout>] [--db <file>]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **fqn root is the cargo target name** (lib, bin, or test file stem) and can collide (spacecraft lib and bin are both `warpspace`). Group by `file_path`.
|
||||||
|
- There is no Impl node. Trait impl methods look like inherent methods (`Type::m`); the fqn carries no trait.
|
||||||
|
- `start_line` includes doc comments and attributes.
|
||||||
|
- CALLS→Struct/Variant means **construction**, not usage. Type mentions in signatures or `Query<&T>` produce no edge. There are no CALLS edges to Constant or Static.
|
||||||
|
- Default scope deduplicates edges to one per (source, target). `--all` gives one per call site. Don't compare counts across the two.
|
||||||
|
- DuckDB: `target` is reserved, so alias it `tgt`. An empty result prints **nothing** and exits 0.
|
||||||
|
- Import paths are raw (`crate::`, `super::`, relative) and are not resolved to definitions. Match on `identifier_name`.
|
||||||
|
- MCP `run_sql` is always unscoped (like `--all`). Use the CLI.
|
||||||
|
- Orbit has **no command to delete an index entry**. Never index worktrees or scratch checkouts into the shared DB. `--db <file>` plus `rm <file>` is the only clean pattern.
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
Callers of a definition:
|
||||||
|
```sql
|
||||||
|
SELECT s.fqn AS caller, s.file_path, s.start_line
|
||||||
|
FROM gl_edge e JOIN gl_definition s ON s.id = e.source_id JOIN gl_definition t ON t.id = e.target_id
|
||||||
|
WHERE e.relationship_kind = 'CALLS' AND t.fqn = 'dirigent_web::components::toast::show_toast'
|
||||||
|
ORDER BY s.file_path;
|
||||||
|
```
|
||||||
|
|
||||||
|
Files importing a name (includes top-level `use` in gated files; excludes `use` inside `#[cfg(test)] mod` and fn bodies, and inline paths):
|
||||||
|
```sql
|
||||||
|
SELECT file_path, import_type, import_path, start_line FROM gl_imported_symbol
|
||||||
|
WHERE identifier_name = 'TrashDir' ORDER BY file_path;
|
||||||
|
```
|
||||||
|
|
||||||
|
Crate import matrix:
|
||||||
|
```sql
|
||||||
|
SELECT split_part(i.file_path,'/',2) from_crate, split_part(i.import_path,'::',1) to_crate, count(*) n
|
||||||
|
FROM gl_imported_symbol i
|
||||||
|
WHERE i.file_path LIKE 'crates/%' AND split_part(i.file_path,'/',2) <> split_part(i.import_path,'::',1)
|
||||||
|
GROUP BY ALL ORDER BY n DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Trait implementors via EXTENDS (lower bound) and method-name overlap (covers cross-file impls, but noisy):
|
||||||
|
```sql
|
||||||
|
WITH tm AS (SELECT m.name FROM gl_definition t
|
||||||
|
JOIN gl_definition m ON m.fqn LIKE t.fqn || '::%' AND m.definition_type='Method'
|
||||||
|
WHERE t.definition_type='Trait' AND t.name='ArchiveBackend')
|
||||||
|
SELECT regexp_replace(d.fqn,'::[^:]+$','') implementor, count(DISTINCT d.name) matched, (SELECT count(*) FROM tm) trait_methods
|
||||||
|
FROM gl_definition d WHERE d.definition_type='Method' AND d.name IN (SELECT name FROM tm)
|
||||||
|
GROUP BY 1 HAVING count(DISTINCT d.name) >= 3 ORDER BY matched DESC;
|
||||||
|
```
|
||||||
|
Always also run `git grep -nE 'impl\b.*\bArchiveBackend\b.*\bfor\b'`.
|
||||||
|
|
||||||
|
Blind files (Rust files with no non-macro definitions: cfg-off, tests, vendor):
|
||||||
|
```sql
|
||||||
|
SELECT f.path, f.size_bytes FROM gl_file f
|
||||||
|
WHERE f.language='rust'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM gl_definition d WHERE d.file_path=f.path AND d.definition_type <> 'Macro')
|
||||||
|
ORDER BY f.size_bytes DESC LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
More templates (hunk→defs, dependents including members, crate-edge delta) are in `scripts/sql/`. Each has a placeholder name to replace.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Review recipes: detail
|
||||||
|
|
||||||
|
Source: `docs/workpad/research/2026-09-29-17-orbit/phase-2-review-workflows.md` in reliquary (7 historical ranges in spacecraft and dirigent, orbit 0.133). Every script takes the env file that `review-setup.sh` prints (`DB BASE_WT TIP_WT BASE_SHA TIP_SHA REPO`). `review-pass.sh` runs all of them. You only need the individual commands to re-run one recipe with other flags.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=<plugin>/skills/orbit/scripts; O=$ORBIT_REVIEW_HOME/out/<name>
|
||||||
|
$S/review-setup.sh <repo> <base> <tip> <name> > $O.env # 2 detached worktrees, both indexed -t 4 into <name>.duckdb
|
||||||
|
python3 $S/defdiff.py $O.env --json $O.defdiff.json # recipe 1
|
||||||
|
python3 $S/preflight.py $O.env $O.defdiff.json # recipe 5 (read first)
|
||||||
|
python3 $S/blast.py $O.env $O.defdiff.json [--measure] # recipe 2
|
||||||
|
python3 $S/traitcov.py $O.env $O.defdiff.json [--trait Name] [--all-traits] # recipe 3
|
||||||
|
python3 $S/boundary.py $O.env $O.defdiff.json $S/rules/<repo>.rules [--audit] # recipe 4
|
||||||
|
python3 $S/context.py $O.env $O.defdiff.json [--budget 8000] # recipe 6
|
||||||
|
$S/review-teardown.sh <repo> <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commits live in one DB. `orbit sql --repo <worktree> --db <db>` scopes to one commit. Definition ids differ between commits, so the scripts join on `(fqn, kind)`.
|
||||||
|
|
||||||
|
## 1. defdiff: changed definitions
|
||||||
|
|
||||||
|
Markers: `+` added, `-` removed, `>` moved (untested), `S` signature changed (with a word diff), `~` body touched (size and member changes), `?` relocated candidate.
|
||||||
|
- Within visible code: precision and recall were both 100 % on 7 ranges. Every miss was in cfg-test, cfg-feature, or a gated file.
|
||||||
|
- Blind: `impl` headers and where-clauses (there is no Impl node), attributes/derives (these count as touching the item), signatures longer than 12 lines (cut).
|
||||||
|
- The "unattributed lines" tally at the bottom feeds the preflight.
|
||||||
|
|
||||||
|
## 2. blast: dependents
|
||||||
|
|
||||||
|
For each `S`, `-`, `>` definition, and each `~` with member changes, it lists CALLS callers (for types: callers of methods, assoc fns and variants), importing files, and EXTENDS implementors, split into in-diff and **OUTSIDE diff**. For removed items it also checks whether a same-name item survives and whether the old path is kept alive by a `pub use` re-export or dangles. The re-export check was 8/8 correct.
|
||||||
|
|
||||||
|
Recall against grep: **49 % at definition level, 93 % at file level** (callers plus importers). The gaps and the grep that closes each:
|
||||||
|
|
||||||
|
| gap | share of misses | close with |
|
||||||
|
|---|---|---|
|
||||||
|
| `#[cfg(test)]` | 36/136 | `git grep -nw Name`, read the test module |
|
||||||
|
| enum variant literal / multi-line pattern | 32 | `git grep -nE 'Enum::\w+'`; for an added variant, find every `match` on the enum |
|
||||||
|
| fn passed by reference (Bevy system, callback) | 21 | `git grep -nw fn_name`; ask "still registered?" |
|
||||||
|
| file gated at parent `mod` | 17 | read the file / `git grep` |
|
||||||
|
| `Type::default()`, derive/trait assoc calls | 6 | `git grep -n 'Type::'` |
|
||||||
|
| struct literal, return type in signature | 12 | `git grep -nw Type` |
|
||||||
|
| `rsx!` body | 4 | `git grep -nw` |
|
||||||
|
| cfg-feature / cfg-other | 6 | read the region |
|
||||||
|
| dyn/field/closure receiver | 2 | `git grep -n '\.method('` |
|
||||||
|
|
||||||
|
Orbit-only dependents are real: they are callers of a type's methods, which a grep for the type name can't find.
|
||||||
|
|
||||||
|
## 3. traitcov: trait implementors
|
||||||
|
|
||||||
|
Sources: `E` = EXTENDS (same-file impls only), `G` = `git grep 'impl … Trait … for Type'` (text, so it also sees gated and test impls), `H` = at least 60 % method-name overlap (noisy: delegating facades such as `Archivist` also match). For implementors orbit can't see, it text-checks that the new method exists in the impl file. `defines 20/21` often just means a default method isn't overridden.
|
||||||
|
- Blind: external traits, blanket impls, impls on external types, multi-line `impl` headers (the `G` regex misses them).
|
||||||
|
|
||||||
|
## 4. boundary: crate and module edges
|
||||||
|
|
||||||
|
Reports new crate→crate import edges (tip minus base), new imports in changed files checked against the rules file, new intra-crate module edges, **inline qualified paths** in changed files (these are not import rows), and **Cargo.toml dependency changes**. `--audit` checks every tip import against the rules, which validates the rules file itself.
|
||||||
|
|
||||||
|
Rules format (`scripts/rules/*.rules`, one per repo; the spacecraft and dirigent files were derived from AGENTS.md and crate CLAUDE.md files on 2026-09-29 and may drift):
|
||||||
|
```
|
||||||
|
allow <crate> : <crate> <crate> ... # only these workspace crates may be imported
|
||||||
|
deny <src-crate-or-path-glob> -> <import-regex> # note
|
||||||
|
```
|
||||||
|
The compiler already enforces Cargo. This recipe's value is in documented rules beyond Cargo, and in doc drift (for example, a new edge not listed in AGENTS.md).
|
||||||
|
|
||||||
|
## 5. preflight: blindness
|
||||||
|
|
||||||
|
Checks index freshness (manifest sha = worktree HEAD = requested sha, clean tree, 0 skipped/errored), changed files with no definitions and the `mod` line that gates each one, the share of changed lines in cfg-test/cfg-feature/cfg-other/gated files, the Cargo features of touched crates, and CALLS-blind constructs in added lines. Measured blind share across the 7 ranges: **6 % to 71 %**.
|
||||||
|
|
||||||
|
## 6. context: compact brief
|
||||||
|
|
||||||
|
One line per type or callable in touched files, with change markers, within a byte budget (5–10 KB). Unchanged items in large files collapse to a pointer (`orbit grep --path <file>`). Prefer this to `orbit repo-map api`, which is about 200 KB for the same area, needs a **directory** prefix (a file path silently returns nothing), and shows `let` lines as signatures about 2 % of the time.
|
||||||
|
|
||||||
|
## Uncommitted changes
|
||||||
|
|
||||||
|
`tip=$(git -C <repo> stash create)` makes a dangling commit object from the tracked working-tree changes and index. It changes no refs, files or stash list. Pass it as `<tip>`. Untracked new files are **not** included: read them directly and list them as blind. Tested 2026-09-29 on a scratch clone. If `git stash create` prints nothing, the tree has no tracked changes.
|
||||||
|
|
||||||
|
## Stop conditions
|
||||||
|
|
||||||
|
Stop and fall back to grep plus reading if any of these hold: the blind share is over 60 %, the index is not fresh or clean, the plan's key items are all in blind files, or the question is behavioural (runtime ordering, Bevy schedule, async).
|
||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 2: blast radius of changed definitions, with a grep cross-check that closes orbit's gaps.
|
||||||
|
|
||||||
|
Usage: blast.py <env-file> <defdiff.json> [--ops S-~>] [--kinds ...] [--measure] [--top N]
|
||||||
|
For each changed definition (default ops: S signature, - removed, > moved, ~ body with member changes):
|
||||||
|
orbit: CALLS callers (tip; base for removed), construction sites (CALLS->Variant for enums),
|
||||||
|
importing files (gl_imported_symbol.identifier_name), EXTENDS implementors (traits)
|
||||||
|
split: dependents inside the diff vs OUTSIDE the diff (the review-relevant ones)
|
||||||
|
--measure: git grep -w at the same commit, attribute hits to enclosing orbit definitions,
|
||||||
|
report recall and classify every grep-only site (the gap list).
|
||||||
|
"""
|
||||||
|
import collections, json, re, subprocess, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q, git
|
||||||
|
from regions import cfg_regions, region_of
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
|
||||||
|
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
|
||||||
|
arg = lambda k, d: sys.argv[sys.argv.index(k) + 1] if k in sys.argv else d
|
||||||
|
OPS = set(arg('--ops', 'S->~'))
|
||||||
|
MEASURE = '--measure' in sys.argv
|
||||||
|
TOP = int(arg('--top', '6'))
|
||||||
|
GENERIC = {'new', 'default', 'build', 'from', 'fmt', 'run', 'drop', 'clone', 'eq', 'deref', 'into', 'len',
|
||||||
|
'get', 'set', 'id', 'name', 'start', 'update', 'apply', 'is_empty', 'as_str', 'to_string', 'hash', 'serialize'}
|
||||||
|
CALLABLE = {'Function', 'Method', 'AssociatedFunction'}
|
||||||
|
|
||||||
|
def defs_at(wt):
|
||||||
|
ds = q("SELECT id, file_path, fqn, name, definition_type k, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=wt, db=DB)
|
||||||
|
by = collections.defaultdict(list)
|
||||||
|
for d in ds: by[d['file_path']].append(d)
|
||||||
|
return {d['id']: d for d in ds}, by
|
||||||
|
|
||||||
|
def edges_at(wt, kind):
|
||||||
|
return q(f"SELECT e.source_id s, e.target_id t FROM gl_edge e JOIN gl_definition d ON d.id=e.target_id "
|
||||||
|
f"WHERE e.relationship_kind='{kind}'", repo=wt, db=DB)
|
||||||
|
|
||||||
|
SIDE = {}
|
||||||
|
for side, wt in (('tip', TW), ('base', BW)):
|
||||||
|
byid, byfile = defs_at(wt)
|
||||||
|
calls = collections.defaultdict(set); ext = collections.defaultdict(set)
|
||||||
|
for e in edges_at(wt, 'CALLS'): calls[e['t']].add(e['s'])
|
||||||
|
for e in edges_at(wt, 'EXTENDS'): ext[e['t']].add(e['s'])
|
||||||
|
imps = collections.defaultdict(set); globs = collections.defaultdict(set)
|
||||||
|
for r in q("SELECT DISTINCT file_path, identifier_name n, import_path p, import_type it FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=wt, db=DB):
|
||||||
|
if r['n']: imps[r['n']].add(r['file_path'])
|
||||||
|
if r['it'] in ('GlobUse', 'ReExportGlob'): globs[r['p'].split('::')[-1]].add(r['file_path'])
|
||||||
|
children = collections.defaultdict(list)
|
||||||
|
for d in byid.values():
|
||||||
|
children[d['fqn'].rsplit('::', 1)[0]].append(d)
|
||||||
|
SIDE[side] = dict(byid=byid, byfile=byfile, calls=calls, ext=ext, imps=imps, globs=globs, children=children, wt=wt,
|
||||||
|
rev=T if side == 'tip' else B)
|
||||||
|
|
||||||
|
changed_files = set(dd['changed_files'])
|
||||||
|
TIP_REEXPORTS = q("SELECT file_path, identifier_name n, import_path p, import_type it, start_line s FROM gl_imported_symbol "
|
||||||
|
"WHERE import_type LIKE 'ReExport%' OR import_type = 'PubUseGroup'", repo=TW, db=DB)
|
||||||
|
TIP_IMPORTS = collections.defaultdict(list)
|
||||||
|
for x in q("SELECT file_path, identifier_name n, import_path p FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=TW, db=DB):
|
||||||
|
TIP_IMPORTS[x['n']].append(x)
|
||||||
|
touched = {'tip': set(dd['touched_tip']), 'base': set(dd['touched_base'])}
|
||||||
|
|
||||||
|
def innermost(byfile, f, ln):
|
||||||
|
cov = [d for d in byfile.get(f, []) if d['s'] <= ln <= d['e'] and d['k'] not in ('Module', 'Field', 'Variant')]
|
||||||
|
return min(cov, key=lambda d: d['e'] - d['s']) if cov else None
|
||||||
|
|
||||||
|
def grep_sites(S, row):
|
||||||
|
n = row['name']
|
||||||
|
if row['k'] == 'Method': pat = rf'(\.|::){n}\b'
|
||||||
|
elif row['k'] == 'AssociatedFunction': pat = rf'{row["fqn"].split("::")[-2]}::{n}\b'
|
||||||
|
else: pat = rf'\b{n}\b'
|
||||||
|
try: out = git(REPO, 'grep', '-nE', pat, S['rev'], '--', '*.rs')
|
||||||
|
except subprocess.CalledProcessError: return []
|
||||||
|
res = []
|
||||||
|
parts = row['fqn'].split('::')
|
||||||
|
owner = parts[-2] if len(parts) > 1 else ''
|
||||||
|
# Rust name-resolution approximation: the site's file must be able to see the name
|
||||||
|
vis = {row['file']} | S['imps'].get(n, set()) | S['globs'].get(owner, set())
|
||||||
|
if row['k'] in ('Method', 'AssociatedFunction'):
|
||||||
|
vis = None # methods resolve through the receiver type, not imports
|
||||||
|
for l in out.splitlines():
|
||||||
|
_, f, ln, txt = l.split(':', 3); ln = int(ln); t = txt.strip()
|
||||||
|
if vis is not None and f not in vis and not re.search(rf'\b{owner}::{n}\b', t): continue
|
||||||
|
if t.startswith(('//', '///', '//!')): continue
|
||||||
|
if re.search(rf'\b(fn|struct|enum|trait|const|static|type|mod)\s+{n}\b', t): continue
|
||||||
|
res.append((f, ln, t))
|
||||||
|
return res
|
||||||
|
|
||||||
|
_src = {}
|
||||||
|
def lines_of(S, f):
|
||||||
|
if (S['wt'], f) not in _src:
|
||||||
|
try: _src[(S['wt'], f)] = open(f"{S['wt']}/{f}", encoding='utf-8', errors='replace').read().split('\n')
|
||||||
|
except FileNotFoundError: _src[(S['wt'], f)] = []
|
||||||
|
return _src[(S['wt'], f)]
|
||||||
|
|
||||||
|
def enclosing_macro(L, ln, limit=400):
|
||||||
|
depth = 0
|
||||||
|
for i in range(ln - 2, max(-1, ln - 2 - limit), -1):
|
||||||
|
x = re.sub(r'"(\\.|[^"\\])*"', '""', re.sub(r'//.*', '', L[i]))
|
||||||
|
depth += x.count('}') + x.count(')') + x.count(']') - x.count('{') - x.count('(') - x.count('[')
|
||||||
|
if depth < 0:
|
||||||
|
m = re.search(r'(\w+)!\s*[\{\(\[]', x)
|
||||||
|
if m: return m.group(1)
|
||||||
|
if re.match(r'\s*((pub|async|unsafe|const)\s+)*fn\b|\s*impl\b', x): return None
|
||||||
|
depth = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
def classify_gap(S, row, f, ln, t, imported_files):
|
||||||
|
if re.match(r'(pub(\([^)]*\))?\s+)?use\b', t) or f in imported_files and t.endswith((',', '};', '{')) and '(' not in t:
|
||||||
|
return 'import-line'
|
||||||
|
real = [d for d in S['byfile'].get(f, []) if d['k'] != 'Macro']
|
||||||
|
if not real: return 'file-no-defs(gated/test)'
|
||||||
|
reg = region_of(cfg_regions(lines_of(S, f)), ln)
|
||||||
|
if reg: return f"cfg-{reg[2]}"
|
||||||
|
n = row['name']
|
||||||
|
if re.match(rf'(unsafe\s+)?impl\b.*\b{n}\b', t): return 'impl-header(no edge by design)'
|
||||||
|
if row['k'] in ('Enum', 'Struct') and (re.search(rf'\b{n}(::\w+)?\s*(\(.*\)|\{{.*\}}|)\s*(=>|\|)', t) or re.search(r'\b(if|while) let\b|matches!', t)):
|
||||||
|
return 'pattern-match(no edge by design)'
|
||||||
|
if row['k'] in ('Constant', 'Static'): return 'const-read(no edge by design)'
|
||||||
|
if row['k'] == 'Enum' and re.search(rf'\b{n}::[A-Z]\w*\s*(\{{|\(|$)', t): return 'variant-literal-or-multiline-pattern'
|
||||||
|
if row['k'] in ('Enum', 'Struct') and re.search(rf'\b{n}::\w+\s*\(', t): return 'derived/trait-assoc-call(Type::default() etc.)'
|
||||||
|
if row['k'] in CALLABLE and (re.search(r'add_systems|add_observer|\.observe\(|run_if|in_set|\.after\(|\.before\(', t)
|
||||||
|
or re.search(rf'\b{n}\b(?!\s*(\(|::|<))', t)):
|
||||||
|
return 'fn-ref(bevy system/callback)'
|
||||||
|
if re.search(rf'\w+!\s*[\(\[{{][^;]*\b{row["name"]}\b', t): return 'macro-arg'
|
||||||
|
if row['k'] in ('Struct', 'Enum', 'Trait', 'TypeAlias') and not re.search(rf'\b{row["name"]}\s*(\{{|\(|::)', t):
|
||||||
|
return 'type-mention(no edge by design)'
|
||||||
|
m = enclosing_macro(lines_of(S, f), ln)
|
||||||
|
if m: return f'inside-macro-body({m}!)'
|
||||||
|
if row['k'] == 'Method' and re.search(rf'\.{row["name"]}\b', t): return 'method-on-unresolved-receiver(dyn/field/closure)'
|
||||||
|
if not innermost(S['byfile'], f, ln): return 'outside-any-def(static/const init?)'
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
def dependents(row):
|
||||||
|
side = 'base' if row['op'] == '-' else 'tip'
|
||||||
|
S = SIDE[side]; did = row['bid'] if side == 'base' else row['tid']
|
||||||
|
tgt = {did}
|
||||||
|
if row['k'] in ('Enum', 'Struct'): # construction sites hit variants / the struct itself
|
||||||
|
tgt |= {c['id'] for c in S['children'].get(row['fqn'], []) if c['k'] in ('Variant', 'Method', 'AssociatedFunction')}
|
||||||
|
callers = set().union(*[S['calls'].get(t, set()) for t in tgt]) - tgt
|
||||||
|
impls = S['ext'].get(did, set())
|
||||||
|
importers = S['imps'].get(row['name'], set()) - {row['file']}
|
||||||
|
return side, S, callers, impls, importers
|
||||||
|
|
||||||
|
def tgt_ids(row, S):
|
||||||
|
did = row['bid'] if row['op'] == '-' else row['tid']
|
||||||
|
return {did} | {c['id'] for c in S['children'].get(row['fqn'], [])}
|
||||||
|
|
||||||
|
def fmt_def(S, i):
|
||||||
|
d = S['byid'][i]; return f"{d['file_path']}:{d['s']} {d['fqn'].split('::')[-1]}"
|
||||||
|
|
||||||
|
rows = [r for r in dd['rows'] if r['op'] in OPS and (r['op'] != '~' or r.get('members'))
|
||||||
|
and r['k'] not in ('Module',) and not r.get('collapsed')]
|
||||||
|
tot = collections.Counter(); gaps_all = collections.Counter(); gap_ex = collections.defaultdict(list)
|
||||||
|
print(f"# blast radius {B[:8]}..{T[:8]} targets={len(rows)} (callers split: in-diff / OUTSIDE diff)")
|
||||||
|
for r in rows:
|
||||||
|
side, S, callers, impls, importers = dependents(r)
|
||||||
|
tset = touched[side]
|
||||||
|
outside = sorted(c for c in callers if c not in tset)
|
||||||
|
imp_out = sorted(f for f in importers if f not in changed_files)
|
||||||
|
line = (f"{r['op']} {r['k']} {r['fqn'].split('::',1)[-1]} [{r['file']}] callers={len(callers)} outside={len(outside)}"
|
||||||
|
f" importers={len(importers)} outside={len(imp_out)}" + (f" implementors={len(impls)}" if r['k'] == 'Trait' else ''))
|
||||||
|
print(line)
|
||||||
|
for c in outside[:TOP]: print(f" <- {fmt_def(S, c)}")
|
||||||
|
if len(outside) > TOP: print(f" … {len(outside)-TOP} more outside callers")
|
||||||
|
if imp_out and not outside: print(f" imports in: {', '.join(imp_out[:TOP])}{' …' if len(imp_out)>TOP else ''}")
|
||||||
|
if r['op'] == '-':
|
||||||
|
tail = '::'.join(r['fqn'].split('::')[-2:]) # parent_module::Name
|
||||||
|
stale = sorted({x['file_path'] for x in TIP_IMPORTS.get(r['name'], []) if x['p'].endswith(tail)})
|
||||||
|
surv = sorted({d['file_path'] for d in SIDE['tip']['byid'].values() if d['name'] == r['name'] and d['k'] == r['k']})
|
||||||
|
if surv: print(f" = same-name {r['k']} survives at tip in: {', '.join(surv[:3])}{' …' if len(surv)>3 else ''}")
|
||||||
|
if stale:
|
||||||
|
par = r['fqn'].split('::')[-2]
|
||||||
|
mods = [d for d in SIDE['tip']['byid'].values() if d['k'] == 'Module' and d['name'] == par]
|
||||||
|
rex = [x for x in TIP_REEXPORTS if (x['n'] == r['name'] or x['it'] in ('ReExportGlob',)) and
|
||||||
|
(x['file_path'] == r['file'] or any(m['file_path'] == x['file_path'] and m['s'] <= x['s'] <= m['e'] for m in mods))]
|
||||||
|
if rex: print(f" = old path '…{tail}' kept alive by re-export at {rex[0]['file_path']}:{rex[0]['s']} ({len(stale)} importers use it)")
|
||||||
|
else: print(f" ! tip imports via old path '…{tail}' with NO re-export found (dangling unless cfg-gated): {', '.join(stale[:TOP])}")
|
||||||
|
same_name = sum(1 for d in S['byid'].values() if d['name'] == r['name'] and d['k'] not in ('Field', 'Module'))
|
||||||
|
if MEASURE and (r['name'] in GENERIC or same_name > 1):
|
||||||
|
tot['skipped_ambiguous'] += 1; print(f" measure: skipped (name defined {same_name}x at {side})")
|
||||||
|
elif MEASURE:
|
||||||
|
sites = grep_sites(S, r)
|
||||||
|
me = S['byid'][r.get('tid') if side == 'tip' else r.get('bid')]
|
||||||
|
found, missed = set(), {}; skipped = collections.Counter()
|
||||||
|
for f, ln, t in sites:
|
||||||
|
if f == me['file_path'] and me['s'] <= ln <= me['e']: continue
|
||||||
|
g = classify_gap(S, r, f, ln, t, importers)
|
||||||
|
if g in ('import-line',) or 'by design' in g: skipped[g] += 1; continue
|
||||||
|
enc = innermost(S['byfile'], f, ln)
|
||||||
|
if enc and enc['id'] in callers: found.add(enc['id']); continue
|
||||||
|
if enc and enc['id'] in tgt_ids(r, S): continue
|
||||||
|
key = enc['id'] if enc else (f, g)
|
||||||
|
if key in missed: continue
|
||||||
|
missed[key] = g
|
||||||
|
if len(gap_ex[g]) < 3: gap_ex[g].append(f"{f}:{ln} {t[:60]}")
|
||||||
|
gaps = collections.Counter(missed.values())
|
||||||
|
dep_files = {k[0] if isinstance(k, tuple) else S['byid'][k]['file_path'] for k in missed} | {S['byid'][c]['file_path'] for c in found}
|
||||||
|
cover = importers | {S['byid'][c]['file_path'] for c in callers} | {r['file']}
|
||||||
|
tot['dep_files'] += len(dep_files); tot['dep_files_covered'] += len(dep_files & cover)
|
||||||
|
n_true = len(found) + len(missed)
|
||||||
|
for c in list(callers - found)[:2]:
|
||||||
|
if len(gap_ex['orbit-only']) < 6: gap_ex['orbit-only'].append(f"{r['name']} <- {fmt_def(S, c)}")
|
||||||
|
tot['true'] += n_true; tot['hit'] += len(found); tot['orbit_only'] += len(callers - found)
|
||||||
|
for g, c in gaps.items(): gaps_all[g] += c
|
||||||
|
print(f" measure: dependents(grep∪orbit)={n_true} orbit={len(found)} recall={len(found)/n_true if n_true else float('nan'):.0%} "
|
||||||
|
f"orbit_only={len(callers-found)} files_covered_by_callers∪importers={len(dep_files & cover)}/{len(dep_files)} missed_by={dict(gaps)} not_counted={dict(skipped)}")
|
||||||
|
if MEASURE:
|
||||||
|
print(f"# TOTAL dependents={tot['true']} orbit_found={tot['hit']} recall={tot['hit']/max(1,tot['true']):.0%} orbit_only={tot['orbit_only']} "
|
||||||
|
f"ambiguous_skipped={tot['skipped_ambiguous']} file-level(callers∪importers)={tot['dep_files_covered']}/{tot['dep_files']}={tot['dep_files_covered']/max(1,tot['dep_files']):.0%}")
|
||||||
|
print(f"# orbit-only examples: {' | '.join(gap_ex['orbit-only'])}")
|
||||||
|
for g, c in gaps_all.most_common(): print(f"# gap {c:4d} {g:40s} e.g. {' | '.join(gap_ex[g][:2])}")
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 4: module/crate boundary guard for a diff.
|
||||||
|
|
||||||
|
Usage: boundary.py <env-file> <defdiff.json> <rules-file> [--audit] [--measure]
|
||||||
|
- NEW crate->crate import edges (tip minus base, whole repo) with an example file
|
||||||
|
- NEW import rows in changed files, checked against allow/deny rules
|
||||||
|
- NEW intra-crate module edges (crate::a -> crate::b) introduced by changed files
|
||||||
|
--audit also check every tip import against the rules (validates the rules themselves)
|
||||||
|
--measure compare files with new imports (orbit) against files whose diff adds a `use` line (text)
|
||||||
|
Imports are statement-level and kept even in cfg-gated/test files (unlike definitions).
|
||||||
|
Raw paths: `super::`/relative paths are attributed to the file's own crate; re-exports are not followed.
|
||||||
|
"""
|
||||||
|
import collections, fnmatch, json, os, re, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q, git
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2])); RULES = sys.argv[3]
|
||||||
|
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
|
||||||
|
|
||||||
|
def root_pkg():
|
||||||
|
m = re.search(r'\[package\][^\[]*?name\s*=\s*"([^"]+)"', open(f"{TW}/Cargo.toml").read(), re.S)
|
||||||
|
return m.group(1).replace('-', '_') if m else '_root'
|
||||||
|
ROOT = root_pkg()
|
||||||
|
CRATES = {d.replace('-', '_') for d in os.listdir(f"{TW}/crates") if os.path.isdir(f"{TW}/crates/{d}")} | {ROOT}
|
||||||
|
|
||||||
|
def crate_of(f):
|
||||||
|
p = f.split('/')
|
||||||
|
return p[1].replace('-', '_') if p[0] == 'crates' and len(p) > 2 else ROOT
|
||||||
|
|
||||||
|
def dst_of(f, path):
|
||||||
|
head = path.split('::')[0]
|
||||||
|
if head in ('crate', 'self', 'super', ''): return crate_of(f)
|
||||||
|
return head.replace('-', '_')
|
||||||
|
|
||||||
|
allow, deny = {}, []
|
||||||
|
for line in open(RULES):
|
||||||
|
line = line.split('#', 1)[0].strip() if not line.lstrip().startswith('deny') else line.strip()
|
||||||
|
if line.startswith('allow'):
|
||||||
|
a, b = line[5:].split(':', 1); allow[a.strip()] = set(b.split())
|
||||||
|
elif line.startswith('deny'):
|
||||||
|
body, _, note = line[4:].partition('#')
|
||||||
|
src, _, rx = body.partition('->'); deny.append((src.strip(), re.compile(rx.strip()), note.strip()))
|
||||||
|
|
||||||
|
def rows(wt):
|
||||||
|
return q("SELECT file_path f, import_path p, identifier_name n, import_type it, start_line s FROM gl_imported_symbol "
|
||||||
|
"WHERE file_path LIKE '%.rs' AND import_type <> 'ModDeclaration'", repo=wt, db=DB)
|
||||||
|
R = {'base': rows(BW), 'tip': rows(TW)}
|
||||||
|
renames = dd['renames']
|
||||||
|
def key(r, side):
|
||||||
|
f = renames.get(r['f'], r['f']) if side == 'base' else r['f']
|
||||||
|
return (f, r['p'], r['n'])
|
||||||
|
|
||||||
|
def violations(r):
|
||||||
|
src, dst = crate_of(r['f']), dst_of(r['f'], r['p'])
|
||||||
|
out = []
|
||||||
|
if src in allow and dst in CRATES and dst != src and dst not in allow[src]:
|
||||||
|
out.append(f"allow-list: {src} may not import {dst}")
|
||||||
|
for g, rx, note in deny:
|
||||||
|
if fnmatch.fnmatch(r['f'], g) and rx.search(r['p']) and dst != src: out.append(f"deny: {note or rx.pattern}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
# crate edges
|
||||||
|
def crate_edges(side):
|
||||||
|
e = collections.defaultdict(list)
|
||||||
|
for r in R[side]:
|
||||||
|
s, d = crate_of(r['f']), dst_of(r['f'], r['p'])
|
||||||
|
if d in CRATES and d != s: e[(s, d)].append(r['f'])
|
||||||
|
return e
|
||||||
|
be, te = crate_edges('base'), crate_edges('tip')
|
||||||
|
print(f"# boundary {B[:8]}..{T[:8]} root_pkg={ROOT} workspace_crates={len(CRATES)} rules: allow={len(allow)} deny={len(deny)}")
|
||||||
|
new_edges = sorted(set(te) - set(be)); gone = sorted(set(be) - set(te))
|
||||||
|
print(f"new crate edges: {len(new_edges)}" + ''.join(f"\n + {s} -> {d} ({len(te[(s,d)])} imports, e.g. {te[(s,d)][0]})" for s, d in new_edges))
|
||||||
|
if gone: print(f"removed crate edges: " + ', '.join(f"{s}->{d}" for s, d in gone))
|
||||||
|
|
||||||
|
changed = set(dd['changed_files'])
|
||||||
|
# grep fallback 1: inline qualified paths (`other_crate::x::Y` without a `use`) are NOT import rows
|
||||||
|
cre = re.compile(r'\b(' + '|'.join(sorted(CRATES, key=len, reverse=True)) + r')::')
|
||||||
|
def inline_edges(wt, files):
|
||||||
|
te_ = collections.defaultdict(set)
|
||||||
|
for f in files:
|
||||||
|
try: src = open(f"{wt}/{f}", errors='replace').read().split('\n')
|
||||||
|
except FileNotFoundError: continue
|
||||||
|
for i, l in enumerate(src):
|
||||||
|
t = l.strip()
|
||||||
|
if t.startswith(('//', 'use ', 'pub use ')): continue
|
||||||
|
for m in cre.finditer(l):
|
||||||
|
if m.group(1) != crate_of(f): te_[(crate_of(f), m.group(1))].add(f"{f}:{i+1}")
|
||||||
|
return te_
|
||||||
|
inv_ren = {v: k for k, v in renames.items()}
|
||||||
|
text_edges = inline_edges(TW, changed)
|
||||||
|
base_text = inline_edges(BW, {inv_ren.get(f, f) for f in changed})
|
||||||
|
imp_pairs = set(te)
|
||||||
|
extra = {k: v for k, v in text_edges.items() if k not in imp_pairs}
|
||||||
|
if extra: print("crate edges seen only as inline paths in changed files (no import row):" + ''.join(
|
||||||
|
f"\n ~ {s} -> {d} e.g. {sorted(v)[0]}{' [NEW: not in base imports or base inline paths]' if (s, d) not in be and (s, d) not in base_text else ''}" for (s, d), v in sorted(extra.items())))
|
||||||
|
# grep fallback 2: Cargo.toml dependency changes (the compiler-enforced graph)
|
||||||
|
cd = git(REPO, 'diff', '-U0', B, T, '--', '*Cargo.toml'); cf = None; deps = []
|
||||||
|
for l in cd.split('\n'):
|
||||||
|
if l.startswith('+++ '): cf = l[6:]
|
||||||
|
m = re.match(r'^([-+])\s*([\w-]+)\s*=', l)
|
||||||
|
if m and cf and not l.startswith(('+++', '---')) and m.group(2).replace('-', '_') in CRATES | {'dioxus', 'opencode_client'}:
|
||||||
|
deps.append(f"{m.group(1)}{m.group(2)} in {cf}")
|
||||||
|
print(f"Cargo.toml workspace-dep changes: {deps if deps else 'none'}")
|
||||||
|
for dline in deps:
|
||||||
|
sign, rest = dline[0], dline[1:]; dname, _, cf2 = rest.partition(' in ')
|
||||||
|
sc = cf2.split('/')[1].replace('-', '_') if cf2.startswith('crates/') else ROOT
|
||||||
|
if sign == '+' and sc in allow and dname.replace('-', '_') not in allow[sc]:
|
||||||
|
print(f" !! {sc} gains dependency {dname}, not in documented allow-list")
|
||||||
|
bkeys = {key(r, 'base') for r in R['base']}
|
||||||
|
new_rows = [r for r in R['tip'] if r['f'] in changed and key(r, 'tip') not in bkeys]
|
||||||
|
tkeys = {key(r, 'tip') for r in R['tip']}
|
||||||
|
gone_rows = [r for r in R['base'] if renames.get(r['f'], r['f']) in changed and key(r, 'base') not in tkeys]
|
||||||
|
print(f"new import rows in changed files: {len(new_rows)} (in {len({r['f'] for r in new_rows})} files); removed rows: {len(gone_rows)}")
|
||||||
|
viol = [(r, v) for r in new_rows for v in violations(r)]
|
||||||
|
print(f"rule violations among NEW imports: {len(viol)}" + ''.join(f"\n !! {r['f']}:{r['s']} use {r['p']} [{v}]" for r, v in viol[:20]))
|
||||||
|
|
||||||
|
# intra-crate module edges introduced by changed files
|
||||||
|
def mod_edges(rs):
|
||||||
|
e = set()
|
||||||
|
for r in rs:
|
||||||
|
p = r['p'].split('::')
|
||||||
|
if p[0] == 'crate' and len(p) > 2:
|
||||||
|
f = r['f']; parts = f.split('/')
|
||||||
|
src_mod = (parts[3] if parts[0] == 'crates' and len(parts) > 4 else parts[1] if parts[0] == 'src' and len(parts) > 2 else '(root)').replace('.rs', '')
|
||||||
|
if src_mod != p[1]: e.add((crate_of(f), src_mod, p[1]))
|
||||||
|
return e
|
||||||
|
allbase = mod_edges(R['base'])
|
||||||
|
nm = sorted(mod_edges(new_rows) - allbase)
|
||||||
|
print(f"new intra-crate module edges: {len(nm)}" + ''.join(f"\n + {c}: {a} -> {b}" for c, a, b in nm[:15]) + (f"\n … {len(nm)-15} more" if len(nm) > 15 else ''))
|
||||||
|
|
||||||
|
if '--audit' in sys.argv:
|
||||||
|
allv = collections.Counter(); ex = {}
|
||||||
|
for r in R['tip']:
|
||||||
|
for v in violations(r):
|
||||||
|
k = (crate_of(r['f']), dst_of(r['f'], r['p']), v); allv[k] += 1; ex.setdefault(k, f"{r['f']}:{r['s']} {r['p']}")
|
||||||
|
print(f"# audit: existing violations at tip: {sum(allv.values())}")
|
||||||
|
for k, n in allv.most_common(12): print(f" {n:4d} {k[0]} -> {k[1]} [{k[2]}] e.g. {ex[k]}")
|
||||||
|
|
||||||
|
if '--measure' in sys.argv:
|
||||||
|
diff = git(REPO, 'diff', '-U0', B, T, '--', '*.rs'); f = None; text_files = set(); text_del = set()
|
||||||
|
for l in diff.split('\n'):
|
||||||
|
if l.startswith('+++ '): f = l[6:]
|
||||||
|
elif l.startswith('--- '): of = l[6:]
|
||||||
|
elif re.match(r'^\+\s*(pub(\([^)]*\))?\s+)?use\s', l): text_files.add(f)
|
||||||
|
elif re.match(r'^-\s*(pub(\([^)]*\))?\s+)?use\s', l): text_del.add(renames.get(of, of))
|
||||||
|
ofiles = {r['f'] for r in new_rows}; ogone = {renames.get(r['f'], r['f']) for r in gone_rows}
|
||||||
|
print(f"# measure: files with added `use` line (text)={len(text_files)} orbit-new-import files={len(ofiles)} both={len(text_files & ofiles)} "
|
||||||
|
f"recall={len(text_files & ofiles)/max(1,len(text_files)):.0%}; text-missed={sorted(text_files - ofiles)[:5]}; orbit-only={len(ofiles - text_files)}")
|
||||||
|
print(f"# measure: files with removed `use` line (text)={len(text_del)} orbit-removed-import files={len(ogone)} both={len(text_del & ogone)}")
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 6: compact API context of the touched area, sized for an LLM reviewer brief.
|
||||||
|
|
||||||
|
Usage: context.py <env-file> <defdiff.json> [--budget BYTES] [--all-files]
|
||||||
|
Per changed file (visible to orbit), one line per type/callable at tip with its declaration text
|
||||||
|
read from the source (more reliable than repo-map's extractor), change markers from defdiff
|
||||||
|
(+ added, S signature, ~ body, > moved), untouched items collapsed when over budget.
|
||||||
|
Compare: `orbit repo-map api <dir>` (directory prefix only; a file path returns nothing).
|
||||||
|
"""
|
||||||
|
import collections, json, re, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
|
||||||
|
DB, TW = env['DB'], env['TIP_WT']
|
||||||
|
BUDGET = int(sys.argv[sys.argv.index('--budget') + 1]) if '--budget' in sys.argv else 8000
|
||||||
|
SHOW = {'Struct', 'Enum', 'Trait', 'TypeAlias', 'Function', 'Method', 'AssociatedFunction', 'Constant', 'Static', 'Macro'}
|
||||||
|
|
||||||
|
ops = {r.get('tid'): r['op'] for r in dd['rows'] if r.get('tid')}
|
||||||
|
changed = [f for f in dd['changed_files'] if f.endswith('.rs')]
|
||||||
|
defs = collections.defaultdict(list)
|
||||||
|
for d in q("SELECT id, file_path f, fqn, name, definition_type k, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=TW, db=DB):
|
||||||
|
if d['f'] in changed and d['k'] in SHOW: defs[d['f']].append(d)
|
||||||
|
|
||||||
|
def decl(lines, d):
|
||||||
|
out = []
|
||||||
|
for l in lines[d['s'] - 1:d['e']]:
|
||||||
|
t = l.strip()
|
||||||
|
if not out and (not t or t.startswith(('///', '//', '#[', '#!'))): continue
|
||||||
|
out.append(t)
|
||||||
|
if '{' in t or t.endswith(';') or len(out) > 8: break
|
||||||
|
s = re.sub(r'\s+', ' ', ' '.join(out)).split('{')[0].strip()
|
||||||
|
s = re.sub(r'\(\s+', '(', s); s = re.sub(r',\s*\)', ')', s)
|
||||||
|
return s[:160]
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for f in changed:
|
||||||
|
ds = sorted(defs.get(f, []), key=lambda d: d['s'])
|
||||||
|
if not ds: continue
|
||||||
|
try: lines = open(f"{TW}/{f}", errors='replace').read().split('\n')
|
||||||
|
except FileNotFoundError: continue
|
||||||
|
body = []
|
||||||
|
nchg = 0
|
||||||
|
for d in ds:
|
||||||
|
op = ops.get(d['id'], ' ')
|
||||||
|
nchg += op != ' '
|
||||||
|
ind = ' ' if d['k'] in ('Method', 'AssociatedFunction') else ''
|
||||||
|
body.append((op, f"{op} {ind}{decl(lines, d)} L{d['s']}"))
|
||||||
|
blocks.append((f, nchg, body))
|
||||||
|
|
||||||
|
out = []; size = 0
|
||||||
|
is_test = lambda f: '/tests/' in f or f.endswith('_test.rs') or f.endswith('tests.rs')
|
||||||
|
blocks.sort(key=lambda b: (is_test(b[0]), -b[1]))
|
||||||
|
for f, nchg, body in blocks:
|
||||||
|
head = f"## {f} ({nchg} changed / {len(body)} items)"
|
||||||
|
full = '\n'.join([head] + [x for _, x in body])
|
||||||
|
unchanged = sum(1 for op, _ in body if op == ' ')
|
||||||
|
slim = '\n'.join([head] + [x for op, x in body if op != ' '] + ([f" … {unchanged} unchanged items (orbit grep --path {f})"] if unchanged else []))
|
||||||
|
txt = full if len(full) <= 2000 and size + len(full) <= BUDGET else slim
|
||||||
|
if size + len(txt) > BUDGET:
|
||||||
|
out.append(f"## {f} ({nchg} changed) — omitted (budget)"); continue
|
||||||
|
out.append(txt); size += len(txt)
|
||||||
|
print(f"# context: {len(blocks)} visible changed files, budget {BUDGET}B")
|
||||||
|
print('\n'.join(out))
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 1: map `git diff base..tip` onto definitions of both indexed commits.
|
||||||
|
|
||||||
|
Usage: defdiff.py <env-file> [--json out.json] [--all-kinds]
|
||||||
|
env-file: output of review-setup.sh (DB, BASE_WT, TIP_WT, BASE_SHA, TIP_SHA, REPO)
|
||||||
|
|
||||||
|
Prints a compact per-file report:
|
||||||
|
+ added - removed > moved (same fqn, other file)
|
||||||
|
S signature changed (declaration text differs)
|
||||||
|
~ body touched (a diff hunk hits the definition; size shown)
|
||||||
|
? relocated candidate (removed+added pair with same name+kind, different fqn)
|
||||||
|
and an 'unattributed' tally of changed lines no definition covers (the blindness input).
|
||||||
|
"""
|
||||||
|
import collections, json, re, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q, git
|
||||||
|
from regions import cfg_regions, region_of
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1])
|
||||||
|
DB, BW, TW, REPO = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO']
|
||||||
|
B, T = env['BASE_SHA'], env['TIP_SHA']
|
||||||
|
JSON_OUT = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None
|
||||||
|
|
||||||
|
SQL = """SELECT id, file_path, fqn, name, definition_type k, start_line s, end_line e
|
||||||
|
FROM gl_definition WHERE file_path LIKE '%.rs'"""
|
||||||
|
ROLLUP = {'Field', 'Variant'} # reported through their parent
|
||||||
|
CONTAINER = {'Module'} # only used if nothing smaller covers a line
|
||||||
|
|
||||||
|
def load(wt):
|
||||||
|
defs = q(SQL, repo=wt, db=DB)
|
||||||
|
byfile = collections.defaultdict(list)
|
||||||
|
for d in defs: byfile[d['file_path']].append(d)
|
||||||
|
return defs, byfile
|
||||||
|
|
||||||
|
bdefs, bfile = load(BW)
|
||||||
|
tdefs, tfile = load(TW)
|
||||||
|
|
||||||
|
# ---- hunks (-U0, rename detection so moved files map old->new path)
|
||||||
|
diff = git(REPO, 'diff', '-U0', '-M', '--no-color', B, T, '--', '*.rs')
|
||||||
|
old_lines = collections.defaultdict(set); new_lines = collections.defaultdict(set)
|
||||||
|
old_text = collections.defaultdict(dict); new_text = collections.defaultdict(dict)
|
||||||
|
renames = {}
|
||||||
|
of = nf = None
|
||||||
|
for line in diff.split('\n'):
|
||||||
|
if line.startswith('--- '):
|
||||||
|
of = None if line[4:] == '/dev/null' else line[6:]
|
||||||
|
elif line.startswith('+++ '):
|
||||||
|
nf = None if line[4:] == '/dev/null' else line[6:]
|
||||||
|
if of and nf and of != nf: renames[of] = nf
|
||||||
|
elif line.startswith('@@'):
|
||||||
|
m = re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
|
||||||
|
os_, oc, ns, nc = int(m[1]), int(m[2] or 1), int(m[3]), int(m[4] or 1)
|
||||||
|
oi, ni = os_, ns
|
||||||
|
elif line.startswith('-') and of:
|
||||||
|
old_lines[of].add(oi); old_text[of][oi] = line[1:]; oi += 1
|
||||||
|
elif line.startswith('+') and nf:
|
||||||
|
new_lines[nf].add(ni); new_text[nf][ni] = line[1:]; ni += 1
|
||||||
|
|
||||||
|
def innermost(defs, ln):
|
||||||
|
cov = [d for d in defs if d['s'] <= ln <= d['e']]
|
||||||
|
if not cov: return None
|
||||||
|
non_container = [d for d in cov if d['k'] not in CONTAINER]
|
||||||
|
cov = non_container or cov
|
||||||
|
cov.sort(key=lambda d: d['e'] - d['s'])
|
||||||
|
d = cov[0]
|
||||||
|
if d['k'] in ROLLUP: # roll fields/variants up to their parent type
|
||||||
|
parents = [p for p in cov if p['k'] not in ROLLUP]
|
||||||
|
if parents: d = parents[0]
|
||||||
|
return d
|
||||||
|
|
||||||
|
def imports(wt):
|
||||||
|
r = collections.defaultdict(list)
|
||||||
|
for d in q("SELECT DISTINCT file_path, start_line s, end_line e FROM gl_imported_symbol WHERE file_path LIKE '%.rs'", repo=wt, db=DB):
|
||||||
|
r[d['file_path']].append((d['s'], d['e']))
|
||||||
|
return r
|
||||||
|
bimp, timp = imports(BW), imports(TW)
|
||||||
|
|
||||||
|
def attribute(lines, byfile, texts, wt, imp):
|
||||||
|
hit = collections.defaultdict(set); unattr = collections.Counter(); samples = collections.defaultdict(list)
|
||||||
|
for f, ls in lines.items():
|
||||||
|
defs = byfile.get(f, [])
|
||||||
|
real = [d for d in defs if d['k'] != 'Macro'] # macro_rules! are indexed even in unreachable files
|
||||||
|
for ln in ls:
|
||||||
|
d = innermost(defs, ln)
|
||||||
|
if d: hit[d['id']].add(ln); continue
|
||||||
|
t = texts[f].get(ln, '').strip()
|
||||||
|
reg = region_of(cfg_regions(src(wt, f)), ln)
|
||||||
|
cat = (f"cfg-{reg[2]}" if reg else
|
||||||
|
'file-has-no-defs' if not real else
|
||||||
|
'import' if any(a <= ln <= b for a, b in imp.get(f, [])) else
|
||||||
|
'impl-header/brace' if re.match(r'(unsafe\s+)?impl\b', t) or t in ('}', '};', '});') else
|
||||||
|
'blank/comment' if not t or t.startswith('//') else
|
||||||
|
'use/mod-decl' if re.match(r'(pub(\(.*?\))?\s+)?(use|mod)\s', t) else
|
||||||
|
'attribute' if t.startswith('#') else 'other')
|
||||||
|
unattr[(f, cat)] += 1
|
||||||
|
if len(samples[(f, cat)]) < 2: samples[(f, cat)].append(f"L{ln}: {t[:70]}")
|
||||||
|
return hit, unattr, samples
|
||||||
|
|
||||||
|
|
||||||
|
# ---- declaration text (signature) from the worktree sources
|
||||||
|
_src = {}
|
||||||
|
def src(wt, f):
|
||||||
|
if (wt, f) not in _src:
|
||||||
|
try: _src[(wt, f)] = open(f"{wt}/{f}", encoding='utf-8', errors='replace').read().split('\n')
|
||||||
|
except FileNotFoundError: _src[(wt, f)] = []
|
||||||
|
return _src[(wt, f)]
|
||||||
|
|
||||||
|
def decl(wt, d):
|
||||||
|
lines = src(wt, d['file_path'])[d['s'] - 1:d['e']]
|
||||||
|
out = []
|
||||||
|
for l in lines:
|
||||||
|
t = l.strip()
|
||||||
|
if not out and (t.startswith(('///', '//', '#[', '#!')) or not t): continue
|
||||||
|
out.append(t)
|
||||||
|
joined = ' '.join(out)
|
||||||
|
if d['k'] in ROLLUP: return joined.rstrip(',')
|
||||||
|
if '{' in t or t.endswith(';') or len(out) > 12: break
|
||||||
|
s = ' '.join(out)
|
||||||
|
s = s.split('{')[0] if '{' in s else s
|
||||||
|
return re.sub(r'\s+', ' ', s).strip()
|
||||||
|
|
||||||
|
bhit, bun, bsam = attribute(old_lines, bfile, old_text, BW, bimp)
|
||||||
|
thit, tun, tsam = attribute(new_lines, tfile, new_text, TW, timp)
|
||||||
|
|
||||||
|
# ---- match definitions across commits on (fqn, kind), tie-break by file then order
|
||||||
|
def key(d): return (d['fqn'], d['k'])
|
||||||
|
bidx = collections.defaultdict(list); tidx = collections.defaultdict(list)
|
||||||
|
for d in bdefs: bidx[key(d)].append(d)
|
||||||
|
for d in tdefs: tidx[key(d)].append(d)
|
||||||
|
pairs, added, removed = [], [], []
|
||||||
|
for k in set(bidx) | set(tidx):
|
||||||
|
bl, tl = sorted(bidx.get(k, []), key=lambda d: (d['file_path'], d['s'])), sorted(tidx.get(k, []), key=lambda d: (d['file_path'], d['s']))
|
||||||
|
# pair same-file (after rename mapping) first
|
||||||
|
rest_t = list(tl)
|
||||||
|
for bd in list(bl):
|
||||||
|
nfp = renames.get(bd['file_path'], bd['file_path'])
|
||||||
|
m = next((td for td in rest_t if td['file_path'] == nfp), None)
|
||||||
|
if m: pairs.append((bd, m)); rest_t.remove(m); bl.remove(bd)
|
||||||
|
for bd, td in zip(bl, rest_t): pairs.append((bd, td))
|
||||||
|
removed += bl[len(rest_t):]; added += rest_t[len(bl):]
|
||||||
|
|
||||||
|
# fields/variants: roll into parent change notes
|
||||||
|
def parent_fqn(d): return d['fqn'].rsplit('::', 1)[0]
|
||||||
|
member_notes = collections.defaultdict(list)
|
||||||
|
variants = {d['fqn'] for d in bdefs + tdefs if d['k'] == 'Variant'}
|
||||||
|
def owner(d): # Enum::Variant::field -> (Enum, 'Variant.field')
|
||||||
|
p = parent_fqn(d)
|
||||||
|
if p in variants: return parent_fqn({'fqn': p}), p.rsplit('::', 1)[1] + '.' + d['name']
|
||||||
|
return p, d['name']
|
||||||
|
for side, lst, sign in (('t', added, '+'), ('b', removed, '-')):
|
||||||
|
for d in lst:
|
||||||
|
if d['k'] in ROLLUP:
|
||||||
|
o, n = owner(d); member_notes[(side, o)].append(sign + n)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for d in added:
|
||||||
|
if d['k'] not in ROLLUP: rows.append(dict(op='+', tid=d['id'], file=d['file_path'], k=d['k'], fqn=d['fqn'], name=d['name'], lines=f"L{d['s']}-{d['e']}", new_len=d['e']-d['s']+1))
|
||||||
|
for d in removed:
|
||||||
|
if d['k'] not in ROLLUP: rows.append(dict(op='-', bid=d['id'], file=d['file_path'], k=d['k'], fqn=d['fqn'], name=d['name'], lines=f"L{d['s']}-{d['e']}", old_len=d['e']-d['s']+1))
|
||||||
|
for bd, td in pairs:
|
||||||
|
if td['k'] in ROLLUP: continue
|
||||||
|
moved = renames.get(bd['file_path'], bd['file_path']) != td['file_path']
|
||||||
|
touched = td['id'] in thit or bd['id'] in bhit
|
||||||
|
mem = member_notes.get(('t', td['fqn']), []) + member_notes.get(('b', bd['fqn']), [])
|
||||||
|
ol, nl = bd['e'] - bd['s'] + 1, td['e'] - td['s'] + 1
|
||||||
|
if not (moved or touched or mem or ol != nl): continue
|
||||||
|
sb, st = decl(BW, bd), decl(TW, td)
|
||||||
|
op = '>' if moved else ('S' if sb != st else '~')
|
||||||
|
r = dict(op=op, bid=bd['id'], tid=td['id'], file=td['file_path'], k=td['k'], fqn=td['fqn'], name=td['name'], lines=f"L{td['s']}-{td['e']}", old_len=ol, new_len=nl)
|
||||||
|
if moved: r['from'] = bd['file_path']
|
||||||
|
if sb != st: r['sig_old'], r['sig_new'] = sb, st
|
||||||
|
if mem: r['members'] = mem
|
||||||
|
rows.append(r)
|
||||||
|
|
||||||
|
# relocation candidates: removed + added with same (name, kind), different fqn
|
||||||
|
rem_by = collections.defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
if r['op'] == '-': rem_by[(r['name'], r['k'])].append(r)
|
||||||
|
GENERIC = {'new', 'default', 'build', 'from', 'fmt', 'run', 'plugin', 'tests', 'drop', 'clone', 'eq', 'deref'}
|
||||||
|
for r in rows:
|
||||||
|
if r['op'] == '+' and (r['name'], r['k']) in rem_by and r['name'] not in GENERIC:
|
||||||
|
r['relocated_from'] = sorted({x['file'] for x in rem_by[(r['name'], r['k'])]})[:3]
|
||||||
|
|
||||||
|
# ---- collapse children of added/removed types into their parent row (keeps JSON complete)
|
||||||
|
TYPES = {'Struct', 'Enum', 'Trait', 'Module'}
|
||||||
|
whole = {(r['op'], r['fqn']) for r in rows if r['op'] in '+-' and r['k'] in TYPES - {'Module'}}
|
||||||
|
for r in rows:
|
||||||
|
par = r['fqn'].rsplit('::', 1)[0]
|
||||||
|
if r['op'] in '+-' and (r['op'], par) in whole:
|
||||||
|
r['collapsed'] = True
|
||||||
|
nchild = collections.Counter((r['op'], r['fqn'].rsplit('::', 1)[0]) for r in rows if r.get('collapsed'))
|
||||||
|
for r in rows:
|
||||||
|
if (r['op'], r['fqn']) in nchild: r['children'] = nchild[(r['op'], r['fqn'])]
|
||||||
|
|
||||||
|
# ---- print
|
||||||
|
def sigdiff(a, b, ctx=25):
|
||||||
|
i = 0
|
||||||
|
while i < min(len(a), len(b)) and a[i] == b[i]: i += 1
|
||||||
|
j = 0
|
||||||
|
while j < min(len(a), len(b)) - i and a[-1 - j] == b[-1 - j]: j += 1
|
||||||
|
pre = ('…' + a[max(0, i - ctx):i]) if i > ctx else a[:i]
|
||||||
|
sa, sb = a[i:len(a) - j], b[i:len(b) - j]
|
||||||
|
post = a[len(a) - j:][:ctx] + ('…' if j > ctx else '')
|
||||||
|
return f" sig: {pre}[-{sa}-]{{+{sb}+}}{post}"
|
||||||
|
order = {'-': 0, '>': 1, 'S': 2, '+': 3, '~': 4}
|
||||||
|
byf = collections.defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
if not r.get('collapsed'): byf[r['file']].append(r)
|
||||||
|
cnt = collections.Counter(r['op'] for r in rows)
|
||||||
|
print(f"# defdiff {B[:8]}..{T[:8]} files_with_changes={len(set(new_lines)|set(old_lines))} "
|
||||||
|
f"removed={cnt['-']} moved={cnt['>']} sig={cnt['S']} added={cnt['+']} body={cnt['~']}")
|
||||||
|
for f in sorted(byf):
|
||||||
|
print(f)
|
||||||
|
for r in sorted(byf[f], key=lambda r: (order[r['op']], r['lines'])):
|
||||||
|
extra = ''
|
||||||
|
if r['op'] == '~': extra = f" {r['old_len']}->{r['new_len']} lines"
|
||||||
|
if r['op'] == '>': extra = f" from {r['from']}"
|
||||||
|
if r.get('relocated_from'): extra += f" (same name removed in {', '.join(r['relocated_from'])})"
|
||||||
|
if r.get('children'): extra += f" (+{r['children']} members)" if r['op'] == '+' else f" (-{r['children']} members)"
|
||||||
|
if r.get('members'): extra += f" members {' '.join(r['members'][:8])}{' …' if len(r['members'])>8 else ''}"
|
||||||
|
print(f" {r['op']} {r['k']} {r['fqn'].split('::',1)[-1]} {r['lines']}{extra}")
|
||||||
|
if 'sig_old' in r and r['op'] in 'S>':
|
||||||
|
print(sigdiff(r['sig_old'], r['sig_new']))
|
||||||
|
un = collections.Counter()
|
||||||
|
for (f, c), n in list(bun.items()) + list(tun.items()): un[c] += n
|
||||||
|
tot = sum(len(v) for v in old_lines.values()) + sum(len(v) for v in new_lines.values())
|
||||||
|
print(f"# changed lines: {tot}; unattributed: {sum(un.values())} " + ' '.join(f"{c}={n}" for c, n in un.most_common()))
|
||||||
|
perfile = collections.Counter()
|
||||||
|
for (f, c), n in list(bun.items()) + list(tun.items()):
|
||||||
|
if c != 'blank/comment': perfile[(f, c)] += n
|
||||||
|
for (f, c), n in perfile.most_common(12): print(f"# {n:4d} {c:16s} {f}")
|
||||||
|
if JSON_OUT:
|
||||||
|
allun = collections.Counter()
|
||||||
|
for (f, c), n in list(bun.items()) + list(tun.items()): allun[f"{f}|{c}"] += n
|
||||||
|
json.dump(dict(touched_tip=sorted(thit), touched_base=sorted(bhit), rows=rows, unattributed=dict(allun),
|
||||||
|
samples={f"{f}|{c}": s for (f, c), s in list(bsam.items()) + list(tsam.items())},
|
||||||
|
changed_files=sorted(set(new_lines) | set(old_lines)), renames=renames), open(JSON_OUT, 'w'), indent=1)
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Append one orbit-usage feedback record (JSONL). Cheap: one call per orbit query that informed a decision.
|
||||||
|
# Usage: fb.sh <recipe|adhoc> <verdict> "<purpose>" "<orbit command>" <result_rows> ["<note>"] [gap]
|
||||||
|
# verdict: useful | wrong | incomplete | grep-needed | noise | unused
|
||||||
|
# gap (optional): cfg-test | cfg-feature | gated-file | dyn | fn-ref | macro | rsx | cross-file-impl |
|
||||||
|
# external-trait | inline-path | pattern-match | stale-index | sig-truncated | other
|
||||||
|
# Env: ORBIT_FEEDBACK (default ~/.local/share/orbit-skill/feedback.jsonl), ORBIT_AGENT (e.g. search, reviewer)
|
||||||
|
set -euo pipefail
|
||||||
|
F=${ORBIT_FEEDBACK:-$HOME/.local/share/orbit-skill/feedback.jsonl}; mkdir -p "$(dirname "$F")"
|
||||||
|
top=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||||
|
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "$top/.git")
|
||||||
|
python3 - "$F" "$top" "$common" "$@" <<'PY'
|
||||||
|
import json, sys, subprocess, datetime, os
|
||||||
|
F, top, common, recipe, verdict, purpose, cmd, rows = sys.argv[1:9]
|
||||||
|
note = sys.argv[9] if len(sys.argv) > 9 else ''
|
||||||
|
gap = sys.argv[10] if len(sys.argv) > 10 else ''
|
||||||
|
g = lambda *a: subprocess.run(['git', '-C', top, *a], capture_output=True, text=True).stdout.strip()
|
||||||
|
rec = dict(ts=datetime.datetime.now().astimezone().isoformat(timespec='seconds'),
|
||||||
|
repo=os.path.basename(os.path.dirname(common)) if common.endswith('.git') else os.path.basename(top),
|
||||||
|
commit=g('rev-parse', '--short=8', 'HEAD'), dirty=bool(g('status', '--porcelain')),
|
||||||
|
orbit=subprocess.run(['orbit', '--version'], capture_output=True, text=True).stdout.split()[-1],
|
||||||
|
agent=os.environ.get('ORBIT_AGENT', ''), recipe=recipe, purpose=purpose, cmd=cmd,
|
||||||
|
rows=int(rows) if rows.isdigit() else rows, verdict=verdict, gap=gap, note=note)
|
||||||
|
open(F, 'a').write(json.dumps(rec, ensure_ascii=False) + '\n')
|
||||||
|
PY
|
||||||
+35
@@ -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
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"""Tiny helper: run orbit sql against an isolated DB, scoped to a worktree's commit."""
|
||||||
|
import json, os, subprocess, sys
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
env = {}
|
||||||
|
for line in open(path):
|
||||||
|
line = line.strip()
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
env[k] = v
|
||||||
|
return env
|
||||||
|
|
||||||
|
def q(sql, repo=None, db=None, all_=False):
|
||||||
|
cmd = ['orbit', 'sql', sql, '-F', 'json']
|
||||||
|
if db: cmd += ['--db', db]
|
||||||
|
if all_: cmd += ['--all']
|
||||||
|
elif repo: cmd += ['--repo', repo]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
sys.exit(f"orbit sql failed: {r.stderr.strip()}\n{sql}")
|
||||||
|
if 'not indexed' in r.stderr:
|
||||||
|
sys.exit(f"STALE/UNINDEXED scope: {r.stderr.strip()}")
|
||||||
|
return json.loads(r.stdout or '[]')
|
||||||
|
|
||||||
|
def git(repo, *args):
|
||||||
|
return subprocess.run(['git', '-C', repo, *args], capture_output=True, text=True, check=True).stdout
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 5: blindness preflight — what orbit cannot see for THIS diff. Run before trusting any orbit answer.
|
||||||
|
|
||||||
|
Usage: preflight.py <env-file> <defdiff.json>
|
||||||
|
Checks (all cheap, no compilation):
|
||||||
|
1. index freshness: manifest commit == worktree HEAD, worktree clean (orbit indexes the WORKING TREE
|
||||||
|
but labels rows with HEAD's sha), skipped/errored files from the index JSON
|
||||||
|
2. changed files orbit has no definitions for (gated at a parent `mod`, tests/ dirs, outside module tree)
|
||||||
|
3. changed lines inside #[cfg(test)] / #[cfg(feature)] / other cfg regions (from defdiff attribution)
|
||||||
|
4. Cargo features of touched crates (orbit evaluates ALL features off, default ones too)
|
||||||
|
5. constructs in changed hunks that CALLS edges miss: add_systems/observers, rsx!, dyn, macro args
|
||||||
|
Prints a verdict line per area: OK / PARTIAL / BLIND, and the grep fallbacks to use.
|
||||||
|
"""
|
||||||
|
import collections, json, os, re, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q, git
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
|
||||||
|
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
|
||||||
|
H = os.path.dirname(DB); NAME = os.path.basename(DB).rsplit('.', 1)[0]
|
||||||
|
out = []
|
||||||
|
|
||||||
|
# 1. freshness
|
||||||
|
man = {m['repo_path']: m for m in q("SELECT repo_path, commit_sha, status FROM _orbit_manifest", db=DB, all_=True)}
|
||||||
|
for side, wt, sha in (('base', BW, B), ('tip', TW, T)):
|
||||||
|
m = man.get(wt); head = git(wt, 'rev-parse', 'HEAD').strip(); dirty = git(wt, 'status', '--porcelain', '--untracked-files=normal').strip()
|
||||||
|
js = f"{H}/out/{NAME}-{side}.index.json"
|
||||||
|
sk = json.load(open(js))['processing'] if os.path.exists(js) else {}
|
||||||
|
ok = m and m['commit_sha'] == head == sha and m['status'] == 'indexed' and not dirty and not sk.get('skipped_files') and not sk.get('errored_files')
|
||||||
|
out.append(f"[{'OK' if ok else 'STALE/BLIND'}] index {side}: manifest={m['commit_sha'][:8] if m else 'MISSING'} head={head[:8]} "
|
||||||
|
f"dirty={len(dirty.splitlines())} skipped={sk.get('skipped_files','?')} errored={sk.get('errored_files','?')}")
|
||||||
|
|
||||||
|
# 2. changed files without definitions
|
||||||
|
defs_by_file = collections.Counter()
|
||||||
|
for r in q("SELECT file_path, count(*) n FROM gl_definition WHERE definition_type <> 'Macro' GROUP BY 1", repo=TW, db=DB):
|
||||||
|
defs_by_file[r['file_path']] = r['n']
|
||||||
|
files = q("SELECT path, size_bytes FROM gl_file WHERE language = 'rust'", repo=TW, db=DB)
|
||||||
|
size = {f['path']: f['size_bytes'] for f in files}
|
||||||
|
changed = [f for f in dd['changed_files'] if f.endswith('.rs')]
|
||||||
|
blind = [f for f in changed if f in size and defs_by_file[f] == 0]
|
||||||
|
gone = [f for f in changed if f not in size]
|
||||||
|
def why(f, depth=0):
|
||||||
|
p = f.split('/')
|
||||||
|
if '/tests/' in f and p[2] == 'tests': return 'integration test w/ #![cfg] or not a target'
|
||||||
|
if re.search(r'(^|/)(tests?|test_\w+|\w+_tests?)(\.rs|/)', f): return 'test module (cfg(test) at parent mod)'
|
||||||
|
# find the gating `mod` decl for this file: parent dir mod.rs/lib.rs or sibling-named file
|
||||||
|
stem = p[-1][:-3] if p[-1] not in ('mod.rs', 'lib.rs', 'main.rs') else p[-2]
|
||||||
|
for cand in ('/'.join(p[:-1] + ['mod.rs']), '/'.join(p[:-1]) + '.rs', '/'.join(p[:-1] + ['lib.rs']), '/'.join(p[:-1] + ['main.rs']),
|
||||||
|
'/'.join(p[:-2] + ['mod.rs']), '/'.join(p[:-2]) + '.rs', '/'.join(p[:-2] + ['lib.rs'])):
|
||||||
|
try: L = open(f"{TW}/{cand}", errors='replace').read().split('\n')
|
||||||
|
except (FileNotFoundError, IsADirectoryError): continue
|
||||||
|
for i, l in enumerate(L):
|
||||||
|
if re.match(rf'\s*(pub(\([^)]*\))?\s+)?mod\s+{re.escape(stem)}\s*;', l):
|
||||||
|
for j in range(i - 1, max(-1, i - 4), -1):
|
||||||
|
m = re.match(r'\s*#\[cfg\((.*)\)\]', L[j])
|
||||||
|
if m: return f"gated at {cand}:{i+1} by cfg({m.group(1)})"
|
||||||
|
if depth < 6 and cand != f:
|
||||||
|
up = why(cand, depth + 1)
|
||||||
|
if up.startswith('gated'): return up
|
||||||
|
return f"declared at {cand}:{i+1}, no cfg found up the mod chain"
|
||||||
|
return 'not reachable from a crate root / gated higher up'
|
||||||
|
tot = len(changed)
|
||||||
|
out.append(f"[{'OK' if not blind else 'PARTIAL'}] {len(blind)}/{tot} changed .rs files have NO definitions in orbit (read/grep them):")
|
||||||
|
for f in sorted(blind, key=lambda f: -size[f])[:12]:
|
||||||
|
out.append(f" {f} ({size[f]//1024} KB) — {why(f)}")
|
||||||
|
if len(blind) > 12: out.append(f" … {len(blind)-12} more")
|
||||||
|
if gone: out.append(f" (deleted at tip: {len(gone)} files)")
|
||||||
|
|
||||||
|
# 3. changed lines by visibility
|
||||||
|
un = collections.Counter()
|
||||||
|
for k, n in dd['unattributed'].items(): un[k.split('|')[1]] += n
|
||||||
|
dstat = git(REPO, 'diff', '--numstat', B, T, '--', '*.rs')
|
||||||
|
nchanged = sum(int(a) + int(b) for a, b, *_ in (l.split('\t') for l in dstat.splitlines()) if a != '-')
|
||||||
|
blindl = un['cfg-test'] + un['cfg-feature'] + un['cfg-other'] + un['cfg-debug'] + un['file-has-no-defs']
|
||||||
|
out.append(f"[{'OK' if not blindl else 'PARTIAL'}] {blindl}/{nchanged} changed lines ({blindl/max(1,nchanged):.0%}) are in code orbit cannot see; unattributed: "
|
||||||
|
+ ', '.join(f"{k}={v}" for k, v in un.most_common() if k not in ('blank/comment',)))
|
||||||
|
|
||||||
|
# 4. features of touched crates
|
||||||
|
crates = sorted({f.split('/')[1] for f in changed if f.startswith('crates/')} | ({'.'} if any(not f.startswith('crates/') for f in changed) else set()))
|
||||||
|
feats = []
|
||||||
|
for c in crates:
|
||||||
|
path = f"{TW}/Cargo.toml" if c == '.' else f"{TW}/crates/{c}/Cargo.toml"
|
||||||
|
try: txt = open(path).read()
|
||||||
|
except FileNotFoundError: continue
|
||||||
|
m = re.search(r'^\[features\]\s*\n(.*?)(^\[|\Z)', txt, re.S | re.M)
|
||||||
|
if not m: continue
|
||||||
|
names = [x.group(1) for x in re.finditer(r'^([\w-]+)\s*=', m.group(1), re.M)]
|
||||||
|
d = re.search(r'^default\s*=\s*\[(.*?)\]', m.group(1), re.M | re.S)
|
||||||
|
dflt = re.findall(r'"([\w-]+)"', d.group(1)) if d else []
|
||||||
|
feats.append(f"{c if c != '.' else '(root)'}: {len([n for n in names if n != 'default'])} features" + (f", default={dflt}" if dflt else ''))
|
||||||
|
out.append(f"[INFO] orbit indexes with ALL cargo features OFF (defaults too). Touched crates with features: " + ('; '.join(feats) if feats else 'none'))
|
||||||
|
|
||||||
|
# 5. constructs CALLS misses, counted on added lines
|
||||||
|
diff = git(REPO, 'diff', '-U0', B, T, '--', '*.rs')
|
||||||
|
pat = {'add_systems/observer (fn refs, no CALLS)': r'add_systems|add_observer|\.observe\(', 'rsx! (components, no CALLS)': r'rsx!',
|
||||||
|
'dyn Trait (dispatch, few CALLS)': r'\bdyn\s+\w', 'assert!/matches!/vec! args (missed calls)': r'\b(assert\w*|matches|vec|format|debug_assert\w*)!'}
|
||||||
|
cnt = collections.Counter()
|
||||||
|
for l in diff.split('\n'):
|
||||||
|
if l.startswith('+') and not l.startswith('+++'):
|
||||||
|
for k, rx in pat.items():
|
||||||
|
if re.search(rx, l): cnt[k] += 1
|
||||||
|
out.append("[INFO] added lines with CALLS-blind constructs: " + (', '.join(f"{k}: {v}" for k, v in cnt.most_common()) or 'none'))
|
||||||
|
print(f"# preflight {B[:8]}..{T[:8]} ({os.path.basename(REPO)})")
|
||||||
|
print('\n'.join(out))
|
||||||
|
print("# fallbacks: blind files -> read them / git grep; cfg-test lines -> read the test module; "
|
||||||
|
"callers of fn refs/rsx/dyn -> git grep -nw <name>; treat every 'no callers/no implementors' as UNKNOWN until grep agrees")
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
"""cfg-region scanner: which line ranges of a Rust file sit under #[cfg(...)] / #![cfg(...)].
|
||||||
|
|
||||||
|
Text heuristic, no compilation. For each `#[cfg(COND)]` line, the gated item is the next
|
||||||
|
non-attribute line; its extent is brace-balanced (or up to `;`). `#![cfg(..)]` gates the whole file.
|
||||||
|
Returns list of (start, end, kind, cond) with kind in test|feature|debug|other.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
CFG = re.compile(r'^\s*#(!?)\[cfg\((.*)\)\]\s*$')
|
||||||
|
|
||||||
|
def kind_of(cond):
|
||||||
|
c = cond.replace(' ', '')
|
||||||
|
if c == 'test' or c.startswith('all(test') or 'test,' in c and c.startswith('any('): return 'test'
|
||||||
|
if 'feature' in c: return 'feature'
|
||||||
|
if 'debug_assertions' in c: return 'debug'
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
def cfg_regions(lines):
|
||||||
|
out = []
|
||||||
|
n = len(lines)
|
||||||
|
for i, l in enumerate(lines):
|
||||||
|
m = CFG.match(l)
|
||||||
|
if not m: continue
|
||||||
|
inner, cond = m.group(1), m.group(2)
|
||||||
|
if 'not(' in cond.replace(' ', '') and kind_of(cond) != 'test':
|
||||||
|
# cfg(not(feature=..)) items are KEPT by orbit (features off) — not a blind spot
|
||||||
|
continue
|
||||||
|
if inner:
|
||||||
|
out.append((1, n, kind_of(cond), cond)); continue
|
||||||
|
j = i + 1
|
||||||
|
while j < n and (lines[j].strip().startswith(('#[', '///', '//')) or not lines[j].strip()): j += 1
|
||||||
|
if j >= n: continue
|
||||||
|
is_item = re.match(r'\s*(pub(\([^)]*\))?\s+)?(async\s+|unsafe\s+|const\s+|extern\s+"[^"]*"\s+)*(fn|struct|enum|trait|impl|mod|use|static|const|type|macro_rules!)\b', lines[j])
|
||||||
|
depth, k, opened = 0, j, False
|
||||||
|
while k < n:
|
||||||
|
s = re.sub(r'//.*', '', lines[k])
|
||||||
|
s = re.sub(r'"(\\.|[^"\\])*"', '""', s)
|
||||||
|
depth += s.count('{') - s.count('}')
|
||||||
|
if s.count('{'): opened = True
|
||||||
|
if (opened and depth <= 0) or (not opened and s.rstrip().endswith((';',) if is_item else (';', ','))): break
|
||||||
|
if not opened and depth < 0: k -= 1; break # field/arm at end of enclosing block
|
||||||
|
k += 1
|
||||||
|
out.append((i + 1, k + 1, kind_of(cond), cond))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def region_of(regions, ln):
|
||||||
|
best = None
|
||||||
|
for s, e, k, c in regions:
|
||||||
|
if s <= ln <= e and (best is None or e - s < best[1] - best[0]): best = (s, e, k, c)
|
||||||
|
return best
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end orbit review pass. Usage: review-pass.sh <repo> <base> <tip> <name> [rules-file]
|
||||||
|
# Writes $H/out/<name>.{env,defdiff.txt,defdiff.json,preflight.txt,blast.txt,traits.txt,boundary.txt,context.txt}
|
||||||
|
# and prints them in reading order. No compilation. Isolated DB. Run review-teardown.sh <repo> <name> afterwards.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO=$1; BASE=$2; TIP=$3; NAME=$4; RULES=${5:-}
|
||||||
|
H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}; R=$(dirname "$(realpath "$0")"); O=$H/out/$NAME; mkdir -p "$H/out"
|
||||||
|
"$R/review-setup.sh" "$REPO" "$BASE" "$TIP" "$NAME" > "$O.env"
|
||||||
|
python3 "$R/defdiff.py" "$O.env" --json "$O.defdiff.json" > "$O.defdiff.txt"
|
||||||
|
python3 "$R/preflight.py" "$O.env" "$O.defdiff.json" > "$O.preflight.txt"
|
||||||
|
python3 "$R/blast.py" "$O.env" "$O.defdiff.json" > "$O.blast.txt"
|
||||||
|
python3 "$R/traitcov.py" "$O.env" "$O.defdiff.json" > "$O.traits.txt"
|
||||||
|
if [ -n "$RULES" ]; then python3 "$R/boundary.py" "$O.env" "$O.defdiff.json" "$RULES" > "$O.boundary.txt"; else echo "# boundary: no rules file" > "$O.boundary.txt"; fi
|
||||||
|
python3 "$R/context.py" "$O.env" "$O.defdiff.json" > "$O.context.txt"
|
||||||
|
for s in preflight defdiff blast traits boundary context; do echo; cat "$O.$s.txt"; done
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: review-setup.sh <repo> <base-rev> <tip-rev> <review-name>
|
||||||
|
# Creates detached worktrees for base and tip under $ORBIT_REVIEW_HOME/worktrees,
|
||||||
|
# indexes both into ONE isolated DB ($ORBIT_REVIEW_HOME/<name>.duckdb) with -t 4,
|
||||||
|
# and prints env lines to eval. Never touches the shared ~/.gitlab/orbit DB.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO=$(realpath "$1"); BASE=$2; TIP=$3; NAME=$4
|
||||||
|
H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}
|
||||||
|
DB=$H/$NAME.duckdb; mkdir -p "$H/worktrees" "$H/out"
|
||||||
|
B=$(git -C "$REPO" rev-parse "$BASE^{commit}"); T=$(git -C "$REPO" rev-parse "$TIP^{commit}")
|
||||||
|
for side in base tip; do
|
||||||
|
sha=$([ $side = base ] && echo $B || echo $T)
|
||||||
|
wt=$H/worktrees/$NAME-$side
|
||||||
|
[ -d "$wt" ] || git -C "$REPO" worktree add --detach --quiet "$wt" "$sha"
|
||||||
|
out=$H/out/$NAME-$side.index.json
|
||||||
|
orbit index "$wt" -t 4 --db "$DB" > "$out" 2> "$out.log"
|
||||||
|
skipped=$(python3 -c "import json,sys;d=json.load(open('$out'));p=d['processing'];print(p['skipped_files']+p.get('errored_files',0))")
|
||||||
|
echo "# $side $sha indexed: skipped_files=$skipped ($out)" >&2
|
||||||
|
[ "$skipped" = 0 ] || echo "WARNING: $side skipped files; re-run or read them by hand" >&2
|
||||||
|
done
|
||||||
|
echo "DB=$DB"; echo "BASE_WT=$H/worktrees/$NAME-base"; echo "TIP_WT=$H/worktrees/$NAME-tip"
|
||||||
|
echo "BASE_SHA=$B"; echo "TIP_SHA=$T"; echo "REPO=$REPO"
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: review-teardown.sh <repo> <name> — removes the two detached worktrees and the isolated DB.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO=$1; NAME=$2; H=${ORBIT_REVIEW_HOME:-$HOME/.cache/orbit-review}
|
||||||
|
for side in base tip; do
|
||||||
|
wt=$H/worktrees/$NAME-$side
|
||||||
|
[ -d "$wt" ] && git -C "$REPO" worktree remove --force "$wt"
|
||||||
|
done
|
||||||
|
git -C "$REPO" worktree prune
|
||||||
|
rm -f "$H/$NAME.duckdb" "$H/$NAME.duckdb.wal"
|
||||||
|
echo "removed worktrees + $NAME.duckdb; remaining worktrees:"; git -C "$REPO" worktree list
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Boundary rules for dirigent, from crates/*/CLAUDE.md and docs/architecture/rules.md.
|
||||||
|
deny crates/dirigent_acp_api/src/** -> ^(dirigent_core|dirigent_archivist)\b # acp_api CLAUDE.md: no dependency on core/archivist (archivist dev-dep only)
|
||||||
|
deny crates/dirigent_logs/** -> ^dirigent_\w+ # logs CLAUDE.md: depends on no other dirigent crate
|
||||||
|
deny crates/dirigent_diff/** -> ^dioxus\b # diff CLAUDE.md: no Dioxus dependency
|
||||||
|
deny crates/dirigent_web/** -> ^opencode_client\b.*OpenCodeClient|^opencode_client::OpenCodeClient # rules.md: web must not import OpenCodeClient
|
||||||
|
deny crates/dirigent_appservice/** -> ^(dirigent_|opencode_client)\w* # appservice CLAUDE.md: no workspace path deps
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Boundary rules for spacecraft, derived from AGENTS.md "Dependency Graph" and crates/*/CLAUDE.md.
|
||||||
|
# Format: allow <crate> : <crate> <crate> ... (only these workspace crates may be imported; others flagged)
|
||||||
|
# deny <src-crate-or-path-glob> -> <import_path-regex> # note
|
||||||
|
# 'warpspace' is the root package (src/). Leaf = allow with empty list.
|
||||||
|
allow warpspace_engine :
|
||||||
|
allow warpspace_protocol : warpspace_engine
|
||||||
|
allow warpspace_core : warpspace_engine warpspace_protocol warpspace_squad
|
||||||
|
allow warpspace_sync_core : warpspace_engine warpspace_protocol
|
||||||
|
allow warpspace_domains : warpspace_core warpspace_sync_core warpspace_engine warpspace_protocol warpspace_squad
|
||||||
|
allow warpspace_combat : warpspace_core warpspace_sync_core warpspace_domains warpspace_engine warpspace_protocol warpspace_squad warpspace_meshgen warpspace_billboard
|
||||||
|
allow warpspace_hero : warpspace_core warpspace_domains warpspace_combat warpspace_sync_core warpspace_engine warpspace_protocol
|
||||||
|
allow warpspace_client : warpspace_core warpspace_domains warpspace_combat warpspace_hero warpspace_sync_core warpspace_engine warpspace_protocol warpspace_billboard warpspace_profile warpspace_skybox
|
||||||
|
allow warpspace_profile : warpspace_engine warpspace_protocol
|
||||||
|
allow warpspace_meshgen :
|
||||||
|
allow warpspace_skybox :
|
||||||
|
allow warpspace_universe :
|
||||||
|
allow warpspace_billboard :
|
||||||
|
deny crates/warpspace_core/** -> ^(warpspace_domains|warpspace_combat|warpspace_hero|warpspace_client)\b # core CLAUDE.md: dependency floor
|
||||||
|
deny crates/warpspace_combat/** -> ^(warpspace_hero|warpspace_client)\b # combat CLAUDE.md
|
||||||
|
allow warpspace_replay : warpspace warpspace_domains warpspace_combat warpspace_client warpspace_engine
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Changed files that orbit has no (non-macro) definitions for. Paste the diff's file list into the IN (...).
|
||||||
|
SELECT f.path, f.size_bytes FROM gl_file f
|
||||||
|
WHERE f.path IN ('crates/dirigent_web/src/server.rs', 'crates/dirigent_archivist/src/storage/trash.rs',
|
||||||
|
'crates/dirigent_archivist/src/coordinator/tests.rs')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM gl_definition d WHERE d.file_path = f.path AND d.definition_type <> 'Macro')
|
||||||
|
ORDER BY f.size_bytes DESC;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Crate->crate import edges present at TIP but not at BASE (both commits in one --db; run with --all).
|
||||||
|
-- Crate = 2nd path segment under crates/; import crate = first segment of import_path (crate/self/super = own).
|
||||||
|
WITH e AS (
|
||||||
|
SELECT commit_sha, split_part(file_path, '/', 2) AS src,
|
||||||
|
CASE WHEN split_part(import_path, '::', 1) IN ('crate', 'self', 'super') THEN split_part(file_path, '/', 2)
|
||||||
|
ELSE split_part(import_path, '::', 1) END AS dst, file_path
|
||||||
|
FROM gl_imported_symbol WHERE file_path LIKE 'crates/%' AND import_type <> 'ModDeclaration')
|
||||||
|
SELECT src, dst, count(*) AS n, min(file_path) AS example FROM e
|
||||||
|
WHERE commit_sha = 'bfbbcd962e8fc176c0612972136a052811b18bf9' AND src <> dst
|
||||||
|
AND dst IN (SELECT DISTINCT split_part(path, '/', 2) FROM gl_file WHERE path LIKE 'crates/%')
|
||||||
|
AND (src, dst) NOT IN (SELECT src, dst FROM e WHERE commit_sha = '21cdd1fdd55806cafcee0391065e005ba845f169')
|
||||||
|
GROUP BY 1, 2 ORDER BY 1, 2;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Callers of a definition AND of its members (methods/assoc fns/variants), grouped by file.
|
||||||
|
-- For types this catches `x.method()` users that a grep for the type name misses. Replace the fqn.
|
||||||
|
WITH tgt AS (
|
||||||
|
SELECT id FROM gl_definition WHERE fqn = 'dirigent_archivist::storage::trash::TrashDir'
|
||||||
|
UNION
|
||||||
|
SELECT id FROM gl_definition
|
||||||
|
WHERE fqn LIKE 'dirigent_archivist::storage::trash::TrashDir::%'
|
||||||
|
AND definition_type IN ('Method', 'AssociatedFunction', 'Variant'))
|
||||||
|
SELECT s.file_path, string_agg(DISTINCT s.name, ', ') AS callers, count(*) AS edges
|
||||||
|
FROM gl_edge e JOIN gl_definition s ON s.id = e.source_id
|
||||||
|
WHERE e.relationship_kind = 'CALLS' AND e.target_id IN (SELECT id FROM tgt)
|
||||||
|
GROUP BY 1 ORDER BY 1;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Innermost definitions covering a changed line range (one hunk). Replace FILE/START/END.
|
||||||
|
-- Run: orbit sql --repo <tip-worktree> --db <db> -f hunk-to-defs.sql
|
||||||
|
SELECT definition_type, fqn, start_line, end_line
|
||||||
|
FROM gl_definition
|
||||||
|
WHERE file_path = 'crates/dirigent_archivist/src/storage/backup.rs'
|
||||||
|
AND start_line <= 450 AND end_line >= 438 -- hunk END / START
|
||||||
|
AND definition_type NOT IN ('Module', 'Field', 'Variant')
|
||||||
|
ORDER BY end_line - start_line LIMIT 3;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Implementors of an in-repo trait: EXTENDS (same-file impls only) UNION method-name overlap (cross-file,
|
||||||
|
-- noisy: facades that delegate also match). Replace trait name; tune the 0.6 threshold.
|
||||||
|
WITH t AS (SELECT fqn FROM gl_definition WHERE definition_type = 'Trait' AND name = 'ArchiveBackend'),
|
||||||
|
tm AS (SELECT m.name FROM gl_definition m, t WHERE m.fqn LIKE t.fqn || '::%' AND m.definition_type = 'Method'),
|
||||||
|
ext AS (SELECT s.fqn AS implementor, 'EXTENDS' AS via FROM gl_edge e
|
||||||
|
JOIN gl_definition s ON s.id = e.source_id JOIN gl_definition d ON d.id = e.target_id, t
|
||||||
|
WHERE e.relationship_kind = 'EXTENDS' AND d.fqn = t.fqn),
|
||||||
|
ovl AS (SELECT regexp_replace(d.fqn, '::[^:]+$', '') AS implementor, 'overlap ' || count(DISTINCT d.name) || '/' || (SELECT count(*) FROM tm) AS via
|
||||||
|
FROM gl_definition d, t WHERE d.definition_type = 'Method' AND d.name IN (SELECT name FROM tm)
|
||||||
|
AND d.fqn NOT LIKE t.fqn || '::%'
|
||||||
|
GROUP BY 1 HAVING count(DISTINCT d.name) >= 0.6 * (SELECT count(*) FROM tm))
|
||||||
|
SELECT implementor, string_agg(via, ' + ') AS via FROM (SELECT * FROM ext UNION ALL SELECT * FROM ovl) GROUP BY 1 ORDER BY 1;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Files importing a name (statement-level `use`; includes files whose defs are cfg-gated, but NOT
|
||||||
|
-- `use` inside #[cfg(test)] mod blocks or fn bodies; NOT inline paths like `krate::m::Name`).
|
||||||
|
SELECT file_path, import_type, import_path, start_line
|
||||||
|
FROM gl_imported_symbol WHERE identifier_name = 'TrashDir' ORDER BY file_path;
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recipe 3: trait-impl coverage for traits touched by a diff (or named with --trait).
|
||||||
|
|
||||||
|
Usage: traitcov.py <env-file> <defdiff.json> [--trait Name ...] [--all-traits]
|
||||||
|
For each trait: its methods at tip (+/- vs base), implementors from three sources, and per
|
||||||
|
implementor which trait methods it defines (and whether the diff touched them):
|
||||||
|
E = EXTENDS edge (orbit; only when `impl Trait for Type` sits in the type's own file)
|
||||||
|
G = `git grep 'impl … Trait … for Type'` at tip (text; sees cfg-gated/test impls too)
|
||||||
|
H = method-name overlap heuristic (orbit; >= 60% of trait methods defined on the type)
|
||||||
|
Implementors only in G are cross-file impls, impls on external types, or cfg-gated/test impls.
|
||||||
|
"""
|
||||||
|
import collections, json, re, subprocess, sys
|
||||||
|
sys.path.insert(0, __file__.rsplit('/', 1)[0])
|
||||||
|
from ob import load_env, q, git
|
||||||
|
from regions import cfg_regions, region_of
|
||||||
|
|
||||||
|
env = load_env(sys.argv[1]); dd = json.load(open(sys.argv[2]))
|
||||||
|
DB, BW, TW, REPO, B, T = env['DB'], env['BASE_WT'], env['TIP_WT'], env['REPO'], env['BASE_SHA'], env['TIP_SHA']
|
||||||
|
names = [sys.argv[i + 1] for i, a in enumerate(sys.argv) if a == '--trait']
|
||||||
|
|
||||||
|
D = {}
|
||||||
|
for side, wt in (('tip', TW), ('base', BW)):
|
||||||
|
D[side] = q("SELECT id, fqn, name, definition_type k, file_path f, start_line s, end_line e FROM gl_definition WHERE file_path LIKE '%.rs'", repo=wt, db=DB)
|
||||||
|
traits = {d['fqn']: d for d in D['tip'] if d['k'] == 'Trait'}
|
||||||
|
btraits = {d['fqn']: d for d in D['base'] if d['k'] == 'Trait'}
|
||||||
|
touched = set(dd['touched_tip'])
|
||||||
|
if '--all-traits' in sys.argv: sel = set(traits)
|
||||||
|
elif names: sel = {f for f, d in traits.items() if d['name'] in names}
|
||||||
|
else:
|
||||||
|
sel = set()
|
||||||
|
for r in dd['rows']:
|
||||||
|
par = r['fqn'].rsplit('::', 1)[0]
|
||||||
|
if r['k'] == 'Trait' and r['fqn'] in traits: sel.add(r['fqn'])
|
||||||
|
if par in traits and r['op'] != '~' or par in traits and r['op'] == '~': sel.add(par)
|
||||||
|
for r in dd['rows']:
|
||||||
|
if r['k'] == 'Trait' and r['op'] == '-': print(f"- Trait {r['fqn']} REMOVED — check leftover impls with: git grep -n 'impl .*{r['name']}' {T[:8]}")
|
||||||
|
|
||||||
|
ext = collections.defaultdict(set)
|
||||||
|
for e in q("SELECT s.fqn src, t.fqn tgt FROM gl_edge e JOIN gl_definition s ON s.id=e.source_id JOIN gl_definition t ON t.id=e.target_id WHERE e.relationship_kind='EXTENDS'", repo=TW, db=DB):
|
||||||
|
ext[e['tgt']].add(e['src'])
|
||||||
|
methods_of = collections.defaultdict(dict)
|
||||||
|
for d in D['tip']:
|
||||||
|
if d['k'] in ('Method', 'AssociatedFunction', 'Constant', 'TypeAlias'):
|
||||||
|
methods_of[d['fqn'].rsplit('::', 1)[0]][d['name']] = d
|
||||||
|
bmethods_of = collections.defaultdict(dict)
|
||||||
|
for d in D['base']:
|
||||||
|
if d['k'] in ('Method', 'AssociatedFunction'):
|
||||||
|
bmethods_of[d['fqn'].rsplit('::', 1)[0]][d['name']] = d
|
||||||
|
types_by_name = collections.defaultdict(list)
|
||||||
|
for d in D['tip']:
|
||||||
|
if d['k'] in ('Struct', 'Enum', 'TypeAlias', 'Trait'): types_by_name[d['name']].append(d)
|
||||||
|
_src = {}
|
||||||
|
def regions(f):
|
||||||
|
if f not in _src:
|
||||||
|
try: _src[f] = cfg_regions(open(f"{TW}/{f}", encoding='utf-8', errors='replace').read().split('\n'))
|
||||||
|
except FileNotFoundError: _src[f] = []
|
||||||
|
return _src[f]
|
||||||
|
|
||||||
|
stats = collections.Counter()
|
||||||
|
for tf in sorted(sel):
|
||||||
|
t = traits[tf]; tm = methods_of.get(tf, {}); bm = bmethods_of.get(tf, {})
|
||||||
|
added = sorted(set(tm) - set(bm)); removed = sorted(set(bm) - set(tm))
|
||||||
|
sigchg = sorted(r['name'] for r in dd['rows'] if r['op'] == 'S' and r['fqn'].rsplit('::', 1)[0] == tf)
|
||||||
|
print(f"Trait {tf} [{t['f']}:{t['s']}] methods={len(tm)}" + (f" +{added}" if added else '') + (f" -{removed}" if removed else '') + (f" sig~{sigchg}" if sigchg else ''))
|
||||||
|
# G: text impls at tip
|
||||||
|
try: g = git(REPO, 'grep', '-nE', rf'^\s*(unsafe\s+)?impl\b.*\b{t["name"]}\b(<[^>]*>)?\s+for\s+', T, '--', '*.rs')
|
||||||
|
except subprocess.CalledProcessError: g = ''
|
||||||
|
G = {}
|
||||||
|
for l in g.splitlines():
|
||||||
|
_, f, ln, txt = l.split(':', 3)
|
||||||
|
m = re.search(rf'\bfor\s+&?(\'\w+\s+)?(mut\s+)?(dyn\s+)?([\w:]+)', txt)
|
||||||
|
if not m: continue
|
||||||
|
ty = m.group(4).split('::')[-1]; reg = region_of(regions(f), int(ln))
|
||||||
|
G[(ty, f)] = f"cfg-{reg[2]}" if reg else ''
|
||||||
|
E = {x.split('::')[-1]: x for x in ext.get(tf, set())}
|
||||||
|
# H: overlap heuristic
|
||||||
|
H = {}
|
||||||
|
if tm:
|
||||||
|
for owner, ms in methods_of.items():
|
||||||
|
if owner == tf: continue
|
||||||
|
ov = len(set(ms) & set(tm))
|
||||||
|
if ov >= max(2, 0.6 * len(tm)): H[owner.split('::')[-1]] = owner
|
||||||
|
impl_types = sorted(set(E) | {ty for ty, _ in G} | set(H))
|
||||||
|
for ty in impl_types:
|
||||||
|
src = ''.join(c for c, ok in (('E', ty in E), ('G', any(k[0] == ty for k in G)), ('H', ty in H)) if ok)
|
||||||
|
gfiles = [f for (tt, f) in G if tt == ty]; gates = {G[(ty, f)] for f in gfiles if G[(ty, f)]}
|
||||||
|
owners = [E.get(ty)] if ty in E else ([H[ty]] if ty in H else [d['fqn'] for d in types_by_name.get(ty, [])])
|
||||||
|
owners = [o for o in owners if o]
|
||||||
|
have = set(); tch = set()
|
||||||
|
for o in owners:
|
||||||
|
for n, d in methods_of.get(o, {}).items():
|
||||||
|
if n in tm: have.add(n); tch |= {n} if d['id'] in touched else set()
|
||||||
|
missing = sorted(set(added) - have) if added else []
|
||||||
|
cover = f"defines {len(have)}/{len(tm)}" if owners else "type not indexed (external/gated)"
|
||||||
|
flag = ''
|
||||||
|
if missing:
|
||||||
|
# grep fallback: look for `fn <m>` in the impl files (text; sees gated code)
|
||||||
|
txt = {m: any(re.search(rf'\bfn\s+{m}\b', open(f"{TW}/{f}", errors='replace').read()) for f in gfiles) for m in missing}
|
||||||
|
ok = [m for m, v in txt.items() if v]; bad = [m for m, v in txt.items() if not v]
|
||||||
|
if ok: flag += f" new method(s) {ok} found by text in impl file (orbit can't see it)"
|
||||||
|
if bad: flag += f" !! new method(s) {bad} NOT found in impl file (default impl? not updated?)"
|
||||||
|
print(f" [{src:3s}] {ty} {cover}{' touched:'+','.join(sorted(tch)) if tch else ''}"
|
||||||
|
f"{' impl-in: '+','.join(sorted(set(gfiles)))[:120] if gfiles and 'E' not in src else ''}{' '+','.join(gates) if gates else ''}{flag}")
|
||||||
|
stats['G'] += any(k[0] == ty for k in G); stats['E'] += ty in E; stats['H'] += ty in H
|
||||||
|
stats['EG'] += (ty in E and any(k[0] == ty for k in G)); stats['HG'] += (ty in H and any(k[0] == ty for k in G))
|
||||||
|
stats['H_only'] += (ty in H and not any(k[0] == ty for k in G))
|
||||||
|
print(f"# implementors: grep={stats['G']} extends={stats['E']} (∩grep {stats['EG']}) heuristic={stats['H']} (∩grep {stats['HG']}, heuristic-only {stats['H_only']})")
|
||||||
Reference in New Issue
Block a user