From 5d11d9c9af74a66fb9a512a85cbbb84a26878552 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 21 Aug 2026 16:23:03 +0100 Subject: [PATCH 1/5] Add `ortho_config` user's guide --- docs/ortho-config-users-guide.md | 575 +++++++++++++++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 docs/ortho-config-users-guide.md diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md new file mode 100644 index 00000000..b8250f50 --- /dev/null +++ b/docs/ortho-config-users-guide.md @@ -0,0 +1,575 @@ +# OrthoConfig user's guide + +Configuration should not be the hardest part of writing a command-line +application. OrthoConfig describes settings as a Rust struct, then loads that +struct from defaults, a configuration file, environment variables, and +command-line arguments. + +This guide starts with a small working CLI and grows it one practical task at a +time. Stop as soon as the application has what it needs. + +## Install OrthoConfig + +OrthoConfig needs Serde to turn merged values into the application's +configuration type. Add `clap` when the application defines its own command or +subcommand parser: + + +```toml +[dependencies] +clap = { version = "4.5", features = ["derive"] } +ortho_config = "0.9.0" +serde = { version = "1.0", features = ["derive"] } +``` + +The default features support TOML and the JSON-backed merge machinery used by +the derive. Optional `json5`, `yaml`, and `metrics` features are covered later. + +## Build the first layered CLI + +Start with one struct. The `prefix` is used for environment variables and for +the default file-discovery names. A trailing underscore is conventional and +keeps names such as `ACME_PORT` easy to read. + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, + + #[ortho_config(default = 8080, cli_short = 'p')] + port: u16, + + #[ortho_config(default = String::from("info"))] + log_level: String, +} + +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!( + "host={} port={} log_level={}", + config.host, config.port, config.log_level + ); + Ok(()) +} +``` + +That one definition provides three spellings for each field: + +| Rust field | Command line | Environment | TOML | +| ----------- | ---------------- | ---------------- | ----------- | +| `host` | `--host` | `ACME_HOST` | `host` | +| `port` | `--port` or `-p` | `ACME_PORT` | `port` | +| `log_level` | `--log-level` | `ACME_LOG_LEVEL` | `log_level` | + +_Table 1: Rust fields and their command-line, environment, and TOML names._ + +Values are merged from lowest to highest precedence: + +1. `#[ortho_config(default = ...)]` values; +2. configuration files; +3. environment variables; and +4. command-line arguments. + +This means a checked-in file can provide team defaults, an environment variable +can adapt them for a deployment, and a one-off CLI option can override both. + +## See the configuration surface + +Different sources suit different moments. Start with durable team settings in +`.acme.toml`: + + +```toml +host = "0.0.0.0" +port = 9000 +log_level = "debug" +``` + +At deployment time, an environment variable can change the host without +rewriting the file. For a one-off run, a CLI option can change the port again: + + +```console +$ ACME_HOST=api.internal cargo run -- --port 3000 +host=api.internal port=3000 log_level=debug +``` + +The result shows all three surfaces working together: `log_level` comes from +TOML, `ACME_HOST` supplies `host`, and `--port` wins for `port`. The command +uses POSIX shell syntax; in PowerShell, set `$env:ACME_HOST = "api.internal"` +before running the same Cargo command. + +TOML is available by default. Enable the `yaml` or `json5` crate feature when +those formats are a better fit for application users; the +[file-format section](#enable-another-file-format) covers the details. + +By default, discovery checks an explicit `--config-path`, the +`ACME_CONFIG_PATH` environment variable, project and home dotfiles, and the +platform configuration directory. Explicitly requested files are required: a +missing `--config-path` is an error rather than a silent fallback. + +TOML naturally handles lists and nested values. For example, an application +could add `workers: Vec` and `labels: BTreeMap` to its +configuration struct, then use: + + +```toml +[[workers]] +name = "queue-a" +concurrency = 4 + +[[workers]] +name = "queue-b" +concurrency = 2 + +[labels] +region = "eu-west" +tier = "worker" +``` + +For vectors, `merge_strategy = "append"` appends higher-precedence values; +`merge_strategy = "replace"` replaces the collection. Use +`merge_strategy = "keyed"` for keyed collection merging. Choose the policy +deliberately when operators may combine file, environment, and CLI values. + +Configuration files can also contain `extends` entries. Relative paths are +resolved from the file that declares them, and parent layers are merged before +the child. OrthoConfig reports a missing parent with its absolute path and the +referencing file so the failure is actionable. + +## Make discovery match the application + +Application names do not need to bend around OrthoConfig's defaults. Put the +discovery contract beside the struct when the public flag or filenames are part +of the CLI design: + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config( + prefix = "ACME_", + discovery( + app_name = "acme-server", + config_file_name = "server.toml", + dotfile_name = ".acme-server.toml", + project_file_name = ".acme-server.toml", + config_cli_long = "config", + config_cli_short = 'c', + config_cli_visible = true + ) +)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) +} +``` + +This application accepts `--config` and `-c`, reads `ACME_CONFIG_PATH`, looks +for `.acme-server.toml` in project locations, and uses `server.toml` in +platform configuration directories. Keeping these choices in the derive also +exposes them through `OrthoConfigDocs`. + +### Handle every `load_first` outcome + +`ConfigDiscovery::load_first` distinguishes three outcomes. `Ok(Some(...))` +contains the first successfully parsed candidate. `Ok(None)` means discovery +had no candidates to try. `Err(...)` means candidates existed but none loaded +successfully; surface or map that error rather than treating it as absence: + + +```rust +use ortho_config::{ConfigDiscovery, OrthoResult}; + +fn load_discovered_config(discovery: &ConfigDiscovery) -> OrthoResult<()> { + match discovery.load_first() { + Ok(Some(_config)) => { + // Merge or deserialize the discovered Figment value. + println!("discovery=loaded"); + Ok(()) + } + Ok(None) => { + // Continue with application defaults. + println!("discovery=absent"); + Ok(()) + } + Err(error) => Err(error), + } +} + +fn main() -> OrthoResult<()> { + let discovery = ConfigDiscovery::builder("acme").build(); + load_discovered_config(&discovery) +} +``` + +## Test discovery without changing the process environment + +Tests that mutate environment variables interfere with one another. Build a +`ConfigDiscovery` with `MapEnv` instead. Each test owns its values and can run +in parallel: + + +```rust +use ortho_config::{ConfigDiscovery, MapEnv}; +use std::sync::Arc; + +fn main() { + let environment = Arc::new( + MapEnv::new() + .with_var("ACME_CONFIG", "/srv/acme/server.toml") + .with_var("HOME", "/home/tester"), + ); + + let discovery = ConfigDiscovery::builder("acme") + .env_var("ACME_CONFIG") + .env_source(environment) + .clear_project_roots() + .build(); + + assert_eq!( + discovery.candidates().first().map(|path| path.as_path()), + Some(std::path::Path::new("/srv/acme/server.toml")) + ); + println!("candidate=/srv/acme/server.toml"); +} +``` + +`ProcessEnv` remains the default, so production applications do not need to +change. `EnvSource` deliberately supports lookup by name but not enumeration; +discovery cannot accidentally scan or log unrelated environment values. + +## Give each subcommand its own settings + +Many CLIs have global options plus commands with different configuration. Derive +`OrthoConfig` for each subcommand's argument struct and merge only the +selected command: + + +```rust +use clap::{Parser, Subcommand}; +use ortho_config::{OrthoConfig, OrthoResult, SubcmdConfigMerge}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Parser)] +#[command(name = "acme")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Serve(ServeConfig), +} + +#[derive(Debug, Default, Parser, Deserialize, Serialize, OrthoConfig)] +#[command(name = "serve")] +#[ortho_config(prefix = "ACME_SERVE_")] +struct ServeConfig { + #[arg(long)] + port: Option, +} + +fn main() -> OrthoResult<()> { + match Cli::parse().command { + Command::Serve(cli) => { + let config = cli.load_and_merge()?; + println!("port={:?}", config.port); + } + } + Ok(()) +} +``` + +For an enum with many variants, derive `SelectedSubcommandMerge` and use +`load_globals_and_merge_selected_subcommand`. The generated match keeps the +entry point small. Add `#[ortho_config(cli_default_as_absent)]` to a field when +a `clap` default should not override a value supplied by a file or environment +variable. + +## Handle errors at the application boundary + +Library APIs return `OrthoResult`, whose error is an `Arc`. +Propagate it while loading, then render or map it where the application owns +the user experience: + + +```rust +use ortho_config::{OrthoConfig, OrthoError}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, OrthoConfig)] +struct Config { + port: u16, +} + +fn main() { + match Config::load_from_iter(["acme", "--port", "not-a-number"]) { + Ok(config) => println!("port={}", config.port), + Err(error) => match error.as_ref() { + OrthoError::CliParsing(clap_error) => eprintln!("{clap_error}"), + other => eprintln!("configuration error: {other}"), + }, + } +} +``` + +Preserve `clap`'s display-only exits for `--help` and `--version`; use +`is_display_request` when a wider application error layer needs to distinguish +them. `OrthoError::try_aggregate` combines independent validation failures +without inventing an error for an empty collection. The result extension traits +`OrthoResultExt`, `OrthoMergeExt`, and `ResultIntoFigment` keep conversions +explicit at integration boundaries. + +## Localize help and parse failures together + +Localization is most reliable when the command metadata is translated before +parsing and any resulting error goes through the same localizer. +`LocalizedParse` provides that path for the common case: + + +```rust +use clap::Parser; +use ortho_config::{LocalizedParse, NoOpLocalizer}; + +#[derive(Debug, Parser)] +#[command(name = "acme", bin_name = "acme")] +struct Cli { + #[arg(long)] + verbose: bool, +} + +fn main() -> Result<(), clap::Error> { + let localizer = NoOpLocalizer::new(); + let cli = Cli::try_parse_localized_from( + ["acme", "--verbose"], + &localizer, + )?; + assert!(cli.verbose); + println!("verbose={}", cli.verbose); + Ok(()) +} +``` + +Use `FluentLocalizer` for translated catalogues. Use `LocalizeCmd::with_base` +with `parse_localized_command` when catalogue identifiers must use an explicit +root rather than the binary name. Missing translations fall back to the original +`clap` text and emit a warning event, so users still receive a useful error. + +## Add production diagnostics + +OrthoConfig emits structured `tracing` events for discovery attempts, selected +files, skips, and failures. The library does not install a subscriber; the +binary should do that once during start-up. Add the subscriber with its +environment-filter support: + + +```toml +[dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +``` + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + tracing_subscriber::fmt() + .with_env_filter("ortho_config=debug") + .with_writer(std::io::stderr) + .try_init() + .ok(); + + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) +} +``` + +Do not log configuration values or candidate paths around these events. The +crate's own diagnostics avoid path and value fields because configuration +locations and values may be sensitive. + +Metrics are a low-cost opt-in when the application already has a `metrics` +recorder: + + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["metrics"] } +``` + +The feature emits bounded counters such as discovery attempts, outcomes, and +failures. OrthoConfig never installs a recorder, and enabling the feature does +nothing visible until the application installs one. + +## Generate help from the same metadata + +`#[derive(OrthoConfig)]` also implements `OrthoConfigDocs`. The metadata +records fields, source names, precedence, discovery, defaults, and nested +subcommands. Derive `OrthoConfigSubcommandDocs` on a `clap::Subcommand` enum so +the generated tree includes every variant. + +Inspect the metadata in code: + + +```rust +use ortho_config::{OrthoConfig, OrthoConfigDocs}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + /// Address on which the service listens. + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, +} + +fn main() { + let metadata = Config::get_doc_metadata(); + assert_eq!(metadata.fields.len(), 1); + assert_eq!(metadata.fields[0].name, "host"); + println!("field={}", metadata.fields[0].name); +} +``` + +Or use `cargo-orthohelp` to emit intermediate representation (IR), Unix man +pages, PowerShell help, compact agent context, or all formats: + + +```console +cargo orthohelp --package hello_world --format agent-context +``` + +The tool builds a small bridge against the selected package. Keep the root +configuration type public and ensure its documentation metadata is available +from the selected library or binary target. `--format all` includes agent +context as well as IR, man pages, and PowerShell output. + +## Offer a compact contract to automation + +Agent context complements human help with a small, stable JSON description of +commands, inputs, output modes, interaction, and mutation boundaries. A common +application convention is `context --json`; `cargo-orthohelp` uses +`--format agent-context` for generation. + +The smallest valid context created by `AgentContext::new("acme")` serializes to +this shape: + + +```json +{ + "schema_version": "1", + "kind": "acme.agent_context", + "package": "acme", + "commands": [], + "profiles": { "supported": false }, + "feedback": { "supported": false }, + "policy": { "agent_native": "warn" }, + "skill_manifests": [] +} +``` + +Fill `AgentCommand` entries only with claims the executable honours. +`SkillManifest` and `SkillCommandRef` link skills to real commands. They do not +replace command validation or grant an agent capabilities that the CLI does not +have. + +## Use an aliased dependency + +Cargo permits dependency aliases. In v0.9.0 the derive macros can generate +paths through that alias, which is useful in workspaces that reserve the +canonical crate name: + + +```toml +[dependencies] +config_layer = { package = "ortho_config", version = "0.9.0" } +serde = { version = "1.0", features = ["derive"] } +``` + +Name the alias on every type that derives an OrthoConfig macro: + + +```rust +use config_layer::{OrthoConfig, OrthoResult}; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +#[ortho_config(crate = "config_layer", prefix = "ACME_")] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + let config = Config::load_from_iter(["acme"])?; + assert_eq!(config.port, 8080); + println!("port={}", config.port); + Ok(()) +} +``` + +The same attribute is supported by `SelectedSubcommandMerge`. OrthoConfig +re-exports the dependencies used by generated code, so a derive-only consumer +does not need direct `figment`, `uncased`, `xdg`, or format-parser dependencies. + +## Enable another file format + +TOML is enabled by default. Enable `json5` or `yaml` when users already work in +that format. YAML uses YAML 1.2 semantics in v0.9.0: legacy words such as `yes` +and `on` remain strings, and duplicate mapping keys are rejected. + + +```yaml +enabled: yes +mode: on +port: 8080 +``` + +Enable YAML with `features = ["yaml"]`; this also requires the `serde_json` +feature, which is part of the default feature set. If defaults are disabled, +enable both explicitly. Treat a change from v0.8.0 YAML parsing as a data +migration and run representative production files through v0.9.0 before +deploying. + +## A practical path from here + +For a new CLI, begin with the first layered struct and add only the required +sections. A typical progression is: + +1. choose stable CLI and environment names; +2. add a project file for durable settings; +3. customize discovery if the defaults are not part of the public interface; +4. split independent commands into subcommand configurations; +5. localize help and initialize tracing at the application boundary; and +6. generate human and agent documentation once the command surface stabilizes. + +The [Hello World application](../examples/hello_world/) demonstrates these +pieces in a larger layout. The +[v0.9.0 migration guide](v0-9-0-migration-guide.md) explains compatibility +changes for existing v0.8.0 users, and the +[API documentation](https://docs.rs/ortho_config) is the source for complete +type and method signatures. From 0a2b36eb2bebd538bc326abdafc127686e469bbe Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 21 Aug 2026 17:55:25 +0200 Subject: [PATCH 2/5] Add execplan for root whitaker binary (3.5.1) Draft the execution plan for roadmap item 3.5.1: add a real `whitaker` binary at the root package and move the current installer orchestration behind an internal library boundary. The plan places the CLI domain, ports, and adapters in a new `crates/whitaker_cli` crate rather than in the root package, because the root package's library requires `feature(rustc_private)` and is excluded from `make test` by `TEST_EXCLUDES`. The root `src/main.rs` becomes a thin composition root. Scope is limited to `install` and `ls`; `check` and `doctor`, the configuration model, and the deprecation shim remain with their own roadmap items. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/3-5-1-root-whitaker-binary.md | 1332 ++++++++++++++++++ 1 file changed, 1332 insertions(+) create mode 100644 docs/execplans/3-5-1-root-whitaker-binary.md diff --git a/docs/execplans/3-5-1-root-whitaker-binary.md b/docs/execplans/3-5-1-root-whitaker-binary.md new file mode 100644 index 00000000..02a4b5aa --- /dev/null +++ b/docs/execplans/3-5-1-root-whitaker-binary.md @@ -0,0 +1,1332 @@ +# Add a root `whitaker` binary behind an internal library boundary + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, +`Decision log`, `Outcomes & retrospective`, `Conformance basis`, and +`Verification plan` must be kept up to date as work proceeds. + +Status: DRAFT + +## Purpose / big picture + +Today a user who wants Whitaker installs and runs a binary called +`whitaker-installer`, and reaches the lint inventory through a generated +wrapper script called `whitaker-ls`. The product is named Whitaker but there +is no program called `whitaker`. + +After this change there is. A user runs: + +```console +whitaker --help +whitaker install +whitaker ls +whitaker ls --json +``` + +and gets exactly the behaviour `whitaker-installer` and `whitaker-installer +list` give today, from a real Rust binary named `whitaker`, installable with +`cargo install whitaker` or `cargo binstall whitaker`. + +That is the entire user-visible outcome. It is deliberately narrow. The +commands `whitaker check` and `whitaker doctor` described in the CLI design +document are **not** part of this plan; they are separate roadmap items that +depend on this one. This plan builds the foundation they land on: a real +root binary, and an internal library boundary that separates decision-making +policy from the input/output work that carries it out. + +The second half of the outcome is invisible to users but is the reason the +work is worth doing. The installer's orchestration logic currently lives +inside a binary target (`installer/src/main.rs`, plus two binary-private +modules) where no other program can reach it and no integration test can call +it directly. This plan moves that orchestration into a library crate with +explicit ports, so that the four subcommands still to come can be built by +composing that library rather than by copying the binary. + +## Definitions + +Terms used throughout, defined here so no prior knowledge is assumed. + +**Dylint.** A tool that runs custom Rust lints compiled as dynamic libraries. +Whitaker's lints are Dylint lints. `cargo-dylint` and `dylint-link` are the +two helper binaries Dylint needs. + +**Lint bundle / staged library.** A compiled Dylint lint library copied into a +known directory with a filename encoding the toolchain it was built for. +"Staging" is the act of copying it there. + +**Prebuilt artefact.** A `.tar.zst` archive of already-compiled lint libraries +published on GitHub Releases, so users do not have to compile lints locally. + +**`cargo-binstall`.** A tool that installs a Rust binary by downloading a +prebuilt release archive instead of compiling. It reads a +`[package.metadata.binstall]` table from `Cargo.toml` to learn the archive URL +pattern. + +**Port (hexagonal architecture).** A Rust trait, owned by the domain layer, +describing something the domain needs from the outside world (for example +"install the Dylint tools") without saying how it is done. + +**Adapter.** A concrete implementation of a port that does the real work, for +example by spawning a process or writing a file. + +**Composition root.** The single place — here, `src/main.rs` — where concrete +adapters are constructed and handed to the domain. Nothing else in the program +chooses implementations. + +**Driving vs driven.** A _driving_ adapter calls into the domain (the CLI +parser). A _driven_ adapter is called by the domain (the installer). + +**ExecPlan plateau.** A milestone that leaves the repository correct, +coherent, and safe to stop at. + +## Context and orientation + +You have only this repository and this document. Here is what exists. + +### The workspace + +The Cargo workspace root is the repository root. `Cargo.toml` line 2 declares +members `["common", "crates/*", "installer", "suite"]`. The root directory is +itself a package: + +```toml +[package] +name = "whitaker" +version = "0.2.7" +edition = "2024" +``` + +That root package **has no binary today**. It has only a library, +`src/lib.rs`, which is the shared support library for Whitaker's Dylint lint +crates. Its first two lines matter a great deal to this plan: + +```rust +//! Core Whitaker library surfaces shared configuration and helpers for lint crates. +#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] +``` + +Under the `dylint-driver` feature this library links `rustc_driver` and the +private compiler crates. A comment in that file records the consequence: + +```rust +// Unit tests of this crate should not pull the compiler driver to avoid the +// duplicated `std`/`core` link errors seen during all-features test runs. +``` + +This is why `Makefile` line 24 excludes the root package from the test run: +`TEST_EXCLUDES` contains `--exclude whitaker`. Read that line before starting +work; it is the single most important constraint on where new code may live. + +### The installer + +`installer/` is the package `whitaker-installer`. It already has a library +(`installer/src/lib.rs`, 84 lines) exposing about 25 public modules, and four +binaries declared with `autobins = false` in `installer/Cargo.toml` lines +11-27. The one users install is `whitaker-installer`, built from +`installer/src/main.rs`. The other three (`whitaker-package-lints`, +`whitaker-package-installer`, `whitaker-package-dependency-binary`) are +release-packaging utilities and are out of scope here. + +`installer/src/main.rs` is 402 lines. It parses `whitaker_installer::cli::Cli` +with clap and dispatches: + +```rust +fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { + match &cli.command { + Some(Command::List(args)) => run_list(args, stdout), + Some(Command::Install(args)) => run_install(args, stderr), + None => run_install(cli.install_args(), stderr), + } +} +``` + +The important fact is what else is in that file and in two modules declared +only by it (`mod install_flow;` and `mod staged_suite;`, lines 7-8). These are +**binary-private**: they are not part of the `whitaker_installer` library and +no other crate can call them. They contain real orchestration: + +- `run_install`, `run_dry`, `try_fast_path_installation`, `finish_install`, + `finish_install_and_record_metrics`, `resolve_requested_crates`, + `generate_and_report_wrapper`, `ensure_whitaker_workspace`, + `resolve_toolchain`, `ensure_toolchain_installed`, `exit_code_for_run_result` + (all in `installer/src/main.rs`); +- `install_flow::try_prebuilt_installation`, `install_flow::detect_host_target` + and the `PrebuiltInstallationHooks` struct + (`installer/src/install_flow/mod.rs`); +- `staged_suite::try_test_staged_suite_installation` + (`installer/src/staged_suite.rs`, a debug-only test hook). + +Moving those behind a library boundary is the "internal library boundary" half +of this task. + +### Existing seams + +The installer is not a monolith. It already has dependency-injection seams, +but they are inconsistent — three different styles for the same concern: + +| Trait / seam | Defined at | Style | +| --- | --- | --- | +| `deps::CommandExecutor` | `installer/src/deps/mod.rs:36` | public trait object | +| `toolchain::CommandRunner` | `installer/src/toolchain/mod.rs:44` | **private** trait object, same shape | +| `dirs::BaseDirs` | `installer/src/dirs.rs:41` | public trait object, `mockall` | +| `builder::CrateBuilder` | `installer/src/builder.rs:58` | public trait object, `mockall` | +| `artefact::download::ArtefactDownloader` | `installer/src/artefact/download.rs:29` | public trait object, `mockall` | +| `install_flow::PrebuiltInstallationHooks` | `installer/src/install_flow/mod.rs:135` | **bare `fn` pointers** | + +_Table 1: Existing dependency-injection seams in the installer._ + +And three places spawn processes with no seam at all: +`installer/src/git.rs` (`Command::new("git")`), +`installer/src/builder.rs:83` (`Command::new("cargo")` inside +`Builder::build_crate`), and `install_flow::detect_host_target` +(`Command::new("rustc")`). + +This plan does **not** unify all of those. Doing so would be a large, +independently valuable refactor with its own risk profile. This plan defines +the ports the new CLI needs and implements them over the installer library as +it stands, leaving the installer's internal seam inconsistency for a later +item. That choice is recorded in `Decision log`. + +### Where tests live + +- Unit tests: colocated, either `#[cfg(test)] mod tests` inline or a sibling + `_tests.rs` file declared with `#[cfg(test)] mod foo_tests;`. +- Behavioural tests: `/tests/behaviour_*.rs` integration binaries, + paired with Gherkin files in `/tests/features/*.feature`, bound with + `#[scenario(path = "...", index = N)]`. **The bindings are index-based; + reordering scenarios in a feature file silently rebinds them.** See the + warning comment at `installer/tests/behaviour_cli/scenarios.rs:7`. +- Shared behavioural state uses a "World" struct fixture, for example + `CliWorld` in `installer/tests/behaviour_cli/support.rs`. +- End-to-end CLI tests spawn the binary via the Cargo-provided + `env!("CARGO_BIN_EXE_")`. There is no `assert_cmd` in this + workspace. +- Snapshots live in a `snapshots/` directory beside the test that writes them, + with `insta`'s default `____.snap` naming. Two exist + today, for example + `crates/whitaker_clones_core/src/ast/snapshots/whitaker_clones_core__ast__lowering__tests__ast_feature_vector_add_function.snap`. +- Kani harnesses are `#[cfg(kani)]` submodules beside the code, run by + `scripts/run-kani.sh` via `make kani`. +- Verus proofs are standalone files in `verus/` at the repository root, run by + `scripts/run-verus.sh` via `make verus`. They are _models_ of the + implementation, not proofs of the literal Rust source; the trust boundary is + documented in `docs/developers-guide.md`. + +### The gates + +Run from the repository root. All four must pass before any commit. + +```console +$ make check-fmt # cargo fmt --all -- --check +$ make typecheck # cargo check --workspace --all-targets --all-features +$ make lint # cargo doc, cargo clippy -D warnings, and the Whitaker suite +$ make test # cargo nextest run over the workspace minus TEST_EXCLUDES, + # then cargo test --workspace --doc --all-features +``` + +Capture output for review, because long output is truncated in agent +transcripts: + +```console +make test 2>&1 | tee /tmp/test-whitaker-3-5-1-root-whitaker-binary.out +``` + +Markdown changes additionally need `make markdownlint`. Do **not** run +`make fmt` for a targeted documentation edit: it runs `mdformat-all` and +reflows every Markdown file in the repository. + +## Conformance basis + +There is no Terms of Reference document in this repository. The upstream +artefacts are: + +- **Design:** `docs/whitaker-cli-design.md`, at the revision present in the + working tree, specifically §Public CLI surface and §Compatibility and + migration. Referred to below as `CLI-DESIGN`. +- **Roadmap:** `docs/roadmap.md` item 3.5.1 (line 145). Its stated + prerequisite, item 3.2.1, is marked done (line 108). +- **ADRs:** `docs/adr-001-prebuilt-dylint-libraries.md` constrains the + prebuilt-artefact path this plan must not disturb. No existing ADR covers + the CLI boundary; this plan creates one (see `EP-M4`). +- **Standards:** `AGENTS.md`, `docs/documentation-style-guide.md`, + `docs/scripting-standards.md`. + +Requirement identifiers used in this plan, each quoting or paraphrasing +`CLI-DESIGN`: + +| ID | Statement | Source | +| --- | --- | --- | +| `CLI-REQ-BIN` | "Add a real `whitaker` binary at the root package" | `CLI-DESIGN` §Compatibility and migration, step 1 | +| `CLI-REQ-LIB` | "move the current installer logic behind an internal library boundary" | `CLI-DESIGN` §Compatibility and migration, step 1 | +| `CLI-REQ-BINSTALL` | "copy the working `cargo-binstall` metadata pattern from `whitaker-installer` onto `whitaker`" | `CLI-DESIGN` §Compatibility and migration, step 1 | +| `CLI-REQ-LS` | "`whitaker-ls` disappears in favour of `whitaker ls`"; `ls` "must support `--json`" | `CLI-DESIGN` §Public CLI surface, §Bundle manifests | +| `CLI-REQ-L10N` | "Every human-facing string, including `--help` … should be localizable"; command names and rule codes "are never translated" | `CLI-DESIGN` §Accessibility and localization requirements | +| `CLI-REQ-EXIT` | "install and configuration failures should produce distinct operational errors" | `CLI-DESIGN` §`whitaker check` | +| `CLI-REQ-SHIM` | `whitaker-installer` "survives for one compatibility release as a thin shim" | `CLI-DESIGN` §Public CLI surface | + +_Table 2: Upstream requirements traced by this plan._ + +Trace chain: + +```plaintext +CLI-REQ-BIN -> EP-M2 -> tests::e2e::whitaker_help_lists_install_and_ls +CLI-REQ-LIB -> EP-M1 -> whitaker_cli::domain unit suite + EP-INV-PARITY +CLI-REQ-BINSTALL -> EP-M3 -> tests::behaviour_binstall::whitaker_package_metadata + -> EP-LEM-NAME (verus/whitaker_artefact_naming.rs) +CLI-REQ-LS -> EP-M2 -> tests::snapshot::ls_json_and_text +CLI-REQ-L10N -> EP-M2 -> tests::e2e::help_parses_through_localizer +CLI-REQ-EXIT -> EP-M1 -> EP-INV-EXIT (kani + rstest) +CLI-REQ-SHIM -> deferred to roadmap 3.9.1, not this plan +``` + +Requirements explicitly **not** discharged here, with their owning roadmap +item: `whitaker check` (3.5.2); release artefacts and CI packaging (3.5.3); +rule codes and selector precedence (3.6.1, 3.6.2); `whitaker.toml`, +`dylint.toml` bridging and `DYLINT_*` migration (3.6.3); `--locale`/`--colour` +/`--progress` (3.6.4); unified install internals (3.7.x); `doctor`, failure +recording, bundle manifests (3.8.x); the `whitaker-installer` deprecation shim +and `list` alias (3.9.1). + +## Constraints + +Hard invariants. Violation requires escalation, not a workaround. + +1. **`whitaker-installer` keeps working unchanged.** Its command-line surface, + exit codes, and output must be byte-identical before and after. It is a + released binary documented in `docs/users-guide.md`, published to GitHub + Releases, installed by `make install-smoke`, and exercised by + `installer/tests/behaviour_cli.rs`. `CLI-DESIGN` schedules its deprecation + for a later release (`CLI-REQ-SHIM`, roadmap 3.9.1), not this one. This is + not a compatibility shim invented to make a milestone viable: it is the + currently shipping product, and this plan adds a second entry point beside + it rather than replacing it. +2. **The public library surface of `whitaker_installer` must not shrink.** The + integration tests under `installer/tests/` compile against it as an + external crate and can only see `pub` items. `installer/Cargo.toml` lines + 29-55 gate `StubExecutor` and `InstallerError::StubMismatch` behind the + `test-support` feature, which roadmap item 3.2.2 declares a supported + surface for external test suites. Items may be added; existing ones may not + be removed or narrowed. +3. **The root package must remain named `whitaker` at version `0.2.7`**, and + the new binary must be named `whitaker`, so that `cargo install whitaker` + and `cargo binstall whitaker` resolve correctly. +4. **No new lint suppressions.** `Cargo.toml` `[workspace.lints]` sets + `unsafe_code = "forbid"`, `missing_docs = "deny"`, `allow_attributes = + "deny"` and clippy `pedantic` at warn with `-D warnings`. Adding + `#[allow(...)]` to get past a gate is a tolerance breach. +5. **No file may exceed 400 lines** (`AGENTS.md`). Every module needs a `//!` + doc comment. +6. **Direct environment mutation in tests is forbidden** (`AGENTS.md`). Use + `temp-env`, or dependency injection through a port. +7. **The prebuilt-artefact download path must not change behaviour.** It is + governed by `docs/adr-001-prebuilt-dylint-libraries.md` and by the release + workflow's published asset names. +8. **Caret dependency requirements only** (`AGENTS.md`); no `*` or `>=`. + +## Tolerances (exception triggers) + +Stop and escalate — do not improvise — when any of these is reached. + +- **Scope.** More than 45 files changed, or more than 2,500 net added lines + across the whole plan. The estimate is roughly 30 files and 1,800 lines. +- **Root-binary feasibility.** If `EP-M0` shows a binary in the root package + cannot build under `--all-features`, stop at the end of `EP-M0` and + escalate with the options in `Risk R1`. Do not silently relocate the binary + to a differently-named package: that would break `cargo install whitaker` + and violate Constraint 3. +- **Interface.** If discharging `CLI-REQ-LIB` requires removing or narrowing + any existing `pub` item in `whitaker_installer`, stop (Constraint 2). +- **Dependencies.** Four new dependencies are pre-authorized and listed in + `Interfaces and dependencies`: `ortho_config`, `googletest`, + `pretty_assertions`, and `insta` promoted to a root dev-dependency. Any + fifth new external dependency triggers escalation. +- **Iterations.** If a gate still fails after three fix attempts on the same + root cause, stop and escalate with the captured log path. +- **Verification.** If a Kani harness exceeds 15 minutes, or a Verus proof + exceeds 5 minutes, stop and escalate rather than raising the bound or the + timeout. +- **Behaviour drift.** If any existing `installer/tests/` test needs its + assertions changed (as opposed to being moved or added to), stop: that is + evidence of a Constraint 1 violation. +- **Ambiguity.** If `CLI-DESIGN` and `docs/roadmap.md` disagree on whether a + behaviour belongs to 3.5.1, stop and present both readings. + +## Risks + +**R1 — A binary in the root package may not build under `--all-features`.** +Severity: high. Likelihood: medium-high. +The root library sets `feature(rustc_private)` and links `rustc_driver` when +`dylint-driver` is enabled. `make typecheck`, `make lint`, and `make test` all +pass `--all-features`, which enables it. `src/lib.rs` records that all-features +test runs produce "duplicated `std`/`core` link errors", and `Makefile` line 24 +excludes the package from the test run for that reason. A binary target in the +same package may inherit the same failure. +Mitigation: `EP-M0` is a timeboxed prototyping milestone that answers this +empirically before any design is committed. If it fails, the recommended +remedy is to extract the Dylint driver library out of the root package into +`crates/whitaker_lint_core`, leaving the root package as the CLI package — +which permanently removes the conflict — but that is a scope increase +requiring approval, not an autonomous decision. + +**R2 — Index-based BDD scenario bindings break silently.** +Severity: medium. Likelihood: medium. +`#[scenario(path = "...", index = N)]` binds by position. Inserting a scenario +in the middle of an existing `.feature` file rebinds every later scenario to +the wrong step definitions, and the suite may still pass. +Mitigation: put all new scenarios in **new** feature files +(`crates/whitaker_cli/tests/features/*.feature`); never insert into +`installer/tests/features/installer.feature`. `EP-M2` acceptance includes +re-running `installer/tests/behaviour_cli.rs` unchanged. + +**R3 — Two packages publishing artefacts with one URL template may collide.** +Severity: high. Likelihood: low-medium. +`installer/Cargo.toml` lines 95-101 template the release URL as +`{name}-{target}-v{version}.{archive-format}`. Giving the `whitaker` package +the same pattern means two packages generate asset names from the same +template. Because `whitaker` is a proper prefix of `whitaker-installer`, and +target triples themselves contain `-`, an ambiguous split is conceivable. +Mitigation: `EP-LEM-NAME`, a Verus proof that the composed name determines its +fields uniquely, plus a `proptest` differential check. See `Verification +plan`. + +**R4 — `--help` and `--version` may exit non-zero.** +Severity: medium. Likelihood: medium. +clap reports `--help` as an `Err` variant. Routing every `Err` to exit code 1 +would make `whitaker --help` fail. `ortho_config::is_display_request` exists +precisely to distinguish this case, and it is easy to omit. +Mitigation: `EP-INV-EXIT` covers it with both a parameterized test and an +end-to-end assertion on the real process exit status. + +**R5 — Promoting binary-private modules widens the public API.** +Severity: medium. Likelihood: medium. +`install_flow` and `staged_suite` are binary-private today. Making them +reachable from a new crate could expose test-only machinery — `staged_suite` +in particular is a debug-only hook driven by the +`WHITAKER_INSTALLER_TEST_STAGE_SUITE` environment variable. +Mitigation: promote to `pub` only what the new ports need; keep +`staged_suite`'s hook behind the existing `#[cfg(debug_assertions)]` and +`test_support` gating; record the resulting surface in the `EP-M4` ADR. + +**R6 — `ortho_config` pulls a large dependency subtree.** +Severity: low. Likelihood: high (it is certain; the question is whether it +matters). `ortho_config` 0.9.0 depends on `figment`, `fluent-bundle`, +`fluent-syntax`, `unic-langid`, `clap-dispatch`, `directories`, `xdg`, and +more. +Mitigation: adopt it in this plan for localized parsing only, so the cost is +paid once at the point the roadmap already commits to it (item 3.6.3), not +twice. Confirm `make typecheck` build time does not regress by more than 30%; +report if it does. + +**R7 — Snapshot tests of `--help` are brittle across clap versions.** +Severity: low. Likelihood: medium. +`insta` snapshots of help text change whenever clap adjusts its formatting. +Mitigation: snapshot the _structure_ — the subcommand list and the option +names — rather than full rendered help; assert full text only for the +stable `ls --json` output, which is a machine contract. + +## Verification plan + +This change is mostly a refactor plus a new entry point, so it would be easy +to claim it introduces no invariants. That is not true, and saying so would be +the vacuous option. Three genuine obligations arise, and one lemma. + +### Axioms (assumed, not verified here) + +- `clap` 4.5 parses an argument vector into the derived struct according to + its documented derive semantics. Third-party internals are not verified. +- `ortho_config` 0.9.0's `LocalizedParse::try_parse_localized_from` and + `is_display_request` behave as documented. Repository-owned logic built on + them **is** verified, against the real interface. +- `cargo-binstall` resolves `pkg-url` by substituting `{name}`, `{target}`, + `{version}`, and `{archive-format}` literally. +- The GitHub release workflow publishes assets under exactly the names + produced by `installer/src/artefact/naming.rs`. +- Kani sequentializes concurrency; no obligation below concerns concurrency. + +### EP-INV-PARITY — install-argument parity + +- **Obligation.** For every argument vector `v` that `whitaker-installer` + accepts as an install invocation, `whitaker install v` parses to an + `InstallRequest` equal to the one `whitaker-installer` produces from `v`; + and for every `v` that `whitaker-installer` rejects, `whitaker install v` is + rejected too. +- **Method.** Property test (`proptest`), differential. +- **Rationale.** This is the precise formal content of "move the current + installer behaviour behind a library boundary _without changing it_". The + flag surface has 14 options with two documented conflict pairs; enumerating + it by hand would miss combinations, and the space is far too large for + bounded model checking over strings. +- **Domain.** Generated argument vectors over the 14 flags declared in + `installer/src/cli.rs:77-181`, including repeated `--lint`, repeated `-v`, + the `--lint` / `--individual-lints` conflict, the `-v` / `-q` conflict, + paths containing spaces and non-ASCII characters, and empty values. +- **Artefact.** `crates/whitaker_cli/tests/property_arg_parity.rs`. +- **Evidence.** `cargo nextest run -p whitaker_cli property_arg_parity`. Red + stage: written before the mapping exists, so it fails to compile, then fails + on a deliberately incomplete mapping that drops `--jobs`. Discharged when it + passes with 1,024 generated cases and the regression file is committed. +- **Non-vacuity.** The generator must be _classified_: record via + `proptest::prop_assume!`-free construction and explicit + `Strategy::prop_map` that each of the 14 flags appears set in at least 5% of + cases, that both conflict pairs are generated, and that at least one case + has zero flags. A run where any flag is never exercised is a **failure**, + not a pass. Negative control: temporarily drop `--no-update` from the + `whitaker install` mapping; the test must fail naming that flag. Restore + afterwards and record the transcript. + +### EP-INV-ROUTE — routing totality and conflict rejection + +- **Obligation.** The function mapping a parsed CLI to a domain `Request` is + total (never panics, never returns a "cannot happen" error) over all + reachable flag combinations, and rejects exactly the two documented conflict + pairs. +- **Method.** Bounded model check (Kani), complemented by parameterized + `rstest` cases. +- **Rationale.** Totality over a combinatorial flag space is exactly what + bounded exhaustive exploration is for, and the space is small enough to + explore completely once flags are modelled as booleans rather than strings. + A property test would sample it; Kani covers it. +- **Domain.** The boolean flags modelled as a bitmask, plus a bounded + `Option` for `--jobs` and a bounded 0-3 count for `-v`. Ten booleans + gives 1,024 states; with the two bounded integers the harness explores under + 10^5 states, well inside Kani's practical range for non-heap types. + `#[kani::unwind(4)]` bounds the single loop over requested lints, capped at + three entries. +- **Artefact.** `crates/whitaker_cli/src/domain/routing/kani.rs`, gated + `#[cfg(kani)]`, registered in `scripts/run-kani.sh` alongside the existing + named harnesses. +- **Evidence.** `make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1.out`. Expect + `VERIFICATION:- SUCCESSFUL` for + `verify_route_request_is_total_over_bounded_flags` and + `verify_route_request_rejects_documented_conflicts`. +- **Non-vacuity.** The harness must drive the **production** routing function, + not a re-implementation. Assumptions must not collapse the space: assert + before the main property that at least one satisfying assignment reaches + each of the three routing outcomes (install, list, conflict-rejected) by + running three separate `#[kani::proof]` reachability harnesses that assert + `false` under a constraint selecting that outcome, and confirming each + reports a counterexample — proving the branch is reachable. Negative + control: remove the `--lint` / `--individual-lints` conflict check from the + production function; `verify_route_request_rejects_documented_conflicts` + must fail with a concrete counterexample. Restore and record. + +### EP-INV-EXIT — exit-code policy + +- **Obligation.** The process exit code is `0` for success and for a clap + display request (`--help`, `--version`); `1` for an operational failure. No + input produces any other code, and no display request produces a non-zero + code. +- **Method.** Parameterized tests (`rstest` with `googletest` matchers) over + the finite partition of outcome kinds, plus an end-to-end assertion on the + real spawned process. +- **Rationale.** The outcome space is a small finite partition — the natural + fit for parameterized testing. The end-to-end case is what makes it + non-vacuous, because the unit-level mapping can be right while `main` + discards it. +- **Domain.** Every variant class of `InstallerError` grouped by kind, plus + `Ok(())`, plus clap `ErrorKind::DisplayHelp` and `DisplayVersion`, plus a + genuine parse error. +- **Artefact.** `crates/whitaker_cli/src/domain/exit_tests.rs` and + `crates/whitaker_cli/tests/e2e_exit_codes.rs`. +- **Evidence.** `cargo nextest run -p whitaker_cli exit`. Red: the e2e test + asserting `whitaker --help` exits 0 fails against a naive `Err => 1` + implementation. +- **Non-vacuity.** The e2e test spawns the real binary through + `env!("CARGO_BIN_EXE_whitaker")` and reads `ExitStatus::code()`, so a + mapping that is correct in a unit but unwired in `main` is caught. Negative + control: drop the `is_display_request` branch; `whitaker --help` must then + exit 1 and the test must fail. + +### EP-LEM-NAME — release-asset name unambiguity + +- **Obligation.** The composed release-asset name + `{name}-{target}-v{version}.{ext}` determines `(name, target, version)` + uniquely. Formally: for well-formed field triples `(n₁,t₁,vs₁)` and + `(n₂,t₂,vs₂)` drawn from the admissible alphabets, if + `compose(n₁,t₁,vs₁) = compose(n₂,t₂,vs₂)` then the triples are equal. +- **Method.** Formal proof (Verus), plus a `proptest` differential check + against the Rust implementation. +- **Rationale.** This is a genuine new obligation created by this change, not + a restatement. Before this plan only `whitaker-installer` published under + this template. Adding `whitaker` — a **proper prefix** of + `whitaker-installer` — into a template whose separator `-` also occurs + inside every target triple creates a real ambiguity hazard: a wrong split + means `cargo binstall whitaker` silently fetches the installer's archive. + The guarantee must hold for all admissible inputs, not a sampled subset, so + a prover rather than a property test is the right instrument; the property + test then ties the proven model back to the Rust code. +- **Domain.** Unbounded. `name` over `[a-z0-9_-]+` drawn from the published + package set; `target` a Rust target triple; `version` a semantic version + string. The proof proceeds by showing the `-v` delimiter preceding the + version cannot occur inside a well-formed target triple, which pins the + version boundary, and that the package-name set is prefix-free **once the + following separator is included** — the non-obvious step, and the one that + fails if a future package is named such that the property breaks. +- **Artefact.** `verus/whitaker_artefact_naming.rs`, added to the + `decomposition`/`clone-detector` group structure in `scripts/run-verus.sh` + as a new `packaging` group. +- **Evidence.** `make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1.out`. Expect + `verification results:: N verified, 0 errors`. +- **Non-vacuity.** The proof must not assume its conclusion. Inspect it for + `assume`: there must be none in the final version, and the well-formedness + predicates must be shown _inhabited_ by an explicit witness lemma exhibiting + a concrete satisfying triple (`whitaker`, `x86_64-unknown-linux-gnu`, + `0.2.7`) before the injectivity theorem is stated — otherwise the theorem is + vacuously true over an empty domain. Negative control: weaken the + well-formedness predicate to permit a package name containing `-v` followed + by digits; the injectivity proof must then fail. Record that failure + transcript before restoring the predicate. + +### Deliberately not verified + +- The internals of `clap`, `ortho_config`, `figment`, or `cargo-binstall`. +- The installer's existing behaviour beyond parity. This plan asserts the new + binary matches the old one; it does not re-verify what the old one does. + That is already covered by `installer/tests/`. +- Localization catalogue content. `EP-M2` wires `NoOpLocalizer`, so there is + no translation logic to verify. Fluent catalogues arrive with roadmap 3.6.4. + +## Plan of work + +### Stage A — prototype and decide (EP-M0, no production code) + +Answer `Risk R1` before designing around either outcome. Create a throwaway +`src/main.rs` in the root package containing only: + +```rust +//! Feasibility spike: does a root-package binary build under --all-features? +fn main() { println!("spike"); } +``` + +Then run, capturing output: + +```console +cargo check -p whitaker --bins --all-features 2>&1 | tee /tmp/spike-a.out +cargo check --workspace --all-targets --all-features 2>&1 | tee /tmp/spike-b.out +``` + +Then add `use whitaker::greet;` and a call to it, and repeat, because a bin +that never references the library may not link it — which would make the first +result misleading: + +```console +cargo check -p whitaker --bins --all-features 2>&1 | tee /tmp/spike-c.out +``` + +Go/no-go: + +- **Both succeed:** proceed to Stage B with the binary in the root package. +- **Either fails with duplicate `std`/`core` symbols:** delete the spike file, + record the transcript in `Surprises & discoveries`, set status `BLOCKED`, + and escalate with the `Risk R1` options. Do not proceed. + +Delete the spike file before Stage B regardless of outcome. + +### Stage B — red tests and feature specifications + +No production behaviour yet. Write the failing specifications first. + +Create the crate skeleton `crates/whitaker_cli/` with `src/lib.rs` containing +only module declarations and doc comments, and add it to the workspace. Add +the four dependencies. Then write, in this order: + +1. `crates/whitaker_cli/tests/features/whitaker_cli.feature` — the Gherkin + specification, reproduced in full under `Artefacts and notes`. +2. `crates/whitaker_cli/tests/behaviour_cli.rs` with step definitions and a + `CliWorld` fixture modelled on `installer/tests/behaviour_cli/support.rs`. +3. `crates/whitaker_cli/tests/e2e_exit_codes.rs` (`EP-INV-EXIT`). +4. `crates/whitaker_cli/tests/property_arg_parity.rs` (`EP-INV-PARITY`). +5. `crates/whitaker_cli/src/domain/routing/kani.rs` (`EP-INV-ROUTE`), plus the + three reachability harnesses. +6. `verus/whitaker_artefact_naming.rs` (`EP-LEM-NAME`), starting with the + witness lemma. + +Validation for Stage B: every one of the above must **fail**, and the failure +must be the expected one. Record each red transcript. A test that fails +because a module does not exist is acceptable only for the compile-time +skeleton; the behavioural and property tests must reach a genuine assertion +failure once the skeleton compiles. + +### Stage C — implementation + +Build the library, then the binary, then the packaging metadata. Each step +below names its file and what goes in it; see `Interfaces and dependencies` +for exact signatures. + +1. **Promote the binary-private orchestration.** In `installer/src/lib.rs`, + add `pub mod install_flow;` and move `installer/src/install_flow/` into the + library. Move the orchestration functions currently in + `installer/src/main.rs` (`run_install`, `run_dry`, + `try_fast_path_installation`, `finish_install`, + `finish_install_and_record_metrics`, `resolve_requested_crates`, + `generate_and_report_wrapper`, `ensure_whitaker_workspace`, + `resolve_toolchain`, `ensure_toolchain_installed`) into a new + `installer/src/orchestration/` module tree, each file under 400 lines. + Leave `staged_suite` binary-private (`Risk R5`); expose only the single + entry point the fast path needs, behind its existing gating. + `installer/src/main.rs` becomes a thin composition root calling the + library. **No behaviour changes.** Run `make test` here: every existing + `installer/tests/` test must pass **unmodified**. If any assertion needs + changing, that is a Constraint 1 breach — stop. + +2. **Define the domain and ports** in `crates/whitaker_cli/src/domain/` and + `crates/whitaker_cli/src/ports/`. The domain owns `Request`, `Outcome`, + `ExitCode`, the routing function, and the exit-code policy. It imports + nothing from `std::process`, `std::fs`, or `whitaker_installer`. This is + the dependency rule, and it is checkable: `crates/whitaker_cli/src/domain/` + must contain no `use whitaker_installer` and no `use std::{fs, process}`. + +3. **Define the driving adapter** in `crates/whitaker_cli/src/cli/`: the clap + `Parser`/`Subcommand`/`Args` structs for `whitaker`, `whitaker install`, + and `whitaker ls`, mirroring `installer/src/cli.rs` field for field, plus + the `ortho_config` localized-parse entry point. + +4. **Define the driven adapters** in `crates/whitaker_cli/src/adapters/`, + implementing the ports over `whitaker_installer`'s now-public + orchestration. + +5. **Add the composition root** at `src/main.rs` in the root package: build + the adapters, call `whitaker_cli::run`, map the outcome to a process exit + code. Target under 60 lines. Declare `[[bin]] name = "whitaker"` with + `autobins = false` in the root `Cargo.toml`, matching the convention at + `installer/Cargo.toml:11-27`. + +6. **Remove `--exclude whitaker` from `TEST_EXCLUDES`** if and only if + `EP-M0` showed the package tests cleanly; otherwise leave it and note in + `Surprises & discoveries` that root-package tests remain excluded, with all + `whitaker_cli` tests living in the non-excluded crate (which is why they + were put there). + +7. **Add binstall metadata.** Parameterize + `installer/src/binstall_metadata.rs` over the package name rather than + hardcoding `"whitaker-installer"` (currently at lines 52 and 77), and add + the `[package.metadata.binstall]` block to the root `Cargo.toml` mirroring + `installer/Cargo.toml:95-101`, including the + `overrides.x86_64-pc-windows-msvc` entry with `pkg-fmt = "zip"`. + +Validation after each numbered step: `make check-fmt && make typecheck && +make lint && make test`, captured with `tee`. Commit after each step. + +### Stage D — verification, documentation, and wider validation + +1. Turn the Verus proof green; run the negative control and record it. +2. Turn the Kani harnesses green; run the negative control and record it. +3. Run the `proptest` non-vacuity classification report and confirm every flag + is exercised. +4. Write the ADR (`EP-M4`). +5. Update `docs/users-guide.md`, `docs/developers-guide.md`, + `docs/whitaker-cli-design.md`, `docs/whitaker-dylint-suite-design.md`, and + `docs/roadmap.md`. +6. Run `make markdownlint` and `make nixie`. + +## Milestones and plateaus + +### EP-M0 — feasibility established (prototyping) + +- **Outcome.** A recorded, evidence-backed answer to whether the `whitaker` + binary can live in the root package. No production code; the spike file is + deleted. +- **Requirements.** De-risks `CLI-REQ-BIN`. +- **Acceptance evidence.** `/tmp/spike-a.out`, `/tmp/spike-b.out`, + `/tmp/spike-c.out`, summarized in `Surprises & discoveries`. +- **Conformance check.** No interface, dependency, or format change. +- **Recovery.** `git checkout -- .` — nothing is committed. +- **Remaining gaps.** Everything. +- **Compatibility decision.** None required. + +### EP-M1 — installer orchestration behind a library boundary + +- **Outcome.** `installer/src/main.rs` is a thin composition root. All + orchestration is in the `whitaker_installer` library. `whitaker-installer` + behaves identically. `crates/whitaker_cli` exists with its domain and ports, + no adapters yet. +- **Requirements.** `CLI-REQ-LIB`; `EP-INV-ROUTE` and `EP-INV-EXIT` green. +- **Acceptance evidence.** All existing `installer/tests/` pass unmodified; + `make kani` reports `VERIFICATION:- SUCCESSFUL` for the two routing + harnesses; `crates/whitaker_cli/src/domain/` contains no `use + whitaker_installer` (grep-checkable). +- **Conformance check.** Public surface of `whitaker_installer` grew, never + shrank; no persisted-format change; the prebuilt path is untouched. +- **Recovery.** Revert the milestone's commits; nothing outside the workspace + changed. +- **Remaining gaps.** No `whitaker` binary yet. +- **Compatibility decision.** None. This is a pre-1.0, application-internal + boundary; callers are updated in the same change. + +### EP-M2 — the `whitaker` binary works + +- **Outcome.** `whitaker --help`, `whitaker install`, `whitaker ls`, and + `whitaker ls --json` all work, with behaviour matching `whitaker-installer`. +- **Requirements.** `CLI-REQ-BIN`, `CLI-REQ-LS`, `CLI-REQ-L10N`, + `CLI-REQ-EXIT`; `EP-INV-PARITY` green. +- **Acceptance evidence.** The BDD scenarios in `Artefacts and notes` pass; + `insta` snapshots for `ls` text and JSON are committed; `whitaker --help` + exits 0. +- **Conformance check.** Command names are untranslated (`CLI-REQ-L10N`); + `--json` is on `ls` only, not global, as `CLI-DESIGN` requires. +- **Recovery.** The binary is additive; reverting removes it and leaves + `EP-M1` intact. +- **Remaining gaps.** `check` and `doctor` are absent by design. +- **Compatibility decision.** `whitaker-installer` remains, per Constraint 1 + — named consumer: existing users following `docs/users-guide.md`, and the + published GitHub release assets. Its removal is roadmap 3.9.1. + +### EP-M3 — installable via binstall + +- **Outcome.** The root package carries binstall metadata; asset naming is + proven unambiguous. +- **Requirements.** `CLI-REQ-BINSTALL`; `EP-LEM-NAME` green. +- **Acceptance evidence.** `make verus` verifies + `verus/whitaker_artefact_naming.rs`; a behavioural test asserts the root + package's binstall table matches the shared template constants. +- **Conformance check.** `docs/adr-001-prebuilt-dylint-libraries.md` still + holds; no release-workflow change is made here (that is roadmap 3.5.3), so + no published asset changes. +- **Recovery.** Metadata-only; revert is safe. +- **Remaining gaps.** CI does not yet _publish_ a `whitaker` artefact — 3.5.3. + The plan must say so plainly in the ADR rather than implying binstall works + end-to-end today. +- **Compatibility decision.** None. + +### EP-M4 — documented + +- **Outcome.** An ADR records the boundary; the user guide, developers' guide, + CLI design document, and suite design document reflect reality. +- **Requirements.** `AGENTS.md` documentation rules. +- **Acceptance evidence.** `make markdownlint` and `make nixie` pass; the + roadmap item 3.5.1 checkbox is ticked. +- **Conformance check.** Every discovery from `Surprises & discoveries` is + reconciled against `CLI-DESIGN`; anything that contradicts it is either + fixed in the design document or recorded in `Decision log`. +- **Recovery.** Documentation-only. +- **Remaining gaps.** None for 3.5.1. +- **Compatibility decision.** None. + +## Interfaces and dependencies + +### New dependencies + +Add to `[workspace.dependencies]` in the root `Cargo.toml`, caret-pinned: + +```toml +ortho_config = "0.9.0" +googletest = "0.14.3" +pretty_assertions = "1.4.1" +``` + +`insta` is already a workspace dependency (`insta = { version = "1", features += ["json"] }`); add it as a dev-dependency of `crates/whitaker_cli`. + +`ortho_config` is a normal dependency of `crates/whitaker_cli`. The other +three are dev-dependencies only. + +**`googletest` ordering rule.** When combining with `rstest`, `#[gtest]` must +come **before** `#[rstest]`, otherwise the test registers twice and runs +twice. Document this in `docs/developers-guide.md`: + +```rust +#[gtest] +#[rstest] +#[case::install_with_lint(&["install", "--lint", "module_max_lines"])] +fn routes_to_install(#[case] argv: &[&str]) -> googletest::Result<()> { + verify_that!(route(parse(argv)?), matches_pattern!(Request::Install(_))) +} +``` + +### `crates/whitaker_cli` layout + +```plaintext +crates/whitaker_cli/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # run(); re-exports; no logic +│ ├── domain/ +│ │ ├── mod.rs +│ │ ├── request.rs # Request, InstallRequest, ListRequest +│ │ ├── outcome.rs # Outcome, ExitCode +│ │ ├── routing/ +│ │ │ ├── mod.rs # route(): pure +│ │ │ └── kani.rs # #[cfg(kani)] harnesses +│ │ └── exit.rs # exit-code policy: pure +│ ├── ports/ +│ │ ├── mod.rs +│ │ ├── install.rs # InstallService +│ │ └── inventory.rs # LintInventory +│ ├── adapters/ +│ │ ├── mod.rs +│ │ ├── installer.rs # InstallService over whitaker_installer +│ │ └── inventory.rs # LintInventory over whitaker_installer +│ └── cli/ +│ ├── mod.rs # Cli, Command; localized parse entry point +│ ├── install_args.rs +│ └── list_args.rs +└── tests/ + ├── features/whitaker_cli.feature + ├── behaviour_cli.rs + ├── e2e_exit_codes.rs + └── property_arg_parity.rs +``` + +### Required signatures + +In `crates/whitaker_cli/src/ports/install.rs`: + +```rust +/// Performs an installation on behalf of the domain. +pub trait InstallService { + /// Runs an installation and reports what happened. + /// + /// # Errors + /// + /// Returns an error when the installation cannot complete. + fn install(&self, request: &InstallRequest) -> Result; +} +``` + +In `crates/whitaker_cli/src/ports/inventory.rs`: + +```rust +/// Reports the lints currently staged on this machine. +pub trait LintInventory { + /// Lists staged lints in the given staging directory. + /// + /// # Errors + /// + /// Returns an error when the staging directory cannot be scanned. + fn list(&self, request: &ListRequest) -> Result, CliError>; +} +``` + +In `crates/whitaker_cli/src/domain/routing/mod.rs` — pure, total, and the +subject of `EP-INV-ROUTE`: + +```rust +/// Maps a parsed command line onto a domain request. +/// +/// # Errors +/// +/// Returns [`RoutingError`] when mutually exclusive flags are combined. +pub fn route(cli: &Cli) -> Result; +``` + +In `crates/whitaker_cli/src/domain/exit.rs` — pure, the subject of +`EP-INV-EXIT`: + +```rust +/// Maps an outcome onto a process exit code. +#[must_use] +pub const fn exit_code_for(outcome: &Outcome) -> ExitCode; +``` + +In `crates/whitaker_cli/src/lib.rs`: + +```rust +/// Runs the Whitaker command-line interface. +/// +/// # Errors +/// +/// Returns an error when the command cannot be completed. +pub fn run( + cli: &Cli, + installer: &dyn InstallService, + inventory: &dyn LintInventory, + stdout: &mut dyn std::io::Write, + stderr: &mut dyn std::io::Write, +) -> Result; +``` + +The root `src/main.rs` constructs the two adapters, calls `run`, and converts +the returned `Outcome` with `exit_code_for`. That is all it does. + +### Localized parsing + +Use `ortho_config`'s `LocalizedParse` with `NoOpLocalizer` at this milestone: + +```rust +use ortho_config::{LocalizedParse as _, NoOpLocalizer, is_display_request}; + +let cli = match Cli::try_parse_localized_from(std::env::args_os(), &NoOpLocalizer) { + Ok(cli) => cli, + Err(err) if is_display_request(&err) => { err.print()?; return Ok(ExitCode::SUCCESS); } + Err(err) => { err.print()?; return Ok(ExitCode::FAILURE); } +}; +``` + +This establishes the localization seam that roadmap 3.6.4 fills with a +`FluentLocalizer` and an `en-GB` catalogue, without adopting a configuration +model this plan does not own. + +## Concrete steps + +All commands run from the repository root, +`/home/leynos/.lody/repos/github---leynos---whitaker/worktrees/0c485c79-c21a-486e-b126-29c3ef23084f`. + +Confirm the branch first: + +```console +$ git branch --show-current +3-5-1-root-whitaker-binary +``` + +Stage A, the feasibility spike, is given verbatim under `Plan of work`. + +Create the crate skeleton: + +```console +mkdir -p crates/whitaker_cli/src/{domain/routing,ports,adapters,cli} \ + crates/whitaker_cli/tests/features +``` + +Add `crates/whitaker_cli` to the workspace — it is already covered by the +`crates/*` glob in `Cargo.toml` line 2, so no edit is needed there; only the +new `crates/whitaker_cli/Cargo.toml` is required. + +Run a focused test while iterating: + +```console +cargo nextest run -p whitaker_cli 2>&1 \ + | tee /tmp/nextest-whitaker_cli-3-5-1-root-whitaker-binary.out +``` + +Run the full gate before every commit: + +```console +make check-fmt 2>&1 | tee /tmp/check-fmt-whitaker-3-5-1-root-whitaker-binary.out +make typecheck 2>&1 | tee /tmp/typecheck-whitaker-3-5-1-root-whitaker-binary.out +make lint 2>&1 | tee /tmp/lint-whitaker-3-5-1-root-whitaker-binary.out +make test 2>&1 | tee /tmp/test-whitaker-3-5-1-root-whitaker-binary.out +``` + +Run them **sequentially**, never in parallel: this environment relies on build +caching and concurrent Cargo jobs contend on the shared package-cache lock. + +Verification runs: + +```console +make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1-root-whitaker-binary.out +make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1-root-whitaker-binary.out +``` + +Note that `make test` uses the default nextest profile, which skips +`behaviour_cli` and `behaviour_toolchain`. Before the final commit of `EP-M2`, +run the CI profile once so the new behavioural binary is actually executed: + +```console +make test NEXTEST_PROFILE=ci 2>&1 \ + | tee /tmp/test-ci-whitaker-3-5-1-root-whitaker-binary.out +``` + +Smoke-test the real binary: + +```console +cargo run --bin whitaker -- --help +cargo run --bin whitaker -- ls --json +``` + +Expected shape of the first: + +```plaintext +Usage: whitaker + +Commands: + install Install or repair Whitaker dependencies and lint bundles + ls Show installed lints and bundle metadata + help Print this message or the help of the given subcommand(s) +``` + +## Validation and acceptance + +### Red-Green-Refactor evidence to record + +**Red.** Before any production code in Stage C: + +```console +cargo nextest run -p whitaker_cli 2>&1 | tail -20 +``` + +Expect the BDD scenarios and `e2e_exit_codes` to fail. The e2e failure must +name the missing `whitaker` binary or a wrong exit code — not a compile error +in the test itself. + +**Green.** After the minimal implementation of each Stage C step, the focused +command for that step passes. + +**Refactor.** After splitting any file approaching 400 lines, re-run the +focused command and then the full gate. + +### Behaviour to observe + +Acceptance is phrased as things a person can do: + +1. Run `whitaker --help`. Observe `install` and `ls` listed as subcommands, + and `echo $?` printing `0`. +2. Run `whitaker ls --json` in a workspace with staged lints. Observe the same + JSON that `whitaker-installer list --json` prints, byte for byte. +3. Run `whitaker install --dry-run`. Observe the same output that + `whitaker-installer --dry-run` prints. +4. Run `whitaker install --lint module_max_lines --individual-lints`. Observe + a clear rejection and `echo $?` printing `2` (clap's usage-error code) or + `1` per the routing policy — whichever the implementation settles on must + be asserted in `EP-INV-EXIT` and documented, not left implicit. +5. Run `whitaker-installer --help`. Observe it is unchanged from before this + plan. + +### Quality criteria + +- **Tests.** `make test` and `make test NEXTEST_PROFILE=ci` pass. Every + existing `installer/tests/` test passes **without assertion changes**. +- **Verification.** `EP-INV-PARITY`, `EP-INV-ROUTE`, `EP-INV-EXIT`, and + `EP-LEM-NAME` are all discharged, each with its recorded negative-control + failure transcript. An obligation without a recorded negative control is not + discharged. +- **Lint/typecheck.** `make check-fmt`, `make typecheck`, `make lint` pass + with no new suppressions. +- **Documentation.** `make markdownlint` and `make nixie` pass. +- **Performance.** No benchmark threshold. Report if `make typecheck` wall + time regresses more than 30% (`Risk R6`). +- **Security.** No new network or filesystem capability is introduced; the new + crate performs I/O only through the two ports, both backed by existing + installer code. + +## Idempotence and recovery + +Every step is re-runnable. The spike in `EP-M0` writes one file that is +deleted afterwards. The Stage C steps are additive except for the moves in +step 1, which are pure relocations verifiable by the unchanged test suite. + +If a milestone must be abandoned, `git revert` its commits; nothing writes +outside the repository except `/tmp` logs and the normal Cargo target +directory. The `make test` target backs up and restores `~/.local/bin/whitaker` +around its run (`Makefile` lines 92-139) — if a run is interrupted, check that +file was restored before re-running. + +Do not create an isolated Cargo cache. Use the shared default cache and let +Cargo's package-cache lock serialize access; if another job holds it, wait. + +## Artefacts and notes + +### Feature specification (`crates/whitaker_cli/tests/features/whitaker_cli.feature`) + +Scenarios must be **appended** to this new file only, never inserted into +existing feature files, because `#[scenario(index = N)]` binds by position +(`Risk R2`). + +```gherkin +Feature: The root whitaker command-line interface + + Scenario: The root command lists its subcommands + Given the whitaker binary is available + When I run whitaker with "--help" + Then the command succeeds + And the output lists the subcommand "install" + And the output lists the subcommand "ls" + + Scenario: Requesting help exits successfully + Given the whitaker binary is available + When I run whitaker with "--help" + Then the exit code is 0 + + Scenario: Listing staged lints as text + Given a staging directory containing a staged suite library + When I run whitaker with "ls" + Then the command succeeds + And the output names the staged suite + + Scenario: Listing staged lints as JSON + Given a staging directory containing a staged suite library + When I run whitaker with "ls --json" + Then the command succeeds + And the output is valid JSON + + Scenario: A dry-run install reports its configuration without building + Given a Whitaker workspace checkout + When I run whitaker with "install --dry-run" + Then the command succeeds + And no lint library is staged + + Scenario: Conflicting lint selection flags are rejected + Given the whitaker binary is available + When I run whitaker with "install --lint module_max_lines --individual-lints" + Then the command fails + And the error names both conflicting options + + Scenario: The legacy installer binary is unaffected + Given the whitaker-installer binary is available + When I run whitaker-installer with "--help" + Then the command succeeds + And the output is unchanged from the recorded snapshot +``` + +### Verus proof skeleton (`verus/whitaker_artefact_naming.rs`) + +The witness lemma comes first, so the injectivity theorem is not vacuous: + +```rust +use vstd::prelude::*; + +verus! { + +/// A release-asset name is well formed when its fields are drawn from the +/// admissible alphabets and no field contains the composed delimiter. +pub open spec fn well_formed(name: Seq, target: Seq, version: Seq) -> bool; + +/// Exhibits a satisfying triple so `well_formed` is not empty. +proof fn lemma_well_formed_is_inhabited() + ensures exists|n: Seq, t: Seq, v: Seq| well_formed(n, t, v), +{ /* witness: ("whitaker", "x86_64-unknown-linux-gnu", "0.2.7") */ } + +/// Composition determines its fields uniquely. +proof fn lemma_compose_is_injective( + n1: Seq, t1: Seq, v1: Seq, + n2: Seq, t2: Seq, v2: Seq, +) + requires + well_formed(n1, t1, v1), + well_formed(n2, t2, v2), + compose(n1, t1, v1) =~= compose(n2, t2, v2), + ensures n1 =~= n2, t1 =~= t2, v1 =~= v2, +{ /* by delimiter disjointness, then prefix-freedom of the name set */ } + +} // verus! +``` + +The proof must contain no `assume` in its final form. Per +`docs/developers-guide.md`, Verus proofs here are models of the +implementation, not proofs of the literal Rust source; the `proptest` +differential check in `EP-LEM-NAME` is what ties the model to the code. + +## Signposts + +Read these before starting. + +| Document | Why | +| --- | --- | +| `docs/whitaker-cli-design.md` | The specification. §Public CLI surface and §Compatibility and migration are normative for this plan. | +| `docs/roadmap.md` | Items 3.5.1 through 3.9.3 — what belongs here and what does not. | +| `docs/users-guide.md` | The user-facing surface that must be updated in `EP-M4`. | +| `docs/developers-guide.md` | Installer architecture, Kani harness conventions, the Verus trust boundary. | +| `docs/ortho-config-users-guide.md` | Layering, subcommand merging, and the localization API. | +| `docs/rstest-bdd-users-guide.md` | Writing `#[scenario]` bindings and step functions. | +| `docs/rust-testing-with-rstest-fixtures.md` | Fixture patterns for the `CliWorld` fixture. | +| `docs/rust-doctest-dry-guide.md` | Doctests are gated by `make test`; keep them DRY. | +| `docs/complexity-antipatterns-and-refactoring-strategies.md` | The suite lints this repository against itself; keep routing flat. | +| `docs/whitaker-dylint-suite-design.md` | Workspace layout, updated in `EP-M4`. | +| `docs/whitaker-clone-detector-design.md` | Confirms `whitaker_clones_core`/`whitaker_sarif` naming so the new crate name does not collide. | +| `docs/documentation-style-guide.md` | The ADR template and naming (`docs/adr-NNN-*.md`). | +| `docs/adr-001-prebuilt-dylint-libraries.md` | The prebuilt path this plan must not disturb. | +| `AGENTS.md` | Gates, commit rules, the 400-line limit, the test-environment rules. | + +Skills to load: `leta` for symbol navigation instead of grep; +`hexagonal-architecture` for the port and adapter boundaries; +`kani` for `EP-INV-ROUTE`; `verus` for `EP-LEM-NAME`; `proptest` for +`EP-INV-PARITY`; `rust-unit-testing` for `googletest` and `insta` assertion +style; `execplans` for keeping this document current. + +## Progress + +- [ ] EP-M0 — feasibility spike for a root-package binary. +- [ ] EP-M1 — installer orchestration moved behind the library boundary. +- [ ] EP-M2 — the `whitaker` binary with `install` and `ls`. +- [ ] EP-M3 — binstall metadata and the asset-naming proof. +- [ ] EP-M4 — ADR and documentation updates; roadmap 3.5.1 ticked. + +## Surprises & discoveries + +- Observation: the root `whitaker` package is excluded from `make test`. + Evidence: `Makefile` line 24, `TEST_EXCLUDES` contains `--exclude whitaker`; + `src/lib.rs` records "duplicated `std`/`core` link errors ... during + all-features test runs". + Impact: drove the decision to place all testable CLI logic in + `crates/whitaker_cli` rather than in the root package, and created `EP-M0`. + +- Observation: `install_flow` and `staged_suite` are binary-private, so real + orchestration is unreachable from any other crate today. + Evidence: `installer/src/main.rs:7-8` declares `mod install_flow;` and + `mod staged_suite;`, neither appears in `installer/src/lib.rs`. + Impact: `CLI-REQ-LIB` is a genuine code move, not a re-export. + +- Observation: three distinct dependency-injection styles coexist in the + installer for the same concern. + Evidence: Table 1 above. + Impact: unifying them is deliberately out of scope; see `Decision log`. + +- Observation: `#[gtest]` must precede `#[rstest]` or the test runs twice. + Evidence: the `googletest` 0.14.3 crate documentation. + Impact: recorded as a convention for `docs/developers-guide.md`. + +## Decision log + +- **Decision:** Place the CLI domain, ports, and adapters in a new crate + `crates/whitaker_cli`, and make the root `src/main.rs` a thin composition + root. + **Rationale:** The root package cannot be tested by `make test` and its + library requires `feature(rustc_private)`. Putting logic there would make it + untestable under the repository's own gates. `CLI-DESIGN` requires the + _binary_ at the root package; it says nothing about where the library lives, + and "an internal library boundary" is precisely what a separate crate gives. + **Date/Author:** 2026-08-21, planning agent. + +- **Decision:** Adopt `ortho_config` in this plan for localized argument + parsing only (`LocalizedParse`, `NoOpLocalizer`, `is_display_request`), not + for configuration layering. + **Rationale:** The task brief asks for `ortho_config` with localized help. + `CLI-DESIGN` §Compatibility and migration sequences the configuration switch + as step 3, and `docs/roadmap.md` gives it its own item, 3.6.3, which + _requires_ 3.5.1. Wiring `whitaker.toml` discovery and the `dylint.toml` + bridge here would take work from 3.6.3 and half-activate a configuration + model this plan cannot finish. Taking the localization seam now satisfies + `CLI-REQ-L10N`, pays the dependency cost once, and shapes the argument types + so 3.6.3 adds `#[derive(OrthoConfig)]` and `load_and_merge()` without + restructuring. **This narrowing should be confirmed before implementation + begins.** + **Date/Author:** 2026-08-21, planning agent. + +- **Decision:** Do not unify the installer's three dependency-injection styles. + **Rationale:** It is a large refactor with its own risk profile and no + requirement in `CLI-DESIGN` driving it. This plan defines the ports the CLI + needs and implements them over the installer as it stands. Folding the + refactor in would breach the scope tolerance and blur the parity evidence + that `EP-INV-PARITY` depends on. + **Date/Author:** 2026-08-21, planning agent. + +- **Decision:** Keep `whitaker-installer` fully functional and unchanged. + **Rationale:** Not compatibility theatre. It is the currently shipping, + documented, published binary; `CLI-DESIGN` schedules its deprecation for a + named later release and `docs/roadmap.md` item 3.9.1 owns that work. The + named consumers are existing users following `docs/users-guide.md` and the + GitHub release assets. + **Date/Author:** 2026-08-21, planning agent. + +- **Decision:** Verify asset-name unambiguity with Verus rather than tests + alone. + **Rationale:** Adding a second package to a shared URL template, where the + new name is a proper prefix of the old one and the separator occurs inside + every target triple, creates a real ambiguity hazard whose guarantee must + hold for all admissible inputs. A sampled property test cannot establish + that; a prover can, and the property test then ties the proven model to the + Rust implementation. + **Date/Author:** 2026-08-21, planning agent. + +- **Decision:** The plan file is named `3-5-1-root-whitaker-binary.md`, not the + filename given in the task brief. + **Rationale:** The brief's filename referenced roadmap item 6.5.1, a + different item (a SARIF emitter for brain-trust diagnostics). The task body, + branch name, and required pull-request title all identify 3.5.1. Confirmed + with the requester before drafting. + **Date/Author:** 2026-08-21, planning agent. + +## Outcomes & retrospective + +To be completed at each milestone boundary and at completion. Before setting +this plan to `COMPLETE`, reconcile every entry in `Surprises & discoveries` +against `docs/whitaker-cli-design.md`: update the design document where a +discovery contradicts it, raise an ADR where the architecture changed, and +record a purely mechanical difference here. Do not mark the plan `COMPLETE` +while any upstream change or deviation is unrecorded. + +## Revision note + +Initial draft, 2026-08-21. Covers roadmap item 3.5.1 only. Two points need +explicit confirmation before implementation begins: the `ortho_config` +narrowing recorded in `Decision log`, and the `EP-M0` go/no-go on placing the +binary in the root package. From aacea3cf5884881de9d16a7374c94bca6d273f75 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 21 Aug 2026 18:22:37 +0200 Subject: [PATCH 3/5] Rewrite 3.5.1 execplan after design review A six-perspective review invalidated the first draft's structural bet. `cargo package -p whitaker` fails: the root package depends on four `publish = false` rustc shim crates and lacks crates.io metadata, so `cargo install whitaker` was unreachable. Extracting the Dylint driver library into `crates/whitaker_lint_core` is therefore a precondition, not a contingency, and it is now milestone EP-M0. That removes the need for a separate CLI crate, makes `CARGO_BIN_EXE_whitaker` available to the end-to-end tests, and confines `ortho_config` to the CLI binary. Record the `whitaker` name collision the first draft missed: the installer generates an executable named `whitaker` that a `cargo install`ed binary deterministically shadows, breaking `whitaker --all` and this repository's own `make lint`. The binary now forwards unrecognized arguments to `cargo dylint`. Cut both original verification obligations as vacuous. Nothing inverts a release-asset name, and clap rejects the flag conflicts before any repository-owned routing code runs. Verify argv classification totality and disjointness instead, bounded by Kani and closed unbounded by Verus. Settle exit codes at 0, 2, and 1. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/3-5-1-root-whitaker-binary.md | 2067 ++++++++++-------- 1 file changed, 1104 insertions(+), 963 deletions(-) diff --git a/docs/execplans/3-5-1-root-whitaker-binary.md b/docs/execplans/3-5-1-root-whitaker-binary.md index 02a4b5aa..c55a9682 100644 --- a/docs/execplans/3-5-1-root-whitaker-binary.md +++ b/docs/execplans/3-5-1-root-whitaker-binary.md @@ -9,12 +9,9 @@ Status: DRAFT ## Purpose / big picture -Today a user who wants Whitaker installs and runs a binary called -`whitaker-installer`, and reaches the lint inventory through a generated -wrapper script called `whitaker-ls`. The product is named Whitaker but there -is no program called `whitaker`. - -After this change there is. A user runs: +The product is called Whitaker, but there is no program called `whitaker` — +only a binary called `whitaker-installer` and a shell script the installer +generates. After this change there is a real one: ```console whitaker --help @@ -23,71 +20,229 @@ whitaker ls whitaker ls --json ``` -and gets exactly the behaviour `whitaker-installer` and `whitaker-installer -list` give today, from a real Rust binary named `whitaker`, installable with -`cargo install whitaker` or `cargo binstall whitaker`. - -That is the entire user-visible outcome. It is deliberately narrow. The -commands `whitaker check` and `whitaker doctor` described in the CLI design -document are **not** part of this plan; they are separate roadmap items that -depend on this one. This plan builds the foundation they land on: a real -root binary, and an internal library boundary that separates decision-making -policy from the input/output work that carries it out. - -The second half of the outcome is invisible to users but is the reason the -work is worth doing. The installer's orchestration logic currently lives -inside a binary target (`installer/src/main.rs`, plus two binary-private -modules) where no other program can reach it and no integration test can call -it directly. This plan moves that orchestration into a library crate with -explicit ports, so that the four subcommands still to come can be built by -composing that library rather than by copying the binary. +These do exactly what `whitaker-installer` and `whitaker-installer list` do +today. Crucially, the existing workflow keeps working too: + +```console +whitaker --all -- -p whitaker-common --all-targets +``` + +That form is not a subcommand. It is the contract of the shell script the +installer writes to `~/.local/bin/whitaker`, and it is what this repository's +own `make lint` runs. The new binary forwards it to `cargo dylint`, so a user +whose `PATH` resolves to the new binary sees no regression. See `Decision log` +entry D-2 and requirement `CLI-REQ-FWD`. + +The invisible half of the outcome is the reason the work is worth doing, and +it is larger than it first appears. Two structural problems block a root +binary today, and both are fixed here: + +1. **The root package cannot be published.** It depends on four + `publish = false` compiler-shim crates, and its manifest has no + `description`, `license`, or `repository`. So `cargo install whitaker` + cannot resolve — the headline outcome is unreachable until this is fixed. +2. **The installer's orchestration is trapped inside a binary target.** Ten + functions in `installer/src/main.rs` and two binary-private modules cannot + be called by any other program or tested directly. + +The commands `whitaker check` and `whitaker doctor` are **not** in this plan; +they are separate roadmap items that depend on this one. ## Definitions -Terms used throughout, defined here so no prior knowledge is assumed. +Defined here so no prior knowledge is assumed. **Dylint.** A tool that runs custom Rust lints compiled as dynamic libraries. Whitaker's lints are Dylint lints. `cargo-dylint` and `dylint-link` are the -two helper binaries Dylint needs. +helper binaries it needs. -**Lint bundle / staged library.** A compiled Dylint lint library copied into a -known directory with a filename encoding the toolchain it was built for. -"Staging" is the act of copying it there. +**Dylint driver.** Code that links the private `rustc_*` compiler crates and +therefore needs `#![feature(rustc_private)]` and a nightly toolchain. It is +contagious: anything depending on it inherits the constraint. + +**Lint bundle / staged library.** A compiled lint library copied into a known +directory under a filename encoding the toolchain it was built for. **Prebuilt artefact.** A `.tar.zst` archive of already-compiled lint libraries -published on GitHub Releases, so users do not have to compile lints locally. +published on GitHub Releases, so users need not compile lints locally. + +**`cargo-binstall`.** Installs a Rust binary by downloading a prebuilt release +archive rather than compiling. It reads `[package.metadata.binstall]` from the +package's manifest **as published on crates.io**. + +**Wrapper script.** The executable file named `whitaker` that +`installer/src/wrapper.rs` writes into the user's binary directory. Its whole +body sets `DYLINT_LIBRARY_PATH` and runs `exec cargo dylint "$@"`. + +**Port / adapter (hexagonal architecture).** A port is a trait, owned by the +policy layer, describing something it needs from the outside world. An adapter +implements it. A _driving_ adapter calls into the policy (the CLI parser); a +_driven_ adapter is called by it (the installer). + +**Composition root.** The one place — `src/main.rs` — where concrete adapters +are constructed. Nothing else selects implementations. + +**Plateau.** A milestone leaving the repository correct, coherent, and safe to +stop at. + +## Progress -**`cargo-binstall`.** A tool that installs a Rust binary by downloading a -prebuilt release archive instead of compiling. It reads a -`[package.metadata.binstall]` table from `Cargo.toml` to learn the archive URL -pattern. +- [ ] `EP-M0` — extract the Dylint driver library; root package becomes a + publishable, testable CLI package. +- [ ] `EP-M1` — installer orchestration moved behind the library boundary. +- [ ] `EP-M2` — the `whitaker` binary: `install`, `ls`, and `cargo dylint` + forwarding. +- [ ] `EP-M3` — binstall metadata and crates.io name reservation. +- [ ] `EP-M4` — ADR `docs/adr-005-whitaker-cli-boundary.md` and documentation. -**Port (hexagonal architecture).** A Rust trait, owned by the domain layer, -describing something the domain needs from the outside world (for example -"install the Dylint tools") without saying how it is done. +Record an ISO-8601 UTC timestamp against each item as it completes, for +example `- [x] (2026-08-21T14:05Z) EP-M0 …`. Split any partially completed +item into "done" and "remaining" rather than leaving it ambiguous. -**Adapter.** A concrete implementation of a port that does the real work, for -example by spawning a process or writing a file. +## Constraints + +Hard invariants. Violation requires escalation, not a workaround. + +1. **`whitaker-installer` keeps working unchanged.** Its command-line surface, + exit codes, and output must be identical before and after. It is a released + binary documented in `docs/users-guide.md`, published to GitHub Releases, + exercised by `make install-smoke` and `installer/tests/`. Its deprecation + is roadmap 3.9.1, not this plan. This is not a shim invented to make a + milestone viable; it is the shipping product. +2. **`whitaker --all` keeps working.** The wrapper script's contract is + documented at `docs/users-guide.md:25-27` and consumed by `Makefile:185` + and `.github/workflows/ci.yml:154`. Named consumers: existing users, and + this repository's own lint gate. +3. **The public library surface of `whitaker_installer` must not shrink.** + Integration tests under `installer/tests/` compile against it as an + external crate and see only `pub` items; `installer/Cargo.toml:29-55` gates + `StubExecutor` and `InstallerError::StubMismatch` behind the `test-support` + feature, which roadmap 3.2.2 declares supported. Additions are fine; + removals and signature narrowings are not. +4. **The root package stays named `whitaker` at version `0.2.7`**, and the new + binary is named `whitaker`. +5. **The Dylint lint crates must keep building and their UI tests passing** + throughout `EP-M0`. They are the oracle for that milestone. +6. **No new lint suppressions.** `Cargo.toml [workspace.lints]` sets + `unsafe_code = "forbid"`, `missing_docs = "deny"`, `allow_attributes = + "deny"`, clippy `pedantic` at warn with `-D warnings`. Adding `#[allow(…)]` + to pass a gate is a tolerance breach. Adding an `excluded_crates` entry to + `dylint.toml` counts as a suppression for this purpose and requires the + same escalation. +7. **No file may exceed 400 lines** (`AGENTS.md`). Every module needs a `//!` + doc comment. +8. **No direct environment mutation in tests** (`AGENTS.md`). Use `temp-env` + or inject through a port. +9. **The prebuilt-artefact path must not change behaviour.** Governed by + `docs/adr-001-prebuilt-dylint-libraries.md`. +10. **Caret dependency requirements only**; no `*` or `>=`. + +## Tolerances (exception triggers) -**Composition root.** The single place — here, `src/main.rs` — where concrete -adapters are constructed and handed to the domain. Nothing else in the program -chooses implementations. +Stop and escalate; do not improvise. + +- **Scope.** More than 70 files changed, or more than 2,500 net added lines. + `EP-M0` alone touches roughly 40 files, almost all mechanically. +- **`EP-M0` gate.** If `cargo package -p whitaker --no-verify` still fails + after the extraction, stop. That command is the milestone's whole point. +- **Interface.** If anything requires removing or narrowing an existing `pub` + item in `whitaker_installer`, stop (Constraint 3). Additive `*_for` + functions are the sanctioned pattern. +- **Behaviour drift.** If any existing test under `installer/tests/` or any + Dylint UI fixture needs its _assertions_ changed, stop. Relocation is fine; + changed expectations are evidence of a Constraint 1 or 5 breach. +- **Dependencies.** Three new dependencies are pre-authorized: `ortho_config`, + `googletest`, `pretty_assertions`. A fourth triggers escalation. +- **Iterations.** Three failed fix attempts on one root cause; report the log + path. +- **Verification.** Kani over 15 minutes for this plan's harnesses in + aggregate, or Verus over 10 minutes, or more than one working day spent + authoring either. These are the repository's first sequence-shaped proofs; + budget overrun is the expected failure mode. +- **Ambiguity.** If `docs/whitaker-cli-design.md` and `docs/roadmap.md` + disagree on whether a behaviour belongs to 3.5.1, stop and present both + readings. This fired twice during planning; see `Decision log` D-2 and D-3. -**Driving vs driven.** A _driving_ adapter calls into the domain (the CLI -parser). A _driven_ adapter is called by the domain (the installer). +## Risks -**ExecPlan plateau.** A milestone that leaves the repository correct, -coherent, and safe to stop at. +**R1 — `EP-M0` is a wide mechanical rename that can break the lint suite.** +Severity: high. Likelihood: medium. +Eleven lint-crate manifests and roughly 25 `use whitaker::` sites move. Note +`crates/test_must_not_have_example/Cargo.toml:34` uses a literal path +dependency, not the `workspace = true` alias, so a global search-and-replace +on the alias misses it. +Mitigation: the Dylint UI suite already exercises every affected crate. Run it +before and after and require identical results. Do the extraction as one +commit so bisection is clean. + +**R2 — Argument forwarding can swallow a real subcommand.** +Severity: high. Likelihood: medium. +The binary must decide, from argv alone, whether to dispatch a subcommand or +forward to `cargo dylint`. Get it wrong and `whitaker install` silently runs +`cargo dylint install`, or `whitaker --all` prints a usage error. +Mitigation: this is the plan's one genuinely new decision procedure, and it is +the subject of both verification obligations — `EP-INV-DISPATCH` (Kani) and +`EP-LEM-DISPATCH` (Verus). + +**R3 — Index-based BDD scenario bindings break silently.** +Severity: medium. Likelihood: medium. +`#[scenario(path = "…", index = N)]` binds by position; inserting a scenario +mid-file rebinds every later one, often still passing. See the warning at +`installer/tests/behaviour_cli/scenarios.rs:7`. +Mitigation: new scenarios go only in new feature files. + +**R4 — A new test binary named `behaviour_cli` is skipped by default.** +Severity: medium. Likelihood: high if unaddressed. +`.config/nextest.toml` sets +`default-filter = "not (binary(behaviour_toolchain) | binary(behaviour_cli) | kind(example))"`, +and nextest's `binary()` matches by binary _name_, not package-qualified name. +Mitigation: name the new file `behaviour_whitaker.rs`, and run +`make test NEXTEST_PROFILE=ci` at every milestone boundary. + +**R5 — `--help` and `--version` may exit non-zero.** +Severity: medium. Likelihood: medium. +clap reports both as `Err`. Routing every `Err` to a failure code makes +`whitaker --help` fail. +Mitigation: `EP-INV-EXIT`, asserted end-to-end on the real process. + +**R6 — Publishing instructions may precede name reservation.** +Severity: medium. Likelihood: low, but severe if it lands. +The name `whitaker` is unregistered on crates.io. If `EP-M4` publishes +`cargo install whitaker` before the name is claimed, and 3.5.3 slips, users +following official documentation install someone else's crate. +Mitigation: `EP-M3` reserves the name; `EP-M4` is explicitly gated behind it. + +**R7 — `ortho_config` brings a second `toml` stack.** +Severity: low. Likelihood: certain. +`ortho_config` 0.9.0 reaches `figment` → `toml 0.8` + `toml_edit 0.22` +alongside the workspace's existing `toml 1.x` + `toml_edit 0.25`, and enables +`figment`'s `test` feature (pulling `tempfile` and `parking_lot`) into the +production graph. Roughly 26 genuinely new lock entries; about 80% of the +closure is already present. +Mitigation: after `EP-M0` the root package is _not_ a dependency of the lint +crates, so this cost is confined to the CLI binary. Record the stripped +release size in `EP-M3` rather than guessing. + +**R8 — Removing `--exclude whitaker` switches on six never-run test binaries.** +Severity: medium. Likelihood: high. +`tests/{build_config,config_loading,lint_template,locale_resolution,nextest_ui_filter,ui_harness}.rs` +total roughly 1,045 lines and have never run under `make test`, because +`Makefile:23` excludes the package. `tests/ui_harness.rs` drives the Dylint UI +harness under `RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked +-D warnings"`. +Mitigation: treat this as its own step inside `EP-M0` with its own validation, +not a one-line edit. Note `Makefile:64-68` `DOCTEST_EXCLUDES` carries a +_second_ `--exclude whitaker`; decide both explicitly. `make coverage` +(`Makefile:151`) reuses the same recipe, so a bad landing reddens two CI jobs. ## Context and orientation -You have only this repository and this document. Here is what exists. +You have only this repository and this document. Run everything from the +repository root; obtain it with `git rev-parse --show-toplevel`. -### The workspace +### The root package today -The Cargo workspace root is the repository root. `Cargo.toml` line 2 declares -members `["common", "crates/*", "installer", "suite"]`. The root directory is -itself a package: +The repository root is itself a Cargo package: ```toml [package] @@ -96,39 +251,49 @@ version = "0.2.7" edition = "2024" ``` -That root package **has no binary today**. It has only a library, -`src/lib.rs`, which is the shared support library for Whitaker's Dylint lint -crates. Its first two lines matter a great deal to this plan: +It has **no binary**, only `src/lib.rs`, which is the shared support library +for the Dylint lint crates. Its second line is the crux: ```rust -//! Core Whitaker library surfaces shared configuration and helpers for lint crates. #![cfg_attr(feature = "dylint-driver", feature(rustc_private))] ``` -Under the `dylint-driver` feature this library links `rustc_driver` and the -private compiler crates. A comment in that file records the consequence: +Under that feature the library links `rustc_driver`. A comment in the same +file records the consequence — "duplicated `std`/`core` link errors seen +during all-features test runs" — and that is why `Makefile:23` excludes the +package from the test run. -```rust -// Unit tests of this crate should not pull the compiler driver to avoid the -// duplicated `std`/`core` link errors seen during all-features test runs. +Two things follow, and this plan exists to fix both. First, the package cannot +be published: + +```console +cargo package -p whitaker --no-verify --allow-dirty +``` + +```plaintext +warning: manifest has no description, license, license-file, documentation, + homepage or repository +error: failed to prepare local package for uploading +Caused by: + no matching package named `rustc_ast` found + location searched: crates.io index + required by package `whitaker v0.2.7` ``` -This is why `Makefile` line 24 excludes the root package from the test run: -`TEST_EXCLUDES` contains `--exclude whitaker`. Read that line before starting -work; it is the single most important constraint on where new code may live. +`crates/rustc_{ast,hir,lint,middle,session,span,attr_data_structures}` are all +`publish = false`. Second, eleven lint crates depend on this package, so +anything added to its `[dependencies]` propagates to all of them — Cargo has +no per-target dependency tables. ### The installer `installer/` is the package `whitaker-installer`. It already has a library -(`installer/src/lib.rs`, 84 lines) exposing about 25 public modules, and four -binaries declared with `autobins = false` in `installer/Cargo.toml` lines -11-27. The one users install is `whitaker-installer`, built from -`installer/src/main.rs`. The other three (`whitaker-package-lints`, -`whitaker-package-installer`, `whitaker-package-dependency-binary`) are -release-packaging utilities and are out of scope here. +(`installer/src/lib.rs`) exposing about 25 public modules, and four binaries +declared with `autobins = false` (`installer/Cargo.toml:11-27`). Users install +`whitaker-installer`, built from `installer/src/main.rs`; the other three are +release-packaging utilities and are out of scope. -`installer/src/main.rs` is 402 lines. It parses `whitaker_installer::cli::Cli` -with clap and dispatches: +`installer/src/main.rs` is 402 lines and dispatches: ```rust fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { @@ -140,673 +305,588 @@ fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> } ``` -The important fact is what else is in that file and in two modules declared -only by it (`mod install_flow;` and `mod staged_suite;`, lines 7-8). These are -**binary-private**: they are not part of the `whitaker_installer` library and -no other crate can call them. They contain real orchestration: +Note the `None` arm: bare `whitaker-installer` installs. `Cli` flattens +`InstallArgs` alongside the optional subcommand (`installer/src/cli.rs:55-63`, +with `install_args()` at `:283-288`). + +What matters here is what else lives in that binary and cannot be reached from +anywhere else. `installer/src/main.rs:7-8` declares `mod install_flow;` and +`mod staged_suite;` — neither appears in `installer/src/lib.rs`. Together with +ten functions in `main.rs` (`run_install`, `run_dry`, +`try_fast_path_installation`, `finish_install`, +`finish_install_and_record_metrics`, `resolve_requested_crates`, +`generate_and_report_wrapper`, `ensure_whitaker_workspace`, +`resolve_toolchain`, `ensure_toolchain_installed`) they hold the real +orchestration. + +Useful detail for `EP-M1`: items inside `installer/src/install_flow/mod.rs` +are already `pub(crate)`, and `PrebuiltInstallationHooks` at `:136` is fully +private. Once the module sits inside the library, `pub(crate)` already means +library-internal, so only a small named facade needs to become `pub`. Do not +over-promote. + +### The wrapper script — the contested name + +`installer/src/wrapper.rs:105` writes `bin_dir.join("whitaker")`, an +executable whose body is: + +```bash +export DYLINT_LIBRARY_PATH="{library_path}" +exec cargo dylint "$@" +``` + +This is not a convenience. It is currently the only way to run Whitaker's +lints. It is documented (`docs/users-guide.md:25-27, 51-57`), wired into +`Makefile:74-75` and `:185`, and run by `.github/workflows/ci.yml:154`. -- `run_install`, `run_dry`, `try_fast_path_installation`, `finish_install`, - `finish_install_and_record_metrics`, `resolve_requested_crates`, - `generate_and_report_wrapper`, `ensure_whitaker_workspace`, - `resolve_toolchain`, `ensure_toolchain_installed`, `exit_code_for_run_result` - (all in `installer/src/main.rs`); -- `install_flow::try_prebuilt_installation`, `install_flow::detect_host_target` - and the `PrebuiltInstallationHooks` struct - (`installer/src/install_flow/mod.rs`); -- `staged_suite::try_test_staged_suite_installation` - (`installer/src/staged_suite.rs`, a debug-only test hook). +`Makefile:9` prepends `$(HOME)/.cargo/bin` to `PATH` while `Makefile:6` +appends `$HOME/.local/bin`. So a `cargo install`ed binary at +`~/.cargo/bin/whitaker` **deterministically wins** over the script at +`~/.local/bin/whitaker`. `Makefile:99-139` wraps the whole test run in a +`trap`-based backup and restore of that file, failing loudly on modification — +somebody has already been burned by it. -Moving those behind a library boundary is the "internal library boundary" half -of this task. +That is why this plan forwards unrecognized arguments rather than rejecting +them (`Decision log` D-2). -### Existing seams +### Existing dependency-injection seams -The installer is not a monolith. It already has dependency-injection seams, -but they are inconsistent — three different styles for the same concern: +The installer already has seams, in three styles for one concern: -| Trait / seam | Defined at | Style | +| Trait or seam | Defined at | Style | | --- | --- | --- | -| `deps::CommandExecutor` | `installer/src/deps/mod.rs:36` | public trait object | -| `toolchain::CommandRunner` | `installer/src/toolchain/mod.rs:44` | **private** trait object, same shape | -| `dirs::BaseDirs` | `installer/src/dirs.rs:41` | public trait object, `mockall` | -| `builder::CrateBuilder` | `installer/src/builder.rs:58` | public trait object, `mockall` | -| `artefact::download::ArtefactDownloader` | `installer/src/artefact/download.rs:29` | public trait object, `mockall` | -| `install_flow::PrebuiltInstallationHooks` | `installer/src/install_flow/mod.rs:135` | **bare `fn` pointers** | +| `deps::CommandExecutor` | `installer/src/deps/mod.rs:36` | public trait, `mockall` | +| `toolchain::CommandRunner` | `installer/src/toolchain/mod.rs:44` | **private** trait, `mockall`, same shape | +| `dirs::BaseDirs` | `installer/src/dirs.rs:41` | public trait, `mockall` | +| `builder::CrateBuilder` | `installer/src/builder.rs:58` | public trait, `mockall` | +| `artefact::download::ArtefactDownloader` | `installer/src/artefact/download.rs:29` | public trait, `mockall` | +| `install_flow::PrebuiltInstallationHooks` | `installer/src/install_flow/mod.rs:136` | bare `fn` pointers | _Table 1: Existing dependency-injection seams in the installer._ -And three places spawn processes with no seam at all: -`installer/src/git.rs` (`Command::new("git")`), -`installer/src/builder.rs:83` (`Command::new("cargo")` inside -`Builder::build_crate`), and `install_flow::detect_host_target` -(`Command::new("rustc")`). +Three places spawn processes with no seam at all: `installer/src/git.rs`, +`Builder::build_crate` (`installer/src/builder.rs:83`), and +`install_flow::detect_host_target`. -This plan does **not** unify all of those. Doing so would be a large, -independently valuable refactor with its own risk profile. This plan defines -the ports the new CLI needs and implements them over the installer library as -it stands, leaving the installer's internal seam inconsistency for a later -item. That choice is recorded in `Decision log`. +This plan does **not** unify them (`Decision log` D-5). ### Where tests live -- Unit tests: colocated, either `#[cfg(test)] mod tests` inline or a sibling - `_tests.rs` file declared with `#[cfg(test)] mod foo_tests;`. -- Behavioural tests: `/tests/behaviour_*.rs` integration binaries, - paired with Gherkin files in `/tests/features/*.feature`, bound with - `#[scenario(path = "...", index = N)]`. **The bindings are index-based; - reordering scenarios in a feature file silently rebinds them.** See the - warning comment at `installer/tests/behaviour_cli/scenarios.rs:7`. -- Shared behavioural state uses a "World" struct fixture, for example - `CliWorld` in `installer/tests/behaviour_cli/support.rs`. -- End-to-end CLI tests spawn the binary via the Cargo-provided - `env!("CARGO_BIN_EXE_")`. There is no `assert_cmd` in this - workspace. -- Snapshots live in a `snapshots/` directory beside the test that writes them, - with `insta`'s default `____.snap` naming. Two exist - today, for example - `crates/whitaker_clones_core/src/ast/snapshots/whitaker_clones_core__ast__lowering__tests__ast_feature_vector_add_function.snap`. -- Kani harnesses are `#[cfg(kani)]` submodules beside the code, run by - `scripts/run-kani.sh` via `make kani`. -- Verus proofs are standalone files in `verus/` at the repository root, run by +- Unit tests: colocated, inline `#[cfg(test)] mod tests` or a sibling + `_tests.rs` declared with `#[cfg(test)] mod foo_tests;`. +- Behavioural tests: `/tests/behaviour_*.rs` with Gherkin files in + `/tests/features/*.feature`, bound by `#[scenario(path, index)]`. + Shared state uses a "World" fixture; see + `installer/tests/behaviour_cli/support.rs`. +- End-to-end CLI tests spawn `env!("CARGO_BIN_EXE_")`. **Cargo sets + that variable only for integration tests of the package that declares the + binary.** There is no `assert_cmd` in this workspace. +- Snapshots: a `snapshots/` directory beside the test, `insta`'s default + `____.snap` naming. +- Kani: `#[cfg(kani)] mod verification` colocated with the code — see + `docs/developers-guide.md:737` — run by `scripts/run-kani.sh` via + `make kani`. +- Verus: standalone files in `verus/` at the repository root, run by `scripts/run-verus.sh` via `make verus`. They are _models_ of the implementation, not proofs of the literal Rust source; the trust boundary is documented in `docs/developers-guide.md`. ### The gates -Run from the repository root. All four must pass before any commit. - ```console -$ make check-fmt # cargo fmt --all -- --check -$ make typecheck # cargo check --workspace --all-targets --all-features -$ make lint # cargo doc, cargo clippy -D warnings, and the Whitaker suite -$ make test # cargo nextest run over the workspace minus TEST_EXCLUDES, - # then cargo test --workspace --doc --all-features +make check-fmt +make typecheck +make lint +make test +make test NEXTEST_PROFILE=ci +make markdownlint ``` -Capture output for review, because long output is truncated in agent -transcripts: +Capture output, because long transcripts are truncated: ```console -make test 2>&1 | tee /tmp/test-whitaker-3-5-1-root-whitaker-binary.out +make test 2>&1 | tee /tmp/test-whitaker-3-5-1.out ``` -Markdown changes additionally need `make markdownlint`. Do **not** run -`make fmt` for a targeted documentation edit: it runs `mdformat-all` and -reflows every Markdown file in the repository. +Run them **sequentially** — this environment relies on build caching and +concurrent Cargo jobs contend on the package-cache lock. Do not run `make fmt` +for a targeted documentation edit; it reflows every Markdown file. Note that +`make markdownlint` also runs a `typos` spelling gate enforcing Oxford +spelling (`-ize`, but `behaviour` keeps `-our`), and `typos` only inspects +git-tracked files — an untracked draft passes and then fails once committed. ## Conformance basis -There is no Terms of Reference document in this repository. The upstream -artefacts are: +No Terms of Reference document exists. Upstream artefacts: -- **Design:** `docs/whitaker-cli-design.md`, at the revision present in the - working tree, specifically §Public CLI surface and §Compatibility and - migration. Referred to below as `CLI-DESIGN`. -- **Roadmap:** `docs/roadmap.md` item 3.5.1 (line 145). Its stated - prerequisite, item 3.2.1, is marked done (line 108). +- **Design:** `docs/whitaker-cli-design.md` at the revision in the working + tree, §Public CLI surface and §Compatibility and migration. Cited as + `CLI-DESIGN`. +- **Roadmap:** `docs/roadmap.md` item 3.5.1 (line 145). Its prerequisite, + 3.2.1 (line 108), is done. - **ADRs:** `docs/adr-001-prebuilt-dylint-libraries.md` constrains the - prebuilt-artefact path this plan must not disturb. No existing ADR covers - the CLI boundary; this plan creates one (see `EP-M4`). -- **Standards:** `AGENTS.md`, `docs/documentation-style-guide.md`, - `docs/scripting-standards.md`. - -Requirement identifiers used in this plan, each quoting or paraphrasing -`CLI-DESIGN`: + prebuilt path. No ADR covers the CLI boundary; this plan creates + `docs/adr-005-whitaker-cli-boundary.md`. +- **Standards:** `AGENTS.md`, `docs/documentation-style-guide.md`. | ID | Statement | Source | | --- | --- | --- | | `CLI-REQ-BIN` | "Add a real `whitaker` binary at the root package" | `CLI-DESIGN` §Compatibility and migration, step 1 | -| `CLI-REQ-LIB` | "move the current installer logic behind an internal library boundary" | `CLI-DESIGN` §Compatibility and migration, step 1 | -| `CLI-REQ-BINSTALL` | "copy the working `cargo-binstall` metadata pattern from `whitaker-installer` onto `whitaker`" | `CLI-DESIGN` §Compatibility and migration, step 1 | -| `CLI-REQ-LS` | "`whitaker-ls` disappears in favour of `whitaker ls`"; `ls` "must support `--json`" | `CLI-DESIGN` §Public CLI surface, §Bundle manifests | -| `CLI-REQ-L10N` | "Every human-facing string, including `--help` … should be localizable"; command names and rule codes "are never translated" | `CLI-DESIGN` §Accessibility and localization requirements | -| `CLI-REQ-EXIT` | "install and configuration failures should produce distinct operational errors" | `CLI-DESIGN` §`whitaker check` | -| `CLI-REQ-SHIM` | `whitaker-installer` "survives for one compatibility release as a thin shim" | `CLI-DESIGN` §Public CLI surface | +| `CLI-REQ-LIB` | "move the current installer logic behind an internal library boundary" | ditto | +| `CLI-REQ-BINSTALL` | "copy the working `cargo-binstall` metadata pattern from `whitaker-installer` onto `whitaker`" | ditto | +| `CLI-REQ-LS` | "`whitaker-ls` disappears in favour of `whitaker ls`" | `CLI-DESIGN` §Public CLI surface | +| `CLI-REQ-L10N` | "Every human-facing string, including `--help` … should be localizable"; command names are never translated | `CLI-DESIGN` §Accessibility and localization | +| `CLI-REQ-FWD` | Derived from Constraint 2; no upstream text. Recorded as a deviation in `Decision log` D-2 and added to `CLI-DESIGN` in `EP-M4`. | this plan | _Table 2: Upstream requirements traced by this plan._ -Trace chain: - ```plaintext -CLI-REQ-BIN -> EP-M2 -> tests::e2e::whitaker_help_lists_install_and_ls -CLI-REQ-LIB -> EP-M1 -> whitaker_cli::domain unit suite + EP-INV-PARITY -CLI-REQ-BINSTALL -> EP-M3 -> tests::behaviour_binstall::whitaker_package_metadata - -> EP-LEM-NAME (verus/whitaker_artefact_naming.rs) -CLI-REQ-LS -> EP-M2 -> tests::snapshot::ls_json_and_text -CLI-REQ-L10N -> EP-M2 -> tests::e2e::help_parses_through_localizer -CLI-REQ-EXIT -> EP-M1 -> EP-INV-EXIT (kani + rstest) -CLI-REQ-SHIM -> deferred to roadmap 3.9.1, not this plan +CLI-REQ-BIN -> EP-M0, EP-M2 -> tests::behaviour_whitaker::help_lists_subcommands +CLI-REQ-LIB -> EP-M1 -> installer/tests/ unchanged + EP-INV-PARITY +CLI-REQ-BINSTALL -> EP-M3 -> tests::behaviour_binstall::whitaker_package_metadata +CLI-REQ-LS -> EP-M2 -> tests::behaviour_whitaker::ls_text_and_json +CLI-REQ-L10N -> EP-M2 -> localized parse entry point; NoOpLocalizer +CLI-REQ-FWD -> EP-M2 -> EP-INV-DISPATCH (Kani) + EP-LEM-DISPATCH (Verus) ``` -Requirements explicitly **not** discharged here, with their owning roadmap -item: `whitaker check` (3.5.2); release artefacts and CI packaging (3.5.3); -rule codes and selector precedence (3.6.1, 3.6.2); `whitaker.toml`, -`dylint.toml` bridging and `DYLINT_*` migration (3.6.3); `--locale`/`--colour` -/`--progress` (3.6.4); unified install internals (3.7.x); `doctor`, failure -recording, bundle manifests (3.8.x); the `whitaker-installer` deprecation shim -and `list` alias (3.9.1). - -## Constraints - -Hard invariants. Violation requires escalation, not a workaround. - -1. **`whitaker-installer` keeps working unchanged.** Its command-line surface, - exit codes, and output must be byte-identical before and after. It is a - released binary documented in `docs/users-guide.md`, published to GitHub - Releases, installed by `make install-smoke`, and exercised by - `installer/tests/behaviour_cli.rs`. `CLI-DESIGN` schedules its deprecation - for a later release (`CLI-REQ-SHIM`, roadmap 3.9.1), not this one. This is - not a compatibility shim invented to make a milestone viable: it is the - currently shipping product, and this plan adds a second entry point beside - it rather than replacing it. -2. **The public library surface of `whitaker_installer` must not shrink.** The - integration tests under `installer/tests/` compile against it as an - external crate and can only see `pub` items. `installer/Cargo.toml` lines - 29-55 gate `StubExecutor` and `InstallerError::StubMismatch` behind the - `test-support` feature, which roadmap item 3.2.2 declares a supported - surface for external test suites. Items may be added; existing ones may not - be removed or narrowed. -3. **The root package must remain named `whitaker` at version `0.2.7`**, and - the new binary must be named `whitaker`, so that `cargo install whitaker` - and `cargo binstall whitaker` resolve correctly. -4. **No new lint suppressions.** `Cargo.toml` `[workspace.lints]` sets - `unsafe_code = "forbid"`, `missing_docs = "deny"`, `allow_attributes = - "deny"` and clippy `pedantic` at warn with `-D warnings`. Adding - `#[allow(...)]` to get past a gate is a tolerance breach. -5. **No file may exceed 400 lines** (`AGENTS.md`). Every module needs a `//!` - doc comment. -6. **Direct environment mutation in tests is forbidden** (`AGENTS.md`). Use - `temp-env`, or dependency injection through a port. -7. **The prebuilt-artefact download path must not change behaviour.** It is - governed by `docs/adr-001-prebuilt-dylint-libraries.md` and by the release - workflow's published asset names. -8. **Caret dependency requirements only** (`AGENTS.md`); no `*` or `>=`. - -## Tolerances (exception triggers) - -Stop and escalate — do not improvise — when any of these is reached. - -- **Scope.** More than 45 files changed, or more than 2,500 net added lines - across the whole plan. The estimate is roughly 30 files and 1,800 lines. -- **Root-binary feasibility.** If `EP-M0` shows a binary in the root package - cannot build under `--all-features`, stop at the end of `EP-M0` and - escalate with the options in `Risk R1`. Do not silently relocate the binary - to a differently-named package: that would break `cargo install whitaker` - and violate Constraint 3. -- **Interface.** If discharging `CLI-REQ-LIB` requires removing or narrowing - any existing `pub` item in `whitaker_installer`, stop (Constraint 2). -- **Dependencies.** Four new dependencies are pre-authorized and listed in - `Interfaces and dependencies`: `ortho_config`, `googletest`, - `pretty_assertions`, and `insta` promoted to a root dev-dependency. Any - fifth new external dependency triggers escalation. -- **Iterations.** If a gate still fails after three fix attempts on the same - root cause, stop and escalate with the captured log path. -- **Verification.** If a Kani harness exceeds 15 minutes, or a Verus proof - exceeds 5 minutes, stop and escalate rather than raising the bound or the - timeout. -- **Behaviour drift.** If any existing `installer/tests/` test needs its - assertions changed (as opposed to being moved or added to), stop: that is - evidence of a Constraint 1 violation. -- **Ambiguity.** If `CLI-DESIGN` and `docs/roadmap.md` disagree on whether a - behaviour belongs to 3.5.1, stop and present both readings. - -## Risks - -**R1 — A binary in the root package may not build under `--all-features`.** -Severity: high. Likelihood: medium-high. -The root library sets `feature(rustc_private)` and links `rustc_driver` when -`dylint-driver` is enabled. `make typecheck`, `make lint`, and `make test` all -pass `--all-features`, which enables it. `src/lib.rs` records that all-features -test runs produce "duplicated `std`/`core` link errors", and `Makefile` line 24 -excludes the package from the test run for that reason. A binary target in the -same package may inherit the same failure. -Mitigation: `EP-M0` is a timeboxed prototyping milestone that answers this -empirically before any design is committed. If it fails, the recommended -remedy is to extract the Dylint driver library out of the root package into -`crates/whitaker_lint_core`, leaving the root package as the CLI package — -which permanently removes the conflict — but that is a scope increase -requiring approval, not an autonomous decision. - -**R2 — Index-based BDD scenario bindings break silently.** -Severity: medium. Likelihood: medium. -`#[scenario(path = "...", index = N)]` binds by position. Inserting a scenario -in the middle of an existing `.feature` file rebinds every later scenario to -the wrong step definitions, and the suite may still pass. -Mitigation: put all new scenarios in **new** feature files -(`crates/whitaker_cli/tests/features/*.feature`); never insert into -`installer/tests/features/installer.feature`. `EP-M2` acceptance includes -re-running `installer/tests/behaviour_cli.rs` unchanged. - -**R3 — Two packages publishing artefacts with one URL template may collide.** -Severity: high. Likelihood: low-medium. -`installer/Cargo.toml` lines 95-101 template the release URL as -`{name}-{target}-v{version}.{archive-format}`. Giving the `whitaker` package -the same pattern means two packages generate asset names from the same -template. Because `whitaker` is a proper prefix of `whitaker-installer`, and -target triples themselves contain `-`, an ambiguous split is conceivable. -Mitigation: `EP-LEM-NAME`, a Verus proof that the composed name determines its -fields uniquely, plus a `proptest` differential check. See `Verification -plan`. - -**R4 — `--help` and `--version` may exit non-zero.** -Severity: medium. Likelihood: medium. -clap reports `--help` as an `Err` variant. Routing every `Err` to exit code 1 -would make `whitaker --help` fail. `ortho_config::is_display_request` exists -precisely to distinguish this case, and it is easy to omit. -Mitigation: `EP-INV-EXIT` covers it with both a parameterized test and an -end-to-end assertion on the real process exit status. - -**R5 — Promoting binary-private modules widens the public API.** -Severity: medium. Likelihood: medium. -`install_flow` and `staged_suite` are binary-private today. Making them -reachable from a new crate could expose test-only machinery — `staged_suite` -in particular is a debug-only hook driven by the -`WHITAKER_INSTALLER_TEST_STAGE_SUITE` environment variable. -Mitigation: promote to `pub` only what the new ports need; keep -`staged_suite`'s hook behind the existing `#[cfg(debug_assertions)]` and -`test_support` gating; record the resulting surface in the `EP-M4` ADR. - -**R6 — `ortho_config` pulls a large dependency subtree.** -Severity: low. Likelihood: high (it is certain; the question is whether it -matters). `ortho_config` 0.9.0 depends on `figment`, `fluent-bundle`, -`fluent-syntax`, `unic-langid`, `clap-dispatch`, `directories`, `xdg`, and -more. -Mitigation: adopt it in this plan for localized parsing only, so the cost is -paid once at the point the roadmap already commits to it (item 3.6.3), not -twice. Confirm `make typecheck` build time does not regress by more than 30%; -report if it does. - -**R7 — Snapshot tests of `--help` are brittle across clap versions.** -Severity: low. Likelihood: medium. -`insta` snapshots of help text change whenever clap adjusts its formatting. -Mitigation: snapshot the _structure_ — the subcommand list and the option -names — rather than full rendered help; assert full text only for the -stable `ls --json` output, which is a machine contract. +Explicitly **not** discharged here, with owning roadmap items: `whitaker +check` (3.5.2); release-artefact publishing (3.5.3); rule codes and selector +precedence (3.6.1, 3.6.2); `whitaker.toml`, `dylint.toml` bridging and +`DYLINT_*` migration (3.6.3); `--locale`/`--colour`/`--progress` (3.6.4); the +`--build-only` → `--build-from-source` rename and `--offline` (3.7.1); bundle +manifests (3.7.3); `doctor` and failure recording (3.8.x); the full `ls` +surface (3.8.1, 3.8.2); the `whitaker-installer` deprecation shim and `list` +alias (3.9.1); wrapper-script and `--skip-wrapper` removal (3.9.3). ## Verification plan -This change is mostly a refactor plus a new entry point, so it would be easy -to claim it introduces no invariants. That is not true, and saying so would be -the vacuous option. Three genuine obligations arise, and one lemma. - -### Axioms (assumed, not verified here) - -- `clap` 4.5 parses an argument vector into the derived struct according to - its documented derive semantics. Third-party internals are not verified. +The first draft proposed a Verus proof of release-asset-name injectivity and a +Kani harness proving that a routing function rejects two conflicting flag +pairs. Both were **vacuous** and have been cut. Nothing in this repository +ever inverts an asset name — `cargo-binstall` composes by literal +substitution, and a grep for `rsplit_once|splitn|strip_prefix` across +`installer/src/` finds nothing — so injectivity of a never-inverted function +is load-bearing for nothing. And both conflict pairs are enforced by clap +attributes (`installer/src/cli.rs:108`, `:113`, `:131`), which reject the +input during parsing, so a routing function can never receive one; proving it +rejects them proves a property of an unreachable branch. Recording this is +required by the ExecPlan discipline: a passing check that cannot fail is not +evidence. + +What replaced them is a genuinely new obligation. The forwarding behaviour +(`CLI-REQ-FWD`) introduces a decision procedure that did not previously exist +and that no third-party library owns: given argv, dispatch a subcommand or +forward to `cargo dylint`. Getting it wrong silently breaks either the new CLI +or every existing user. That is worth verifying properly. + +### Axioms (assumed, not verified) + +- `clap` 4.5 parses argv per its documented derive semantics. - `ortho_config` 0.9.0's `LocalizedParse::try_parse_localized_from` and `is_display_request` behave as documented. Repository-owned logic built on them **is** verified, against the real interface. -- `cargo-binstall` resolves `pkg-url` by substituting `{name}`, `{target}`, - `{version}`, and `{archive-format}` literally. -- The GitHub release workflow publishes assets under exactly the names - produced by `installer/src/artefact/naming.rs`. -- Kani sequentializes concurrency; no obligation below concerns concurrency. - -### EP-INV-PARITY — install-argument parity - -- **Obligation.** For every argument vector `v` that `whitaker-installer` - accepts as an install invocation, `whitaker install v` parses to an - `InstallRequest` equal to the one `whitaker-installer` produces from `v`; - and for every `v` that `whitaker-installer` rejects, `whitaker install v` is - rejected too. -- **Method.** Property test (`proptest`), differential. -- **Rationale.** This is the precise formal content of "move the current - installer behaviour behind a library boundary _without changing it_". The - flag surface has 14 options with two documented conflict pairs; enumerating - it by hand would miss combinations, and the space is far too large for - bounded model checking over strings. -- **Domain.** Generated argument vectors over the 14 flags declared in - `installer/src/cli.rs:77-181`, including repeated `--lint`, repeated `-v`, - the `--lint` / `--individual-lints` conflict, the `-v` / `-q` conflict, - paths containing spaces and non-ASCII characters, and empty values. -- **Artefact.** `crates/whitaker_cli/tests/property_arg_parity.rs`. -- **Evidence.** `cargo nextest run -p whitaker_cli property_arg_parity`. Red - stage: written before the mapping exists, so it fails to compile, then fails - on a deliberately incomplete mapping that drops `--jobs`. Discharged when it - passes with 1,024 generated cases and the regression file is committed. -- **Non-vacuity.** The generator must be _classified_: record via - `proptest::prop_assume!`-free construction and explicit - `Strategy::prop_map` that each of the 14 flags appears set in at least 5% of - cases, that both conflict pairs are generated, and that at least one case - has zero flags. A run where any flag is never exercised is a **failure**, - not a pass. Negative control: temporarily drop `--no-update` from the - `whitaker install` mapping; the test must fail naming that flag. Restore - afterwards and record the transcript. - -### EP-INV-ROUTE — routing totality and conflict rejection - -- **Obligation.** The function mapping a parsed CLI to a domain `Request` is - total (never panics, never returns a "cannot happen" error) over all - reachable flag combinations, and rejects exactly the two documented conflict - pairs. -- **Method.** Bounded model check (Kani), complemented by parameterized - `rstest` cases. -- **Rationale.** Totality over a combinatorial flag space is exactly what - bounded exhaustive exploration is for, and the space is small enough to - explore completely once flags are modelled as booleans rather than strings. - A property test would sample it; Kani covers it. -- **Domain.** The boolean flags modelled as a bitmask, plus a bounded - `Option` for `--jobs` and a bounded 0-3 count for `-v`. Ten booleans - gives 1,024 states; with the two bounded integers the harness explores under - 10^5 states, well inside Kani's practical range for non-heap types. - `#[kani::unwind(4)]` bounds the single loop over requested lints, capped at - three entries. -- **Artefact.** `crates/whitaker_cli/src/domain/routing/kani.rs`, gated - `#[cfg(kani)]`, registered in `scripts/run-kani.sh` alongside the existing - named harnesses. +- `cargo-binstall` resolves `pkg-url` by literal substitution of `{name}`, + `{target}`, `{version}`, `{archive-format}`. +- The wrapper script's contract is `exec cargo dylint "$@"` with + `DYLINT_LIBRARY_PATH` set (`installer/src/wrapper.rs:107-113`). +- Kani sequentializes concurrency; no obligation here concerns concurrency. + +### `EP-INV-DISPATCH` — argv classification is total and disjoint (bounded) + +- **Obligation.** For every argv, `classify(argv)` returns exactly one of + `Subcommand(_)`, `Forward`, or `Display`. No argv whose first non-global + token names a Whitaker subcommand is ever classified `Forward`, and no argv + the wrapper script would have accepted is classified as a usage error. +- **Method.** Bounded model check (Kani), plus parameterized `rstest` cases. +- **Rationale.** Totality and disjointness over a combinatorial token space is + what bounded exhaustive exploration is for, and `classify` is small and pure. +- **Domain.** argv modelled as a bounded sequence of _token tags_, not + strings: an enum over `{Install, Ls, Help, Version, DoubleDash, GlobalFlag, + DylintFlag, Other}`, length bounded at 6 — under 10^5 states. + `#[kani::unwind(7)]`, one greater than the maximum iteration count. +- **Siting.** `classify` and its token enum live in `src/cli/dispatch.rs` in + the root package and take the token enum rather than `clap::Cli`, so the + harness compiles a small pure module. This is deliberate: siting Kani where + it must codegen `clap` plus `ortho_config` plus `whitaker_installer` would + be a roughly 250-crate graph, and a symbolic `Vec` hits the + documented heap cliff. Tokenizing real argv is a separate, testable function + that is _not_ part of the harness. +- **Artefact.** `src/cli/dispatch.rs`, `#[cfg(kani)] mod verification`. + Register a new `whitaker-cli` group in `scripts/run-kani.sh`, adding both a + group function and a `case` arm. **Note the `*)` fallback at + `scripts/run-kani.sh:97` routes unrecognized arguments into the + _decomposition_ group**, so a mistyped filter silently verifies the wrong + package and reports success. Add the arm before the fallback and confirm the + harness names appear in the output. - **Evidence.** `make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1.out`. Expect - `VERIFICATION:- SUCCESSFUL` for - `verify_route_request_is_total_over_bounded_flags` and - `verify_route_request_rejects_documented_conflicts`. -- **Non-vacuity.** The harness must drive the **production** routing function, - not a re-implementation. Assumptions must not collapse the space: assert - before the main property that at least one satisfying assignment reaches - each of the three routing outcomes (install, list, conflict-rejected) by - running three separate `#[kani::proof]` reachability harnesses that assert - `false` under a constraint selecting that outcome, and confirming each - reports a counterexample — proving the branch is reachable. Negative - control: remove the `--lint` / `--individual-lints` conflict check from the - production function; `verify_route_request_rejects_documented_conflicts` - must fail with a concrete counterexample. Restore and record. - -### EP-INV-EXIT — exit-code policy - -- **Obligation.** The process exit code is `0` for success and for a clap - display request (`--help`, `--version`); `1` for an operational failure. No - input produces any other code, and no display request produces a non-zero - code. -- **Method.** Parameterized tests (`rstest` with `googletest` matchers) over - the finite partition of outcome kinds, plus an end-to-end assertion on the - real spawned process. -- **Rationale.** The outcome space is a small finite partition — the natural - fit for parameterized testing. The end-to-end case is what makes it - non-vacuous, because the unit-level mapping can be right while `main` - discards it. -- **Domain.** Every variant class of `InstallerError` grouped by kind, plus - `Ok(())`, plus clap `ErrorKind::DisplayHelp` and `DisplayVersion`, plus a - genuine parse error. -- **Artefact.** `crates/whitaker_cli/src/domain/exit_tests.rs` and - `crates/whitaker_cli/tests/e2e_exit_codes.rs`. -- **Evidence.** `cargo nextest run -p whitaker_cli exit`. Red: the e2e test - asserting `whitaker --help` exits 0 fails against a naive `Err => 1` - implementation. -- **Non-vacuity.** The e2e test spawns the real binary through - `env!("CARGO_BIN_EXE_whitaker")` and reads `ExitStatus::code()`, so a - mapping that is correct in a unit but unwired in `main` is caught. Negative - control: drop the `is_display_request` branch; `whitaker --help` must then - exit 1 and the test must fail. - -### EP-LEM-NAME — release-asset name unambiguity - -- **Obligation.** The composed release-asset name - `{name}-{target}-v{version}.{ext}` determines `(name, target, version)` - uniquely. Formally: for well-formed field triples `(n₁,t₁,vs₁)` and - `(n₂,t₂,vs₂)` drawn from the admissible alphabets, if - `compose(n₁,t₁,vs₁) = compose(n₂,t₂,vs₂)` then the triples are equal. -- **Method.** Formal proof (Verus), plus a `proptest` differential check - against the Rust implementation. -- **Rationale.** This is a genuine new obligation created by this change, not - a restatement. Before this plan only `whitaker-installer` published under - this template. Adding `whitaker` — a **proper prefix** of - `whitaker-installer` — into a template whose separator `-` also occurs - inside every target triple creates a real ambiguity hazard: a wrong split - means `cargo binstall whitaker` silently fetches the installer's archive. - The guarantee must hold for all admissible inputs, not a sampled subset, so - a prover rather than a property test is the right instrument; the property - test then ties the proven model back to the Rust code. -- **Domain.** Unbounded. `name` over `[a-z0-9_-]+` drawn from the published - package set; `target` a Rust target triple; `version` a semantic version - string. The proof proceeds by showing the `-v` delimiter preceding the - version cannot occur inside a well-formed target triple, which pins the - version boundary, and that the package-name set is prefix-free **once the - following separator is included** — the non-obvious step, and the one that - fails if a future package is named such that the property breaks. -- **Artefact.** `verus/whitaker_artefact_naming.rs`, added to the - `decomposition`/`clone-detector` group structure in `scripts/run-verus.sh` - as a new `packaging` group. + `VERIFICATION:- SUCCESSFUL` for `verify_classify_is_total_and_disjoint`. +- **Non-vacuity.** Three separate reachability harnesses each assert `false` + under a constraint selecting one outcome; each **must report a + counterexample**, proving that outcome reachable. A harness that verifies + successfully here is a failure. Negative control: remove `Ls` from the + subcommand table; `verify_classify_is_total_and_disjoint` must fail with a + concrete argv that names `ls` yet classifies as `Forward`. Restore and + record the transcript. + +### `EP-LEM-DISPATCH` — classification is total for unbounded argv + +- **Obligation.** For every finite token sequence of _any_ length, exactly one + classification applies. Kani bounds length at 6; this closes the tail. +- **Method.** Formal proof (Verus) over `Seq`. +- **Rationale.** The guarantee must hold for all admissible inputs — a user's + `cargo dylint` invocation has no length bound. Bounded checking cannot + establish it. The proof is a genuine mutual-exclusivity and exhaustiveness + argument over the guard predicates, not a restatement. +- **Domain.** Unbounded `Seq`. This would be the repository's first + sequence-shaped proof; every file in `verus/` today is numeric or + vector-algebraic. Budget accordingly and respect the verification tolerance. +- **Artefact.** `verus/whitaker_cli_dispatch.rs`, added as a new `cli` group + in `scripts/run-verus.sh` — which needs **both** the `case` at `:10-26` and + the second `case` at `:53-54` edited, not one. - **Evidence.** `make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1.out`. Expect `verification results:: N verified, 0 errors`. -- **Non-vacuity.** The proof must not assume its conclusion. Inspect it for - `assume`: there must be none in the final version, and the well-formedness - predicates must be shown _inhabited_ by an explicit witness lemma exhibiting - a concrete satisfying triple (`whitaker`, `x86_64-unknown-linux-gnu`, - `0.2.7`) before the injectivity theorem is stated — otherwise the theorem is - vacuously true over an empty domain. Negative control: weaken the - well-formedness predicate to permit a package name containing `-v` followed - by digits; the injectivity proof must then fail. Record that failure - transcript before restoring the predicate. - -### Deliberately not verified - -- The internals of `clap`, `ortho_config`, `figment`, or `cargo-binstall`. -- The installer's existing behaviour beyond parity. This plan asserts the new - binary matches the old one; it does not re-verify what the old one does. - That is already covered by `installer/tests/`. -- Localization catalogue content. `EP-M2` wires `NoOpLocalizer`, so there is - no translation logic to verify. Fluent catalogues arrive with roadmap 3.6.4. +- **Non-vacuity.** No `assume` in the final proof. A witness lemma must + exhibit an inhabiting sequence for each of the three classes _before_ the + disjointness theorem is stated, or the theorem is vacuously true over an + empty domain. Negative control: widen one guard so two overlap; the + disjointness proof must fail. Record that transcript before restoring. + +### `EP-INV-PARITY` — install-argument parity + +- **Obligation.** For every argv `v` that `whitaker-installer` accepts as an + install invocation, `whitaker install v` produces an equal `InstallRequest`; + and every `v` the installer rejects, `whitaker install v` rejects with the + same exit code. +- **Method.** Differential property test (`proptest`). +- **Rationale.** This is the precise formal content of "move the behaviour + without changing it". Fourteen options with two conflict pairs is far too + large to enumerate and too string-shaped for a model checker. +- **Domain.** Generated argv over the 14 options declared across `InstallArgs` + (`installer/src/cli.rs:77-123`) and its three `#[command(flatten)]` groups + `LintSelectionFlags` (`:129`), `ExecutionFlags` (`:143`), `SkipFlags` + (`:157`). **Must include** short forms (`-t`, `-l`, `-j`, `-v`, `-q`), the + long alias `--verbosity` (`:107`), repeated `--lint`, repeated `-v`, both + conflict pairs, paths with spaces and non-ASCII characters, and the explicit + `whitaker-installer install …` form as well as the bare form. +- **Artefact.** `tests/property_arg_parity.rs` in the root package. +- **Evidence.** `cargo nextest run -p whitaker property_arg_parity`. Red: a + deliberately incomplete mapping that drops `--jobs` must fail naming it. +- **Non-vacuity.** Classify on **pairs**, not single flags: 2^14 is 16,384 + subsets, so marginal per-flag coverage says nothing about the combinations + where the conflicts live. Require every unordered pair of options to co-occur + in at least one case, and every option to appear set in at least 5% of cases. + Run 4,096 cases — clap parsing is tens of microseconds, so this costs well + under a second. A run where any pair is never exercised is a **failure**. + Negative control: drop `--no-update` from the mapping; the test must fail + naming it. + +### `EP-INV-EXIT` — exit-code policy + +- **Obligation.** Exit `0` for success and for a clap display request + (`--help`, `--version`); **`2` for an argument-parsing or usage error**; `1` + for an operational failure. Forwarded invocations propagate `cargo dylint`'s + exit code unchanged. +- **Method.** Parameterized `rstest` over the finite partition, plus + end-to-end assertions on the real spawned process. +- **Rationale.** A small finite partition is exactly what parameterized tests + are for. The end-to-end case is what makes it non-vacuous: the unit mapping + can be right while `main` discards it. +- **Ground truth, not a free choice.** `installer/src/main.rs:42` calls + `Cli::parse()`, whose `Error::exit()` terminates with clap's code — **2** — + never reaching `exit_code_for_run_result` at `:391-398`, the only source of + `1`. So `whitaker-installer --lint x --individual-lints` exits 2 today, and + Constraint 1 plus `EP-INV-PARITY` require `whitaker install` to match. The + localized-parse arm must therefore use clap's own code, not a blanket + failure code. +- **Domain.** `Ok(())`; each variant class of `InstallerError`; clap + `DisplayHelp` and `DisplayVersion`; a genuine usage error; a forwarded + invocation returning a non-zero code. +- **Artefact.** `src/cli/exit.rs` unit tests and `tests/e2e_exit_codes.rs`, + **both in the root package** — because `env!("CARGO_BIN_EXE_whitaker")` is + defined only for integration tests of the package declaring the binary. This + is why `EP-M0` must remove `--exclude whitaker` from `TEST_EXCLUDES`; the + end-to-end obligation is otherwise unrunnable. +- **Evidence.** `cargo nextest run -p whitaker exit`. +- **Non-vacuity.** The end-to-end test spawns the real binary and reads + `ExitStatus::code()`. Negative control: drop the `is_display_request` branch; + `whitaker --help` must then exit non-zero and the test must fail. + +### Not verified, deliberately + +- Internals of `clap`, `ortho_config`, `figment`, or `cargo-binstall`. +- The installer's existing behaviour beyond parity — already covered by + `installer/tests/`. +- Localization catalogue content: `EP-M2` wires `NoOpLocalizer`, so there is + no translation logic yet. Catalogues arrive with 3.6.4. ## Plan of work -### Stage A — prototype and decide (EP-M0, no production code) - -Answer `Risk R1` before designing around either outcome. Create a throwaway -`src/main.rs` in the root package containing only: - -```rust -//! Feasibility spike: does a root-package binary build under --all-features? -fn main() { println!("spike"); } -``` - -Then run, capturing output: +### Stage A — extract the driver library (`EP-M0`) + +This is the precondition, not a spike. Do it first and completely. + +1. Create `crates/whitaker_lint_core` and move `src/config.rs`, `src/hir/`, + `src/lints/`, `src/testing/`, the `dylint-driver` feature, and the root + `tests/` files that exercise them (`build_config.rs`, `config_loading.rs`, + `lint_template.rs`, `locale_resolution.rs`, `nextest_ui_filter.rs`, + `ui_harness.rs`, plus `tests/features/` and `tests/support/`). +2. Repoint the eleven lint-crate manifests and `suite/`. Do not miss the + literal path dependency at + `crates/test_must_not_have_example/Cargo.toml:34`. +3. Strip the root `[package]` to a CLI package and add what crates.io + requires: `description`, `license.workspace = true`, + `repository.workspace = true`, `homepage.workspace = true`, + `documentation.workspace = true`. Remove the now-unused optional `rustc_*` + dependencies and the `dylint-driver` feature. **Also remove the unused + `whitaker-installer` dependency at `Cargo.toml:71`** — grep confirms zero + `whitaker_installer::` references under `src/` or `tests/` today — and + re-add it deliberately in `EP-M1`, so the before-and-after dependency + measurement is honest. +4. Remove `--exclude whitaker` from `TEST_EXCLUDES` (`Makefile:23`) **and** + decide `DOCTEST_EXCLUDES` (`Makefile:64-68`) explicitly. Expect R8: six + test binaries begin running for the first time. Fix what they surface; if + any fails for a pre-existing reason unrelated to this plan, record it in + `Surprises & discoveries` and escalate rather than papering over it. +5. Add `-p whitaker` to `WHITAKER_PACKAGES` (`Makefile:81`) so the project's + own lint suite covers the new code. Check `dylint.toml:35-52` + `excluded_crates`: it currently names `whitaker`, and the meaning of that + entry changes once the package changes character. Under Constraint 6, any + new entry needs escalation. + +**Gate — the whole point of the milestone:** ```console -cargo check -p whitaker --bins --all-features 2>&1 | tee /tmp/spike-a.out -cargo check --workspace --all-targets --all-features 2>&1 | tee /tmp/spike-b.out +cargo package -p whitaker --no-verify ``` -Then add `use whitaker::greet;` and a call to it, and repeat, because a bin -that never references the library may not link it — which would make the first -result misleading: +must succeed. Then `make check-fmt && make typecheck && make lint && +make test NEXTEST_PROFILE=ci`, and the Dylint UI suite must be unchanged. + +Then answer the link question the first draft got wrong. `cargo check` does +**not** invoke the linker, so it cannot observe duplicate `std`/`core` +symbols. Use: ```console -cargo check -p whitaker --bins --all-features 2>&1 | tee /tmp/spike-c.out +cargo build --workspace --bins --all-features +cargo test -p whitaker --all-features --no-run ``` -Go/no-go: +under the same `RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked +-D warnings"` the test recipe uses. After the extraction the root package no +longer enables `rustc_private` at all, so this should be clean; if it is not, +stop and escalate. -- **Both succeed:** proceed to Stage B with the binary in the root package. -- **Either fails with duplicate `std`/`core` symbols:** delete the spike file, - record the transcript in `Surprises & discoveries`, set status `BLOCKED`, - and escalate with the `Risk R1` options. Do not proceed. +### Stage B — red tests and feature specifications -Delete the spike file before Stage B regardless of outcome. +No production behaviour yet. -### Stage B — red tests and feature specifications +1. `tests/features/whitaker_cli.feature` — reproduced in full under + `Artefacts and notes`. +2. `tests/behaviour_whitaker.rs` with a `CliWorld` fixture modelled on + `installer/tests/behaviour_cli/support.rs`. Named to avoid R4. +3. `tests/e2e_exit_codes.rs` (`EP-INV-EXIT`). +4. `tests/property_arg_parity.rs` (`EP-INV-PARITY`). +5. `src/cli/dispatch.rs` with the token enum, `classify`, and the + `#[cfg(kani)] mod verification` harnesses including the three reachability + harnesses (`EP-INV-DISPATCH`). +6. `verus/whitaker_cli_dispatch.rs`, witness lemmas first + (`EP-LEM-DISPATCH`). -No production behaviour yet. Write the failing specifications first. - -Create the crate skeleton `crates/whitaker_cli/` with `src/lib.rs` containing -only module declarations and doc comments, and add it to the workspace. Add -the four dependencies. Then write, in this order: - -1. `crates/whitaker_cli/tests/features/whitaker_cli.feature` — the Gherkin - specification, reproduced in full under `Artefacts and notes`. -2. `crates/whitaker_cli/tests/behaviour_cli.rs` with step definitions and a - `CliWorld` fixture modelled on `installer/tests/behaviour_cli/support.rs`. -3. `crates/whitaker_cli/tests/e2e_exit_codes.rs` (`EP-INV-EXIT`). -4. `crates/whitaker_cli/tests/property_arg_parity.rs` (`EP-INV-PARITY`). -5. `crates/whitaker_cli/src/domain/routing/kani.rs` (`EP-INV-ROUTE`), plus the - three reachability harnesses. -6. `verus/whitaker_artefact_naming.rs` (`EP-LEM-NAME`), starting with the - witness lemma. - -Validation for Stage B: every one of the above must **fail**, and the failure -must be the expected one. Record each red transcript. A test that fails -because a module does not exist is acceptable only for the compile-time -skeleton; the behavioural and property tests must reach a genuine assertion -failure once the skeleton compiles. +Every one must **fail**, for the expected reason. Record each red transcript. ### Stage C — implementation -Build the library, then the binary, then the packaging metadata. Each step -below names its file and what goes in it; see `Interfaces and dependencies` -for exact signatures. - -1. **Promote the binary-private orchestration.** In `installer/src/lib.rs`, - add `pub mod install_flow;` and move `installer/src/install_flow/` into the - library. Move the orchestration functions currently in - `installer/src/main.rs` (`run_install`, `run_dry`, - `try_fast_path_installation`, `finish_install`, - `finish_install_and_record_metrics`, `resolve_requested_crates`, - `generate_and_report_wrapper`, `ensure_whitaker_workspace`, - `resolve_toolchain`, `ensure_toolchain_installed`) into a new - `installer/src/orchestration/` module tree, each file under 400 lines. - Leave `staged_suite` binary-private (`Risk R5`); expose only the single - entry point the fast path needs, behind its existing gating. - `installer/src/main.rs` becomes a thin composition root calling the - library. **No behaviour changes.** Run `make test` here: every existing - `installer/tests/` test must pass **unmodified**. If any assertion needs - changing, that is a Constraint 1 breach — stop. - -2. **Define the domain and ports** in `crates/whitaker_cli/src/domain/` and - `crates/whitaker_cli/src/ports/`. The domain owns `Request`, `Outcome`, - `ExitCode`, the routing function, and the exit-code policy. It imports - nothing from `std::process`, `std::fs`, or `whitaker_installer`. This is - the dependency rule, and it is checkable: `crates/whitaker_cli/src/domain/` - must contain no `use whitaker_installer` and no `use std::{fs, process}`. - -3. **Define the driving adapter** in `crates/whitaker_cli/src/cli/`: the clap - `Parser`/`Subcommand`/`Args` structs for `whitaker`, `whitaker install`, - and `whitaker ls`, mirroring `installer/src/cli.rs` field for field, plus - the `ortho_config` localized-parse entry point. - -4. **Define the driven adapters** in `crates/whitaker_cli/src/adapters/`, - implementing the ports over `whitaker_installer`'s now-public - orchestration. - -5. **Add the composition root** at `src/main.rs` in the root package: build - the adapters, call `whitaker_cli::run`, map the outcome to a process exit - code. Target under 60 lines. Declare `[[bin]] name = "whitaker"` with - `autobins = false` in the root `Cargo.toml`, matching the convention at +1. **Promote the installer orchestration.** Add `pub mod install_flow;` to + `installer/src/lib.rs`, move `installer/src/install_flow/` into the + library, and move the ten `main.rs` orchestration functions into a new + `installer/src/orchestration/` tree, each file under 400 lines. + `installer/src/main.rs` becomes a thin composition root. + `try_fast_path_installation` calls + `staged_suite::try_test_staged_suite_installation` + (`installer/src/main.rs:87`), so the fast path and the staged-suite hook + move together; keep the hook behind its existing `#[cfg(debug_assertions)]` + and `test_support` gating and expose only the facade the fast path needs. + `installer/src/main.rs` also carries `#[cfg(test)] mod tests;` at `:402`; + those unit tests move with their subjects. That is relocation, not an + assertion change, and does not trip the behaviour-drift tolerance. + **No behaviour changes.** Validate with `make test NEXTEST_PROFILE=ci` — + not plain `make test`, which skips `behaviour_cli` and + `behaviour_toolchain`, the very binaries that would detect a dispatch + regression. + +2. **Wrapper generation becomes a parameter.** Give + `generate_and_report_wrapper` an argument naming which scripts to write. + `whitaker-installer` passes `{whitaker, whitaker-ls}`, preserving + Constraint 1 exactly. `whitaker install` passes `{whitaker-ls}` — it must + not overwrite the binary the user just invoked. + +3. **Build the CLI in the root package.** `src/cli/` holds the clap types; + `src/cli/dispatch.rs` the classifier; `src/cli/exit.rs` the exit policy; + `src/adapters/` the driven adapters over `whitaker_installer`. The root + `Cli` carries `command: Command` (not `Option`) plus the shared `-q`/`-v` + flags that `CLI-DESIGN` declares common options; `whitaker install` + **re-exports** `whitaker_installer::cli::InstallArgs` rather than mirroring + it field for field, so parity is true by construction and `EP-INV-PARITY` + guards the request mapping rather than struct shape. + +4. **Argument forwarding.** When `classify` returns `Forward`, resolve the + staged library directory the way the wrapper script does, set + `DYLINT_LIBRARY_PATH`, and run `cargo dylint` with the original arguments, + propagating its exit code. Keep this in one small adapter behind + `LintRunner`. + +5. **Composition root** at `src/main.rs`: construct adapters, parse via + `ortho_config`, dispatch, map to an exit code. Target under 60 lines. + Declare `[[bin]] name = "whitaker"` with `autobins = false`, matching `installer/Cargo.toml:11-27`. -6. **Remove `--exclude whitaker` from `TEST_EXCLUDES`** if and only if - `EP-M0` showed the package tests cleanly; otherwise leave it and note in - `Surprises & discoveries` that root-package tests remain excluded, with all - `whitaker_cli` tests living in the non-excluded crate (which is why they - were put there). - -7. **Add binstall metadata.** Parameterize - `installer/src/binstall_metadata.rs` over the package name rather than - hardcoding `"whitaker-installer"` (currently at lines 52 and 77), and add - the `[package.metadata.binstall]` block to the root `Cargo.toml` mirroring - `installer/Cargo.toml:95-101`, including the +6. **binstall metadata, additively.** Do **not** change the signatures of + `expand_pkg_url` or `expand_bin_dir` (`installer/src/binstall_metadata.rs:45`, + `:73`) — they are consumed by + `installer/tests/behaviour_installer_release.rs:155`, + `installer/tests/behaviour_binstall.rs:10-18`, + `installer/src/installer_packaging_tests.rs:274`, and their own doctests, + so narrowing them trips the interface tolerance. Add + `expand_pkg_url_for(package, version, target)` and + `expand_bin_dir_for(package, version, target)`, and reimplement the + existing two-argument forms as delegates. `load_cargo_toml` resolves the + manifest through `env!("CARGO_MANIFEST_DIR")`, baked to `installer/` at + compile time, so give it a `&Path` parameter for the root package's test to + use. Then add `[package.metadata.binstall]` to the root `Cargo.toml` + mirroring `installer/Cargo.toml:95-101`, including the `overrides.x86_64-pc-windows-msvc` entry with `pkg-fmt = "zip"`. - -Validation after each numbered step: `make check-fmt && make typecheck && -make lint && make test`, captured with `tee`. Commit after each step. - -### Stage D — verification, documentation, and wider validation - -1. Turn the Verus proof green; run the negative control and record it. -2. Turn the Kani harnesses green; run the negative control and record it. -3. Run the `proptest` non-vacuity classification report and confirm every flag - is exercised. -4. Write the ADR (`EP-M4`). -5. Update `docs/users-guide.md`, `docs/developers-guide.md`, - `docs/whitaker-cli-design.md`, `docs/whitaker-dylint-suite-design.md`, and - `docs/roadmap.md`. -6. Run `make markdownlint` and `make nixie`. + `installer/src/installer_packaging.rs:31` has the same hardcoding and is + deliberately **not** touched; it belongs to 3.5.3. + +Validate after each step: `make check-fmt && make typecheck && make lint && +make test NEXTEST_PROFILE=ci`, captured with `tee`. Commit after each. + +### Stage D — verification, reservation, documentation + +1. Turn the Kani harnesses green; confirm the three reachability harnesses + each report a counterexample; run the negative control and record it. +2. Turn the Verus proof green; run the negative control and record it. +3. Run the proptest pairwise classification report; confirm every pair is + exercised. +4. Reserve the crates.io name (`EP-M3`) **before** any documentation tells + users to install it. +5. Write `docs/adr-005-whitaker-cli-boundary.md` using the template at + `docs/documentation-style-guide.md:414`, with the content required by + `EP-M4`. +6. Update `docs/users-guide.md`, `docs/developers-guide.md`, + `docs/whitaker-cli-design.md`, `docs/whitaker-dylint-suite-design.md`, + `docs/publishing.md`, `docs/repository-layout.md`, and `docs/roadmap.md`. +7. `make markdownlint` and `make nixie`. ## Milestones and plateaus -### EP-M0 — feasibility established (prototyping) - -- **Outcome.** A recorded, evidence-backed answer to whether the `whitaker` - binary can live in the root package. No production code; the spike file is - deleted. -- **Requirements.** De-risks `CLI-REQ-BIN`. -- **Acceptance evidence.** `/tmp/spike-a.out`, `/tmp/spike-b.out`, - `/tmp/spike-c.out`, summarized in `Surprises & discoveries`. -- **Conformance check.** No interface, dependency, or format change. -- **Recovery.** `git checkout -- .` — nothing is committed. -- **Remaining gaps.** Everything. -- **Compatibility decision.** None required. - -### EP-M1 — installer orchestration behind a library boundary - -- **Outcome.** `installer/src/main.rs` is a thin composition root. All - orchestration is in the `whitaker_installer` library. `whitaker-installer` - behaves identically. `crates/whitaker_cli` exists with its domain and ports, - no adapters yet. -- **Requirements.** `CLI-REQ-LIB`; `EP-INV-ROUTE` and `EP-INV-EXIT` green. -- **Acceptance evidence.** All existing `installer/tests/` pass unmodified; - `make kani` reports `VERIFICATION:- SUCCESSFUL` for the two routing - harnesses; `crates/whitaker_cli/src/domain/` contains no `use - whitaker_installer` (grep-checkable). -- **Conformance check.** Public surface of `whitaker_installer` grew, never - shrank; no persisted-format change; the prebuilt path is untouched. -- **Recovery.** Revert the milestone's commits; nothing outside the workspace - changed. +### `EP-M0` — root package is a publishable, testable CLI package + +- **Outcome.** The Dylint driver library lives in `crates/whitaker_lint_core`. + The root `whitaker` package has no `rustc_private`, carries crates.io + metadata, is covered by `make test`, and `cargo package -p whitaker + --no-verify` succeeds. No binary yet. +- **Requirements.** Precondition for `CLI-REQ-BIN`. +- **Acceptance evidence.** `cargo package -p whitaker --no-verify` exits 0; + the Dylint UI suite is unchanged; six previously-orphaned test binaries now + run under `make test`; `cargo build --workspace --bins --all-features` links + cleanly. +- **Conformance check.** Lint crates build and lint unchanged; no public + behaviour changed; `dylint.toml` reviewed. +- **Recovery.** One commit; `git revert` is clean. +- **Remaining gaps.** No binary, no CLI. +- **Compatibility decision.** None. `whitaker_lint_core` is a new + application-internal crate; the root library's consumers are all in-tree and + updated in the same change. The root `whitaker` library was never published, + so nothing external depends on its shape. + +### `EP-M1` — installer orchestration behind the library boundary + +- **Outcome.** `installer/src/main.rs` is a thin composition root; all + orchestration is in the `whitaker_installer` library; `whitaker-installer` + behaves identically. +- **Requirements.** `CLI-REQ-LIB`. +- **Acceptance evidence.** `make test NEXTEST_PROFILE=ci` passes with every + existing `installer/tests/` test **unmodified**. Additionally, capture + `whitaker-installer --dry-run` output as an `insta` snapshot _before_ the + move and assert it after — a five-minute change that converts a vague parity + claim into a hard oracle. +- **Conformance check.** Public surface grew, never shrank; prebuilt path + untouched. +- **Recovery.** Revert the milestone's commits. - **Remaining gaps.** No `whitaker` binary yet. -- **Compatibility decision.** None. This is a pre-1.0, application-internal - boundary; callers are updated in the same change. +- **Compatibility decision.** None. Pre-1.0, application-internal; callers + updated in the same change. -### EP-M2 — the `whitaker` binary works +### `EP-M2` — the `whitaker` binary -- **Outcome.** `whitaker --help`, `whitaker install`, `whitaker ls`, and - `whitaker ls --json` all work, with behaviour matching `whitaker-installer`. +- **Outcome.** `whitaker --help`, `whitaker install`, `whitaker ls`, + `whitaker ls --json`, and `whitaker --all -- …` all work. - **Requirements.** `CLI-REQ-BIN`, `CLI-REQ-LS`, `CLI-REQ-L10N`, - `CLI-REQ-EXIT`; `EP-INV-PARITY` green. -- **Acceptance evidence.** The BDD scenarios in `Artefacts and notes` pass; - `insta` snapshots for `ls` text and JSON are committed; `whitaker --help` - exits 0. -- **Conformance check.** Command names are untranslated (`CLI-REQ-L10N`); - `--json` is on `ls` only, not global, as `CLI-DESIGN` requires. -- **Recovery.** The binary is additive; reverting removes it and leaves - `EP-M1` intact. -- **Remaining gaps.** `check` and `doctor` are absent by design. -- **Compatibility decision.** `whitaker-installer` remains, per Constraint 1 - — named consumer: existing users following `docs/users-guide.md`, and the - published GitHub release assets. Its removal is roadmap 3.9.1. - -### EP-M3 — installable via binstall - -- **Outcome.** The root package carries binstall metadata; asset naming is - proven unambiguous. -- **Requirements.** `CLI-REQ-BINSTALL`; `EP-LEM-NAME` green. -- **Acceptance evidence.** `make verus` verifies - `verus/whitaker_artefact_naming.rs`; a behavioural test asserts the root - package's binstall table matches the shared template constants. -- **Conformance check.** `docs/adr-001-prebuilt-dylint-libraries.md` still - holds; no release-workflow change is made here (that is roadmap 3.5.3), so - no published asset changes. -- **Recovery.** Metadata-only; revert is safe. -- **Remaining gaps.** CI does not yet _publish_ a `whitaker` artefact — 3.5.3. - The plan must say so plainly in the ADR rather than implying binstall works - end-to-end today. + `CLI-REQ-FWD`; `EP-INV-DISPATCH`, `EP-LEM-DISPATCH`, `EP-INV-PARITY`, + `EP-INV-EXIT` all discharged. +- **Acceptance evidence.** The BDD scenarios pass under `NEXTEST_PROFILE=ci`; + `whitaker --help` exits 0; `whitaker --all --version` reaches + `cargo dylint`; `whitaker install` does not write a `whitaker` script. +- **Conformance check.** Command names untranslated; `--json` on `ls` only, + not global, as `CLI-DESIGN` requires. +- **Recovery.** The binary is additive in the Cargo graph — but **not** in the + `PATH` namespace. Reverting removes the binary; a user who has already + installed it must `cargo uninstall whitaker` to restore the script's + precedence. Say so in the ADR. +- **Remaining gaps.** `check` and `doctor` absent by design. `ls` output is + the installer's current shape and will be replaced wholesale by 3.8.1 and + 3.8.2 once bundle manifests (3.7.3) exist — it is **not** a stable contract. +- **Compatibility decision.** Two, both with named consumers. (i) + `whitaker-installer` remains, per Constraint 1 — consumers: users following + `docs/users-guide.md`, published release assets; removal is 3.9.1. (ii) + Argument forwarding, per Constraint 2 — consumers: users invoking + `whitaker --all`, `Makefile:185`, `ci.yml:154`; removal is 3.9.3, once + `whitaker check` (3.5.2) supersedes it. + +### `EP-M3` — installable, and the name is ours + +- **Outcome.** Root package carries binstall metadata; the crates.io name + `whitaker` is reserved. +- **Requirements.** `CLI-REQ-BINSTALL`. +- **Acceptance evidence.** A behavioural test asserts the root package's + binstall table matches the shared template constants and that its expanded + `pkg-url` differs from `whitaker-installer`'s for **every** target in the + release matrix (`release.yml:30-38`) — a finite disjointness check over the + ten-element published namespace, which is what the cut Verus proof should + have been. Record the stripped release binary size. +- **Conformance check.** ADR-001 still holds; no published asset changes. +- **Recovery.** Metadata-only. Name reservation is not reversible; that is the + point. +- **Remaining gaps.** CI does not yet _publish_ a `whitaker` release artefact + — roadmap 3.5.3. Until it does, `cargo binstall whitaker` will 404 and fall + back to a source build, or to quickinstall for users who have it enabled. + State this plainly in the ADR and the user guide; do not imply binstall + works end to end. - **Compatibility decision.** None. -### EP-M4 — documented - -- **Outcome.** An ADR records the boundary; the user guide, developers' guide, - CLI design document, and suite design document reflect reality. -- **Requirements.** `AGENTS.md` documentation rules. -- **Acceptance evidence.** `make markdownlint` and `make nixie` pass; the - roadmap item 3.5.1 checkbox is ticked. -- **Conformance check.** Every discovery from `Surprises & discoveries` is - reconciled against `CLI-DESIGN`; anything that contradicts it is either - fixed in the design document or recorded in `Decision log`. +### `EP-M4` — documented + +- **Outcome.** ADR and documentation reflect reality. +- **Acceptance evidence.** `make markdownlint` and `make nixie` pass; roadmap + item 3.5.1 ticked. +- **Required ADR content** (`docs/adr-005-whitaker-cli-boundary.md`): the + `PATH` namespace decision and why forwarding was chosen over the + alternatives; the promoted `whitaker_installer` public surface; the rule + that `whitaker_installer` owns _how_ to install while the CLI owns _whether + and when_, so 3.5.2's lazy repair has an unambiguous home; and the plain + statement that no `whitaker` artefact is published until 3.5.3. +- **Conformance check.** Every `Surprises & discoveries` entry reconciled + against `CLI-DESIGN`; `CLI-REQ-FWD` added to the design document, since it + has no upstream source today. - **Recovery.** Documentation-only. -- **Remaining gaps.** None for 3.5.1. - **Compatibility decision.** None. ## Interfaces and dependencies ### New dependencies -Add to `[workspace.dependencies]` in the root `Cargo.toml`, caret-pinned: +Add to `[workspace.dependencies]`, caret-pinned: ```toml ortho_config = "0.9.0" @@ -814,65 +894,63 @@ googletest = "0.14.3" pretty_assertions = "1.4.1" ``` -`insta` is already a workspace dependency (`insta = { version = "1", features -= ["json"] }`); add it as a dev-dependency of `crates/whitaker_cli`. +`ortho_config` is a normal dependency of the root package only. After `EP-M0` +the lint crates depend on `whitaker_lint_core`, not on `whitaker`, so this +subtree does not propagate into the fifty cross-compiled lint-crate builds in +`rolling-release.yml`. That containment is a direct benefit of `EP-M0` and +should be stated in the ADR. -`ortho_config` is a normal dependency of `crates/whitaker_cli`. The other -three are dev-dependencies only. +`insta` is already a workspace dependency; add it as a root dev-dependency. -**`googletest` ordering rule.** When combining with `rstest`, `#[gtest]` must -come **before** `#[rstest]`, otherwise the test registers twice and runs -twice. Document this in `docs/developers-guide.md`: +**`googletest` ordering rule.** `#[gtest]` must come **before** `#[rstest]`, +or the test registers and runs twice. Document in +`docs/developers-guide.md`: ```rust #[gtest] #[rstest] -#[case::install_with_lint(&["install", "--lint", "module_max_lines"])] -fn routes_to_install(#[case] argv: &[&str]) -> googletest::Result<()> { - verify_that!(route(parse(argv)?), matches_pattern!(Request::Install(_))) +#[case::install(&["install", "--lint", "module_max_lines"])] +fn classifies_as_subcommand(#[case] argv: &[&str]) -> googletest::Result<()> { + verify_that!(classify(&tokenize(argv)), matches_pattern!(Class::Subcommand(_))) } ``` -### `crates/whitaker_cli` layout +Also record how `verify_that!` interacts with the workspace's +`clippy::missing_assert_message` policy, and whether `googletest::Result<()>` +return types survive the `unwrap_used`/`expect_used` denials. -```plaintext -crates/whitaker_cli/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # run(); re-exports; no logic -│ ├── domain/ -│ │ ├── mod.rs -│ │ ├── request.rs # Request, InstallRequest, ListRequest -│ │ ├── outcome.rs # Outcome, ExitCode -│ │ ├── routing/ -│ │ │ ├── mod.rs # route(): pure -│ │ │ └── kani.rs # #[cfg(kani)] harnesses -│ │ └── exit.rs # exit-code policy: pure -│ ├── ports/ -│ │ ├── mod.rs -│ │ ├── install.rs # InstallService -│ │ └── inventory.rs # LintInventory -│ ├── adapters/ -│ │ ├── mod.rs -│ │ ├── installer.rs # InstallService over whitaker_installer -│ │ └── inventory.rs # LintInventory over whitaker_installer -│ └── cli/ -│ ├── mod.rs # Cli, Command; localized parse entry point -│ ├── install_args.rs -│ └── list_args.rs -└── tests/ - ├── features/whitaker_cli.feature - ├── behaviour_cli.rs - ├── e2e_exit_codes.rs - └── property_arg_parity.rs +### Required signatures + +`src/cli/dispatch.rs` — pure, the subject of both verification obligations: + +```rust +/// How an argument vector should be handled. +pub enum Class { + /// Dispatch to a Whitaker subcommand. + Subcommand(Subcommand), + /// Forward verbatim to `cargo dylint`. + Forward, + /// Print help or version and exit successfully. + Display, +} + +/// Classifies a tokenized argument vector. +#[must_use] +pub fn classify(tokens: &[TokenTag]) -> Class; ``` -### Required signatures +`src/cli/exit.rs` — pure, the subject of `EP-INV-EXIT`: -In `crates/whitaker_cli/src/ports/install.rs`: +```rust +/// Maps an outcome onto a process exit code. +#[must_use] +pub const fn exit_code_for(outcome: &Outcome) -> u8; +``` + +`src/ports.rs`: ```rust -/// Performs an installation on behalf of the domain. +/// Performs an installation on behalf of the command layer. pub trait InstallService { /// Runs an installation and reports what happened. /// @@ -881,11 +959,7 @@ pub trait InstallService { /// Returns an error when the installation cannot complete. fn install(&self, request: &InstallRequest) -> Result; } -``` -In `crates/whitaker_cli/src/ports/inventory.rs`: - -```rust /// Reports the lints currently staged on this machine. pub trait LintInventory { /// Lists staged lints in the given staging directory. @@ -893,134 +967,96 @@ pub trait LintInventory { /// # Errors /// /// Returns an error when the staging directory cannot be scanned. - fn list(&self, request: &ListRequest) -> Result, CliError>; + fn list(&self, request: &ListRequest) -> Result; } -``` - -In `crates/whitaker_cli/src/domain/routing/mod.rs` — pure, total, and the -subject of `EP-INV-ROUTE`: - -```rust -/// Maps a parsed command line onto a domain request. -/// -/// # Errors -/// -/// Returns [`RoutingError`] when mutually exclusive flags are combined. -pub fn route(cli: &Cli) -> Result; -``` - -In `crates/whitaker_cli/src/domain/exit.rs` — pure, the subject of -`EP-INV-EXIT`: -```rust -/// Maps an outcome onto a process exit code. -#[must_use] -pub const fn exit_code_for(outcome: &Outcome) -> ExitCode; -``` - -In `crates/whitaker_cli/src/lib.rs`: - -```rust -/// Runs the Whitaker command-line interface. -/// -/// # Errors -/// -/// Returns an error when the command cannot be completed. -pub fn run( - cli: &Cli, - installer: &dyn InstallService, - inventory: &dyn LintInventory, - stdout: &mut dyn std::io::Write, - stderr: &mut dyn std::io::Write, -) -> Result; +/// Runs Dylint on behalf of a forwarded invocation. +pub trait LintRunner { + /// Forwards arguments to `cargo dylint` and returns its exit code. + /// + /// # Errors + /// + /// Returns an error when the subprocess cannot be started. + fn forward(&self, args: &[std::ffi::OsString]) -> Result; +} ``` -The root `src/main.rs` constructs the two adapters, calls `run`, and converts -the returned `Outcome` with `exit_code_for`. That is all it does. +`LintInventory` returns a `StagedInventory` struct rather than a bare `Vec`, +so 3.7.3 and 3.8.1 can add manifest fields without replacing the trait. +`LintRunner` exists now because forwarding needs it, and 3.5.2's `check` will +reuse it. ### Localized parsing -Use `ortho_config`'s `LocalizedParse` with `NoOpLocalizer` at this milestone: - ```rust use ortho_config::{LocalizedParse as _, NoOpLocalizer, is_display_request}; -let cli = match Cli::try_parse_localized_from(std::env::args_os(), &NoOpLocalizer) { +let localizer = NoOpLocalizer::new(); +let cli = match Cli::try_parse_localized_from(std::env::args_os(), &localizer) { Ok(cli) => cli, - Err(err) if is_display_request(&err) => { err.print()?; return Ok(ExitCode::SUCCESS); } - Err(err) => { err.print()?; return Ok(ExitCode::FAILURE); } + Err(err) if is_display_request(&err) => { err.print()?; return Ok(0); } + Err(err) => { err.print()?; return Ok(err.exit_code() as u8); } }; ``` -This establishes the localization seam that roadmap 3.6.4 fills with a -`FluentLocalizer` and an `en-GB` catalogue, without adopting a configuration -model this plan does not own. +Note `NoOpLocalizer::new()`, not a bare unit-struct reference, and +`err.exit_code()` rather than a blanket failure code — otherwise usage errors +exit 1 where the installer exits 2, breaking `EP-INV-PARITY`. Confirm the +exact signature of `is_display_request` against +`docs/ortho-config-users-guide.md:333` before Stage B writes red tests against +it: if it takes `&OrthoError` rather than `&clap::Error`, the guard needs +adjusting. -## Concrete steps +This establishes the seam that 3.6.4 fills with a `FluentLocalizer` and an +`en-GB` catalogue, without adopting a configuration model this plan does not +own. -All commands run from the repository root, -`/home/leynos/.lody/repos/github---leynos---whitaker/worktrees/0c485c79-c21a-486e-b126-29c3ef23084f`. +## Concrete steps -Confirm the branch first: +Run everything from `$(git rev-parse --show-toplevel)`. ```console -$ git branch --show-current -3-5-1-root-whitaker-binary +git branch --show-current ``` -Stage A, the feasibility spike, is given verbatim under `Plan of work`. - -Create the crate skeleton: - -```console -mkdir -p crates/whitaker_cli/src/{domain/routing,ports,adapters,cli} \ - crates/whitaker_cli/tests/features +```plaintext +3-5-1-root-whitaker-binary ``` -Add `crates/whitaker_cli` to the workspace — it is already covered by the -`crates/*` glob in `Cargo.toml` line 2, so no edit is needed there; only the -new `crates/whitaker_cli/Cargo.toml` is required. - -Run a focused test while iterating: +The `EP-M0` gate, which decides whether the milestone succeeded: ```console -cargo nextest run -p whitaker_cli 2>&1 \ - | tee /tmp/nextest-whitaker_cli-3-5-1-root-whitaker-binary.out +cargo package -p whitaker --no-verify ``` -Run the full gate before every commit: +Focused iteration: ```console -make check-fmt 2>&1 | tee /tmp/check-fmt-whitaker-3-5-1-root-whitaker-binary.out -make typecheck 2>&1 | tee /tmp/typecheck-whitaker-3-5-1-root-whitaker-binary.out -make lint 2>&1 | tee /tmp/lint-whitaker-3-5-1-root-whitaker-binary.out -make test 2>&1 | tee /tmp/test-whitaker-3-5-1-root-whitaker-binary.out +cargo nextest run -p whitaker 2>&1 | tee /tmp/nextest-whitaker-3-5-1.out ``` -Run them **sequentially**, never in parallel: this environment relies on build -caching and concurrent Cargo jobs contend on the shared package-cache lock. - -Verification runs: +The full gate, sequentially, before every commit: ```console -make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1-root-whitaker-binary.out -make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1-root-whitaker-binary.out +make check-fmt 2>&1 | tee /tmp/check-fmt-whitaker-3-5-1.out +make typecheck 2>&1 | tee /tmp/typecheck-whitaker-3-5-1.out +make lint 2>&1 | tee /tmp/lint-whitaker-3-5-1.out +make test NEXTEST_PROFILE=ci 2>&1 | tee /tmp/test-whitaker-3-5-1.out ``` -Note that `make test` uses the default nextest profile, which skips -`behaviour_cli` and `behaviour_toolchain`. Before the final commit of `EP-M2`, -run the CI profile once so the new behavioural binary is actually executed: +Verification: ```console -make test NEXTEST_PROFILE=ci 2>&1 \ - | tee /tmp/test-ci-whitaker-3-5-1-root-whitaker-binary.out +make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1.out +make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1.out ``` -Smoke-test the real binary: +Smoke-test the binary: ```console cargo run --bin whitaker -- --help cargo run --bin whitaker -- ls --json +cargo run --bin whitaker -- --all -- -p whitaker-common --all-targets ``` Expected shape of the first: @@ -1034,82 +1070,84 @@ Commands: help Print this message or the help of the given subcommand(s) ``` +Consider adding a `whitaker-smoke` Makefile target alongside `install-smoke`, +installing the root package into a temporary root _positioned to shadow_ a +generated wrapper script, and asserting that `--help`, `ls --json`, and +`--all` all behave. That is the only mechanism that would catch a forwarding +regression before users do. + ## Validation and acceptance ### Red-Green-Refactor evidence to record -**Red.** Before any production code in Stage C: +**Red.** Before Stage C production code: ```console -cargo nextest run -p whitaker_cli 2>&1 | tail -20 +cargo nextest run -p whitaker 2>&1 | tail -20 ``` -Expect the BDD scenarios and `e2e_exit_codes` to fail. The e2e failure must -name the missing `whitaker` binary or a wrong exit code — not a compile error -in the test itself. +The BDD scenarios and `e2e_exit_codes` must fail, naming a missing binary or a +wrong exit code — not a compile error in the test itself. -**Green.** After the minimal implementation of each Stage C step, the focused -command for that step passes. +**Green.** Each Stage C step's focused command passes. **Refactor.** After splitting any file approaching 400 lines, re-run the focused command and then the full gate. ### Behaviour to observe -Acceptance is phrased as things a person can do: - -1. Run `whitaker --help`. Observe `install` and `ls` listed as subcommands, - and `echo $?` printing `0`. -2. Run `whitaker ls --json` in a workspace with staged lints. Observe the same - JSON that `whitaker-installer list --json` prints, byte for byte. -3. Run `whitaker install --dry-run`. Observe the same output that - `whitaker-installer --dry-run` prints. -4. Run `whitaker install --lint module_max_lines --individual-lints`. Observe - a clear rejection and `echo $?` printing `2` (clap's usage-error code) or - `1` per the routing policy — whichever the implementation settles on must - be asserted in `EP-INV-EXIT` and documented, not left implicit. -5. Run `whitaker-installer --help`. Observe it is unchanged from before this - plan. +1. `whitaker --help` lists `install` and `ls`; `echo $?` prints `0`. +2. `whitaker ls --json` prints the same JSON as `whitaker-installer list + --json`. Ordering is deterministic — `installer/src/list.rs:50` sorts by + crate name and `InstalledLints.by_toolchain` is a `BTreeMap` — so the + snapshot is stable. The snapshot is a **regression guard, not a + compatibility promise**; note that inline. +3. `whitaker install --dry-run` prints what `whitaker-installer --dry-run` + prints, and writes nothing to stdout — stdout carries command output only; + diagnostics, progress, and errors go to stderr. +4. `whitaker install --lint module_max_lines --individual-lints` is rejected + and `echo $?` prints `2`, matching `whitaker-installer`. +5. `whitaker --all -- -p whitaker-common --all-targets` runs the lint suite, + exactly as the wrapper script does, and propagates its exit code. +6. `whitaker install` does not create or overwrite a `whitaker` script. +7. `whitaker-installer --help` is unchanged. ### Quality criteria -- **Tests.** `make test` and `make test NEXTEST_PROFILE=ci` pass. Every - existing `installer/tests/` test passes **without assertion changes**. -- **Verification.** `EP-INV-PARITY`, `EP-INV-ROUTE`, `EP-INV-EXIT`, and - `EP-LEM-NAME` are all discharged, each with its recorded negative-control - failure transcript. An obligation without a recorded negative control is not +- **Tests.** `make test NEXTEST_PROFILE=ci` passes. Every existing + `installer/tests/` test and every Dylint UI fixture passes **without + assertion changes**. +- **Publishability.** `cargo package -p whitaker --no-verify` succeeds. +- **Verification.** `EP-INV-DISPATCH`, `EP-LEM-DISPATCH`, `EP-INV-PARITY`, + `EP-INV-EXIT` discharged, each with its recorded negative-control failure + transcript and, for the Kani reachability harnesses, its recorded + counterexample. An obligation without a recorded negative control is not discharged. - **Lint/typecheck.** `make check-fmt`, `make typecheck`, `make lint` pass - with no new suppressions. + with no new suppressions and no new `dylint.toml` exclusions. - **Documentation.** `make markdownlint` and `make nixie` pass. -- **Performance.** No benchmark threshold. Report if `make typecheck` wall - time regresses more than 30% (`Risk R6`). -- **Security.** No new network or filesystem capability is introduced; the new - crate performs I/O only through the two ports, both backed by existing - installer code. +- **Cost.** Record `cargo tree -e normal -p module_max_lines --features + dylint-driver | wc -l` before and after; after `EP-M0` it should _fall_. + Record the stripped size of the `whitaker` binary. ## Idempotence and recovery -Every step is re-runnable. The spike in `EP-M0` writes one file that is -deleted afterwards. The Stage C steps are additive except for the moves in -step 1, which are pure relocations verifiable by the unchanged test suite. +Every step is re-runnable. `EP-M0` is one large mechanical commit; keep it +self-contained so bisection is clean. Stage C steps are additive except the +relocations in step 1, which are verifiable by the unchanged test suite. -If a milestone must be abandoned, `git revert` its commits; nothing writes -outside the repository except `/tmp` logs and the normal Cargo target -directory. The `make test` target backs up and restores `~/.local/bin/whitaker` -around its run (`Makefile` lines 92-139) — if a run is interrupted, check that +`make test` backs up and restores `~/.local/bin/whitaker` (`Makefile:99-139`) +and fails loudly if a test modifies it. If a run is interrupted, confirm that file was restored before re-running. -Do not create an isolated Cargo cache. Use the shared default cache and let -Cargo's package-cache lock serialize access; if another job holds it, wait. +Do not create an isolated Cargo cache; use the shared default and let the +package-cache lock serialize access. ## Artefacts and notes -### Feature specification (`crates/whitaker_cli/tests/features/whitaker_cli.feature`) +### Feature specification (`tests/features/whitaker_cli.feature`) -Scenarios must be **appended** to this new file only, never inserted into -existing feature files, because `#[scenario(index = N)]` binds by position -(`Risk R2`). +New file only — never insert into an existing feature file (`Risk R3`). ```gherkin Feature: The root whitaker command-line interface @@ -1137,19 +1175,33 @@ Feature: The root whitaker command-line interface When I run whitaker with "ls --json" Then the command succeeds And the output is valid JSON + And stdout contains no diagnostic text Scenario: A dry-run install reports its configuration without building Given a Whitaker workspace checkout When I run whitaker with "install --dry-run" Then the command succeeds And no lint library is staged + And stdout is empty Scenario: Conflicting lint selection flags are rejected Given the whitaker binary is available When I run whitaker with "install --lint module_max_lines --individual-lints" Then the command fails + And the exit code is 2 And the error names both conflicting options + Scenario: Legacy lint invocations are forwarded to cargo dylint + Given the whitaker binary is available + When I run whitaker with "--all -- -p whitaker-common --all-targets" + Then the invocation reaches cargo dylint + And the exit code is the exit code cargo dylint returned + + Scenario: Installing does not overwrite the whitaker binary + Given a Whitaker workspace checkout + When I run whitaker with "install --dry-run" + Then no wrapper script named "whitaker" is reported + Scenario: The legacy installer binary is unaffected Given the whitaker-installer binary is available When I run whitaker-installer with "--help" @@ -1157,176 +1209,265 @@ Feature: The root whitaker command-line interface And the output is unchanged from the recorded snapshot ``` -### Verus proof skeleton (`verus/whitaker_artefact_naming.rs`) +### Verus proof skeleton (`verus/whitaker_cli_dispatch.rs`) -The witness lemma comes first, so the injectivity theorem is not vacuous: +Witness lemmas first, so the disjointness theorem is not vacuous: ```rust use vstd::prelude::*; verus! { -/// A release-asset name is well formed when its fields are drawn from the -/// admissible alphabets and no field contains the composed delimiter. -pub open spec fn well_formed(name: Seq, target: Seq, version: Seq) -> bool; - -/// Exhibits a satisfying triple so `well_formed` is not empty. -proof fn lemma_well_formed_is_inhabited() - ensures exists|n: Seq, t: Seq, v: Seq| well_formed(n, t, v), -{ /* witness: ("whitaker", "x86_64-unknown-linux-gnu", "0.2.7") */ } - -/// Composition determines its fields uniquely. -proof fn lemma_compose_is_injective( - n1: Seq, t1: Seq, v1: Seq, - n2: Seq, t2: Seq, v2: Seq, -) - requires - well_formed(n1, t1, v1), - well_formed(n2, t2, v2), - compose(n1, t1, v1) =~= compose(n2, t2, v2), - ensures n1 =~= n2, t1 =~= t2, v1 =~= v2, -{ /* by delimiter disjointness, then prefix-freedom of the name set */ } +/// Token kinds the classifier distinguishes. +pub enum TokenTag { Install, Ls, Help, Version, DoubleDash, GlobalFlag, DylintFlag, Other } + +pub open spec fn is_subcommand(tokens: Seq) -> bool; +pub open spec fn is_display(tokens: Seq) -> bool; +pub open spec fn is_forward(tokens: Seq) -> bool; + +/// Each class is inhabited, so disjointness is not vacuously true. +proof fn lemma_classes_are_inhabited() + ensures + exists|t: Seq| is_subcommand(t), + exists|t: Seq| is_display(t), + exists|t: Seq| is_forward(t), +{ /* witnesses: [Install], [Help], [DylintFlag] */ } + +/// Exactly one class applies to any sequence, of any length. +proof fn lemma_classification_is_total_and_disjoint(tokens: Seq) + ensures + is_subcommand(tokens) || is_display(tokens) || is_forward(tokens), + !(is_subcommand(tokens) && is_forward(tokens)), + !(is_display(tokens) && is_forward(tokens)), + !(is_subcommand(tokens) && is_display(tokens)), + decreases tokens.len(), +{ /* induction on the leading token */ } } // verus! ``` -The proof must contain no `assume` in its final form. Per -`docs/developers-guide.md`, Verus proofs here are models of the -implementation, not proofs of the literal Rust source; the `proptest` -differential check in `EP-LEM-NAME` is what ties the model to the code. +No `assume` in the final version. Per `docs/developers-guide.md`, Verus proofs +here model the implementation rather than the literal Rust source; the Kani +harness in `EP-INV-DISPATCH` is what checks the real code against the same +property within bounds. ## Signposts -Read these before starting. - | Document | Why | | --- | --- | -| `docs/whitaker-cli-design.md` | The specification. §Public CLI surface and §Compatibility and migration are normative for this plan. | -| `docs/roadmap.md` | Items 3.5.1 through 3.9.3 — what belongs here and what does not. | -| `docs/users-guide.md` | The user-facing surface that must be updated in `EP-M4`. | +| `docs/whitaker-cli-design.md` | The specification. §Public CLI surface and §Compatibility and migration are normative. | +| `docs/roadmap.md` | Items 3.5.1 to 3.9.3 — what belongs here and what does not. | +| `docs/users-guide.md` | The `whitaker --all` contract this plan must preserve; updated in `EP-M4`. | | `docs/developers-guide.md` | Installer architecture, Kani harness conventions, the Verus trust boundary. | -| `docs/ortho-config-users-guide.md` | Layering, subcommand merging, and the localization API. | -| `docs/rstest-bdd-users-guide.md` | Writing `#[scenario]` bindings and step functions. | +| `docs/publishing.md` | Publish order, updated in `EP-M4` once the root package is publishable. | +| `docs/repository-layout.md` | Updated for `crates/whitaker_lint_core`. | +| `docs/ortho-config-users-guide.md` | Layering, subcommand merging, the localization API. | +| `docs/rstest-bdd-users-guide.md` | `#[scenario]` bindings and step functions. | | `docs/rust-testing-with-rstest-fixtures.md` | Fixture patterns for the `CliWorld` fixture. | -| `docs/rust-doctest-dry-guide.md` | Doctests are gated by `make test`; keep them DRY. | -| `docs/complexity-antipatterns-and-refactoring-strategies.md` | The suite lints this repository against itself; keep routing flat. | +| `docs/rust-doctest-dry-guide.md` | Doctests are gated; keep them DRY. | +| `docs/complexity-antipatterns-and-refactoring-strategies.md` | The suite lints this repository against itself; keep `classify` flat. | | `docs/whitaker-dylint-suite-design.md` | Workspace layout, updated in `EP-M4`. | -| `docs/whitaker-clone-detector-design.md` | Confirms `whitaker_clones_core`/`whitaker_sarif` naming so the new crate name does not collide. | -| `docs/documentation-style-guide.md` | The ADR template and naming (`docs/adr-NNN-*.md`). | +| `docs/whitaker-clone-detector-design.md` | Confirms `whitaker_clones_core`/`whitaker_sarif` naming so new crate names do not collide. | +| `docs/documentation-style-guide.md` | ADR template and naming; Oxford spelling. | | `docs/adr-001-prebuilt-dylint-libraries.md` | The prebuilt path this plan must not disturb. | -| `AGENTS.md` | Gates, commit rules, the 400-line limit, the test-environment rules. | +| `AGENTS.md` | Gates, commit rules, the 400-line limit, test-environment rules. | -Skills to load: `leta` for symbol navigation instead of grep; -`hexagonal-architecture` for the port and adapter boundaries; -`kani` for `EP-INV-ROUTE`; `verus` for `EP-LEM-NAME`; `proptest` for -`EP-INV-PARITY`; `rust-unit-testing` for `googletest` and `insta` assertion -style; `execplans` for keeping this document current. +_Table 3: Documentation to read before starting._ -## Progress - -- [ ] EP-M0 — feasibility spike for a root-package binary. -- [ ] EP-M1 — installer orchestration moved behind the library boundary. -- [ ] EP-M2 — the `whitaker` binary with `install` and `ls`. -- [ ] EP-M3 — binstall metadata and the asset-naming proof. -- [ ] EP-M4 — ADR and documentation updates; roadmap 3.5.1 ticked. +Skills: `leta` for symbol navigation instead of grep; `hexagonal-architecture` +for the port boundaries; `kani` for `EP-INV-DISPATCH`; `verus` for +`EP-LEM-DISPATCH`; `proptest` for `EP-INV-PARITY`; `rust-unit-testing` for +`googletest` and `insta` style; `execplans` for keeping this document current. ## Surprises & discoveries -- Observation: the root `whitaker` package is excluded from `make test`. - Evidence: `Makefile` line 24, `TEST_EXCLUDES` contains `--exclude whitaker`; - `src/lib.rs` records "duplicated `std`/`core` link errors ... during - all-features test runs". - Impact: drove the decision to place all testable CLI logic in - `crates/whitaker_cli` rather than in the root package, and created `EP-M0`. - -- Observation: `install_flow` and `staged_suite` are binary-private, so real - orchestration is unreachable from any other crate today. - Evidence: `installer/src/main.rs:7-8` declares `mod install_flow;` and - `mod staged_suite;`, neither appears in `installer/src/lib.rs`. - Impact: `CLI-REQ-LIB` is a genuine code move, not a re-export. - -- Observation: three distinct dependency-injection styles coexist in the - installer for the same concern. - Evidence: Table 1 above. - Impact: unifying them is deliberately out of scope; see `Decision log`. +- Observation: the root `whitaker` package cannot be published at all. + Evidence: `cargo package -p whitaker --no-verify --allow-dirty` fails with + "no matching package named `rustc_ast`"; the four `rustc_*` shim crates are + `publish = false`; the manifest has no `description`, `license`, or + `repository`. + Impact: decisive. Turned the driver-library extraction from a contingency + into `EP-M0`, the plan's precondition, and made a separate CLI crate + unnecessary. + +- Observation: the installer writes an executable named `whitaker` into the + user's binary directory, and a `cargo install`ed binary deterministically + shadows it. + Evidence: `installer/src/wrapper.rs:105`; `Makefile:9` prepends + `~/.cargo/bin` while `Makefile:6` appends `~/.local/bin`; `Makefile:99-139` + backs the script up around every test run; `Makefile:185` and `ci.yml:154` + invoke `whitaker --all`. + Impact: without forwarding, this change would break this repository's own + lint gate and every user's primary workflow, with no replacement until + 3.5.2. Drove `CLI-REQ-FWD` and Constraint 2. + +- Observation: `env!("CARGO_BIN_EXE_")` is defined only for integration + tests of the package declaring the binary. + Evidence: all four existing uses are package-local, for example + `installer/tests/behaviour_cli/support.rs:206`. + Impact: end-to-end tests must live in the root package, which is why + removing `--exclude whitaker` is a required part of `EP-M0` rather than an + optional tidy-up. + +- Observation: `cargo check` does not invoke the linker. + Evidence: the risk being tested is a _link_ error, per `src/lib.rs:4-6`. + Impact: the first draft's feasibility spike used `cargo check` and so could + never observe the failure it existed to detect. Replaced with + `cargo build --bins` and `cargo test --no-run`. + +- Observation: both documented flag conflicts are enforced by clap, not by + repository-owned code. + Evidence: `conflicts_with` at `installer/src/cli.rs:108`, `:113`, `:131`. + Impact: a routing function can never receive a conflicting input, so the + first draft's Kani obligation was vacuous. Cut. + +- Observation: nothing in the repository ever parses a release-asset name. + Evidence: `installer/src/binstall_metadata.rs` composes with `.replace()`; + no `rsplit_once`, `splitn`, or `strip_prefix` under `installer/src/`. + Impact: the first draft's Verus injectivity proof was load-bearing for + nothing. Cut and replaced with a finite disjointness test over the + ten-element published namespace. + +- Observation: nextest's `binary()` filter matches by binary name, not + package-qualified name. + Evidence: `.config/nextest.toml` `default-filter` excludes + `binary(behaviour_cli)`. + Impact: a new `behaviour_cli.rs` in any package would be silently skipped. + Drove the `behaviour_whitaker.rs` naming and the `NEXTEST_PROFILE=ci` + discipline. - Observation: `#[gtest]` must precede `#[rstest]` or the test runs twice. - Evidence: the `googletest` 0.14.3 crate documentation. + Evidence: `googletest` 0.14.3 documentation. Impact: recorded as a convention for `docs/developers-guide.md`. ## Decision log -- **Decision:** Place the CLI domain, ports, and adapters in a new crate - `crates/whitaker_cli`, and make the root `src/main.rs` a thin composition - root. - **Rationale:** The root package cannot be tested by `make test` and its - library requires `feature(rustc_private)`. Putting logic there would make it - untestable under the repository's own gates. `CLI-DESIGN` requires the - _binary_ at the root package; it says nothing about where the library lives, - and "an internal library boundary" is precisely what a separate crate gives. - **Date/Author:** 2026-08-21, planning agent. - -- **Decision:** Adopt `ortho_config` in this plan for localized argument - parsing only (`LocalizedParse`, `NoOpLocalizer`, `is_display_request`), not - for configuration layering. - **Rationale:** The task brief asks for `ortho_config` with localized help. - `CLI-DESIGN` §Compatibility and migration sequences the configuration switch - as step 3, and `docs/roadmap.md` gives it its own item, 3.6.3, which - _requires_ 3.5.1. Wiring `whitaker.toml` discovery and the `dylint.toml` - bridge here would take work from 3.6.3 and half-activate a configuration - model this plan cannot finish. Taking the localization seam now satisfies - `CLI-REQ-L10N`, pays the dependency cost once, and shapes the argument types - so 3.6.3 adds `#[derive(OrthoConfig)]` and `load_and_merge()` without - restructuring. **This narrowing should be confirmed before implementation - begins.** - **Date/Author:** 2026-08-21, planning agent. - -- **Decision:** Do not unify the installer's three dependency-injection styles. - **Rationale:** It is a large refactor with its own risk profile and no - requirement in `CLI-DESIGN` driving it. This plan defines the ports the CLI - needs and implements them over the installer as it stands. Folding the - refactor in would breach the scope tolerance and blur the parity evidence - that `EP-INV-PARITY` depends on. - **Date/Author:** 2026-08-21, planning agent. - -- **Decision:** Keep `whitaker-installer` fully functional and unchanged. - **Rationale:** Not compatibility theatre. It is the currently shipping, - documented, published binary; `CLI-DESIGN` schedules its deprecation for a - named later release and `docs/roadmap.md` item 3.9.1 owns that work. The - named consumers are existing users following `docs/users-guide.md` and the - GitHub release assets. - **Date/Author:** 2026-08-21, planning agent. - -- **Decision:** Verify asset-name unambiguity with Verus rather than tests - alone. - **Rationale:** Adding a second package to a shared URL template, where the - new name is a proper prefix of the old one and the separator occurs inside - every target triple, creates a real ambiguity hazard whose guarantee must - hold for all admissible inputs. A sampled property test cannot establish - that; a prover can, and the property test then ties the proven model to the - Rust implementation. - **Date/Author:** 2026-08-21, planning agent. - -- **Decision:** The plan file is named `3-5-1-root-whitaker-binary.md`, not the - filename given in the task brief. - **Rationale:** The brief's filename referenced roadmap item 6.5.1, a - different item (a SARIF emitter for brain-trust diagnostics). The task body, - branch name, and required pull-request title all identify 3.5.1. Confirmed - with the requester before drafting. - **Date/Author:** 2026-08-21, planning agent. +- **D-1: Extract the Dylint driver library into `crates/whitaker_lint_core` as + `EP-M0`, and put the CLI in the root package rather than a new crate.** + Rationale: `cargo package -p whitaker` fails today, so the plan's headline + outcome is unreachable without it. The extraction also lets + `--exclude whitaker` come out of `TEST_EXCLUDES`, which is what makes + `CARGO_BIN_EXE_whitaker` usable and the end-to-end obligation runnable; it + removes the `rustc_private` hazard permanently; and it stops `ortho_config` + propagating into eleven lint crates and fifty cross-compiled builds. Putting + the CLI in the binary's own package is also the universal Rust idiom — + `cargo`, `rustup`, `ruff`, `uv`, and `cargo-dylint` all do it, and + `cargo-dylint` solves the same contamination the same way. + Approved by the requester before drafting. Date/Author: 2026-08-21. + +- **D-2: Resolve the `whitaker` name collision by forwarding unrecognized + arguments to `cargo dylint`.** The ambiguity tolerance fired here: the design + document anticipates wrapper removal but assigns it to 3.9.3, while a root + binary shadows the wrapper immediately. Alternatives considered — removing + wrapper generation now, renaming the script, or pulling `whitaker check` + forward from 3.5.2 — each either strands existing users or is a large scope + increase. Forwarding costs roughly thirty lines, breaks nobody, and keeps + this repository's own `make lint` working. It is a compatibility behaviour + with named consumers (Constraint 2) and a named removal point (3.9.3), not + compatibility theatre. Approved by the requester. Date/Author: 2026-08-21. + +- **D-3: Include `whitaker ls`, explicitly unstable.** The ambiguity tolerance + fired: `docs/roadmap.md:194` places `ls` at 3.8.1 ("Requires 3.6.2 and + 3.7.3") and `--json` at 3.8.2, while `CLI-DESIGN` §Public CLI surface + describes the end state. Both readings were presented. Decision: ship it now + as a rename of `whitaker-installer list`, with its output marked a + regression guard rather than a compatibility promise, and `EP-M2` recording + that 3.8.1 and 3.8.2 will replace it wholesale. Approved by the requester. + Date/Author: 2026-08-21. + +- **D-4: Exit codes are `0` success and display, `2` usage error, `1` + operational failure.** This is not a free choice: `Cli::parse()` in + `installer/src/main.rs:42` already exits 2 via clap on usage errors, and + Constraint 1 plus `EP-INV-PARITY` require matching it. The first draft left + this undecided and its sample code would have produced 1. + Date/Author: 2026-08-21, planning agent. + +- **D-5: Do not unify the installer's three dependency-injection styles.** + Rationale: a large refactor with its own risk profile and no requirement + driving it. Folding it in would blur the parity evidence `EP-INV-PARITY` + depends on. Date/Author: 2026-08-21, planning agent. + +- **D-6: Adopt `ortho_config` for localized parsing only.** The task brief asks + for `ortho_config` with localized help; `CLI-DESIGN` sequences the + configuration switch as migration step 3 and `docs/roadmap.md` gives it item + 3.6.3, which _requires_ 3.5.1. Wiring `whitaker.toml` discovery here would + take work from 3.6.3 and half-activate a model this plan cannot finish. + Counter-argument recorded: with `NoOpLocalizer` there is no observable + localization benefit at this milestone, so the dependency's roughly 26 new + lock entries buy a one-line call-site difference. It is retained because the + brief asks for it and because `EP-M0` confines the cost to the CLI binary. + Verify in `EP-M2` with a twenty-line spike that `#[derive(OrthoConfig)]` + composes with the clap-derived arguments, rather than leaving 3.6.3 to + discover that it does not. Date/Author: 2026-08-21, planning agent. + +- **D-7: Use `googletest` and `pretty_assertions` in new test files only, and + flag the inconsistency.** The task brief authorizes both. However + `docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md` + records the opposite decision for a sibling item — follow the in-repo + `assert_eq!` plus `insta` idiom, on the reading that the brief lists these as + available tools rather than mandates — and neither crate appears anywhere in + the workspace today. Adopting them here makes the repository inconsistent + with itself. This plan follows the brief but raises the conflict for the + reviewer to settle; if consistency is preferred, cut both and use + `assert_eq!` plus `insta`, which covers every assertion described here. + Date/Author: 2026-08-21, planning agent. + +- **D-8: Cut the release-asset-name Verus proof and the routing-conflict Kani + harness; verify argv classification instead.** Both original obligations were + vacuous — see `Verification plan` and `Surprises & discoveries` for the + evidence. The forwarding behaviour introduced by D-2 creates a genuinely new + repository-owned decision procedure whose failure modes are severe in both + directions, which is what the two verification tools now target. + Date/Author: 2026-08-21, planning agent. + +- **D-9: The plan file is named `3-5-1-root-whitaker-binary.md`.** The task + brief gave a filename referencing roadmap 6.5.1, a different item. The task + body, branch name, and required pull-request title all identify 3.5.1. + Confirmed with the requester. Date/Author: 2026-08-21. ## Outcomes & retrospective To be completed at each milestone boundary and at completion. Before setting this plan to `COMPLETE`, reconcile every entry in `Surprises & discoveries` -against `docs/whitaker-cli-design.md`: update the design document where a -discovery contradicts it, raise an ADR where the architecture changed, and -record a purely mechanical difference here. Do not mark the plan `COMPLETE` -while any upstream change or deviation is unrecorded. +against `docs/whitaker-cli-design.md`. In particular, `CLI-REQ-FWD` has no +upstream source today and **must** be added to the design document, since +forwarding is a public behaviour carrying a compatibility commitment. Do not +mark the plan `COMPLETE` while any upstream change or deviation is unrecorded. ## Revision note -Initial draft, 2026-08-21. Covers roadmap item 3.5.1 only. Two points need -explicit confirmation before implementation begins: the `ortho_config` -narrowing recorded in `Decision log`, and the `EP-M0` go/no-go on placing the -binary in the root package. +**Revision 2, 2026-08-21.** Rewritten after a six-perspective design review. + +What changed, and why. The structural bet was wrong: `cargo package -p +whitaker` fails, so the root package cannot be published and the plan's stated +outcome was unreachable. The driver-library extraction, previously filed as a +contingency under a risk entry, is now `EP-M0` and the plan's precondition; +the separate `crates/whitaker_cli` crate it was designed to avoid is gone, +because the extraction makes the root package a normal, testable, publishable +CLI package. That also fixed three downstream defects: `CARGO_BIN_EXE_whitaker` +is now available to the end-to-end tests, the policy layer no longer imports +clap through a supposedly pure domain boundary, and `ortho_config` no longer +propagates into eleven lint crates. + +A collision the first draft missed entirely — the installer generates an +executable named `whitaker` that a `cargo install`ed binary deterministically +shadows, breaking `whitaker --all` and this repository's own `make lint` — is +now Constraint 2, requirement `CLI-REQ-FWD`, and the subject of both +verification obligations. + +Both original verification obligations were cut as vacuous: nothing ever +inverts an asset name, and clap rejects the flag conflicts before any +repository-owned routing code runs. They are replaced by argv-classification +totality and disjointness, bounded by Kani and closed unbounded by Verus. Exit +codes are settled at 0, 2, and 1 rather than left to the implementer. The +feasibility spike now uses `cargo build` rather than `cargo check`, which +cannot link and so could never have observed the failure it tested for. + +How it affects remaining work. `EP-M0` is new and is roughly 40 mechanical +files; the overall scope tolerance rose from 45 to 70 files to accommodate it. +`EP-M1` is unchanged. Three items still want a reviewer's eye: the +`googletest` and `pretty_assertions` inconsistency with the sibling plan for +7.3.1 (D-7), whether `ortho_config` earns its place at this milestone given +that `NoOpLocalizer` does not translate (D-6), and confirmation of +`is_display_request`'s exact signature before Stage B writes red tests against +it. From 22c052617b8a2f46d0f6afb0d4f91d3ac6d9b719 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 24 Aug 2026 04:01:25 +0200 Subject: [PATCH 4/5] Extract the Whitaker Dylint driver core Move compiler-private driver code and its tests into the non-publishable `whitaker_lint_core` package so the root package can become a normal, publishable CLI package. Migrate all in-tree lint consumers and templates to the internal package, and retain the moved tests through a dedicated feature-free core pass. This avoids Cargo feature-unification linkage failures without weakening the all-feature Dylint validation. --- Cargo.lock | 63 ++++++++-------- Cargo.toml | 40 ++-------- Makefile | 30 ++++---- crates/bumpy_road_function/Cargo.toml | 6 +- crates/bumpy_road_function/src/driver/mod.rs | 2 +- crates/bumpy_road_function/tests/ui.rs | 7 +- crates/conditional_max_n_branches/Cargo.toml | 6 +- .../conditional_max_n_branches/src/driver.rs | 2 +- .../src/lib_ui_tests.rs | 7 +- crates/function_attrs_follow_docs/Cargo.toml | 4 +- .../function_attrs_follow_docs/src/driver.rs | 2 +- .../src/tests/ui.rs | 3 +- crates/module_max_lines/Cargo.toml | 4 +- crates/module_max_lines/src/driver.rs | 7 +- crates/module_max_lines/src/lib_ui_tests.rs | 7 +- crates/module_must_have_inner_docs/Cargo.toml | 4 +- .../src/driver/mod.rs | 2 +- .../src/tests/ui.rs | 3 +- crates/no_expect_outside_tests/Cargo.toml | 4 +- .../src/context/mod.rs | 2 +- .../no_expect_outside_tests/src/driver/mod.rs | 6 +- crates/no_expect_outside_tests/src/lib.rs | 2 +- .../src/lib_ui_tests.rs | 4 +- crates/no_std_fs_operations/Cargo.toml | 4 +- crates/no_std_fs_operations/src/driver.rs | 2 +- crates/no_std_fs_operations/src/tests/ui.rs | 3 +- crates/no_unwrap_or_else_panic/Cargo.toml | 4 +- crates/no_unwrap_or_else_panic/src/driver.rs | 6 +- .../src/lib_ui_tests.rs | 9 +-- .../Cargo.toml | 4 +- .../src/collector.rs | 2 +- .../src/driver.rs | 5 +- .../src/driver_tests.rs | 2 +- .../src/visitor.rs | 6 +- .../tests/ui.rs | 18 +++-- crates/test_must_not_have_example/Cargo.toml | 4 +- .../test_must_not_have_example/src/driver.rs | 2 +- .../src/lib_ui_tests.rs | 7 +- crates/whitaker_lint_core/Cargo.toml | 40 ++++++++++ .../whitaker_lint_core/src}/config.rs | 4 +- .../whitaker_lint_core/src}/hir/mod.rs | 0 .../whitaker_lint_core/src}/hir/tests.rs | 0 crates/whitaker_lint_core/src/lib.rs | 30 ++++++++ .../whitaker_lint_core/src}/lints/mod.rs | 0 .../src}/lints/template/content.rs | 17 +++-- .../src}/lints/template/mod.rs | 8 +- .../src}/lints/template/validation.rs | 0 .../whitaker_lint_core/src}/testing/mod.rs | 0 .../whitaker_lint_core/src}/testing/ui/mod.rs | 8 +- .../src}/testing/ui/tests.rs | 0 .../src}/testing/ui/toolchain.rs | 0 .../whitaker_lint_core/tests/build_config.rs | 27 +++++++ .../tests}/config_loading.rs | 2 +- .../tests}/features/config_loading.feature | 0 .../tests}/features/lint_template.feature | 0 .../tests}/features/locale_resolution.feature | 0 .../tests}/features/ui_harness.feature | 0 .../tests}/lint_template.rs | 18 ++--- .../tests}/locale_resolution.rs | 0 .../tests}/nextest_ui_filter.rs | 10 ++- .../tests}/support/locale.rs | 0 .../whitaker_lint_core/tests}/support/mod.rs | 1 + .../whitaker_lint_core/tests}/ui_harness.rs | 2 +- .../tests/workspace_support/mod.rs | 17 +++++ ...ng-plan-2026-08-24-m0-core-test-linkage.md | 74 +++++++++++++++++++ docs/execplans/3-5-1-root-whitaker-binary.md | 63 +++++++++++++++- dylint.toml | 6 +- src/lib.rs | 31 +------- tests/build_config.rs | 30 -------- 69 files changed, 434 insertions(+), 249 deletions(-) create mode 100644 crates/whitaker_lint_core/Cargo.toml rename {src => crates/whitaker_lint_core/src}/config.rs (98%) rename {src => crates/whitaker_lint_core/src}/hir/mod.rs (100%) rename {src => crates/whitaker_lint_core/src}/hir/tests.rs (100%) create mode 100644 crates/whitaker_lint_core/src/lib.rs rename {src => crates/whitaker_lint_core/src}/lints/mod.rs (100%) rename {src => crates/whitaker_lint_core/src}/lints/template/content.rs (89%) rename {src => crates/whitaker_lint_core/src}/lints/template/mod.rs (97%) rename {src => crates/whitaker_lint_core/src}/lints/template/validation.rs (100%) rename {src => crates/whitaker_lint_core/src}/testing/mod.rs (100%) rename {src => crates/whitaker_lint_core/src}/testing/ui/mod.rs (97%) rename {src => crates/whitaker_lint_core/src}/testing/ui/tests.rs (100%) rename {src => crates/whitaker_lint_core/src}/testing/ui/toolchain.rs (100%) create mode 100644 crates/whitaker_lint_core/tests/build_config.rs rename {tests => crates/whitaker_lint_core/tests}/config_loading.rs (99%) rename {tests => crates/whitaker_lint_core/tests}/features/config_loading.feature (100%) rename {tests => crates/whitaker_lint_core/tests}/features/lint_template.feature (100%) rename {tests => crates/whitaker_lint_core/tests}/features/locale_resolution.feature (100%) rename {tests => crates/whitaker_lint_core/tests}/features/ui_harness.feature (100%) rename {tests => crates/whitaker_lint_core/tests}/lint_template.rs (95%) rename {tests => crates/whitaker_lint_core/tests}/locale_resolution.rs (100%) rename {tests => crates/whitaker_lint_core/tests}/nextest_ui_filter.rs (96%) rename {tests => crates/whitaker_lint_core/tests}/support/locale.rs (100%) rename {tests => crates/whitaker_lint_core/tests}/support/mod.rs (99%) rename {tests => crates/whitaker_lint_core/tests}/ui_harness.rs (98%) create mode 100644 crates/whitaker_lint_core/tests/workspace_support/mod.rs create mode 100644 docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md delete mode 100644 tests/build_config.rs diff --git a/Cargo.lock b/Cargo.lock index 1d4844bf..81b941b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,8 +208,8 @@ dependencies = [ "serde", "tempfile", "toml 1.1.4+spec-1.1.0", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -460,8 +460,8 @@ dependencies = [ "serde", "serial_test", "tempfile", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1037,8 +1037,8 @@ dependencies = [ "rustc_session", "rustc_span", "serial_test", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1711,8 +1711,8 @@ dependencies = [ "rustc_session", "rustc_span", "tempfile", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1733,8 +1733,8 @@ dependencies = [ "rustc_session", "rustc_span", "serial_test", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1764,8 +1764,8 @@ dependencies = [ "serde", "temp-env", "tokio", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1794,8 +1794,8 @@ dependencies = [ "serial_test", "tempfile", "toml 1.1.4+spec-1.1.0", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1818,8 +1818,8 @@ dependencies = [ "rustc_span", "serde", "temp-env", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -2383,8 +2383,8 @@ dependencies = [ "serde", "toml 1.1.4+spec-1.1.0", "trybuild", - "whitaker", "whitaker-common", + "whitaker_lint_core", ] [[package]] @@ -2910,8 +2910,8 @@ dependencies = [ "rustc_lint", "rustc_span", "serde", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -3439,26 +3439,6 @@ dependencies = [ [[package]] name = "whitaker" version = "0.2.7" -dependencies = [ - "camino", - "cargo_metadata", - "clap", - "dylint_linting", - "dylint_testing", - "rstest", - "rstest-bdd", - "rstest-bdd-macros", - "rustc_ast", - "rustc_hir", - "rustc_lint", - "rustc_span", - "serde", - "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", - "whitaker-common", - "whitaker-installer", - "whitaker_test_macros", -] [[package]] name = "whitaker-common" @@ -3540,6 +3520,29 @@ dependencies = [ "whitaker_test_macros", ] +[[package]] +name = "whitaker_lint_core" +version = "0.2.7" +dependencies = [ + "camino", + "cargo_metadata", + "clap", + "dylint_linting", + "dylint_testing", + "rstest", + "rstest-bdd", + "rstest-bdd-macros", + "rustc_ast", + "rustc_hir", + "rustc_lint", + "rustc_span", + "serde", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "whitaker-common", + "whitaker_test_macros", +] + [[package]] name = "whitaker_sarif" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 31da331a..e61e6020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ version = "0.2.7" [workspace.dependencies] whitaker-common = { path = "common", version = "0.2.7" } whitaker-installer = { path = "installer", version = "0.2.7" } -whitaker = { path = ".", version = "0.2.7" } +whitaker_lint_core = { path = "crates/whitaker_lint_core", version = "0.2.7" } camino = "1.2.1" cap-std = { version = "4.0.2", features = ["fs_utf8"] } cargo_metadata = "0.23.0" @@ -69,42 +69,14 @@ rustc_span = { path = "crates/rustc_span", version = "0.2.7" } name = "whitaker" version = "0.2.7" edition = "2024" - -[features] -default = [] -dylint-driver = [ - "dep:dylint_linting", - "dep:rustc_ast", - "dep:rustc_hir", - "dep:rustc_lint", - "dep:rustc_span", -] +description = "Whitaker command-line interface" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true [dependencies] -camino = { workspace = true } -cargo_metadata = { workspace = true } -clap = { workspace = true, features = ["derive"] } -serde = { workspace = true } -toml = { workspace = true } -thiserror = { workspace = true } -whitaker-common = { workspace = true } -whitaker-installer = { workspace = true } - -rustc_ast = { workspace = true, optional = true } -rustc_hir = { workspace = true, optional = true } -rustc_lint = { workspace = true, optional = true } -rustc_span = { workspace = true, optional = true } -dylint_linting = { workspace = true, optional = true } - -[dev-dependencies] -whitaker_test_macros = { workspace = true } -whitaker-common = { workspace = true } -rstest = { workspace = true } -rstest-bdd = { workspace = true } -rstest-bdd-macros = { workspace = true } -dylint_testing = { workspace = true } - [lints] workspace = true diff --git a/Makefile b/Makefile index 8829d3fb..b89defae 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,12 @@ CARGO_FLAGS ?= --workspace --all-targets --all-features # Lint every other target rather than `--all-targets`; `typecheck` still builds # the fixtures, and the suite still lints them through the UI harness. CLIPPY_FLAGS ?= --workspace --lib --bins --tests --benches --all-features -TEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle --exclude rustc_session --exclude rustc_span --exclude whitaker --exclude function_attrs_follow_docs --exclude module_max_lines --exclude no_expect_outside_tests +TEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle --exclude rustc_session --exclude rustc_span --exclude whitaker_lint_core --exclude function_attrs_follow_docs --exclude module_max_lines --exclude no_expect_outside_tests TEST_CARGO_FLAGS ?= $(CARGO_FLAGS) $(TEST_EXCLUDES) +# The Dylint driver feature links compiler-private crates. Cargo unifies that +# feature for the all-workspace pass, so exercise core's ordinary tests in a +# dedicated feature-free invocation rather than excluding their coverage. +CORE_TEST_CARGO_FLAGS ?= -p whitaker_lint_core --no-default-features NEXTEST_PROFILE ?= # The cargo test driver. `test` runs `cargo nextest run`; `coverage` # overrides this with `cargo llvm-cov nextest ...` so instrumentation runs @@ -56,14 +60,12 @@ SPELLING_HELPER_PYTEST = PYTHONPATH=scripts $(SPELLING_PY_ENV) \ --with pytest-cov==7.0.0 python -m pytest WORKFLOW_TEST_VENV ?= .venv LINT_CRATES ?= bumpy_road_function conditional_max_n_branches function_attrs_follow_docs module_max_lines module_must_have_inner_docs no_expect_outside_tests test_must_not_have_example no_std_fs_operations no_unwrap_or_else_panic whitaker_suite -# Doctests compile as their own crate and do not inherit the lib's -# `#![cfg_attr(feature = "dylint-driver", feature(rustc_private))]`, so the -# Dylint driver crates cannot link `rustc_driver` from a doctest and fail with -# "use of unstable library feature `rustc_private`". Their examples are covered -# by the unit and UI suites instead, so exclude them from the doctest run. +# Doctests for the Dylint driver crates cannot link `rustc_driver`, because +# doctest crates do not inherit the `rustc_private` feature configuration. +# Their examples are covered by the unit and UI suites instead. DOCTEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures \ --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle \ - --exclude rustc_session --exclude rustc_span --exclude whitaker \ + --exclude rustc_session --exclude rustc_span --exclude whitaker_lint_core \ --exclude rstest_helper_should_be_fixture \ $(foreach crate,$(LINT_CRATES),--exclude $(crate)) CARGO_DYLINT_VERSION ?= 6.0.1 @@ -73,12 +75,11 @@ DYLINT_LINK_VERSION ?= 6.0.1 DYLINT_TOOLS_TOOLCHAIN ?= stable WHITAKER_SCRIPT ?= $(HOME)/.local/bin/whitaker WHITAKER ?= whitaker -# Crates linted by the Whitaker suite. The rustc_* proxy shims, the lint -# crates, the aggregated suite, and the whitaker root crate all require -# rustc_private plumbing (dylint-driver feature, prefer-dynamic RUSTFLAGS) -# that `cargo dylint`'s plain check build cannot provide, so the suite runs -# over the support crates that build as ordinary libraries. -WHITAKER_PACKAGES ?= -p whitaker-common -p whitaker-installer -p whitaker_clones_core -p whitaker_sarif +# Crates linted by the Whitaker suite. The rustc_* proxy shims, lint crates, +# aggregated suite, and `whitaker_lint_core` require `rustc_private` plumbing +# that `cargo dylint`'s plain check build cannot provide. The root CLI does +# not, so the suite covers it with the ordinary support crates. +WHITAKER_PACKAGES ?= -p whitaker -p whitaker-common -p whitaker-installer -p whitaker_clones_core -p whitaker_sarif build: target/debug/$(APP) ## Build debug binary release: target/release/$(APP) ## Build release binary @@ -133,7 +134,9 @@ test: ## Run tests with warnings treated as errors WHITAKER_BACKUP=""; \ fi; \ RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked $(RUST_FLAGS)" $(CARGO) $(TEST_RUNNER) $(CARGO_LOCKED) $(TEST_CARGO_FLAGS) $(BUILD_JOBS) $(if $(NEXTEST_PROFILE),--profile $(NEXTEST_PROFILE)); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) nextest run $(CARGO_LOCKED) $(CORE_TEST_CARGO_FLAGS) $(BUILD_JOBS) $(if $(NEXTEST_PROFILE),--profile $(NEXTEST_PROFILE)); \ RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --workspace --doc --all-features $(DOCTEST_EXCLUDES) $(BUILD_JOBS); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test $(CARGO_LOCKED) $(CORE_TEST_CARGO_FLAGS) --doc $(BUILD_JOBS); \ if [ "$${ACT_WORKFLOW_TESTS:-0}" = "1" ]; then \ $(MAKE) workflow-test; \ fi @@ -323,6 +326,7 @@ publish-check: ## Build, test, and validate packages before publishing rustup component add --toolchain "$$TOOLCHAIN" rust-src rustc-dev llvm-tools-preview; \ RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) build $(CARGO_LOCKED) --workspace --all-features $(BUILD_JOBS); \ RUSTFLAGS="-Z force-unstable-if-unmarked $(RUST_FLAGS)" $(CARGO) +$$TOOLCHAIN nextest run $(CARGO_LOCKED) --profile ci $(TEST_CARGO_FLAGS) $(BUILD_JOBS); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) +$$TOOLCHAIN nextest run $(CARGO_LOCKED) --profile ci $(CORE_TEST_CARGO_FLAGS) $(BUILD_JOBS); \ TMP_DIR=$$(mktemp -d); \ trap 'rm -rf "$$TMP_DIR"' 0 INT TERM HUP; \ DYLINT_TOOLS_DIR="$$TMP_DIR/dylint-tools"; \ diff --git a/crates/bumpy_road_function/Cargo.toml b/crates/bumpy_road_function/Cargo.toml index 11ae9e98..7ff5fa55 100644 --- a/crates/bumpy_road_function/Cargo.toml +++ b/crates/bumpy_road_function/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -39,7 +39,7 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } @@ -48,7 +48,7 @@ rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } toml = { workspace = true } -whitaker = { workspace = true } +whitaker_lint_core = { workspace = true } whitaker-common = { workspace = true } camino = { workspace = true } tempfile = { workspace = true } diff --git a/crates/bumpy_road_function/src/driver/mod.rs b/crates/bumpy_road_function/src/driver/mod.rs index af679b4c..dfa3bfc6 100644 --- a/crates/bumpy_road_function/src/driver/mod.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -9,13 +9,13 @@ use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; use rustc_span::{Ident, Span, symbol::Symbol}; -use whitaker::SharedConfig; use whitaker_common::{ Localizer, complexity_signal::{rasterize_signal, smooth_moving_average}, get_localizer_for_lint, i18n::MessageKey, }; +use whitaker_lint_core::SharedConfig; use crate::analysis::{Settings, detect_bumps, normalize_settings}; diff --git a/crates/bumpy_road_function/tests/ui.rs b/crates/bumpy_road_function/tests/ui.rs index 02256b5b..56a2b858 100644 --- a/crates/bumpy_road_function/tests/ui.rs +++ b/crates/bumpy_road_function/tests/ui.rs @@ -17,14 +17,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/conditional_max_n_branches/Cargo.toml b/crates/conditional_max_n_branches/Cargo.toml index aef24cd7..7fddfe05 100644 --- a/crates/conditional_max_n_branches/Cargo.toml +++ b/crates/conditional_max_n_branches/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -39,12 +39,12 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } whitaker-common = { workspace = true } -whitaker = { workspace = true } +whitaker_lint_core = { workspace = true } camino = { workspace = true } glob = "0.3.0" tempfile = "3.14.0" diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index ba7d2738..cc1a85d0 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -14,7 +14,6 @@ use rustc_hir::{BinOpKind, ExprKind, LoopSource, UnOp}; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{DesugaringKind, Span}; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::{ Arguments, FALLBACK_LOCALE, @@ -26,6 +25,7 @@ use whitaker_common::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::SharedConfig; const LINT_NAME: &str = "conditional_max_n_branches"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); diff --git a/crates/conditional_max_n_branches/src/lib_ui_tests.rs b/crates/conditional_max_n_branches/src/lib_ui_tests.rs index c05dc47a..494b3e6b 100644 --- a/crates/conditional_max_n_branches/src/lib_ui_tests.rs +++ b/crates/conditional_max_n_branches/src/lib_ui_tests.rs @@ -12,14 +12,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/function_attrs_follow_docs/Cargo.toml b/crates/function_attrs_follow_docs/Cargo.toml index 94b5a09a..0f175bc4 100644 --- a/crates/function_attrs_follow_docs/Cargo.toml +++ b/crates/function_attrs_follow_docs/Cargo.toml @@ -23,7 +23,7 @@ dylint-driver = [ "dep:rustc_middle", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -38,7 +38,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 45285d23..38f913eb 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -11,7 +11,6 @@ use rustc_hir as hir; use rustc_hir::attrs::AttributeKind; use rustc_lint::{DiagDecorator, LateContext, LateLintPass, LintContext}; use rustc_span::Span; -use whitaker::{SharedConfig, recover_user_editable_hir_span}; use whitaker_common::i18n::{ Arguments, BundleLookup, @@ -26,6 +25,7 @@ use whitaker_common::i18n::{ }; #[cfg(test)] use whitaker_common::i18n::{I18nError, resolve_message_set}; +use whitaker_lint_core::{SharedConfig, recover_user_editable_hir_span}; /// Lint pass that validates the ordering of doc comments on functions and methods. pub struct FunctionAttrsFollowDocs { diff --git a/crates/function_attrs_follow_docs/src/tests/ui.rs b/crates/function_attrs_follow_docs/src/tests/ui.rs index b0c39131..3e9f9327 100644 --- a/crates/function_attrs_follow_docs/src/tests/ui.rs +++ b/crates/function_attrs_follow_docs/src/tests/ui.rs @@ -18,6 +18,7 @@ fn ui_runs_in_welsh_locale() { run_ui_with_locale("ui-cy", Some("cy")); } fn run_ui_with_locale(directory: &str, locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/module_max_lines/Cargo.toml b/crates/module_max_lines/Cargo.toml index 9a3c2b45..6d3f7c2c 100644 --- a/crates/module_max_lines/Cargo.toml +++ b/crates/module_max_lines/Cargo.toml @@ -18,7 +18,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -30,7 +30,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } fluent-templates = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index 6b150deb..5018ca46 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -8,7 +8,6 @@ use log::debug; use rustc_hir as hir; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{Span, source_map::SourceMap, symbol::Ident}; -use whitaker::{ModuleMaxLinesConfig, SharedConfig, module_body_span, module_header_span}; use whitaker_common::i18n::{ Arguments, DiagnosticMessageSet, @@ -19,6 +18,12 @@ use whitaker_common::i18n::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::{ + ModuleMaxLinesConfig, + SharedConfig, + module_body_span, + module_header_span, +}; const LINT_NAME: &str = "module_max_lines"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("module_max_lines"); diff --git a/crates/module_max_lines/src/lib_ui_tests.rs b/crates/module_max_lines/src/lib_ui_tests.rs index 62e4989e..ed6e659d 100644 --- a/crates/module_max_lines/src/lib_ui_tests.rs +++ b/crates/module_max_lines/src/lib_ui_tests.rs @@ -12,14 +12,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/module_must_have_inner_docs/Cargo.toml b/crates/module_must_have_inner_docs/Cargo.toml index b8c11ea9..c69acdc7 100644 --- a/crates/module_must_have_inner_docs/Cargo.toml +++ b/crates/module_must_have_inner_docs/Cargo.toml @@ -17,7 +17,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -30,7 +30,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } newt-hype = "0.2" [dev-dependencies] diff --git a/crates/module_must_have_inner_docs/src/driver/mod.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs index 6b786836..8839e5d1 100644 --- a/crates/module_must_have_inner_docs/src/driver/mod.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -14,7 +14,6 @@ use rustc_lint::{LateContext, LateLintPass, LintContext}; #[cfg(test)] use rustc_span::DUMMY_SP; use rustc_span::{BytePos, Span, source_map::SourceMap, symbol::Ident}; -use whitaker::{SharedConfig, module_body_span, module_header_span}; use whitaker_common::i18n::{ Arguments, DiagnosticMessageSet, @@ -26,6 +25,7 @@ use whitaker_common::i18n::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::{SharedConfig, module_body_span, module_header_span}; mod inner_attr; mod parser; diff --git a/crates/module_must_have_inner_docs/src/tests/ui.rs b/crates/module_must_have_inner_docs/src/tests/ui.rs index 7782bbd2..74a06ea5 100644 --- a/crates/module_must_have_inner_docs/src/tests/ui.rs +++ b/crates/module_must_have_inner_docs/src/tests/ui.rs @@ -28,6 +28,7 @@ use whitaker_common::test_support::with_locale; #[serial] fn ui_tests_across_locales(#[case] directory: &str, #[case] locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/no_expect_outside_tests/Cargo.toml b/crates/no_expect_outside_tests/Cargo.toml index 4453452f..00632205 100644 --- a/crates/no_expect_outside_tests/Cargo.toml +++ b/crates/no_expect_outside_tests/Cargo.toml @@ -19,7 +19,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -32,7 +32,7 @@ rustc_middle = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } serde = { version = "1.0", features = ["derive"], optional = true } log = { workspace = true, optional = true } diff --git a/crates/no_expect_outside_tests/src/context/mod.rs b/crates/no_expect_outside_tests/src/context/mod.rs index 367b25a6..095c94a5 100644 --- a/crates/no_expect_outside_tests/src/context/mod.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -10,7 +10,6 @@ use rustc_hir as hir; use rustc_hir::{Node, attrs::AttributeKind as HirAttributeKind}; use rustc_lint::LateContext; use rustc_span::sym; -use whitaker::hir::has_test_like_hir_attributes; use whitaker_common::{ Attribute, AttributeKind, @@ -20,6 +19,7 @@ use whitaker_common::{ PARSED_ATTRIBUTE_PLACEHOLDER, in_test_like_context_with, }; +use whitaker_lint_core::hir::has_test_like_hir_attributes; #[derive(Default, Debug, Clone, PartialEq, Eq)] pub(crate) struct ContextSummary { diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 756dca67..209a14bf 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -17,8 +17,8 @@ use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; use rustc_span::{RemapPathScopeComponents, sym}; use serde::Deserialize; -use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; +use whitaker_lint_core::{SharedConfig, hir::has_test_like_hir_attributes}; use crate::{ context::{collect_context, is_cfg_test_attribute, summarize_context}, @@ -85,8 +85,8 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { .is_some(); self.is_test_harness = cx.tcx.sess.opts.test; self.harness_marked_test_functions = if self.is_test_harness { - let mut marked = whitaker::hir::collect_harness_test_functions(cx); - marked.extend(whitaker::hir::collect_rstest_companion_test_functions(cx)); + let mut marked = whitaker_lint_core::hir::collect_harness_test_functions(cx); + marked.extend(whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx)); marked } else { HashSet::new() diff --git a/crates/no_expect_outside_tests/src/lib.rs b/crates/no_expect_outside_tests/src/lib.rs index 2d3372fb..cfe67ab6 100644 --- a/crates/no_expect_outside_tests/src/lib.rs +++ b/crates/no_expect_outside_tests/src/lib.rs @@ -22,7 +22,7 @@ mod lib_ui_tests; mod tests; #[cfg(all(feature = "dylint-driver", test))] mod ui { - whitaker::declare_ui_tests!("ui"); + whitaker_lint_core::declare_ui_tests!("ui"); } // Re-export only the documented lint surface. `impl_late_lint!` also expands diff --git a/crates/no_expect_outside_tests/src/lib_ui_tests.rs b/crates/no_expect_outside_tests/src/lib_ui_tests.rs index a642015f..5a93792b 100644 --- a/crates/no_expect_outside_tests/src/lib_ui_tests.rs +++ b/crates/no_expect_outside_tests/src/lib_ui_tests.rs @@ -84,7 +84,7 @@ struct DependencyRlib { fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( @@ -153,7 +153,7 @@ fn run_fixture_under_test_harness( fn run_fixture_harness_test(spec: &FixtureHarnessRun<'_>) { let crate_name = spec.crate_name; let directory = spec.directory; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, dir| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, dir| { run_fixture_under_test_harness(spec, dir) }) .unwrap_or_else(|error| { diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index a979d087..9ba620e3 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -18,7 +18,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -32,7 +32,7 @@ rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index bf339cb1..ee98afd9 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -7,11 +7,11 @@ use rustc_hir::AmbigArg; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; use rustc_span::{Span, sym}; -use whitaker::SharedConfig; use whitaker_common::{ SimplePath, i18n::{Localizer, get_localizer_for_lint}, }; +use whitaker_lint_core::SharedConfig; use crate::{ config::{LINT_NAME, load_configuration}, diff --git a/crates/no_std_fs_operations/src/tests/ui.rs b/crates/no_std_fs_operations/src/tests/ui.rs index b0e05e71..aea61ca5 100644 --- a/crates/no_std_fs_operations/src/tests/ui.rs +++ b/crates/no_std_fs_operations/src/tests/ui.rs @@ -21,6 +21,7 @@ fn ui_runs_in_fallback_locale() { run_with_locale("ui-fallback", Some("zz")); } fn run_with_locale(directory: &str, locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/no_unwrap_or_else_panic/Cargo.toml b/crates/no_unwrap_or_else_panic/Cargo.toml index c390d311..693af17d 100644 --- a/crates/no_unwrap_or_else_panic/Cargo.toml +++ b/crates/no_unwrap_or_else_panic/Cargo.toml @@ -20,7 +20,7 @@ dylint-driver = [ "dep:rustc_middle", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] clippy = ["dylint-driver", "dep:clippy_utils"] @@ -35,7 +35,7 @@ rustc_middle = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } rustc_ast = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } clippy_utils = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 920d5358..780bbc84 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -7,8 +7,8 @@ use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::i18n::{Localizer, get_localizer_for_lint}; +use whitaker_lint_core::SharedConfig; use crate::{ LINT_NAME, @@ -83,8 +83,8 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { self.is_test_harness = cx.tcx.sess.opts.test; self.harness_test_functions = if self.is_test_harness { - let mut marked = whitaker::hir::collect_harness_test_functions(cx); - marked.extend(whitaker::hir::collect_rstest_companion_test_functions(cx)); + let mut marked = whitaker_lint_core::hir::collect_harness_test_functions(cx); + marked.extend(whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx)); marked } else { HashSet::new() diff --git a/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs b/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs index 3783215a..5f3a136f 100644 --- a/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs +++ b/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs @@ -53,14 +53,13 @@ impl<'a> ExampleHarnessRun<'a> { fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } /// Runs an example-based regression under the dylint UI test harness. @@ -71,7 +70,7 @@ fn ui() { fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( diff --git a/crates/rstest_helper_should_be_fixture/Cargo.toml b/crates/rstest_helper_should_be_fixture/Cargo.toml index e44bb18d..1bc4c6ba 100644 --- a/crates/rstest_helper_should_be_fixture/Cargo.toml +++ b/crates/rstest_helper_should_be_fixture/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", "dep:camino", "dep:cap-std", ] @@ -43,7 +43,7 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] dylint_testing = { workspace = true } diff --git a/crates/rstest_helper_should_be_fixture/src/collector.rs b/crates/rstest_helper_should_be_fixture/src/collector.rs index f9dea57b..9de9350e 100644 --- a/crates/rstest_helper_should_be_fixture/src/collector.rs +++ b/crates/rstest_helper_should_be_fixture/src/collector.rs @@ -235,7 +235,7 @@ pub(crate) fn lower_arg_atom<'tcx>( } fn should_skip_arg_for_unrecoverable_span(span: Span) -> bool { - whitaker::hir::recover_user_editable_hir_span(span).is_none() + whitaker_lint_core::hir::recover_user_editable_hir_span(span).is_none() } fn lower_path_arg<'tcx>( diff --git a/crates/rstest_helper_should_be_fixture/src/driver.rs b/crates/rstest_helper_should_be_fixture/src/driver.rs index f5b4e4ae..31440381 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver.rs @@ -17,12 +17,12 @@ use rustc_hir::{def_id::LocalDefId, intravisit::Visitor}; use rustc_lint::{LateContext, LateLintPass}; use rustc_span::Span; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::{ attributes::AttributePath, i18n::{Localizer, get_localizer_for_lint}, rstest::{RstestDetectionOptions, is_rstest_test_with}, }; +use whitaker_lint_core::SharedConfig; use crate::{ collector::CallSiteCollector, @@ -226,7 +226,8 @@ impl RstestHelperShouldBeFixture { impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture { fn check_crate(&mut self, cx: &LateContext<'tcx>) { self.apply_loaded_crate_configuration(load_configuration(), &load_shared_config()); - self.rstest_collection_roots = whitaker::hir::collect_rstest_companion_test_functions(cx); + self.rstest_collection_roots = + whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx); } fn check_fn( diff --git a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs index 843f8d26..8aa2f30c 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs @@ -6,7 +6,7 @@ use proptest::prelude::*; use rstest::rstest; -use whitaker::SharedConfig; +use whitaker_lint_core::SharedConfig; use super::*; diff --git a/crates/rstest_helper_should_be_fixture/src/visitor.rs b/crates/rstest_helper_should_be_fixture/src/visitor.rs index de7b835c..3f151bd3 100644 --- a/crates/rstest_helper_should_be_fixture/src/visitor.rs +++ b/crates/rstest_helper_should_be_fixture/src/visitor.rs @@ -126,11 +126,13 @@ impl<'tcx> Visitor<'tcx> for CallSiteVisitor<'_, 'tcx> { impl CallSiteVisitor<'_, '_> { fn recover_call_span(&self, span: Span) -> Option { - whitaker::hir::recover_user_editable_hir_span(span).or_else(|| { + whitaker_lint_core::hir::recover_user_editable_hir_span(span).or_else(|| { self.closure_span_fallbacks .iter() .rev() - .find_map(|fallback| whitaker::hir::recover_user_editable_hir_span(*fallback)) + .find_map(|fallback| { + whitaker_lint_core::hir::recover_user_editable_hir_span(*fallback) + }) }) } } diff --git a/crates/rstest_helper_should_be_fixture/tests/ui.rs b/crates/rstest_helper_should_be_fixture/tests/ui.rs index 25c39ca4..e17e6ac7 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui.rs @@ -94,13 +94,17 @@ impl ExampleHarness { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; let lock_path = self.lock.path().display(); - whitaker::testing::ui::run_with_runner(crate_name, directory, |runner_crate, _| { - run_test_runner(example, || { - let mut test = Test::example(runner_crate, example); - test.rustc_flags(["--test"]); - test.run(); - }) - }) + whitaker_lint_core::testing::ui::run_with_runner( + crate_name, + directory, + |runner_crate, _| { + run_test_runner(example, || { + let mut test = Test::example(runner_crate, example); + test.rustc_flags(["--test"]); + test.run(); + }) + }, + ) .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ diff --git a/crates/test_must_not_have_example/Cargo.toml b/crates/test_must_not_have_example/Cargo.toml index 492398ec..92d4c646 100644 --- a/crates/test_must_not_have_example/Cargo.toml +++ b/crates/test_must_not_have_example/Cargo.toml @@ -19,7 +19,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -31,7 +31,7 @@ rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } log = { workspace = true, optional = true } -whitaker = { version = "0.2.7", path = "../../", features = ["dylint-driver"], optional = true } +whitaker_lint_core = { version = "0.2.7", path = "../whitaker_lint_core", features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/test_must_not_have_example/src/driver.rs b/crates/test_must_not_have_example/src/driver.rs index dc46e3ae..1f462974 100644 --- a/crates/test_must_not_have_example/src/driver.rs +++ b/crates/test_must_not_have_example/src/driver.rs @@ -8,7 +8,6 @@ use rustc_hir::Node; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{Ident, Span, Symbol}; use serde::Deserialize; -use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; use whitaker_common::{ AttributePath, i18n::{ @@ -23,6 +22,7 @@ use whitaker_common::{ safe_resolve_message_set, }, }; +use whitaker_lint_core::{SharedConfig, hir::has_test_like_hir_attributes}; use crate::heuristics::{DocExampleViolation, detect_example_violation}; diff --git a/crates/test_must_not_have_example/src/lib_ui_tests.rs b/crates/test_must_not_have_example/src/lib_ui_tests.rs index a97121d8..ed503679 100644 --- a/crates/test_must_not_have_example/src/lib_ui_tests.rs +++ b/crates/test_must_not_have_example/src/lib_ui_tests.rs @@ -10,14 +10,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/whitaker_lint_core/Cargo.toml b/crates/whitaker_lint_core/Cargo.toml new file mode 100644 index 00000000..4f96bbaa --- /dev/null +++ b/crates/whitaker_lint_core/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "whitaker_lint_core" +version = "0.2.7" +edition = "2024" +publish = false + +[features] +default = [] +dylint-driver = [ + "dep:dylint_linting", + "dep:rustc_ast", + "dep:rustc_hir", + "dep:rustc_lint", + "dep:rustc_span", +] + +[dependencies] +camino = { workspace = true } +cargo_metadata = { workspace = true } +clap = { workspace = true, features = ["derive"] } +dylint_linting = { workspace = true, optional = true } +rustc_ast = { workspace = true, optional = true } +rustc_hir = { workspace = true, optional = true } +rustc_lint = { workspace = true, optional = true } +rustc_span = { workspace = true, optional = true } +serde = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +whitaker-common = { workspace = true } + +[dev-dependencies] +dylint_testing = { workspace = true } +rstest = { workspace = true } +rstest-bdd = { workspace = true } +rstest-bdd-macros = { workspace = true } +whitaker-common = { workspace = true } +whitaker_test_macros = { workspace = true } + +[lints] +workspace = true diff --git a/src/config.rs b/crates/whitaker_lint_core/src/config.rs similarity index 98% rename from src/config.rs rename to crates/whitaker_lint_core/src/config.rs index 0314448f..9bb86f01 100644 --- a/src/config.rs +++ b/crates/whitaker_lint_core/src/config.rs @@ -40,7 +40,7 @@ impl SharedConfig { /// ``` /// # #[cfg(feature = "dylint-driver")] /// # { - /// use whitaker::SharedConfig; + /// use whitaker_lint_core::SharedConfig; /// /// let config = SharedConfig::load(); /// assert_eq!(config.module_max_lines.max_lines, 400); @@ -74,7 +74,7 @@ impl SharedConfig { /// # Examples /// /// ``` - /// use whitaker::SharedConfig; + /// use whitaker_lint_core::SharedConfig; /// /// let config = SharedConfig::load_with("whitaker", |_| SharedConfig::default()); /// assert_eq!(config.module_max_lines.max_lines, 400); diff --git a/src/hir/mod.rs b/crates/whitaker_lint_core/src/hir/mod.rs similarity index 100% rename from src/hir/mod.rs rename to crates/whitaker_lint_core/src/hir/mod.rs diff --git a/src/hir/tests.rs b/crates/whitaker_lint_core/src/hir/tests.rs similarity index 100% rename from src/hir/tests.rs rename to crates/whitaker_lint_core/src/hir/tests.rs diff --git a/crates/whitaker_lint_core/src/lib.rs b/crates/whitaker_lint_core/src/lib.rs new file mode 100644 index 00000000..cb69408b --- /dev/null +++ b/crates/whitaker_lint_core/src/lib.rs @@ -0,0 +1,30 @@ +//! Shared configuration and helpers for Whitaker Dylint lint crates. +#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] + +// Link against `rustc_driver` only when consumers need the dylint driver runtime. +// Unit tests of this crate should not pull the compiler driver to avoid the +// duplicated `std`/`core` link errors seen during all-features test runs. +#[cfg(feature = "dylint-driver")] +extern crate rustc_data_structures; +#[cfg(all(feature = "dylint-driver", not(test)))] +extern crate rustc_driver; + +pub mod config; +#[cfg(feature = "dylint-driver")] +pub mod hir; +pub mod lints; +pub mod testing; + +pub use config::{ModuleMaxLinesConfig, SharedConfig}; +#[cfg(feature = "dylint-driver")] +pub use hir::{ + module_body_span, + module_header_span, + recover_user_editable_hir_span, + span_recovery_frames, +}; +pub use lints::{LintCrateTemplate, TemplateError, TemplateFiles}; + +/// Returns a greeting for the library. +#[must_use] +pub const fn greet() -> &'static str { "Hello from Whitaker!" } diff --git a/src/lints/mod.rs b/crates/whitaker_lint_core/src/lints/mod.rs similarity index 100% rename from src/lints/mod.rs rename to crates/whitaker_lint_core/src/lints/mod.rs diff --git a/src/lints/template/content.rs b/crates/whitaker_lint_core/src/lints/template/content.rs similarity index 89% rename from src/lints/template/content.rs rename to crates/whitaker_lint_core/src/lints/template/content.rs index 3e0fa160..1366502c 100644 --- a/src/lints/template/content.rs +++ b/crates/whitaker_lint_core/src/lints/template/content.rs @@ -18,7 +18,7 @@ rustc_span = { workspace = true } whitaker-common = { path = "../../common" } [dev-dependencies] -whitaker = { path = "../../" } +whitaker_lint_core = { path = "../whitaker_lint_core" } "#; const LIB_RS_TEMPLATE: &str = r#"//! Lint crate for `{crate_name}`. @@ -58,7 +58,7 @@ impl_late_lint! { #[cfg(test)] mod tests { - whitaker::declare_ui_tests!("{ui_tests_directory}"); + whitaker_lint_core::declare_ui_tests!("{ui_tests_directory}"); } "#; @@ -150,7 +150,9 @@ mod tests { #[test] fn render_lib_rs_escapes_ui_directory() { let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", "ui/space \"quote\""); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("ui/space \"quote\"");"#)); + assert!( + rendered.contains(r#"whitaker_lint_core::declare_ui_tests!("ui/space \"quote\"");"#) + ); } #[test] @@ -161,7 +163,10 @@ mod tests { "DemoLint", "ui/wave\\multiline\ncase", ); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("ui/wave\\multiline\ncase");"#)); + assert!( + rendered + .contains(r#"whitaker_lint_core::declare_ui_tests!("ui/wave\\multiline\ncase");"#) + ); } #[test] @@ -169,7 +174,7 @@ mod tests { let directory = "ui/\"outer 'inner'\""; let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", directory); let expected = format!( - "whitaker::declare_ui_tests!(\"{}\");", + "whitaker_lint_core::declare_ui_tests!(\"{}\");", escape_rust_string_literal(directory) ); assert!(rendered.contains(expected.as_str())); @@ -178,6 +183,6 @@ mod tests { #[test] fn render_lib_rs_handles_empty_ui_directory() { let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", ""); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("");"#)); + assert!(rendered.contains(r#"whitaker_lint_core::declare_ui_tests!("");"#)); } } diff --git a/src/lints/template/mod.rs b/crates/whitaker_lint_core/src/lints/template/mod.rs similarity index 97% rename from src/lints/template/mod.rs rename to crates/whitaker_lint_core/src/lints/template/mod.rs index 4cf6db5e..34bd3b91 100644 --- a/src/lints/template/mod.rs +++ b/crates/whitaker_lint_core/src/lints/template/mod.rs @@ -25,7 +25,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -41,7 +41,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -61,7 +61,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -271,7 +271,7 @@ mod tests { assert!( files .lib_rs() - .contains("whitaker::declare_ui_tests!(\"ui\");") + .contains("whitaker_lint_core::declare_ui_tests!(\"ui\");") ); } } diff --git a/src/lints/template/validation.rs b/crates/whitaker_lint_core/src/lints/template/validation.rs similarity index 100% rename from src/lints/template/validation.rs rename to crates/whitaker_lint_core/src/lints/template/validation.rs diff --git a/src/testing/mod.rs b/crates/whitaker_lint_core/src/testing/mod.rs similarity index 100% rename from src/testing/mod.rs rename to crates/whitaker_lint_core/src/testing/mod.rs diff --git a/src/testing/ui/mod.rs b/crates/whitaker_lint_core/src/testing/ui/mod.rs similarity index 97% rename from src/testing/ui/mod.rs rename to crates/whitaker_lint_core/src/testing/ui/mod.rs index e786a7d2..af4891db 100644 --- a/src/testing/ui/mod.rs +++ b/crates/whitaker_lint_core/src/testing/ui/mod.rs @@ -130,9 +130,9 @@ impl std::error::Error for HarnessError {} /// /// ```no_run /// use camino::Utf8Path; -/// use whitaker::testing::ui::run_with_runner; +/// use whitaker_lint_core::testing::ui::run_with_runner; /// -/// fn main() -> Result<(), whitaker::testing::ui::HarnessError> { +/// fn main() -> Result<(), whitaker_lint_core::testing::ui::HarnessError> { /// run_with_runner("my_lint", "ui", |crate_name, dir: &Utf8Path| { /// ::dylint_testing::ui_test(crate_name, dir); /// Ok(()) @@ -235,7 +235,7 @@ fn directory_is_rooted(path: &Utf8Path) -> bool { /// # Examples /// /// ```ignore -/// whitaker::run_ui_tests!("ui").expect("UI tests should succeed"); +/// whitaker_lint_core::run_ui_tests!("ui").expect("UI tests should succeed"); /// ``` /// /// # Errors @@ -266,7 +266,7 @@ macro_rules! run_ui_tests { /// # Examples /// /// ```ignore -/// whitaker::declare_ui_tests!("ui"); +/// whitaker_lint_core::declare_ui_tests!("ui"); /// ``` #[macro_export] macro_rules! declare_ui_tests { diff --git a/src/testing/ui/tests.rs b/crates/whitaker_lint_core/src/testing/ui/tests.rs similarity index 100% rename from src/testing/ui/tests.rs rename to crates/whitaker_lint_core/src/testing/ui/tests.rs diff --git a/src/testing/ui/toolchain.rs b/crates/whitaker_lint_core/src/testing/ui/toolchain.rs similarity index 100% rename from src/testing/ui/toolchain.rs rename to crates/whitaker_lint_core/src/testing/ui/toolchain.rs diff --git a/crates/whitaker_lint_core/tests/build_config.rs b/crates/whitaker_lint_core/tests/build_config.rs new file mode 100644 index 00000000..011b8b73 --- /dev/null +++ b/crates/whitaker_lint_core/tests/build_config.rs @@ -0,0 +1,27 @@ +//! Build configuration guards for scoped dynamic-linking expectations. + +use std::fs; + +use toml::Value; + +mod workspace_support; + +use workspace_support::workspace_root; + +#[test] +fn cargo_config_keeps_dynamic_linking_out_of_workspace_configuration() { + let config_path = workspace_root().join(".cargo/config.toml"); + let contents = fs::read_to_string(&config_path) + .unwrap_or_else(|err| panic!("failed to read {config_path:?}: {err}")); + let value: Value = toml::from_str(&contents).expect("cargo config should parse as TOML table"); + + let rustflags = value + .get("build") + .and_then(|table| table.get("rustflags")) + .and_then(Value::as_array); + + assert!( + rustflags.is_none(), + "dynamic linker flags belong to the Dylint-aware Make recipes, not workspace configuration" + ); +} diff --git a/tests/config_loading.rs b/crates/whitaker_lint_core/tests/config_loading.rs similarity index 99% rename from tests/config_loading.rs rename to crates/whitaker_lint_core/tests/config_loading.rs index 9740a637..9e584bf8 100644 --- a/tests/config_loading.rs +++ b/crates/whitaker_lint_core/tests/config_loading.rs @@ -13,8 +13,8 @@ mod support; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; -use whitaker::SharedConfig; use whitaker_common::i18n::normalize_locale; +use whitaker_lint_core::SharedConfig; #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] diff --git a/tests/features/config_loading.feature b/crates/whitaker_lint_core/tests/features/config_loading.feature similarity index 100% rename from tests/features/config_loading.feature rename to crates/whitaker_lint_core/tests/features/config_loading.feature diff --git a/tests/features/lint_template.feature b/crates/whitaker_lint_core/tests/features/lint_template.feature similarity index 100% rename from tests/features/lint_template.feature rename to crates/whitaker_lint_core/tests/features/lint_template.feature diff --git a/tests/features/locale_resolution.feature b/crates/whitaker_lint_core/tests/features/locale_resolution.feature similarity index 100% rename from tests/features/locale_resolution.feature rename to crates/whitaker_lint_core/tests/features/locale_resolution.feature diff --git a/tests/features/ui_harness.feature b/crates/whitaker_lint_core/tests/features/ui_harness.feature similarity index 100% rename from tests/features/ui_harness.feature rename to crates/whitaker_lint_core/tests/features/ui_harness.feature diff --git a/tests/lint_template.rs b/crates/whitaker_lint_core/tests/lint_template.rs similarity index 95% rename from tests/lint_template.rs rename to crates/whitaker_lint_core/tests/lint_template.rs index 935183ec..c98dca85 100644 --- a/tests/lint_template.rs +++ b/crates/whitaker_lint_core/tests/lint_template.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use toml::Value; -use whitaker::lints::{LintCrateTemplate, TemplateError, TemplateFiles}; +use whitaker_lint_core::lints::{LintCrateTemplate, TemplateError, TemplateFiles}; #[derive(Debug, Default)] struct TemplateWorld { @@ -167,23 +167,23 @@ fn then_manifest_reuses_shared_dependencies(world: &TemplateWorld) { panic!("dev-dependencies table should exist"); }; - let Some(whitaker) = dev_dependencies.get("whitaker").and_then(Value::as_table) else { - panic!("whitaker dev-dependency should exist"); + let Some(core) = dev_dependencies + .get("whitaker_lint_core") + .and_then(Value::as_table) + else { + panic!("whitaker_lint_core dev-dependency should exist"); }; - let dev_path = whitaker - .get("path") - .and_then(Value::as_str) - .unwrap_or_default(); + let dev_path = core.get("path").and_then(Value::as_str).unwrap_or_default(); - assert_eq!(dev_path, "../../"); + assert_eq!(dev_path, "../whitaker_lint_core"); } #[then("the library includes UI test harness boilerplate for directory {directory}")] fn then_library_includes_harness(world: &TemplateWorld, directory: StepString) { let files = world.files(); let directory_value = directory.into_inner(); - let expected = format!("whitaker::declare_ui_tests!(\"{directory_value}\");"); + let expected = format!("whitaker_lint_core::declare_ui_tests!(\"{directory_value}\");"); assert!(files.lib_rs().contains(expected.as_str())); } diff --git a/tests/locale_resolution.rs b/crates/whitaker_lint_core/tests/locale_resolution.rs similarity index 100% rename from tests/locale_resolution.rs rename to crates/whitaker_lint_core/tests/locale_resolution.rs diff --git a/tests/nextest_ui_filter.rs b/crates/whitaker_lint_core/tests/nextest_ui_filter.rs similarity index 96% rename from tests/nextest_ui_filter.rs rename to crates/whitaker_lint_core/tests/nextest_ui_filter.rs index 0f748690..f2566d9a 100644 --- a/tests/nextest_ui_filter.rs +++ b/crates/whitaker_lint_core/tests/nextest_ui_filter.rs @@ -12,14 +12,18 @@ //! `ui`, **not** `ui::ui`) and asserts that the nextest filter contains the //! clause needed to capture that pattern. -use std::{fs, path::Path}; +use std::fs; use rstest::{fixture, rstest}; use toml::Value; +mod workspace_support; + +use workspace_support::workspace_root; + /// Parses `.config/nextest.toml` into a [`Value`]. fn load_nextest_config() -> Value { - let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".config/nextest.toml"); + let config_path = workspace_root().join(".config/nextest.toml"); let contents = fs::read_to_string(&config_path) .unwrap_or_else(|err| panic!("failed to read {}: {err}", config_path.display())); toml::from_str(&contents) @@ -62,7 +66,7 @@ fn extract_filter(ui_override: &Value) -> &str { /// name of `ui`. The substring match `test(ui::ui)` does **not** capture /// them because the reported test name is plain `ui`, not `ui::ui`. fn crates_with_integration_ui_test() -> Vec { - let crates_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("crates"); + let crates_dir = workspace_root().join("crates"); let entries = fs::read_dir(&crates_dir) .unwrap_or_else(|err| panic!("failed to read {}: {err}", crates_dir.display())); diff --git a/tests/support/locale.rs b/crates/whitaker_lint_core/tests/support/locale.rs similarity index 100% rename from tests/support/locale.rs rename to crates/whitaker_lint_core/tests/support/locale.rs diff --git a/tests/support/mod.rs b/crates/whitaker_lint_core/tests/support/mod.rs similarity index 99% rename from tests/support/mod.rs rename to crates/whitaker_lint_core/tests/support/mod.rs index 2149af04..8cb1effc 100644 --- a/tests/support/mod.rs +++ b/crates/whitaker_lint_core/tests/support/mod.rs @@ -5,4 +5,5 @@ //! into the configuration and resolution flow. Reach for these utilities //! whenever a test needs to normalize locale input before exercising the i18n //! layer or verifying translation behaviour. + pub mod locale; diff --git a/tests/ui_harness.rs b/crates/whitaker_lint_core/tests/ui_harness.rs similarity index 98% rename from tests/ui_harness.rs rename to crates/whitaker_lint_core/tests/ui_harness.rs index ae8b5ab0..8f134897 100644 --- a/tests/ui_harness.rs +++ b/crates/whitaker_lint_core/tests/ui_harness.rs @@ -5,7 +5,7 @@ use std::{cell::RefCell, convert::Infallible}; use camino::Utf8PathBuf; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use whitaker::testing::ui::{HarnessError, run_with_runner}; +use whitaker_lint_core::testing::ui::{HarnessError, run_with_runner}; #[derive(Debug)] struct StepString(String); diff --git a/crates/whitaker_lint_core/tests/workspace_support/mod.rs b/crates/whitaker_lint_core/tests/workspace_support/mod.rs new file mode 100644 index 00000000..840812b3 --- /dev/null +++ b/crates/whitaker_lint_core/tests/workspace_support/mod.rs @@ -0,0 +1,17 @@ +//! Workspace-root lookup for repository-configuration integration tests. +//! +//! This module is included only by tests that inspect repository-level files. +//! Production inputs must be supplied explicitly by callers instead. + +use std::path::{Path, PathBuf}; + +/// Returns the workspace root for the nested core crate's integration tests. +pub fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .map_or_else( + || panic!("whitaker_lint_core must remain nested under crates/"), + Path::to_path_buf, + ) +} diff --git a/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md b/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md new file mode 100644 index 00000000..86aa917e --- /dev/null +++ b/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md @@ -0,0 +1,74 @@ +# Debugging Plan: M0 core integration-test linkage + +**Generated**: 2026-08-24 +**Issue ID**: EP-M0 gate failure +**Severity**: medium +**Falsification sub-agent**: alchemist +**Planning agent boundary**: This document was prepared by the planning agent. +Falsification must be executed by the named sub-agent, not by the planning +agent. + +## Problem Statement + +After extracting `whitaker_lint_core`, `make test NEXTEST_PROFILE=ci` fails +while linking its `lint_template` integration-test target. The milestone +requires the six formerly excluded test targets to run, so the correction must +preserve that coverage without suppressing a lint or weakening the gate. + +## Context Summary + +| Aspect | Details | +| --- | --- | +| First observed | 2026-08-24, first M0 full gate run | +| Reproduction rate | deterministic under workspace `--all-features` | +| Affected components | core test target and Dylint driver feature | +| Recent changes | root driver library moved to `crates/whitaker_lint_core` | + +### Error Artefacts + +```plaintext +cannot satisfy dependencies so std/core/alloc only shows up once +feature(rustc_private) is needed to link to rustc_driver +``` + +## Hypotheses + +### H1: Feature unification enables the compiler driver in the test executable + +**Claim**: Workspace `--all-features` enables `whitaker_lint_core`'s +`dylint-driver` feature through each lint crate, causing its integration-test +executable to link `rustc_driver` with ordinary dependencies. + +**Plausibility**: High — the failing target is the core integration test and +the linker explicitly names `rustc_driver`. + +**Prediction**: The focused test passes without the feature and reproduces the +link failure when it is enabled directly. + +#### H1 Falsification Plan + +| Step | Action | Expected Negative Result | +| --- | --- | --- | +| 1 | Run `cargo test -p whitaker_lint_core --test lint_template --no-default-features --no-run`. | A link failure disproves H1. | +| 2 | Run the same command with `--features dylint-driver`. | Success disproves H1. | + +**Tooling**: Cargo only; do not edit tracked files or run a repository gate. + +**Confidence on falsification**: High. The two commands vary only the feature +that owns the compiler-private runtime. + +## Recommended Execution Order + +1. **H1** — it is the smallest decisive experiment and fully explains the + captured linker diagnostics. + +## Termination Criteria + +- **Root cause identified**: H1 survives both focused tests. +- **Escalation trigger**: Either result contradicts H1; revise the debugging + plan before editing implementation code. + +## Notes for Executing Agent + +Use the shared Cargo cache. Capture only concise outputs and report a verdict +of falsified, not-falsified, or inconclusive. diff --git a/docs/execplans/3-5-1-root-whitaker-binary.md b/docs/execplans/3-5-1-root-whitaker-binary.md index c55a9682..425ac640 100644 --- a/docs/execplans/3-5-1-root-whitaker-binary.md +++ b/docs/execplans/3-5-1-root-whitaker-binary.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision log`, `Outcomes & retrospective`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: DRAFT +Status: IN PROGRESS ## Purpose / big picture @@ -87,8 +87,10 @@ stop at. ## Progress -- [ ] `EP-M0` — extract the Dylint driver library; root package becomes a - publishable, testable CLI package. +- [x] (2026-08-24T01:58Z) `EP-M0` — extracted the Dylint driver library and + made the root package publishable and testable. Evidence: package, format, + typecheck, lint, test, dynamic binary build, and root test-link gates all + passed; 1,664 workspace tests, 80 feature-free core tests, and doctests ran. - [ ] `EP-M1` — installer orchestration moved behind the library boundary. - [ ] `EP-M2` — the `whitaker` binary: `install`, `ls`, and `cargo dylint` forwarding. @@ -1281,6 +1283,40 @@ for the port boundaries; `kani` for `EP-INV-DISPATCH`; `verus` for ## Surprises & discoveries +- Observation: the checked baseline reproduces the plan's packaging failure. + Evidence: `cargo package -p whitaker --no-verify --allow-dirty` exited + non-zero on 2026-08-24 with `no matching package named rustc_ast`; the + baseline normal dependency tree for `module_max_lines` has 342 entries. + Impact: confirms that `EP-M0` remains the required first milestone and gives + the before-measurement for its dependency-containment objective. + +- Observation: Cargo's workspace-wide `--all-features` unifies the core + crate's `dylint-driver` feature into its integration-test executables. + Evidence: `lint_template` links without the feature but fails with it, + reproducing the `rustc_driver` duplicate `std`/`core` diagnostics; the two + focused commands and logs are recorded in + `docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md`. + Impact: the all-feature workspace pass excludes only the compiler-private + core test target, then `make test` and `publish-check` run its tests and + doctests separately without that feature. All six formerly excluded test + targets remain covered. + +- Observation: the formerly excluded `build_config` test asserted dynamic + linker flags in `.cargo/config.toml`, although that file deliberately keeps + them out of workspace configuration and the Make recipes inject them only + for Dylint-aware commands. + Evidence: the current configuration comment states this policy and the test + failed because `build.rustflags` is absent. + Impact: the test now protects the actual scoped-flag contract; this is a + pre-existing assertion correction exposed by `EP-M0`, not a behaviour change. + +- Observation: the moved template behaviour tests still asserted the old root + `whitaker` dev-dependency and root-relative path. + Evidence: three scenarios failed after the extraction despite the template + correctly rendering `whitaker_lint_core = { path = "../whitaker_lint_core" }`. + Impact: expectations now validate the new internal boundary; no generated + template behaviour changed after the relocation. + - Observation: the root `whitaker` package cannot be published at all. Evidence: `cargo package -p whitaker --no-verify --allow-dirty` fails with "no matching package named `rustc_ast`"; the four `rustc_*` shim crates are @@ -1425,8 +1461,25 @@ for the port boundaries; `kani` for `EP-INV-DISPATCH`; `verus` for body, branch name, and required pull-request title all identify 3.5.1. Confirmed with the requester. Date/Author: 2026-08-21. +- **D-10: Test the compiler-private core outside the workspace all-feature + invocation.** Cargo unifies `dylint-driver` through every lint crate, and + its compiler-private runtime cannot link into an ordinary integration-test + executable. A separate feature-free package invocation preserves each moved + test target and doctest without restoring the old `whitaker` exclusion or + weakening the Dylint driver pass. Date/Author: 2026-08-24, implementation + agent; supported by the H1 falsification experiment. + ## Outcomes & retrospective +- **EP-M0, 2026-08-24.** Complete. The compiler-private driver now resides in + the non-publishable `whitaker_lint_core` package. The root package has its + crates.io metadata and no compiler-private dependencies, and packages + successfully. All in-tree consumers were migrated to the core package. The + feature-unification discovery requires a dedicated feature-free core test + pass, preserving all newly activated coverage without weakening the driver + pass. The milestone is contained in the next commit and can be reverted as + one unit. + To be completed at each milestone boundary and at completion. Before setting this plan to `COMPLETE`, reconcile every entry in `Surprises & discoveries` against `docs/whitaker-cli-design.md`. In particular, `CLI-REQ-FWD` has no @@ -1436,6 +1489,10 @@ mark the plan `COMPLETE` while any upstream change or deviation is unrecorded. ## Revision note +**Revision 3, 2026-08-24.** Implementation started after the requester +approved this plan. The baseline packaging failure and dependency-tree count +were reproduced before `EP-M0`; no scope or architectural decision changed. + **Revision 2, 2026-08-21.** Rewritten after a six-perspective design review. What changed, and why. The structural bet was wrong: `cargo package -p diff --git a/dylint.toml b/dylint.toml index 1b3a1a14..a15bd0f3 100644 --- a/dylint.toml +++ b/dylint.toml @@ -10,8 +10,8 @@ # The common crate contains test support utilities that require ambient access # for fixture management. Use the Rust crate identifier here: `tcx.crate_name()` # reports `whitaker_common`, not the hyphenated Cargo package name. The -# whitaker crate contains the UI test harness which needs ambient access to -# copy compiled lint libraries during test execution. +# `whitaker_lint_core` contains the UI test harness which needs ambient access +# to copy compiled lint libraries during test execution. # # Integration-test targets compile as their own crates named after the test # file, so they are not covered by the `whitaker_common` entry above. @@ -35,7 +35,7 @@ excluded_crates = [ "whitaker_installer", "whitaker_common", - "whitaker", + "whitaker_lint_core", "i18n_packaging", "whitaker_package_lints", "whitaker_package_installer", diff --git a/src/lib.rs b/src/lib.rs index 0e8eedd0..9e8de97c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,30 +1 @@ -//! Core Whitaker library surfaces shared configuration and helpers for lint crates. -#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] - -// Link against `rustc_driver` only when consumers need the dylint driver runtime. -// Unit tests of this crate should not pull the compiler driver to avoid the -// duplicated `std`/`core` link errors seen during all-features test runs. -#[cfg(feature = "dylint-driver")] -extern crate rustc_data_structures; -#[cfg(all(feature = "dylint-driver", not(test)))] -extern crate rustc_driver; - -pub mod config; -#[cfg(feature = "dylint-driver")] -pub mod hir; -pub mod lints; -pub mod testing; - -pub use config::{ModuleMaxLinesConfig, SharedConfig}; -#[cfg(feature = "dylint-driver")] -pub use hir::{ - module_body_span, - module_header_span, - recover_user_editable_hir_span, - span_recovery_frames, -}; -pub use lints::{LintCrateTemplate, TemplateError, TemplateFiles}; - -/// Returns a greeting for the library. -#[must_use] -pub const fn greet() -> &'static str { "Hello from Whitaker!" } +//! Internal library placeholder for the future Whitaker command-line binary. diff --git a/tests/build_config.rs b/tests/build_config.rs deleted file mode 100644 index bd772414..00000000 --- a/tests/build_config.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Build configuration guards for dynamic linking expectations. - -use std::{fs, path::Path}; - -use toml::Value; - -#[test] -fn cargo_config_prefers_dynamic_linking() { - let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".cargo/config.toml"); - let contents = fs::read_to_string(&config_path) - .unwrap_or_else(|err| panic!("failed to read {config_path:?}: {err}")); - let value: Value = toml::from_str(&contents).expect("cargo config should parse as TOML table"); - - let rustflags = value - .get("build") - .and_then(|table| table.get("rustflags")) - .and_then(Value::as_array) - .expect("build.rustflags should be an array"); - - // Expect exactly the pair ["-C", "prefer-dynamic"] to guard against regressions. - let flags: Vec<&str> = rustflags - .iter() - .map(|v| v.as_str().expect("rustflags entries should be strings")) - .collect(); - assert_eq!( - flags, - ["-C", "prefer-dynamic"], - "rustflags must prefer dynamic linkage" - ); -} From cdcf4ee552fdaa65d880e03f94018bca218cba23 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 24 Aug 2026 04:11:04 +0200 Subject: [PATCH 5/5] Expose installer orchestration from the library Move command routing and installation flow behind `whitaker_installer::orchestration` while keeping the legacy binary as a small composition root. Add a normalized `--dry-run` output snapshot to preserve the existing installer contract through the relocation. --- Cargo.lock | 1 + docs/execplans/3-5-1-root-whitaker-binary.md | 11 +- installer/Cargo.toml | 1 + installer/src/install_flow/mod.rs | 5 +- installer/src/lib.rs | 4 + installer/src/main.rs | 393 +---------------- installer/src/orchestration.rs | 394 ++++++++++++++++++ installer/src/staged_suite.rs | 5 +- installer/src/tests/fast_path.rs | 9 +- installer/src/tests/mod.rs | 11 +- installer/tests/dry_run_snapshot.rs | 44 ++ .../dry_run_snapshot__dry_run_output.snap | 17 + 12 files changed, 490 insertions(+), 405 deletions(-) create mode 100644 installer/src/orchestration.rs create mode 100644 installer/tests/dry_run_snapshot.rs create mode 100644 installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap diff --git a/Cargo.lock b/Cargo.lock index 81b941b2..7254292a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3472,6 +3472,7 @@ dependencies = [ "directories-next", "flate2", "fs2", + "insta", "log", "mockall", "rstest", diff --git a/docs/execplans/3-5-1-root-whitaker-binary.md b/docs/execplans/3-5-1-root-whitaker-binary.md index 425ac640..d03aca74 100644 --- a/docs/execplans/3-5-1-root-whitaker-binary.md +++ b/docs/execplans/3-5-1-root-whitaker-binary.md @@ -91,7 +91,9 @@ stop at. made the root package publishable and testable. Evidence: package, format, typecheck, lint, test, dynamic binary build, and root test-link gates all passed; 1,664 workspace tests, 80 feature-free core tests, and doctests ran. -- [ ] `EP-M1` — installer orchestration moved behind the library boundary. +- [x] (2026-08-24T02:08Z) `EP-M1` — moved installer orchestration behind the + library boundary. Evidence: 1,665 tests, doctests, dynamic binary build, and + the `whitaker-installer --dry-run` snapshot all passed unchanged. - [ ] `EP-M2` — the `whitaker` binary: `install`, `ls`, and `cargo dylint` forwarding. - [ ] `EP-M3` — binstall metadata and crates.io name reservation. @@ -1480,6 +1482,13 @@ for the port boundaries; `kani` for `EP-INV-DISPATCH`; `verus` for pass. The milestone is contained in the next commit and can be reverted as one unit. +- **EP-M1, 2026-08-24.** Complete. `whitaker_installer::orchestration` now + owns request routing and installation flow; `whitaker-installer` is a thin + parser, adapter constructor, and exit-code composition root. The existing + installer integration tests remain unmodified, and a normalized dry-run + snapshot protects its public output. The milestone is contained in the next + commit and can be reverted as one unit. + To be completed at each milestone boundary and at completion. Before setting this plan to `COMPLETE`, reconcile every entry in `Surprises & discoveries` against `docs/whitaker-cli-design.md`. In particular, `CLI-REQ-FWD` has no diff --git a/installer/Cargo.toml b/installer/Cargo.toml index c6e4107a..396eee85 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,6 +78,7 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] +insta = { workspace = true } whitaker_test_macros = { workspace = true } rustix = { version = "1.1.4", features = ["process"] } mockall = { workspace = true } diff --git a/installer/src/install_flow/mod.rs b/installer/src/install_flow/mod.rs index 595346df..906eef7f 100644 --- a/installer/src/install_flow/mod.rs +++ b/installer/src/install_flow/mod.rs @@ -6,9 +6,10 @@ use std::{collections::HashSet, fs, io, io::Write, time::Duration}; use camino::{Utf8Path, Utf8PathBuf}; + #[cfg(test)] -use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; -use whitaker_installer::{ +use crate::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; +use crate::{ builder::{library_extension, library_prefix}, cli::InstallArgs, crate_name::CrateName, diff --git a/installer/src/lib.rs b/installer/src/lib.rs index 2e0c1afc..fd634518 100644 --- a/installer/src/lib.rs +++ b/installer/src/lib.rs @@ -45,16 +45,20 @@ pub mod dirs; pub mod error; pub mod git; mod hex; +pub mod install_flow; pub mod install_metrics; pub mod installer_packaging; pub mod list; pub mod list_output; +/// Coordinates installer commands without parsing arguments or exiting. +pub mod orchestration; pub mod output; pub mod pipeline; pub mod prebuilt; pub mod prebuilt_path; pub mod resolution; pub mod scanner; +mod staged_suite; pub mod stager; /// Test-only hooks shared by installer behavioural and integration tests. /// diff --git a/installer/src/main.rs b/installer/src/main.rs index c9688ed6..6b24808a 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -1,402 +1,17 @@ -//! Whitaker installer CLI entrypoint. -//! -//! This binary builds, links, and stages Dylint lint libraries for local use. -//! After installation, it prints shell configuration snippets for enabling -//! library discovery. +//! Composition root for the legacy `whitaker-installer` command. -mod install_flow; -mod staged_suite; - -use std::{io::Write, time::Instant}; - -use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; use whitaker_installer::{ - cli::{Cli, Command, InstallArgs}, - crate_name::CrateName, - deps::SystemCommandExecutor, - dirs::{BaseDirs, SystemBaseDirs}, - error::{InstallerError, Result}, - install_metrics::InstallMode, - list::{determine_target_dir, run_list}, - output::{DryRunInfo, DryRunSkips, ShellSnippet, write_stderr_line}, - pipeline::{PipelineContext, perform_build, stage_libraries}, - prebuilt_path::prebuilt_library_dir, - resolution::{CrateResolutionOptions, resolve_crates, validate_crate_names}, - toolchain::Toolchain, - wrapper::{generate_wrapper_scripts, path_instructions}, -}; - -#[cfg(test)] -use crate::install_flow::ensure_dylint_tools_with_options; -use crate::install_flow::{ - MetricsWriteContext, - PrebuiltInstallationContext, - detect_host_target, - ensure_dylint_tools_with_executor, - try_prebuilt_installation, - write_install_metrics, + cli::Cli, + orchestration::{exit_code_for_run_result, run}, }; fn main() { let cli = Cli::parse(); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - let run_result = run(&cli, &mut stdout, &mut stderr); - let exit_code = exit_code_for_run_result(run_result, &mut stderr); + let exit_code = exit_code_for_run_result(run(&cli, &mut stdout, &mut stderr), &mut stderr); if exit_code != 0 { std::process::exit(exit_code); } } - -/// Routes CLI commands to their respective handlers. -fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { - match &cli.command { - Some(Command::List(args)) => run_list(args, stdout), - Some(Command::Install(args)) => run_install(args, stderr), - None => run_install(cli.install_args(), stderr), - } -} - -/// Returns the set of additional rustup components requested by the CLI flags. -const fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { - if args.execution.cranelift { - &["rustc-codegen-cranelift"] - } else { - &[] - } -} - -/// Attempts prebuilt download and staged-suite fast paths. -/// -/// Returns `Some((staging_path, mode))` if either succeeds, or `None` if -/// the caller should proceed to a full build. -fn try_fast_path_installation( - context: &FastPathContext<'_>, - stderr: &mut dyn Write, -) -> Result> { - let prebuilt_context = PrebuiltInstallationContext { - args: context.args, - dirs: context.dirs, - requested_crates: context.requested_crates, - toolchain_channel: context.toolchain.channel(), - }; - if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr) { - return Ok(Some((staging_path, InstallMode::Download))); - } - if let Some(staging_path) = staged_suite::try_test_staged_suite_installation( - context.requested_crates, - context.toolchain, - context.target_dir, - )? { - return Ok(Some((staging_path, InstallMode::Build))); - } - Ok(None) -} - -/// Runs the install command to build and stage lint libraries. -/// -/// Workflow: (1) check/install Dylint dependencies, (2) locate/clone workspace, -/// (3) resolve crates from CLI flags, (4) build in release mode, (5) stage -/// libraries with toolchain-suffixed names, (6) generate wrapper script. -/// -/// # Errors -/// -/// Returns an error if any step fails. -fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { - let dirs = SystemBaseDirs::new().ok_or_else(|| InstallerError::WorkspaceNotFound { - reason: "could not determine platform directories".to_owned(), - })?; - if args.execution.dry_run { - return run_dry(args, &dirs, stderr); - } - let install_started = Instant::now(); - // Step 1: Check and install Dylint dependencies if needed - if !args.skip.skip_deps { - ensure_dylint_tools(args.quiet, stderr)?; - } - // Step 2: Ensure workspace is available (clone if needed) - let workspace_root = ensure_whitaker_workspace(args, &dirs, stderr)?; - // Step 3: Resolve crates and toolchain - let requested_crates = resolve_requested_crates(args)?; - let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; - ensure_toolchain_installed( - &toolchain, - resolve_additional_components(args), - args.quiet, - stderr, - )?; - let target_dir = determine_target_dir(args.target_dir.as_deref())?; - // Step 3.5: Attempt prebuilt download or staged-suite fast path. - let fast_path_context = FastPathContext { - args, - dirs: &dirs, - requested_crates: &requested_crates, - toolchain: &toolchain, - target_dir: &target_dir, - }; - if let Some((staging_path, install_mode)) = - try_fast_path_installation(&fast_path_context, stderr)? - { - let finish_context = FinishInstallContext { - args, - dirs: &dirs, - staging_path: &staging_path, - install_mode, - install_started, - }; - return finish_install_and_record_metrics(&finish_context, stderr); - } - let context = PipelineContext { - workspace_root: &workspace_root, - toolchain: &toolchain, - target_dir: &target_dir, - jobs: args.jobs, - verbosity: args.verbosity, - experimental: args.lint_selection.experimental, - quiet: args.quiet, - }; - // Step 4: Build and stage - let build_results = perform_build(&context, &requested_crates, stderr)?; - let staging_path = stage_libraries(&context, &build_results, stderr)?; - // Step 5: Generate wrapper scripts if requested - let finish_context = FinishInstallContext { - args, - dirs: &dirs, - staging_path: &staging_path, - install_mode: InstallMode::Build, - install_started, - }; - finish_install_and_record_metrics(&finish_context, stderr) -} - -/// Runs in dry-run mode, showing configuration without side effects. -fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> Result<()> { - use whitaker_installer::workspace::resolve_workspace_path; - - let workspace_root = resolve_workspace_path(dirs)?; - let requested_crates = resolve_requested_crates(args)?; - let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; - toolchain.verify_installed()?; - let target_dir = determine_dry_run_target_dir(args, dirs, &toolchain, &requested_crates)?; - let info = DryRunInfo { - workspace_root: &workspace_root, - toolchain: toolchain.channel(), - target_dir: &target_dir, - verbosity: args.verbosity, - quiet: args.quiet, - skips: DryRunSkips { - deps: args.skip.skip_deps, - wrapper: args.skip.skip_wrapper, - update: args.skip.no_update, - }, - jobs: args.jobs, - crates: &requested_crates, - }; - write_stderr_line(stderr, info.display_text()); - Ok(()) -} - -fn determine_dry_run_target_dir( - args: &InstallArgs, - dirs: &dyn BaseDirs, - toolchain: &Toolchain, - requested_crates: &[CrateName], -) -> Result { - let build_target_dir = determine_target_dir(args.target_dir.as_deref())?; - if !args.should_attempt_prebuilt(requested_crates) { - return Ok(build_target_dir); - } - let Ok(host_target) = detect_host_target() else { - return Ok(build_target_dir); - }; - Ok(prebuilt_library_dir(dirs, toolchain.channel(), &host_target).unwrap_or(build_target_dir)) -} - -/// Checks for and installs Dylint tools if missing. -fn ensure_dylint_tools(quiet: bool, stderr: &mut dyn Write) -> Result<()> { - let executor = SystemCommandExecutor; - ensure_dylint_tools_with_executor(&executor, quiet, stderr) -} - -/// Ensures a Whitaker workspace is available. -fn ensure_whitaker_workspace( - args: &InstallArgs, - dirs: &dyn BaseDirs, - stderr: &mut dyn Write, -) -> Result { - use whitaker_installer::workspace::{ - WorkspaceAction, - clone_directory, - decide_workspace_action, - ensure_workspace, - }; - - if !args.quiet - && let Some(clone_dir) = clone_directory(dirs) - { - let utf8_cwd = std::env::current_dir() - .ok() - .and_then(|p| Utf8PathBuf::try_from(p).ok()); - - let Some(cwd) = utf8_cwd else { - return ensure_workspace(dirs, !args.skip.no_update); - }; - - match decide_workspace_action(&cwd, &clone_dir, !args.skip.no_update) { - WorkspaceAction::CloneTo(dir) => { - write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); - } - WorkspaceAction::UpdateAt(dir) => { - write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); - } - WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} - } - } - - ensure_workspace(dirs, !args.skip.no_update) -} - -/// Detects or overrides the toolchain, then verifies it is installed. -fn resolve_toolchain( - workspace_root: &Utf8Path, - override_channel: Option<&str>, -) -> Result { - override_channel.map_or_else( - || Toolchain::detect(workspace_root), - |channel| Ok(Toolchain::with_override(workspace_root, channel)), - ) -} - -fn ensure_toolchain_installed( - toolchain: &Toolchain, - additional_components: &[&str], - quiet: bool, - stderr: &mut dyn Write, -) -> Result<()> { - let status = toolchain.ensure_installed(additional_components)?; - if status.installed_toolchain() && !quiet { - write_stderr_line( - stderr, - format!("Toolchain {} installed successfully.", toolchain.channel()), - ); - write_stderr_line(stderr, ""); - } - Ok(()) -} - -/// Common final steps: generate wrapper scripts or print shell snippet. -fn finish_install( - args: &InstallArgs, - dirs: &dyn BaseDirs, - staging_path: &Utf8Path, - stderr: &mut dyn Write, -) -> Result<()> { - if args.skip.skip_wrapper { - write_stderr_line(stderr, ""); - write_stderr_line(stderr, ShellSnippet::new(staging_path).display_text()); - } else { - generate_and_report_wrapper(dirs, staging_path, stderr)?; - } - Ok(()) -} - -/// Aggregates final-step inputs required after a successful install run. -struct FinishInstallContext<'a> { - args: &'a InstallArgs, - dirs: &'a dyn BaseDirs, - staging_path: &'a Utf8Path, - install_mode: InstallMode, - install_started: Instant, -} - -/// Aggregates the immutable inputs for fast-path installation attempts. -struct FastPathContext<'a> { - args: &'a InstallArgs, - dirs: &'a dyn BaseDirs, - requested_crates: &'a [CrateName], - toolchain: &'a Toolchain, - target_dir: &'a Utf8PathBuf, -} - -/// Finalize installation and record aggregate installer metrics. -fn finish_install_and_record_metrics( - context: &FinishInstallContext<'_>, - stderr: &mut dyn Write, -) -> Result<()> { - finish_install(context.args, context.dirs, context.staging_path, stderr)?; - let metrics_context = MetricsWriteContext { - quiet: context.args.quiet, - dirs: context.dirs, - install_mode: context.install_mode, - elapsed: context.install_started.elapsed(), - }; - write_install_metrics(&metrics_context, stderr); - Ok(()) -} - -/// Resolves requested crates from the CLI flags. -fn resolve_requested_crates(args: &InstallArgs) -> Result> { - let lint_crates: Vec = args - .lint - .iter() - .map(|name| CrateName::from(name.as_str())) - .collect(); - - let options = CrateResolutionOptions { - individual_lints: args.lint_selection.individual_lints, - experimental: args.lint_selection.experimental, - }; - if !lint_crates.is_empty() { - validate_crate_names(&lint_crates, &options)?; - } - - Ok(resolve_crates(&lint_crates, &options)) -} - -/// Generates wrapper scripts and reports the result. -fn generate_and_report_wrapper( - dirs: &dyn BaseDirs, - staging_path: &Utf8Path, - stderr: &mut dyn Write, -) -> Result<()> { - let result = generate_wrapper_scripts(dirs, staging_path)?; - write_stderr_line(stderr, ""); - write_stderr_line(stderr, "Wrapper scripts created:"); - write_stderr_line(stderr, format!(" - {}", result.whitaker_path.display())); - write_stderr_line(stderr, format!(" - {}", result.whitaker_ls_path.display())); - write_stderr_line(stderr, ""); - - if result.in_path { - write_stderr_line(stderr, "You can now run:"); - write_stderr_line(stderr, " whitaker --all"); - write_stderr_line(stderr, " whitaker-ls"); - } else { - let bin_dir = - result - .whitaker_path - .parent() - .ok_or_else(|| InstallerError::StagingFailed { - reason: "wrapper script path has no parent directory".to_owned(), - })?; - write_stderr_line(stderr, path_instructions(bin_dir)); - write_stderr_line(stderr, ""); - write_stderr_line(stderr, "Then run:"); - write_stderr_line(stderr, " whitaker --all"); - write_stderr_line(stderr, " whitaker-ls"); - } - Ok(()) -} - -fn exit_code_for_run_result(result: Result<()>, stderr: &mut dyn Write) -> i32 { - match result { - Ok(()) => 0, - Err(err) => { - write_stderr_line(stderr, err); - 1 - } - } -} - -#[cfg(test)] -mod tests; diff --git a/installer/src/orchestration.rs b/installer/src/orchestration.rs new file mode 100644 index 00000000..52d58004 --- /dev/null +++ b/installer/src/orchestration.rs @@ -0,0 +1,394 @@ +//! Installer command orchestration shared by Whitaker binaries. +//! +//! This module coordinates parsed installer requests with the library's build, +//! staging, and wrapper adapters. It deliberately owns the operational flow, +//! while callers own command-line parsing and process termination. + +use std::{io::Write, time::Instant}; + +use camino::{Utf8Path, Utf8PathBuf}; + +#[cfg(test)] +use crate::install_flow::ensure_dylint_tools_with_options; +use crate::{ + cli::{Cli, Command, InstallArgs}, + crate_name::CrateName, + deps::SystemCommandExecutor, + dirs::{BaseDirs, SystemBaseDirs}, + error::{InstallerError, Result}, + install_flow::{ + MetricsWriteContext, + PrebuiltInstallationContext, + detect_host_target, + ensure_dylint_tools_with_executor, + try_prebuilt_installation, + write_install_metrics, + }, + install_metrics::InstallMode, + list::{determine_target_dir, run_list}, + output::{DryRunInfo, DryRunSkips, ShellSnippet, write_stderr_line}, + pipeline::{PipelineContext, perform_build, stage_libraries}, + prebuilt_path::prebuilt_library_dir, + resolution::{CrateResolutionOptions, resolve_crates, validate_crate_names}, + staged_suite, + toolchain::Toolchain, + wrapper::{generate_wrapper_scripts, path_instructions}, +}; + +/// Routes CLI commands to their respective handlers. +/// +/// # Errors +/// +/// Returns an error when the selected installer operation cannot complete. +pub fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { + match &cli.command { + Some(Command::List(args)) => run_list(args, stdout), + Some(Command::Install(args)) => run_install(args, stderr), + None => run_install(cli.install_args(), stderr), + } +} + +/// Returns the set of additional rustup components requested by the CLI flags. +const fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { + if args.execution.cranelift { + &["rustc-codegen-cranelift"] + } else { + &[] + } +} + +/// Attempts prebuilt download and staged-suite fast paths. +/// +/// Returns `Some((staging_path, mode))` if either succeeds, or `None` if +/// the caller should proceed to a full build. +fn try_fast_path_installation( + context: &FastPathContext<'_>, + stderr: &mut dyn Write, +) -> Result> { + let prebuilt_context = PrebuiltInstallationContext { + args: context.args, + dirs: context.dirs, + requested_crates: context.requested_crates, + toolchain_channel: context.toolchain.channel(), + }; + if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr) { + return Ok(Some((staging_path, InstallMode::Download))); + } + if let Some(staging_path) = staged_suite::try_test_staged_suite_installation( + context.requested_crates, + context.toolchain, + context.target_dir, + )? { + return Ok(Some((staging_path, InstallMode::Build))); + } + Ok(None) +} + +/// Runs the install command to build and stage lint libraries. +/// +/// Workflow: (1) check/install Dylint dependencies, (2) locate/clone workspace, +/// (3) resolve crates from CLI flags, (4) build in release mode, (5) stage +/// libraries with toolchain-suffixed names, (6) generate wrapper script. +/// +/// # Errors +/// +/// Returns an error if any step fails. +fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { + let dirs = SystemBaseDirs::new().ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: "could not determine platform directories".to_owned(), + })?; + if args.execution.dry_run { + return run_dry(args, &dirs, stderr); + } + let install_started = Instant::now(); + // Step 1: Check and install Dylint dependencies if needed + if !args.skip.skip_deps { + ensure_dylint_tools(args.quiet, stderr)?; + } + // Step 2: Ensure workspace is available (clone if needed) + let workspace_root = ensure_whitaker_workspace(args, &dirs, stderr)?; + // Step 3: Resolve crates and toolchain + let requested_crates = resolve_requested_crates(args)?; + let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; + ensure_toolchain_installed( + &toolchain, + resolve_additional_components(args), + args.quiet, + stderr, + )?; + let target_dir = determine_target_dir(args.target_dir.as_deref())?; + // Step 3.5: Attempt prebuilt download or staged-suite fast path. + let fast_path_context = FastPathContext { + args, + dirs: &dirs, + requested_crates: &requested_crates, + toolchain: &toolchain, + target_dir: &target_dir, + }; + if let Some((staging_path, install_mode)) = + try_fast_path_installation(&fast_path_context, stderr)? + { + let finish_context = FinishInstallContext { + args, + dirs: &dirs, + staging_path: &staging_path, + install_mode, + install_started, + }; + return finish_install_and_record_metrics(&finish_context, stderr); + } + let context = PipelineContext { + workspace_root: &workspace_root, + toolchain: &toolchain, + target_dir: &target_dir, + jobs: args.jobs, + verbosity: args.verbosity, + experimental: args.lint_selection.experimental, + quiet: args.quiet, + }; + // Step 4: Build and stage + let build_results = perform_build(&context, &requested_crates, stderr)?; + let staging_path = stage_libraries(&context, &build_results, stderr)?; + // Step 5: Generate wrapper scripts if requested + let finish_context = FinishInstallContext { + args, + dirs: &dirs, + staging_path: &staging_path, + install_mode: InstallMode::Build, + install_started, + }; + finish_install_and_record_metrics(&finish_context, stderr) +} + +/// Runs in dry-run mode, showing configuration without side effects. +fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> Result<()> { + use crate::workspace::resolve_workspace_path; + + let workspace_root = resolve_workspace_path(dirs)?; + let requested_crates = resolve_requested_crates(args)?; + let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; + toolchain.verify_installed()?; + let target_dir = determine_dry_run_target_dir(args, dirs, &toolchain, &requested_crates)?; + let info = DryRunInfo { + workspace_root: &workspace_root, + toolchain: toolchain.channel(), + target_dir: &target_dir, + verbosity: args.verbosity, + quiet: args.quiet, + skips: DryRunSkips { + deps: args.skip.skip_deps, + wrapper: args.skip.skip_wrapper, + update: args.skip.no_update, + }, + jobs: args.jobs, + crates: &requested_crates, + }; + write_stderr_line(stderr, info.display_text()); + Ok(()) +} + +fn determine_dry_run_target_dir( + args: &InstallArgs, + dirs: &dyn BaseDirs, + toolchain: &Toolchain, + requested_crates: &[CrateName], +) -> Result { + let build_target_dir = determine_target_dir(args.target_dir.as_deref())?; + if !args.should_attempt_prebuilt(requested_crates) { + return Ok(build_target_dir); + } + let Ok(host_target) = detect_host_target() else { + return Ok(build_target_dir); + }; + Ok(prebuilt_library_dir(dirs, toolchain.channel(), &host_target).unwrap_or(build_target_dir)) +} + +/// Checks for and installs Dylint tools if missing. +fn ensure_dylint_tools(quiet: bool, stderr: &mut dyn Write) -> Result<()> { + let executor = SystemCommandExecutor; + ensure_dylint_tools_with_executor(&executor, quiet, stderr) +} + +/// Ensures a Whitaker workspace is available. +fn ensure_whitaker_workspace( + args: &InstallArgs, + dirs: &dyn BaseDirs, + stderr: &mut dyn Write, +) -> Result { + use crate::workspace::{ + WorkspaceAction, + clone_directory, + decide_workspace_action, + ensure_workspace, + }; + + if !args.quiet + && let Some(clone_dir) = clone_directory(dirs) + { + let utf8_cwd = std::env::current_dir() + .ok() + .and_then(|p| Utf8PathBuf::try_from(p).ok()); + + let Some(cwd) = utf8_cwd else { + return ensure_workspace(dirs, !args.skip.no_update); + }; + + match decide_workspace_action(&cwd, &clone_dir, !args.skip.no_update) { + WorkspaceAction::CloneTo(dir) => { + write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); + } + WorkspaceAction::UpdateAt(dir) => { + write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); + } + WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} + } + } + + ensure_workspace(dirs, !args.skip.no_update) +} + +/// Detects or overrides the toolchain, then verifies it is installed. +fn resolve_toolchain( + workspace_root: &Utf8Path, + override_channel: Option<&str>, +) -> Result { + override_channel.map_or_else( + || Toolchain::detect(workspace_root), + |channel| Ok(Toolchain::with_override(workspace_root, channel)), + ) +} + +fn ensure_toolchain_installed( + toolchain: &Toolchain, + additional_components: &[&str], + quiet: bool, + stderr: &mut dyn Write, +) -> Result<()> { + let status = toolchain.ensure_installed(additional_components)?; + if status.installed_toolchain() && !quiet { + write_stderr_line( + stderr, + format!("Toolchain {} installed successfully.", toolchain.channel()), + ); + write_stderr_line(stderr, ""); + } + Ok(()) +} + +/// Common final steps: generate wrapper scripts or print shell snippet. +fn finish_install( + args: &InstallArgs, + dirs: &dyn BaseDirs, + staging_path: &Utf8Path, + stderr: &mut dyn Write, +) -> Result<()> { + if args.skip.skip_wrapper { + write_stderr_line(stderr, ""); + write_stderr_line(stderr, ShellSnippet::new(staging_path).display_text()); + } else { + generate_and_report_wrapper(dirs, staging_path, stderr)?; + } + Ok(()) +} + +/// Aggregates final-step inputs required after a successful install run. +struct FinishInstallContext<'a> { + args: &'a InstallArgs, + dirs: &'a dyn BaseDirs, + staging_path: &'a Utf8Path, + install_mode: InstallMode, + install_started: Instant, +} + +/// Aggregates the immutable inputs for fast-path installation attempts. +struct FastPathContext<'a> { + args: &'a InstallArgs, + dirs: &'a dyn BaseDirs, + requested_crates: &'a [CrateName], + toolchain: &'a Toolchain, + target_dir: &'a Utf8PathBuf, +} + +/// Finalize installation and record aggregate installer metrics. +fn finish_install_and_record_metrics( + context: &FinishInstallContext<'_>, + stderr: &mut dyn Write, +) -> Result<()> { + finish_install(context.args, context.dirs, context.staging_path, stderr)?; + let metrics_context = MetricsWriteContext { + quiet: context.args.quiet, + dirs: context.dirs, + install_mode: context.install_mode, + elapsed: context.install_started.elapsed(), + }; + write_install_metrics(&metrics_context, stderr); + Ok(()) +} + +/// Resolves requested crates from the CLI flags. +fn resolve_requested_crates(args: &InstallArgs) -> Result> { + let lint_crates: Vec = args + .lint + .iter() + .map(|name| CrateName::from(name.as_str())) + .collect(); + + let options = CrateResolutionOptions { + individual_lints: args.lint_selection.individual_lints, + experimental: args.lint_selection.experimental, + }; + if !lint_crates.is_empty() { + validate_crate_names(&lint_crates, &options)?; + } + + Ok(resolve_crates(&lint_crates, &options)) +} + +/// Generates wrapper scripts and reports the result. +fn generate_and_report_wrapper( + dirs: &dyn BaseDirs, + staging_path: &Utf8Path, + stderr: &mut dyn Write, +) -> Result<()> { + let result = generate_wrapper_scripts(dirs, staging_path)?; + write_stderr_line(stderr, ""); + write_stderr_line(stderr, "Wrapper scripts created:"); + write_stderr_line(stderr, format!(" - {}", result.whitaker_path.display())); + write_stderr_line(stderr, format!(" - {}", result.whitaker_ls_path.display())); + write_stderr_line(stderr, ""); + + if result.in_path { + write_stderr_line(stderr, "You can now run:"); + write_stderr_line(stderr, " whitaker --all"); + write_stderr_line(stderr, " whitaker-ls"); + } else { + let bin_dir = + result + .whitaker_path + .parent() + .ok_or_else(|| InstallerError::StagingFailed { + reason: "wrapper script path has no parent directory".to_owned(), + })?; + write_stderr_line(stderr, path_instructions(bin_dir)); + write_stderr_line(stderr, ""); + write_stderr_line(stderr, "Then run:"); + write_stderr_line(stderr, " whitaker --all"); + write_stderr_line(stderr, " whitaker-ls"); + } + Ok(()) +} + +/// Writes an installation error and maps the result to a process exit code. +pub fn exit_code_for_run_result(result: Result<()>, stderr: &mut dyn Write) -> i32 { + match result { + Ok(()) => 0, + Err(err) => { + write_stderr_line(stderr, err); + 1 + } + } +} + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/installer/src/staged_suite.rs b/installer/src/staged_suite.rs index ccc09a57..298f436d 100644 --- a/installer/src/staged_suite.rs +++ b/installer/src/staged_suite.rs @@ -7,7 +7,8 @@ use std::fs; use camino::{Utf8Path, Utf8PathBuf}; -use whitaker_installer::{ + +use crate::{ crate_name::CrateName, error::{InstallerError, Result}, resolution::SUITE_CRATE, @@ -64,9 +65,9 @@ mod tests { use rstest::{fixture, rstest}; use temp_env::{with_var, with_var_unset}; use tempfile::TempDir; - use whitaker_installer::test_support::env_test_guard; use super::*; + use crate::test_support::env_test_guard; struct StagedSuiteSetup { _guard: std::sync::MutexGuard<'static, ()>, diff --git a/installer/src/tests/fast_path.rs b/installer/src/tests/fast_path.rs index 685a5c7e..1485451f 100644 --- a/installer/src/tests/fast_path.rs +++ b/installer/src/tests/fast_path.rs @@ -3,15 +3,15 @@ use camino::{Utf8Path, Utf8PathBuf}; use rstest::{fixture, rstest}; use temp_env::with_var_unset; -use whitaker_installer::{ + +use super::*; +use crate::{ cli::ExecutionFlags, crate_name::CrateName, test_support::{TEST_STAGE_SUITE_ENV, env_test_guard}, toolchain::Toolchain, }; -use super::*; - struct FastPathFixture { args: InstallArgs, dirs: TestBaseDirs, @@ -98,7 +98,8 @@ fn try_fast_path_installation_returns_some_build_path_when_staged_suite_enabled( mut fast_path_fixture: FastPathFixture, ) { use temp_env::with_var; - use whitaker_installer::test_support::TEST_STAGE_SUITE_ENV; + + use crate::test_support::TEST_STAGE_SUITE_ENV; let temp_dir = tempfile::tempdir().expect("create temp dir"); fast_path_fixture.target_dir = diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index 21776648..bb2e180e 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -5,7 +5,9 @@ mod fast_path; use std::{path::PathBuf, time::Duration}; use rstest::{fixture, rstest}; -use whitaker_installer::{ + +use super::*; +use crate::{ cli::{InstallArgs, LintSelectionFlags}, dependency_binaries::DependencyBinaryInstaller, deps::DependencyInstallOptions, @@ -22,16 +24,11 @@ use whitaker_installer::{ }, }; -use super::*; - fn dependency_install_options<'a>( dirs: &'a TestBaseDirs, repository_installer: &'a dyn DependencyBinaryInstaller, quiet: bool, -) -> std::result::Result< - DependencyInstallOptions<'a>, - whitaker_installer::artefact::error::ArtefactError, -> { +) -> std::result::Result, crate::artefact::error::ArtefactError> { Ok(DependencyInstallOptions { dirs, repository_installer, diff --git a/installer/tests/dry_run_snapshot.rs b/installer/tests/dry_run_snapshot.rs new file mode 100644 index 00000000..49202508 --- /dev/null +++ b/installer/tests/dry_run_snapshot.rs @@ -0,0 +1,44 @@ +//! Regression snapshot for the legacy installer's dry-run output. + +use std::{path::Path, process::Command}; + +use insta::assert_snapshot; + +const fn pinned_toolchain_channel() -> &'static str { "nightly-2026-05-28" } + +fn normalized_dry_run_output(output: &str) -> String { + output + .lines() + .map(|line| { + if line.starts_with("Workspace root: ") { + "Workspace root: [workspace]" + } else if line.starts_with("Target directory: ") { + "Target directory: [staging directory]" + } else { + line + } + }) + .collect::>() + .join("\n") +} + +#[test] +fn dry_run_output_matches_snapshot() { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("installer must be nested under the workspace root"); + let output = Command::new(env!("CARGO_BIN_EXE_whitaker-installer")) + .args(["--dry-run", "--toolchain", pinned_toolchain_channel()]) + .current_dir(workspace_root) + .output() + .expect("whitaker-installer should run"); + + assert!( + output.status.success(), + "dry-run must succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8(output.stderr).expect("dry-run stderr must be UTF-8"); + assert_snapshot!("dry_run_output", normalized_dry_run_output(&stderr)); +} diff --git a/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap b/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap new file mode 100644 index 00000000..aef656e0 --- /dev/null +++ b/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap @@ -0,0 +1,17 @@ +--- +source: installer/tests/dry_run_snapshot.rs +expression: normalized_dry_run_output(&stderr) +--- +Dry run - no files will be modified + +Workspace root: [workspace] +Toolchain: nightly-2026-05-28 +Target directory: [staging directory] +Verbosity level: 0 +Quiet: false +Skip deps: false +Skip wrapper: false +No update: false + +Crates to build: + - whitaker_suite