Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b32b3bb
feat(architecture): v1.2.0 — shallow type-inference for call_parity r…
SaschaOnTour Apr 24, 2026
7671be5
fix: Copilot comments
SaschaOnTour Apr 24, 2026
2f4503c
fix: Copilot comments
SaschaOnTour Apr 24, 2026
94bfc4b
fix: linting
SaschaOnTour Apr 24, 2026
805229f
fix: copilot comments
SaschaOnTour Apr 24, 2026
bb17bb0
fix: copilot comments
SaschaOnTour Apr 24, 2026
08b9ef7
fix: copilot comments
SaschaOnTour Apr 25, 2026
3c3f879
fix: linting
SaschaOnTour Apr 25, 2026
a302bab
fix: copilot comments
SaschaOnTour Apr 25, 2026
ca06363
fix: copilot comments
SaschaOnTour Apr 25, 2026
7f496c0
fix: copilot comments
SaschaOnTour Apr 25, 2026
e7fbcbb
fix: copilot comments
SaschaOnTour Apr 25, 2026
75344c0
fix: copilot comments
SaschaOnTour Apr 25, 2026
8a511c8
fix: copilot comments
SaschaOnTour Apr 25, 2026
799c99c
fix: copilot comments
SaschaOnTour Apr 25, 2026
69ef158
fix: copilot comments
SaschaOnTour Apr 25, 2026
1b504d8
fix: copilot comments
SaschaOnTour Apr 25, 2026
b6644fe
fix: copilot comments
SaschaOnTour Apr 25, 2026
ce0ab4c
fix: copilot comments
SaschaOnTour Apr 25, 2026
ab11400
fix: copilot comments
SaschaOnTour Apr 25, 2026
bf4efde
fix: copilot comments
SaschaOnTour Apr 25, 2026
ab3449d
fix: copilot comments
SaschaOnTour Apr 25, 2026
303aa58
fix: copilot comments
SaschaOnTour Apr 25, 2026
537b913
fix: copilot comments
SaschaOnTour Apr 25, 2026
c41ac51
fix: copilot comments
SaschaOnTour Apr 25, 2026
c1bd0d3
fix: copilot comments
SaschaOnTour Apr 25, 2026
ce1da45
fix: copilot comments
SaschaOnTour Apr 26, 2026
1d77e4e
fix: copilot comments
SaschaOnTour Apr 26, 2026
2352307
fix: copilot comments
SaschaOnTour Apr 26, 2026
ff6ab33
fix: make readme more readable
SaschaOnTour Apr 26, 2026
433eee7
fix: merge conflicts
SaschaOnTour Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,415 changes: 138 additions & 1,277 deletions README.md

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions book/adapter-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Use case: adapter parity (call parity)

If your project has multiple frontends — a CLI, an MCP server, a REST API, a web UI — they're supposed to expose the same underlying capabilities. In theory, every CLI command has a matching MCP handler. In practice, it drifts. Someone adds a new application function, MCP picks it up, CLI forgets, and three months later you discover `cmd_export` exists in one adapter but not the other.

**Call Parity makes adapter symmetry a CI-checkable property, not a code-review hope.** It's rustqual's most opinionated architecture rule, and one I haven't found a direct equivalent for in any other Rust static analyzer.

## What it checks

Configure adapter layers and a shared target. Minimal example with two adapters:

```toml
[architecture.layers]
order = ["domain", "application", "cli", "mcp"]

[architecture.layers.application]
paths = ["src/application/**"]

[architecture.layers.cli]
paths = ["src/cli/**"]

[architecture.layers.mcp]
paths = ["src/mcp/**"]

[architecture.call_parity]
adapters = ["cli", "mcp"]
target = "application"
```

`adapters` can list any number of peer layers — REST endpoints, web handlers, gRPC servers, message-queue consumers — they're treated identically.

Two checks run under one rule:

- **Check A — every adapter must delegate.** Each `pub fn` in an adapter layer must (transitively) reach into the `target` layer. A CLI command that doesn't actually call into the application layer is logic in the wrong place. Caught at build time.
- **Check B — every target capability must reach all adapters.** Each `pub fn` in the `target` layer must be (transitively) reached from *every* adapter layer. Add `app::ingest::run`, forget to wire it into CLI, and Check B reports exactly that — by name, in CI, before review.

`call_depth` (default 3) controls how many hops the transitive walk traces.

## Why this is unusual

Static analyzers traditionally fall into two camps:

- **Style and local linters** (Clippy, ESLint, RuboCop) enforce per-function rules. They don't know your architecture.
- **Architecture linters** (ArchUnit, dependency-cruiser) enforce **containment**: "domain doesn't import adapters", "infrastructure doesn't depend on application". They prove what *can't* be called.

Neither proves what **must** be called — that several adapter modules *collectively cover every public capability* of a target module. That requires building a real call graph across files, resolving method receivers through type aliases, wrappers, re-exports, and `Self`, then reverse-walking from each adapter to see what target functions it actually reaches.

I haven't found another tool — for any language — that does this out of the box. The closest neighbours are general-purpose graph queries on top of CodeQL or Joern, where you write the analysis from scratch every time. If you know of one, I'd genuinely like to hear about it.

## The hard part: an honest call graph

The rule itself is simple. The work is making the call graph honest. Real Rust code looks like:

```rust
let session = Session::open_cwd().map_err(map_err)?;
session.diff(path).map_err(map_err)?;
```

A naive analyzer sees `.diff()` on something it can't name and gives up — that turns into a false-positive "your CLI doesn't reach the application." rustqual ships a shallow type-inference engine that resolves receivers end-to-end:

- Method-chain constructors and stdlib combinator returns (`Result::map_err`, `Option::ok`, `Future::await`, `Result::inspect`, …)
- Field access chains (`ctx.session.diff()`)
- Trait dispatch on `dyn Trait` and `impl Trait` (over-approximated to every workspace impl)
- Type aliases — including chains, wrappers (`Box<Hidden>`), and re-exports
- Renamed imports (`use std::sync::Arc as Shared;`) — with shadow detection so a local `crate::wrap::Arc` doesn't masquerade as stdlib
- `Self` substitution across all resolver paths so impl-internal delegation works

Anything that can't be resolved cleanly stays unresolved — no fabricated edges. **False positives kill architectural rules**; missing an edge is recoverable, inventing one isn't.

## Framework extractors and macro transparency

Web frameworks wrap state in extractor types (`State<T>`, `Data<T>`, `Json<T>`). Without help, the call graph stops at the extractor. Add them as transparent wrappers:

```toml
[architecture.call_parity]
adapters = ["cli", "mcp"]
target = "application"
transparent_wrappers = ["State", "Extension", "Json", "Data"]
transparent_macros = ["tracing::instrument", "async_trait::async_trait"]
```

Now `fn h(State(db): State<Db>) { db.query() }` resolves through the `State<T>` peel and the `Db::query` edge is recorded.

The default `transparent_macros` list already covers the common cases; entries here extend it.

## What you'll see

```
✗ ARCH-CALL-PARITY src/cli/commands/sync.rs::cmd_sync (Check A)
pub fn does not (transitively, depth=3) reach the target layer
'application' — adapter has no delegation path

✗ ARCH-CALL-PARITY src/application/export.rs::run_export (Check B)
target fn is unreached by adapter 'cli'
(reachable from: mcp)
```

The first finding says "this CLI command does logic locally instead of delegating". The second says "you added a new application capability and forgot to expose it via CLI".

## Excluding legitimate asymmetries

Sometimes a target function genuinely shouldn't have an adapter for every frontend — debug endpoints, admin-only tooling, internal setup. Use `exclude_targets`:

```toml
[architecture.call_parity]
adapters = ["cli", "mcp"]
target = "application"
exclude_targets = [
"application::admin::*", # admin tools, not exposed via either adapter
"application::setup::run", # bootstrap, called once at startup
]
```

Globs match against the *module path* (with `crate::` stripped), not the layer name. `application::admin::*` matches every `pub fn` under `src/application/admin/**`.

For ad-hoc per-function suppression:

```rust
// qual:allow(architecture) — internal capability, intentionally MCP-only
pub fn admin_purge() { /* … */ }
```

## Why the false-positive rate matters

False positives don't just waste reviewer time, they *teach the team to ignore the tool*. The whole call-parity approach only works if the false-positive rate stays close to zero — which is why the receiver-type-inference engine refuses to fabricate edges. An honest "I don't know" beats a confident wrong answer when the rule is going to fail builds.

## For teams using AI coding assistants

If you're building Rust with Copilot, Claude, Codex, or similar: this rule guards against one of the more common patterns of architectural drift in AI-assisted codebases. When an agent adds `pub fn export_csv()` to your application layer, it tends to wire it into one frontend and forget the others. Check B catches that on the next `cargo` run — before the PR — without you having to write a custom prompt or review checklist.

Combined with rustqual's other architecture rules (layers, forbidden edges, trait contracts), this gives any LLM agent a *structural* feedback loop that's stricter and more reliable than narrative architectural documentation in a repo's README.

## Try it

```toml
# rustqual.toml
[architecture]
enabled = true
[architecture.layers]
order = ["domain", "application", "cli", "mcp"]
# ... layer paths ...

[architecture.call_parity]
adapters = ["cli", "mcp"]
target = "application"
```

```sh
cargo install rustqual
rustqual . --fail-on-warnings
```

## Related

- [architecture-rules.md](./architecture-rules.md) — the broader architecture dimension (layers, forbidden edges, patterns, trait contracts)
- [ai-coding-workflow.md](./ai-coding-workflow.md) — why call parity especially matters for AI-generated code
- [reference-rules.md](./reference-rules.md) — every rule code with details
- [reference-configuration.md](./reference-configuration.md) — every config option
115 changes: 115 additions & 0 deletions book/ai-coding-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Use case: AI-assisted Rust development

This is what rustqual was originally built for. AI coding agents — Claude Code, Cursor, GitHub Copilot, Codex — are productive but consistently produce a recognisable set of structural smells. rustqual catches them mechanically so the agent can self-correct, without you having to spot every issue in code review.

## What AI agents tend to get wrong

- **God-functions** — functions that mix orchestration with logic ("call helper, then if/else, then call another helper, then …"). Hard to test, hard to read, hard to refactor.
- **Long functions with deep nesting** — agents err on the side of inlining everything they need. Cognitive complexity climbs fast.
- **Copy-paste with minor variation** — when asked to "do the same for X", agents often copy the implementation rather than extracting a shared abstraction.
- **Tests without assertions** — agents generate test bodies that *exercise* code without *checking* it. Coverage looks good, real coverage is zero.
- **Architectural drift** — adding code "wherever it fits" instead of respecting the project's layering. The domain layer slowly imports adapters, infrastructure leaks into application, etc.
- **Asymmetric adapters** — when a project has multiple frontends (CLI, REST, MCP), agents tend to wire new functionality into the one they're touching and forget the others.

rustqual catches all of these. The trick is wiring it into the agent's feedback loop so it self-corrects.

## Pattern 1: agent instruction file

Drop this into `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`, or whichever instruction file your tool reads:

```markdown
## Code Quality Rules

- Run `rustqual` after making changes. All findings must be resolved before marking a task complete.
- Follow IOSP: every function is either an Integration (calls other functions, no own logic) or an Operation (contains logic, no calls to project functions). Never mix both.
- Keep functions under 60 lines and cognitive complexity under 15.
- Don't duplicate logic — extract shared patterns into reusable Operations.
- Don't introduce functions with more than 5 parameters.
- Every test function must contain at least one assertion (`assert!`, `assert_eq!`, etc.).
- For public-API functions that are intentionally untested in this crate, mark with `// qual:api` instead of writing a stub test.
```

The agent gets actionable feedback: rustqual tells it which function violated which principle, so it can self-correct without you having to point each issue out.

## Pattern 2: pre-commit hook

Catch violations before they enter version control — useful when the agent runs locally:

```bash
#!/bin/bash
# .git/hooks/pre-commit
if ! rustqual 2>/dev/null; then
echo "rustqual: quality findings detected. Refactor before committing."
exit 1
fi
```

Make it executable: `chmod +x .git/hooks/pre-commit`.

This gives the agent immediate feedback before anything reaches the remote. If you're using Claude Code with hooks (`PostToolUse`), you can wire `rustqual` into the same loop: every Edit triggers a re-check.

## Pattern 3: CI quality gate

```yaml
# .github/workflows/quality.yml
name: Quality Check
on: [pull_request]

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install rustqual cargo-llvm-cov
- run: cargo llvm-cov --lcov --output-path lcov.info
- run: rustqual --diff HEAD~1 --coverage lcov.info --format github
```

`--format github` produces inline annotations on the PR diff — exactly where the issue is, what rule fired, why it matters. `--diff HEAD~1` restricts analysis to the changed files so PRs stay fast even on large codebases.

## Pattern 4: baseline tracking for AI-velocity codebases

If you have a codebase already at the limit of what you can refactor right now, but you want to make sure new AI-generated code doesn't make it worse:

```bash
# Snapshot the current state
rustqual --save-baseline baseline.json

# In CI: fail only on regression
rustqual --compare baseline.json --fail-on-regression
```

This lets you ratchet quality up over time without blocking PRs that don't make things worse. Combined with `--min-quality-score 90`, you get a hard floor plus a no-regression rule — exactly what you want when an agent is generating dozens of PRs a week.

## Why IOSP specifically

The Integration/Operation distinction is what separates rustqual from a generic linter for AI-coding contexts. AI agents naturally produce mixed-concern functions — they don't have an internal pressure to decompose. IOSP makes that pressure mechanical: the agent writes a god-function, rustqual marks it as a violation, the agent reads the finding, splits the function. Repeat until the loop converges on small, single-purpose functions.

Without that constraint, agents settle into "works but unmaintainable" code that passes tests, passes clippy, and rots over six months. With it, the agent is structurally pushed toward decomposition every time.

## Suppression for legitimate exceptions

Not every violation is a bug. Use `// qual:allow` annotations sparingly:

```rust
// qual:allow(iosp) — match dispatcher; splitting would just rename the match
fn dispatch(cmd: Command) -> Result<()> {
match cmd {
Command::Sync => sync_handler(),
Command::Diff => diff_handler(),
// …
}
}
```

The `max_suppression_ratio` config (default 5%) caps how much code can be suppressed. If the agent suppresses too much, that itself becomes a finding.

Full annotation reference: [reference-suppression.md](./reference-suppression.md).

## Related

- [function-quality.md](./function-quality.md) — what IOSP/complexity actually check
- [test-quality.md](./test-quality.md) — assertion density, coverage, untested functions
- [legacy-adoption.md](./legacy-adoption.md) — applying this to a codebase that's already grown messy
- [ci-integration.md](./ci-integration.md) — full CI patterns
Loading
Loading