From 6917f8cb3168d23e6d2d70faa36d15789c3e8b72 Mon Sep 17 00:00:00 2001 From: "Christopher S. Aondona" Date: Fri, 25 Sep 2026 05:01:28 +0100 Subject: [PATCH 1/3] Add inventory CLI skeleton with empty human and JSON report --- Cargo.lock | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 7 ++ src/main.rs | 210 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d2f8d0..7867bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,248 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "buildtrace" version = "0.0.1" +dependencies = [ + "clap", + "serde", + "serde_json", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6fc4220..4c6c075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,11 @@ version = "0.0.1" edition = "2024" license = "MIT OR Apache-2.0" +[[bin]] +name = "cargo-buildtrace" +path = "src/main.rs" + [dependencies] +clap = { version = "4.6.7", features = ["derive"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/src/main.rs b/src/main.rs index 2507542..8598694 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,209 @@ -fn main() { - println!("BuildTrace"); +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Serialize; + +const REPORT_VERSION: u32 = 1; + +#[derive(Debug, Parser)] +#[command( + name = "cargo-buildtrace", + version, + about = "Record what dependency build scripts and procedural macros do during a build" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List build-time executables without executing them + Inventory { + /// Path to the manifest to inspect (defaults to ./Cargo.toml) + #[arg(long, default_value = "Cargo.toml")] + manifest_path: PathBuf, + /// Output format + #[arg(long, value_enum, default_value_t = Format::Human)] + format: Format, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum Format { + Human, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ExecutableKind { + BuildScript, + ProcMacro, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PackageEntry { + name: String, + version: String, + source: String, + checksum: Option, + kind: ExecutableKind, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct Report { + packages: Vec, +} + +#[derive(Debug)] +struct InventoryError(String); + +impl std::fmt::Display for InventoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +fn sort_report(report: &mut Report) { + report + .packages + .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); +} + +/// The single test seam: everything (CLI and tests) goes through here. +/// +/// v0.0.1 skeleton: validates the manifest is readable and returns an empty +/// report. Real package identities arrive with metadata support. +fn inventory(manifest_path: &Path) -> Result { + if !manifest_path.is_file() { + return Err(InventoryError(format!( + "manifest not found: {}", + manifest_path.display() + ))); + } + let mut report = Report::default(); + sort_report(&mut report); + Ok(report) +} + +fn render_human(report: &Report) -> String { + let mut out = String::from("Build-time executables in this dependency graph\n"); + for (title, kind) in [ + ("Build scripts", ExecutableKind::BuildScript), + ("Procedural macros", ExecutableKind::ProcMacro), + ] { + out.push_str(&format!("\n{title}\n")); + let mut any = false; + for entry in report.packages.iter().filter(|e| e.kind == kind) { + any = true; + out.push_str(&format!( + " {} {} {}\n", + entry.name, entry.version, entry.source + )); + } + if !any { + out.push_str(" (none)\n"); + } + } + out +} + +#[derive(Serialize)] +struct JsonReport<'a> { + version: u32, + packages: &'a [PackageEntry], +} + +fn render_json(report: &Report) -> String { + let payload = JsonReport { + version: REPORT_VERSION, + packages: &report.packages, + }; + serde_json::to_string_pretty(&payload).expect("report serializes") +} + +fn run(cli: Cli) -> Result<(), InventoryError> { + let Command::Inventory { + manifest_path, + format, + } = cli.command; + let report = inventory(&manifest_path)?; + match format { + Format::Human => print!("{}", render_human(&report)), + Format::Json => println!("{}", render_json(&report)), + } + Ok(()) +} + +fn main() -> ExitCode { + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn empty_report_renders_both_sections() { + let text = render_human(&Report::default()); + assert!(text.contains("Build scripts")); + assert!(text.contains("Procedural macros")); + } + + #[test] + fn empty_report_json_is_versioned_and_empty() { + let value: Value = + serde_json::from_str(&render_json(&Report::default())).expect("valid JSON"); + assert_eq!(value["version"], Value::from(REPORT_VERSION)); + assert_eq!(value["packages"], Value::Array(vec![])); + } + + #[test] + fn inventory_accepts_readable_manifest() { + let report = inventory(Path::new("Cargo.toml")).expect("repo manifest reads"); + assert!(report.packages.is_empty()); + } + + #[test] + fn inventory_rejects_missing_manifest() { + let err = inventory(Path::new("does-not-exist-Cargo.toml")).expect_err("must fail"); + assert!(err.to_string().contains("manifest not found")); + } + + #[test] + fn report_sorts_by_name_then_version() { + let mut report = Report { + packages: vec![ + entry("b", "2.0.0"), + entry("a", "1.1.0"), + entry("a", "1.0.0"), + ], + }; + sort_report(&mut report); + let names: Vec<(&str, &str)> = report + .packages + .iter() + .map(|e| (e.name.as_str(), e.version.as_str())) + .collect(); + assert_eq!(names, vec![("a", "1.0.0"), ("a", "1.1.0"), ("b", "2.0.0")]); + } + + fn entry(name: &str, version: &str) -> PackageEntry { + PackageEntry { + name: name.to_string(), + version: version.to_string(), + source: "path+test".to_string(), + checksum: None, + kind: ExecutableKind::BuildScript, + } + } } From 5103acc07c51e7fbdee2f6b1e505f048d607a7a5 Mon Sep 17 00:00:00 2001 From: "Christopher S. Aondona" Date: Fri, 25 Sep 2026 05:04:20 +0100 Subject: [PATCH 2/3] Address review: readability check, neutral about text --- src/main.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8598694..1addf95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,12 +77,12 @@ fn sort_report(report: &mut Report) { /// v0.0.1 skeleton: validates the manifest is readable and returns an empty /// report. Real package identities arrive with metadata support. fn inventory(manifest_path: &Path) -> Result { - if !manifest_path.is_file() { - return Err(InventoryError(format!( - "manifest not found: {}", + std::fs::read(manifest_path).map_err(|err| { + InventoryError(format!( + "cannot read manifest {}: {err}", manifest_path.display() - ))); - } + )) + })?; let mut report = Report::default(); sort_report(&mut report); Ok(report) @@ -176,7 +176,7 @@ mod tests { #[test] fn inventory_rejects_missing_manifest() { let err = inventory(Path::new("does-not-exist-Cargo.toml")).expect_err("must fail"); - assert!(err.to_string().contains("manifest not found")); + assert!(err.to_string().contains("cannot read manifest")); } #[test] From bb387b44b76eddcdbf4a15d36319848f2b049cd9 Mon Sep 17 00:00:00 2001 From: "Christopher S. Aondona" Date: Fri, 25 Sep 2026 05:11:59 +0100 Subject: [PATCH 3/3] docs: replace MemoFS agent guidelines with BuildTrace rules Rewrites .agents/rules for the Rust repo (development-commands, git-conventions, testing, code-style, technology-stack) and drops the MemoFS monorepo files. --- .agents/rules/code-style.md | 24 ++++++++++++++ .agents/rules/development-commands.md | 29 +++++++++++++++++ .agents/rules/git-conventions.md | 45 +++++++++++++++++++++++++++ .agents/rules/technology-stack.md | 13 ++++++++ .agents/rules/testing.md | 15 +++++++++ AGENTS.md | 19 +++++++++++ 6 files changed, 145 insertions(+) create mode 100644 .agents/rules/code-style.md create mode 100644 .agents/rules/development-commands.md create mode 100644 .agents/rules/git-conventions.md create mode 100644 .agents/rules/technology-stack.md create mode 100644 .agents/rules/testing.md diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md new file mode 100644 index 0000000..085234d --- /dev/null +++ b/.agents/rules/code-style.md @@ -0,0 +1,24 @@ +## Code Style Rules + +These are enforced by `rustfmt` and Clippy — do not fight them: + +- **Formatting**: `cargo fmt` defaults (4 spaces, 100-column soft width). + Run `cargo fmt --check` before committing. +- **Lints**: `cargo clippy --all-targets -- -D warnings` must be clean. Fix + lints — do not suppress them without a strong, documented reason. +- **File length**: keep source files cohesive and modular (target `<= 500 + LOC` per file); extract helpers or modules when approaching the limit. +- **Module boundaries**: keep them tidy — the single crate splits into + `crates/` when `record` lands, so code that will move together should + already live together. +- **No dead code**: remove it or gate it behind the ticket that needs it. + No commented-out code blocks and no leftover debug output. + +Forbidden patterns: + +- No redundant comments that restate the code +- No `todo!`/`unimplemented!` in merged code unless the tracking ticket is + named next to it +- No `panic!`/`unwrap!`/`expect!` on production paths that handle external + input — return a diagnostic and exit non-zero instead (`expect` is fine + in tests and for truly invariant serialization) diff --git a/.agents/rules/development-commands.md b/.agents/rules/development-commands.md new file mode 100644 index 0000000..fb51736 --- /dev/null +++ b/.agents/rules/development-commands.md @@ -0,0 +1,29 @@ +## Development Commands + +Run all commands from the **repo root** unless otherwise specified. + +```bash +# Check formatting (must pass) +cargo fmt --check + +# Lint with warnings denied (must pass) +cargo clippy --all-targets -- -D warnings + +# Run the full test suite (must pass) +cargo test + +# Run a single test by name +cargo test + +# Build the binary +cargo build + +# Run inventory against the current directory +cargo run -- inventory + +# Run inventory against another manifest +cargo run -- inventory --manifest-path +``` + +The installed binary is named `cargo-buildtrace`, so `cargo buildtrace +inventory` works once it is on `PATH`. diff --git a/.agents/rules/git-conventions.md b/.agents/rules/git-conventions.md new file mode 100644 index 0000000..6971848 --- /dev/null +++ b/.agents/rules/git-conventions.md @@ -0,0 +1,45 @@ +## Git Conventions + +- Branch naming: `feat/`, `fix/`, + `chore/`. When working a tracker ticket, prefix the + ticket number: `feat/06-inventory-cli-skeleton`. +- Work on short-lived branches, one ticket per branch. Merge to `main` when + the gates pass. Tiny doc or chore commits may go direct to `main`. +- Local-only files (`plan.md`, `CONTEXT.md`, `docs/adr/`, `docs/agents/`, + `.scratch/`) are gitignored — never force-add them. +- Never commit secrets, API keys, `.env` files, or `target/` output. + +### Commit Messages + +Commits use the Conventional Commits format unless the user asks otherwise: + +```text +type: subject +type(scope): subject +``` + +Rules: + +- Keep the commit type lowercase: `feat`, `fix`, `chore`, `refactor`, + `docs`, `style`, `test`, `perf`, `ci`, `build`, `revert` +- Write a short subject after the colon, imperative mood, under 100 characters +- Use the body to explain what changed and why when helpful +- Reference the local tracker ticket in the body, e.g. + `.scratch/v0-0-1-minimal-release/issues/06-inventory-cli-skeleton.md` + +Type guide: + +- `feat` - a new feature is introduced with the changes +- `fix` - a bug fix has occurred +- `chore` - changes that do not relate to a fix or feature and do not modify + `src` or test files +- `refactor` - refactored code that neither fixes a bug nor adds a feature +- `docs` - updates to documentation such as the `README` or other markdown + files +- `style` - changes that do not affect the meaning of the code, usually + formatting-related +- `test` - including new tests or correcting previous tests +- `perf` - performance improvements +- `ci` - continuous integration related changes +- `build` - changes that affect the build system or external dependencies +- `revert` - reverts a previous commit diff --git a/.agents/rules/technology-stack.md b/.agents/rules/technology-stack.md new file mode 100644 index 0000000..49d2896 --- /dev/null +++ b/.agents/rules/technology-stack.md @@ -0,0 +1,13 @@ +## Technology Stack + +| Layer | Technology | +|---|---| +| Language | Rust, edition 2024 | +| CLI | `clap` (derive) — binary name `cargo-buildtrace` so `cargo buildtrace` dispatch works | +| Serialization | `serde` + `serde_json` | +| Cargo metadata | `cargo_metadata` (metadata only — never execute dependency code) | +| Formatter | `rustfmt` via `cargo fmt` | +| Linter | Clippy via `cargo clippy -- -D warnings` | +| Testing | `cargo test` (in-module units, `tests/` integration, snapshots) | +| CI | GitHub Actions (`validate.yml`: fmt, clippy, build, test) | +| Platform | Linux-first; `inventory` itself stays portable (no `ptrace` in v0.0.1) | diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md new file mode 100644 index 0000000..dd5e557 --- /dev/null +++ b/.agents/rules/testing.md @@ -0,0 +1,15 @@ +## Testing + +- Unit tests are **mandatory** for every feature. They live in-module under + `#[cfg(test)]` and go through the highest seam possible (the inventory + function over a manifest path, not internals). +- Integration tests live in `tests/` and drive the built binary against + self-contained path-only fixture workspaces: one with a build script, one + with a proc macro, one with neither. +- Snapshot tests pin the human table and JSON report output. +- Required coverage per feature: the no-execution guarantee (running + inventory leaves no build artifacts from dependency code) and determinism + (repeated runs are byte-identical). +- No real network endpoints in any test, ever. +- `cargo test` must pass with no special privileges before a task counts as + done. diff --git a/AGENTS.md b/AGENTS.md index 2ecd7f7..c17bad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,22 @@ Issues are tracked locally as Markdown files under `.scratch/`. See `docs/agents ### Domain docs This is a single-context repository using `CONTEXT.md` and `docs/adr/`. See `docs/agents/domain.md`. + +## Guidelines + +BuildTrace is a Linux-first Cargo build capability recorder and policy-diff tool. + +- **Facts over assumptions**: inspect source code and verify contracts directly. Do not rely on unverified assumptions. +- **Layout**: single `buildtrace` crate now; split into `crates/` when `record` lands. Keep module boundaries tidy so the split stays mechanical. +- **Gates**: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` must pass before a task counts as done. +- **Local-only files**: `plan.md`, `CONTEXT.md`, `docs/adr/`, `docs/agents/`, and `.scratch/` stay untracked. Never commit them. +- **Clean git hygiene**: never commit secrets, API keys, `.env` files, or gitignored build output. + +## Workspace Rules + +- [Development commands](./.agents/rules/development-commands.md) +- [Git conventions](./.agents/rules/git-conventions.md) +- [Testing](./.agents/rules/testing.md) +- [Code style](./.agents/rules/code-style.md) +- [Technology stack](./.agents/rules/technology-stack.md) +