Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`--diff <ref>`.** Report only what the working copy adds relative to a git
ref, with no baseline file to keep up to date. The tree at the merge base of
`<ref>` and `HEAD` is checked out into a temporary worktree, analysed with
the same command and thresholds, and its findings become the baseline for
the run; the worktree is removed before the report prints. It is the merge
base rather than the tip of `<ref>` so a fix that landed on `main` after the
branch was cut does not make the branch look like it introduced the finding.
A finding that got worse than it was at the merge base is still reported,
exactly as with `--baseline`, and the two flags combine. The note at the end
of the report now names what hid the findings, and the merge base it used.

### Changed

- **`--baseline` and `--diff` are rejected on `cost` and `why`.** Neither
command reports findings a baseline could hide, so the flag was accepted and
changed nothing. A flag that silently does nothing is worse than one that is
not accepted, which is the rule the other flags already follow.

## [0.2.0] — 2026-08-18

Three verdict bugs and a set of features that make the tool adoptable on a
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,34 @@ reporting a finding that got *worse*, since a cycle that was benign and now
crashes is new information about an old cycle. Suppressed counts are printed,
so a quiet run is never mistaken for a clean one.

### `--diff <ref>` — the same judgement, without a file to maintain

```sh
overpull check --diff main --fail-on hazard
```

A baseline file is something somebody has to regenerate. `--diff` asks git
instead: the tree at the **merge base** of `<ref>` and `HEAD` is checked out
into a temporary worktree, analysed with the same command and thresholds,
and whatever it reports is the baseline for this run. The worktree is removed
before the report prints.

It is the merge base, not the tip of `<ref>`, on purpose. A fix that landed on
`main` after your branch was cut is not something your branch removed, and a
finding `main` gained since is not something your branch added. Comparing
against the point the histories diverged judges the branch on its own
changes. The report names the commit it compared against:

```
1 finding hidden by --diff main (merge base 1a2b3c4) (1 cycle, 0 barrels)
```

`--diff` and `--baseline` can be combined; a finding known to either is
hidden. Both apply to `barrels`, `cycles` and `check` — `cost` and `why` have
no findings to hide, and reject the flags rather than silently ignore them.
`--diff` needs `git` on `PATH`, and an `--entry` that does not exist yet at
the merge base is simply not an entry there.

## CI

```yaml
Expand Down Expand Up @@ -285,6 +313,8 @@ including `scripts/ci.sh`, which runs every check offline on a laptop.
--json Machine-readable output
--sarif SARIF 2.1.0, for code-scanning dashboards
--baseline <file> Suppress findings already recorded in this file
--diff <ref> Suppress findings already present at the merge base
of <ref> and HEAD (needs git)
--fail-on <level> never | crash | hazard | any
--max-modules <n> `cost` budget: exit 1 above this many modules
--max-bytes <n> `cost` budget in bytes; accepts 900kb, 2mb
Expand Down Expand Up @@ -414,8 +444,8 @@ unresolved bare specifier is still counted as an external package, and
unresolved relative imports are reported.

**Why does `check` show fewer findings than I expected?**
If you passed `--baseline`, the run prints how many findings it hid and says
"no *new* import cycles" rather than "clean". If you did not, check the
If you passed `--baseline` or `--diff`, the run prints how many findings it
hid, and what hid them, and says "no *new* import cycles" rather than "clean". If you did not, check the
`simulated from N entry points` line — the verdicts are only as good as the
entry set, and `--entry` overrides it.

Expand Down
11 changes: 6 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ scope. Nothing here is a promise of a date.

Three verdict bugs went with them; see the [changelog](CHANGELOG.md#020--2026-08-18).

## Next
## Shipped — unreleased

- **`--diff <ref>`.** Analyse the merge base of `<ref>` and `HEAD`, analyse
the working tree, report the difference. No file to keep up to date. See
the [changelog](CHANGELOG.md#unreleased).

**`--diff <ref>`.** A baseline is a file someone has to keep up to date.
Comparing against a git ref directly — analyse the merge base, analyse the
working tree, report the difference — removes that step. The baseline
machinery is already in place; this is the ergonomic half.
## Next

**Workspace awareness.** Read npm/pnpm/Yarn workspace declarations and report
per-package, rather than treating a monorepo as one flat tree. Entry-point
Expand Down
2 changes: 1 addition & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Stages:
| `test` | `cargo test --all-targets` |
| `msrv` | the version `rust-version` claims actually builds |
| `fixtures` | Node throws where the fixtures say it throws, and loads where they say it loads |
| `behaviour` | the release binary's exit codes, and that `--json` / `--sarif` parse |
| `behaviour` | the release binary's exit codes, that `--json` / `--sarif` parse, and that `--diff HEAD` hides a committed finding and removes its checkout |
| `dogfood` | the analyser survives a 1,500-module real graph (needs the network) |

`msrv` is skipped rather than failed when the toolchain is not installed; the
Expand Down
11 changes: 11 additions & 0 deletions scripts/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ behaviour_checks() {
printf ' json and sarif documents parse\n'
"$binary" check --root tests/fixtures/barrel-project --json >/dev/null || return 1
"$binary" check --root tests/fixtures/crashing-cycle --sarif --fail-on never >/dev/null || return 1

# The crashing fixture is committed, so against HEAD it is nothing new; the
# checkout --diff makes must be gone again afterwards.
if have git; then
printf ' --diff HEAD hides a committed crash and cleans up after itself\n'
"$binary" cycles --root tests/fixtures/crashing-cycle --diff HEAD >/dev/null || return 1
if [ "$(git worktree list --porcelain | grep -c '^worktree ')" -ne 1 ]; then
echo " expected the --diff worktree to be removed" >&2
return 1
fi
fi
return 0
}

Expand Down
98 changes: 84 additions & 14 deletions src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
//! there so a pull request is judged on what it adds. Findings that got
//! *worse* than the baseline are still reported — a cycle that was benign
//! and now crashes is new information about an old cycle.
//!
//! A baseline comes from one of two places: a JSON file a previous run
//! wrote, or the findings of the same command over the tree `--diff <ref>`
//! names. Both end up here, in the same shape, judged by the same rules.

use std::collections::HashMap;
use std::fs;
Expand All @@ -27,6 +31,45 @@ pub struct Baseline {
const AMPLIFICATION_SLACK: f64 = 0.05;

impl Baseline {
/// A baseline that knows nothing, so every finding is new.
pub fn empty() -> Self {
Self {
cycles: HashMap::new(),
barrels: HashMap::new(),
}
}

/// Builds a baseline from the JSON documents a run would have printed —
/// what `--diff` produces from the comparison tree. Unlike a file, an
/// empty set of findings is a valid answer here: the other tree may
/// simply have been clean.
pub fn from_parts(parts: &[Value]) -> Self {
let mut baseline = Self::empty();
for part in parts {
baseline.absorb(part);
}
baseline
}

/// Combines two baselines so a finding known to either is known to the
/// result. Where both record the same finding, the worse reading is
/// kept: "not worse than what was already known" has to mean the most
/// that was already known.
pub fn merge(&mut self, other: Self) {
for (key, hazard) in other.cycles {
let entry = self.cycles.entry(key).or_insert(hazard);
if hazard > *entry {
*entry = hazard;
}
}
for (file, amplification) in other.barrels {
let entry = self.barrels.entry(file).or_insert(amplification);
if amplification > *entry {
*entry = amplification;
}
}
}

/// Reads a baseline from a document produced by `--json`, in either
/// shape: a single command's output, or the `check` envelope.
pub fn load(path: &Path) -> Result<Self, String> {
Expand All @@ -41,10 +84,7 @@ impl Baseline {
let document: Value = serde_json::from_str(&text)
.map_err(|error| format!("--baseline `{}` is not JSON: {error}", path.display()))?;

let mut baseline = Self {
cycles: HashMap::new(),
barrels: HashMap::new(),
};
let mut baseline = Self::empty();
match document.get("results").and_then(Value::as_array) {
Some(parts) => {
for part in parts {
Expand Down Expand Up @@ -134,12 +174,7 @@ mod tests {
use super::*;

fn baseline_from(json: &str) -> Baseline {
let mut baseline = Baseline {
cycles: HashMap::new(),
barrels: HashMap::new(),
};
baseline.absorb(&serde_json::from_str(json).unwrap());
baseline
Baseline::from_parts(&[serde_json::from_str(json).unwrap()])
}

#[test]
Expand Down Expand Up @@ -170,12 +205,47 @@ mod tests {
assert!(!baseline.covers_barrel("src/other.ts", 1.0));
}

#[test]
fn merging_keeps_the_worse_reading_of_a_shared_finding() {
let members = vec!["a.ts".to_string(), "b.ts".to_string()];
let mut file =
baseline_from(r#"{"cycles":[{"hazard":"benign","members":["a.ts","b.ts"]}]}"#);
let tree = baseline_from(
r#"{"cycles":[{"hazard":"crash","members":["b.ts","a.ts"]}],
"barrels":[{"file":"src/index.ts","amplification":6.0}]}"#,
);
file.merge(tree);
assert!(
file.covers_cycle(&members, Hazard::Crash),
"known to the tree"
);
assert!(
file.covers_barrel("src/index.ts", 6.0),
"known only to the tree"
);
assert!(!file.covers_barrel("src/other.ts", 1.0));

let mut reversed =
baseline_from(r#"{"cycles":[{"hazard":"crash","members":["a.ts","b.ts"]}]}"#);
reversed.merge(baseline_from(
r#"{"cycles":[{"hazard":"benign","members":["a.ts","b.ts"]}]}"#,
));
assert!(
reversed.covers_cycle(&members, Hazard::Crash),
"merge order must not lose the worse reading"
);
}

#[test]
fn an_empty_set_of_parts_is_a_baseline_that_knows_nothing() {
let baseline = Baseline::from_parts(&[]);
assert!(!baseline.covers_cycle(&["a.ts".to_string()], Hazard::Benign));
assert!(!baseline.covers_barrel("src/index.ts", 0.0));
}

#[test]
fn the_check_envelope_is_unwrapped() {
let mut baseline = Baseline {
cycles: HashMap::new(),
barrels: HashMap::new(),
};
let mut baseline = Baseline::empty();
let document: Value = serde_json::from_str(
r#"{"command":"check","results":[
{"barrels":[{"file":"src/index.ts","amplification":5.0}]},
Expand Down
62 changes: 62 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ pub struct Options {
pub max_bytes: Option<u64>,
/// Findings already recorded here are known, and are not reported again.
pub baseline: Option<PathBuf>,
/// A git ref. Findings present at the merge base of it and HEAD are
/// known, and are not reported again.
pub diff: Option<String>,
}

#[derive(PartialEq, Eq, Clone, Copy)]
Expand Down Expand Up @@ -94,6 +97,9 @@ OPTIONS
--baseline <file> Suppress findings already present in this file, so a
first run on a large codebase reports only what is new.
Create it with: overpull check --json > baseline.json
--diff <ref> Suppress findings already present at the merge base of
<ref> and HEAD, so a branch is judged on what it adds.
No file to keep up to date; needs git.
--fail-on <level> never | crash | hazard | any
crash — throws on your own entry order (default for
`cycles` and `check`)
Expand All @@ -116,6 +122,7 @@ EXAMPLES
overpull why src/legacy/config.ts
overpull check --json > overpull.json
overpull check --baseline overpull.json --fail-on hazard
overpull check --diff main --fail-on hazard

EXIT CODES
0 no findings at or above --fail-on, and no budget exceeded
Expand Down Expand Up @@ -185,6 +192,7 @@ impl Options {
max_modules: None,
max_bytes: None,
baseline: None,
diff: None,
}
}
}
Expand Down Expand Up @@ -218,6 +226,15 @@ fn parse_flags<I: Iterator<Item = String>>(
"a file path",
)?));
}
"--diff" => {
let reference = value(&mut args, "--diff", "a git ref")?;
if reference.starts_with('-') {
return Err(format!(
"--diff needs a git ref such as main or HEAD~1 (got `{reference}`)"
));
}
options.diff = Some(reference);
}
"--fail-on" => {
let level = value(&mut args, "--fail-on", "a level")?;
options.fail_on = match level.as_str() {
Expand Down Expand Up @@ -310,6 +327,11 @@ fn validate(options: &Options) -> Option<String> {
{
Some("--max-modules and --max-bytes apply to `overpull cost`".into())
}
// Only barrels and cycles are findings a baseline can hold; on
// `cost` and `why` either flag would be accepted and change nothing.
Command::Cost | Command::Why if options.baseline.is_some() || options.diff.is_some() => {
Some("--baseline and --diff apply to `overpull barrels`, `cycles` and `check`".into())
}
// A flag that silently does nothing is worse than one that is not
// accepted: `cost` and `why` report no findings to gate on.
Command::Cost if options.fail_on != FailOn::Never => Some(
Expand Down Expand Up @@ -457,6 +479,46 @@ mod tests {
);
}

#[test]
fn diff_takes_a_ref_and_rejects_a_flag_in_its_place() {
assert_eq!(
run_options(&["check", "--diff", "main"]).diff.as_deref(),
Some("main")
);
assert!(matches!(
parse_args(&["check", "--diff"]),
ParseOutcome::Error(_)
));
assert!(matches!(
parse_args(&["check", "--diff", "--json"]),
ParseOutcome::Error(_)
));
}

#[test]
fn baselines_belong_to_commands_that_report_findings() {
for flag in [&["--diff", "main"][..], &["--baseline", "b.json"][..]] {
let mut cost = vec!["cost", "a.ts"];
cost.extend_from_slice(flag);
assert!(
matches!(parse_args(&cost), ParseOutcome::Error(_)),
"{flag:?}"
);
let mut why = vec!["why", "a.ts"];
why.extend_from_slice(flag);
assert!(
matches!(parse_args(&why), ParseOutcome::Error(_)),
"{flag:?}"
);
let mut cycles = vec!["cycles"];
cycles.extend_from_slice(flag);
assert!(
matches!(parse_args(&cycles), ParseOutcome::Run(_)),
"{flag:?}"
);
}
}

#[test]
fn json_and_sarif_are_mutually_exclusive() {
assert!(matches!(
Expand Down
Loading
Loading