rinkaku
rinkaku (輪郭, "outline" in Japanese)
A CLI that condenses large PR diffs — especially LLM-generated ones — into just the signatures of changed symbols and their dependencies, so reviewers and LLMs can grasp the API surface of a change without reading every implementation line.
What it is
- Input: a unified diff via stdin (
gh pr diff 123 | rinkaku),rinkaku --base mainto rungit diffinternally, orrinkaku --pr 123to review a GitHub PR directly. In--basemode, file contents are read viagit show <head>:<path>so extraction always matches the diffed commit, regardless of the working tree's state.--prmode resolves the PR's base and head viagh pr view, fetches both into the local clone, and reuses the samegit show-backed read strategy as--base— it requires running inside a local clone of the target repository, andghinstalled and authenticated (see ADR 0004). In stdin mode, file contents are read off the working tree, which assumes the piped diff is consistent with the current working tree (e.g. it was just produced bygit diffor already applied); a stale or unrelated diff piped via stdin can produce misaligned line numbers. - Core: tree-sitter parses the changed files, finds the definitions that contain changed lines, and slices out their signatures.
- Dependency expansion: each changed symbol is expanded one hop out to
the definitions it references, via tree-sitter tags queries (v1).
LSP-based resolvers (pyright, gopls, etc.) are a pluggable extension
point (
Resolvertrait) planned for a later release. Same-name matches are ranked by path proximity to the referencing file and capped at 3 to keep "Depends on" readable; see Known limitations below. - Languages (v1, built-in): Rust, Go, Python, TypeScript. Each is a
LanguageSupporttrait implementation (grammar crate + tags query + signature-slicing rule), so language support is additive. - Output: Markdown or JSON, designed to be fed to an LLM or read by a human reviewer.
See docs/adr/ for the reasoning behind these choices.
Status
Early development. Diff parsing, tree-sitter extraction, the CLI
(stdin/--base/--pr input, Markdown/JSON output), and 1-hop dependency
expansion (--deps, the tags-based Resolver) are implemented.
Installation
Homebrew
Install script
|
You can also specify a version or install directory:
| VERSION=v0.1.0
| INSTALL_DIR=/.local/bin
From source (cargo install)
GitHub Releases (manual)
Download the tarball for your platform from the
latest release,
extract it, and place the rinkaku binary on your PATH:
Where <target> is one of x86_64-unknown-linux-gnu,
aarch64-unknown-linux-gnu, x86_64-apple-darwin, or
aarch64-apple-darwin.
Updating
Downloads and installs the latest GitHub release for your platform,
replacing the running binary in place. If you installed via Homebrew or
cargo install, prefer brew upgrade or cargo install rinkaku instead
so your package manager's bookkeeping stays in sync — self-update works
either way, but it bypasses those managers.
By default this prompts for confirmation before installing. Pass --yes
(or -y) to skip the prompt and proceed non-interactively:
When stdin is not a terminal (e.g. run from a script or CI) and --yes is
not given, self-update refuses to run rather than silently proceeding.
Usage
# From a GitHub PR (stdin, no local clone required)
|
# From a GitHub PR, run inside a local clone of the target repository
# (fetches the PR via `gh`/`git`; requires `gh` installed and authenticated)
# From a local git diff against a base branch
# JSON output for feeding into another tool or LLM
# Skip dependency resolution (faster, no repo-wide index — see below)
What it looks like
All examples below are captured from the cargo build --release binary
against main post-#9
(the dependency-resolution precision and indexing performance
improvements), not fabricated.
Running rinkaku on
a real rinkaku commit
(a 128-line diff adding the stdin garbage-input warning in main.rs)
produces:
|
```
fn main() -> anyhow::Result<()>
```
Depends on:
- ------
```
fn garbage_input_note( diff_text: &str, report: &rinkaku_core::render::Report, ) -> Option<&'static str>
```
Depends on:
-
... (6 more symbols, same shape)
The 128-line diff (test bodies included) becomes 59 lines of signatures
and their dependencies — the reviewer sees which functions changed and
what they touch, without reading every match arm by hand. On a larger
real diff — rinkaku's own PR #7
(a 2,254-line diff adding dependency resolution across 12 files) —
rinkaku produces 658 lines: about 29% of the original, while surfacing
cross-file dependencies that would otherwise require opening every
changed file to trace by hand.
--format json renders the same data as structured JSON instead
({"files": [{"path", "symbols": [{"name", "kind", "signature", "range", "container", "dependencies", "omitted_matches"}]}], "skipped": [...]}),
for piping into jq or another tool:
| |
When same-name matches are capped
On a repository with many same-named definitions, the same-name cap (see
Known limitations below) shows up directly in the output. Running
rinkaku against astral-sh/ruff
commit 6237ecb4d ("[ty] Add progress reporting to workspace
diagnostics"), a changed LazyWorkDoneProgress struct references Inner,
a name defined 14 times across the repo (mostly unrelated Python test
fixtures and formatter cases named class Inner) — the 3 closest matches
by path proximity are shown, and the rest are reported as a count instead
of listed in full or silently dropped:
```
pub(super) struct LazyWorkDoneProgress { inner: Arc<Inner>, }
```
Depends on:
- ---
This same 810-line diff also shows the noise reduction from filtering _
and single-character identifiers out of referenced names entirely (see
Known limitations): the pre-#9 QA pass on this exact diff found 76 of 188
"Depends on" lines were unrelated def _(...) matches (~40% noise); on
the current main, the same diff produces 295 output lines (down from
405 pre-#9) with zero _-related entries, since _ is no longer looked
up at all.
--deps
--deps 1 (the default) resolves each changed symbol's 1-hop dependencies
by indexing every file git ls-files tracks in the repository — this
makes the output more useful (you see what a changed function calls) but
costs an up-front repo-wide scan. TagsResolver::new prefilters which
files are worth parsing (skipping any file whose content cannot contain
any referenced name at all), which helps when referenced names are
distinctive but has limited effect when they include common
standard-library-style names (see Known limitations). On the ruff
6237ecb4d diff above, --deps 1 took ~6.5s post-#9 (down from ~9.5s
pre-#9) versus ~0.05s for --deps 0 on the same diff — --deps 0 skips
resolution entirely (no "Depends on" sections, no repository scan) and
remains dramatically faster since indexing cost does not depend on diff
size. Prefer --deps 0 for quick iteration or CI checks where the
dependency context isn't needed.
Development
Requires a Rust toolchain (pinned in rust-toolchain.toml;
rustup will install it automatically).
The workspace has two crates: rinkaku-core (the pure diff-condensation
library, published standalone so it can be embedded in other tools) and
rinkaku (the thin CLI binary, depending on rinkaku-core).
CI runs the same make targets on every pull request (see
.github/workflows/).
Architecture
Lightweight ports & adapters: core extraction logic in rinkaku-core is
pure (no IO, no clock, no env), with tree-sitter parsing and future
LSP/process boundaries isolated behind traits (LanguageSupport,
Resolver) defined on the consumer side. See CLAUDE.md and
docs/adr/ for details.
Known limitations
Mitigated in #9: the
original QA pass (see below) found name-only matching noise and slow
--deps 1 indexing severe enough to block merging. Both are improved,
though not eliminated — v1's resolver is still name-only (see "still
open" below).
- Same-name matches are ranked and capped, not resolved. When several
definitions share a referenced name, they are ranked by path proximity
to the referencing file (same file > same directory > shared path
prefix depth > other) and only the top 3 (
MAX_MATCHES_PER_NAME) are shown; the rest are reported as a count ((+N more definitions matched by name)in Markdown,omitted_matchesin JSON) rather than silently dropped or listed in full — see "When same-name matches are capped" above for a real example. This bounds "Depends on" noise but does not guarantee the top 3 include the actually-referenced definition, since ranking is a proximity heuristic, not type-aware resolution. _and single-character identifiers are never resolved. They are filtered out of referenced names entirely, since under name-only resolution they match too many unrelated definitions to be useful (Python's_placeholder convention was the main offender found in QA — see below).- The
--deps 1indexing prefilter has limited effect when a diff references common standard-library-style names.TagsResolver::newskips parsing files whose content cannot contain any referenced name at all (measured ~88% fewer files parsed, ~8x faster indexing on a same-language-only reference set — see PR #9's description for the full numbers). But a name likeVec,Option,String,Some, orOkappears in nearly every Rust file in a real codebase, so a diff whose referenced names include several of these sees a smaller reduction (on the ruff6237ecb4ddiff used above,--deps 1dropped from ~9.5s pre-#9 to ~6.5s post-#9 — better, not solved). The prefilter is a substring match over raw file content, not scoped to actual definitions, so it cannot distinguish "definesVec" from "mentionsVec" without also risking false negatives (seedeps.rs'sshould_parse_filedoc comment) — narrowing this further is left for a future iteration. The dominant cost in--basemode remains the per-filegit showsubprocess invocation for reading tracked files (unrelated to this prefilter, and unaddressed — seedeps.rs's performance doc comment).
Still open — no type resolution (by design, ADR 0003): dependency
resolution matches referenced names against definitions by name alone,
with no type information — it cannot disambiguate overloads, shadowed
names, or same-named symbols in unrelated modules. The ranking and cap
above reduce the resulting noise but do not fix the underlying
imprecision (e.g. an unrelated same-named Python test fixture class can
still outrank a real dependency once same-file/same-directory candidates
are exhausted — see the Inner example above). A future Resolver
implementation backed by an LSP server (pyright, gopls, rust-analyzer,
...) is planned as a higher-precision, opt-in alternative for v2+; see
the Roadmap below.
Roadmap / not yet done
- LSP-backed
Resolverimplementations (pyright, gopls, rust-analyzer, ...) as a higher-precision, opt-in alternative to the v1 tags-basedResolver.
Release
rinkaku and rinkaku-core are versioned independently by
release-please (no
linked-versions grouping): each crate only bumps when a commit touches
its own path, so it's normal for them to be on different versions (e.g.
rinkaku 0.2.0 depending on rinkaku-core 0.1.0). Only rinkaku's
release tag (v{version}, no component prefix) triggers
build-and-publish.yaml; rinkaku-core's tag is prefixed
(rinkaku-core-v{version}) so a rinkaku-core-only release doesn't spin
up the binary build/publish pipeline.
separate-pull-requests: true is set for a reason that isn't obvious
from the config alone: with more than one non-root packages entry (no
. path), release-please's PR-merging step can't find a "root" release
candidate to base the combined PR's title on, and falls back to a title
that omits the version entirely (chore: release main). That title
doesn't match what the next run expects when looking up the
already-merged PR to tag, so tagging silently finds nothing to do and
release-main.yaml aborts with "untagged, merged release PRs
outstanding" -- this bit us for both the v0.2.0 and v0.3.0 releases,
each requiring a manual gh release create + relabeling the PR
autorelease: tagged to recover. separate-pull-requests: true sidesteps
this entirely: each package gets its own PR (and its own title, correctly
including that package's version), so there's no combined-PR title to
compute in the first place.
License
MIT