Skip to content
Merged
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## [Unreleased] - 1.0.1

Fixes for problems found by using v1.0.0 on real projects (Windows, Unity). Not released; the crate version is still 1.0.0.

### Fixed
- Windows: `plumb doctor` (and `enrich`, rule-engine detection in `verify --run`) reported every provider as missing because lookup used the bare name (`cargo`), never `cargo.exe` / `tsc.cmd`. Bare names are now tried with the `PATHEXT` extensions that `std::process::Command` can start (`.com`, `.exe`, `.bat`, `.cmd`); Unix lookup is unchanged.
- `plumb init` printed a Codex `config.toml` snippet with a `\\?\C:\...` path inside a TOML basic string, which does not parse. The verbatim prefix is stripped (`\\?\UNC\` becomes `\\`) and the path is written as a TOML literal string, or an escaped basic string when it contains `'` or control characters. Report paths no longer show `\\?\` either.
- `plumb map --budget N` was rejected; `--budget` is now a visible alias of `--tokens`.
- `plumb init` and `.gitignore`: `--dry-run` shows the lines it would add; it writes `.plumbgraph/*` + `!.plumbgraph/allow.toml` (the previous `.plumbgraph/` made the committed allow-list impossible to commit); any existing rule mentioning `.plumbgraph` (including `!.plumbgraph/`) is respected; it works in a subdirectory of a repository; CRLF files keep CRLF; a `.gitignore` that is not UTF-8 (for example UTF-16 from PowerShell 5) is no longer read as empty and overwritten.

### Added
- C# / Unity dead-code entry points: MonoBehaviour/ScriptableObject/NetworkBehaviour/StateMachineBehaviour/UIBehaviour messages (`Awake`, `Start`, `Update`, `OnTriggerEnter2D`, ...), Unity attributes (`[RuntimeInitializeOnLoadMethod]`, `[MenuItem]`, `[InitializeOnLoad]`, ...), and Unity callback interfaces (`ISerializationCallbackReceiver`, `IHasCustomMenu`, UI EventSystem handlers). Public methods of Unity classes stay findings with a 0.25 penalty and a UnityEvent / Animation Event fp_risk. Rules and limits: `docs/PACKS.md`. On Unity's open-project-1 scripts (commit 608eac9) dead-code findings went from 371 to 166 (medium 97 to 28); on Newtonsoft.Json (52fa3ae) they are identical before and after (204).
- `plumb init --no-gitignore`.
- README: Windows install and PATH setup, and exactly what was tested on Windows.

### Changed
- Index schema 3 / extractor 8 (new `framework_risk` column): existing indexes are rebuilt once.

## [1.0.0] - 2026-10-10

### Added
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,36 @@ Nothing is published yet (no crate, no binaries). Build from source with Rust 1.
git clone https://github.com/grloper/plumbgraph && cd plumbgraph
cargo install --path crates/cli --locked # installs `plumb` (and `plumbgraph`)

plumb map . # ranked, token-budgeted overview
plumb map . --tokens 1500 # ranked overview under a hard token budget (--budget is an alias)
plumb dead-code . # add --lib for libraries (exports = public API)
plumb check-deps . --online # sends package names only to the registries
plumb verify . # one pass/fail gate (exit 1 on new findings)
plumb verify . --run --online # also run diagnostics, semgrep, ast-grep (trusted code only)
plumb init # write AGENTS.md block + MCP config for your agent
plumb init --dry-run # preview: AGENTS.md block, MCP config, .gitignore lines
plumb init # write them
```

In a git work tree `plumb init` adds `.plumbgraph/*` and `!.plumbgraph/allow.toml` to `<project>/.gitignore` (the index is a local cache; the allow-list is meant to be committed). An existing `.plumbgraph` entry is left alone, a `.gitignore` that is not UTF-8 is never rewritten, and `--no-gitignore` skips this step.

`--json` on any command gives machine-readable output; `--fail-on high|medium|low` sets the exit code.

### Windows

The same `cargo install --path crates/cli --locked` works from PowerShell; it puts `plumb.exe` and `plumbgraph.exe` in `%USERPROFILE%\.cargo\bin`. The rustup installer adds that directory to `PATH`; other Rust installs (Chocolatey, for example) do not, and `cargo install` then prints `be sure to add ...\.cargo\bin to your PATH`. Add it for your user and open a new terminal:

```powershell
$bin = "$env:USERPROFILE\.cargo\bin"
$user = [Environment]::GetEnvironmentVariable('Path', 'User')
if (($user -split ';') -notcontains $bin) {
[Environment]::SetEnvironmentVariable('Path', ($user.TrimEnd(';') + ';' + $bin), 'User')
}
# new terminal:
plumb --version
plumb doctor .
```

What was tested on Windows, and only this: Windows 11 Home 64-bit (build 26200) with Rust 1.91.1 `x86_64-pc-windows-gnu` from Chocolatey (no rustup, so `rust-toolchain.toml`'s 1.85 was not used, and the MSVC target was not tried). `cargo install --path crates/cli --locked` built and installed both v1.0.0 and the v1.0.1 changes. On one real repository, v1.0.0 ran `index`, `map`, `dead-code`, `check-deps`, `verify`, `weakening`, `doctor` and `plumb mcp` (driven by one MCP host), and the v1.0.1 build ran `doctor`, `map --budget` and `init --dry-run`. `cargo test --workspace` on that machine: every test passes except 10 that also fail on `main` there (LSP/diagnostics tests that need a real `python3`, which was only the Microsoft Store stub, and the closed-stdout-pipe test, which gets Windows error 232 instead of a broken pipe). CI runs on Linux only.

### Use with an agent (MCP)

```bash
Expand Down Expand Up @@ -126,7 +146,7 @@ Use them together: if you need precise navigation and editing today, an LSP-back
- Go, Java, C# are tier-0 only; C# was not benchmarked on a real repository.
- SCIP: rust-analyzer's SCIP output, scip-go, scip-java and scip-dotnet were not run; scip-python and scip-typescript only on toys.
- MCP: stdio only, tools and resources only (no prompts). Tested with the official TypeScript SDK and the MCP inspector CLI; agent hosts were not tested.
- `weakening` is line-based and heuristic. No Windows testing. No releases or packages yet.
- `weakening` is line-based and heuristic. Windows: one machine, manual runs, no CI (see [Windows](#windows)). No releases or packages yet.

## Documentation

Expand Down
9 changes: 7 additions & 2 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ enum Cmd {
#[arg(default_value = ".")]
path: PathBuf,
/// Hard token budget (estimated as chars/4)
#[arg(long, default_value_t = 1500)]
#[arg(long, visible_alias = "budget", default_value_t = 1500)]
tokens: usize,
/// Boost files changed vs this git revision (use `--changed` alone for HEAD)
#[arg(long, num_args = 0..=1, default_missing_value = "HEAD", value_name = "REV")]
Expand Down Expand Up @@ -278,8 +278,12 @@ enum Cmd {
/// claude, cursor, codex or all (default: whatever is detected)
#[arg(long = "agent")]
agents: Vec<String>,
/// Show what would be written (including the .gitignore lines) without writing
#[arg(long)]
dry_run: bool,
/// Do not add `.plumbgraph/*` and `!.plumbgraph/allow.toml` to .gitignore
#[arg(long)]
no_gitignore: bool,
},
/// Serve the tools over MCP (stdio, JSON-RPC)
Mcp {
Expand Down Expand Up @@ -989,8 +993,9 @@ nothing was executed. `plumb enrich` runs SCIP indexers; `plumb verify --run` ru
path,
agents,
dry_run,
no_gitignore,
} => {
let r = plumbgraph_core::init::init(&path, &agents, dry_run)?;
let r = plumbgraph_core::init::init_with(&path, &agents, dry_run, !no_gitignore)?;
if json {
print_json(&r)?;
} else {
Expand Down
67 changes: 67 additions & 0 deletions crates/cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,73 @@ fn map_and_impact_commands() {
assert_eq!(out.status.code(), Some(2));
}

#[test]
fn map_budget_is_an_alias_of_tokens() {
let t = tempfile::tempdir().unwrap();
write(t.path(), "lib.py", "def core():\n return 1\n");
let run = |args: &[&str]| {
plumb()
.arg("map")
.arg(t.path())
.args(args)
.output()
.unwrap()
};
let by_budget = run(&["--budget", "300"]);
assert!(
by_budget.status.success(),
"{}",
String::from_utf8_lossy(&by_budget.stderr)
);
let by_tokens = run(&["--tokens", "300"]);
assert_eq!(by_budget.stdout, by_tokens.stdout, "same flag, same output");
assert!(String::from_utf8_lossy(&by_budget.stdout).contains("budget 300"));
// giving the budget twice under both names is a usage error, not a silent override
assert_eq!(
run(&["--budget", "300", "--tokens", "500"]).status.code(),
Some(2)
);
// the alias is visible in --help
let help = plumb().args(["map", "--help"]).output().unwrap();
let help = String::from_utf8_lossy(&help.stdout);
assert!(
help.contains("--tokens") && help.contains("--budget"),
"{help}"
);
}

#[test]
fn init_dry_run_shows_the_gitignore_lines() {
let t = project();
git(t.path(), &["init", "-q"]);
let out = plumb()
.args(["init"])
.arg(t.path())
.arg("--dry-run")
.output()
.unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("would add to") && text.contains(".plumbgraph/*"),
"{text}"
);
assert!(text.contains("!.plumbgraph/allow.toml"), "{text}");
assert!(
!t.path().join(".gitignore").exists(),
"dry run writes nothing"
);
let out = plumb()
.args(["init"])
.arg(t.path())
.args(["--no-gitignore"])
.output()
.unwrap();
assert!(out.status.success());
assert!(!t.path().join(".gitignore").exists());
assert!(String::from_utf8_lossy(&out.stdout).contains("--no-gitignore"));
}

// ---------------------------------------------------------------- v1: verify, doctor, init

#[test]
Expand Down
11 changes: 8 additions & 3 deletions crates/core/src/deadcode.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
//! Dead-code analysis: reachability from entry points over the tier-0 reference graph,
//! then a confidence score per unreachable symbol.
//!
//! Confidence model (documented in docs/DEAD_CODE.md):
//! Confidence model:
//! `score = clamp((0.70 + bonuses) * (1 - sum(penalties)), 0, 0.99)`
//! bonuses: private symbol +0.10, name occurs nowhere else in the indexed code +0.15.
//! penalties: dynamic-access markers in file 0.20, name appears in a string literal 0.15,
//! decorated/attributed 0.30, exported (app mode) 0.10, imported-by-name elsewhere 0.20,
//! file has syntax errors 0.10.
//! decorated/attributed 0.30, method of a subclass 0.25, framework risk (e.g. a public
//! Unity component method that a UnityEvent may call) 0.25, exported (app mode) 0.10,
//! imported-by-name elsewhere 0.20, file has syntax errors 0.10.
//! Levels: high >= 0.90, medium >= 0.70, low otherwise (hidden by default).

use crate::store::{Graph, SymRow};
Expand Down Expand Up @@ -556,6 +557,10 @@ fn score(
pen += 0.25;
fp.push("method of a class that extends/implements another type: it may override or implement a base/framework method that is called from outside the indexed code".into());
}
if let Some(r) = &s.framework_risk {
pen += 0.25;
fp.push(sanitize(r));
}
if s.exported {
pen += 0.10;
fp.push("exported/public: may be used by consumers outside the indexed code".into());
Expand Down
Loading
Loading