diff --git a/.gitignore b/.gitignore index 91a28f3..ac9a2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /reports +/catalog/local diff --git a/AGENTS.md b/AGENTS.md index 33d91be..b14ab9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - The public interface is the `devctl` CLI. - Catalog policy lives under `catalog/`. +- Private operator catalog overlays live under `catalog/local/` or `DEVCTL_CATALOG_HOME`; keep them ignored. - Archetypes and contracts are first-class policy; scanners provide evidence, not the source of intent. - Generated standards reports live under `reports/` and remain gitignored. - The CLI may inspect sibling repos, but V0 must not edit them. @@ -27,5 +28,8 @@ - Add laws through catalog + scanner behavior together. - Add archetypes/contracts through catalog + validation behavior together. - Keep contract schemas typed and source-cited; validation should reject malformed catalog policy before scanners run. +- Keep planning scope explicit; `standards plan` must not silently return empty work when the selected pilot catalog matches no repos. +- Run `devctl doctor privacy .` before publishing public branches. +- Run `devctl doctor catalog ` when changing catalog loading or planning behavior. - Record review decisions in `catalog/adjudications.toml`; do not hide findings without a reason. - Never print secret values. Key names, file paths, modes, and line numbers are acceptable. diff --git a/README.md b/README.md index ab5bb0d..695294c 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,154 @@ # devctl -Read-only standards control plane for the local `~/dev` repo forest. +`devctl` is a read-only standards control plane for a local repo forest. -`devctl` inventories repos, audits a small set of high-value engineering laws, -and groups findings into repair tranches. V0 is report-only: it does not edit -target repos. +It answers three questions: -## Commands +- What repos exist here, and what shape are they? +- Which engineering standards are drifting? +- What PR-sized repair tranches should happen next? + +V0 is intentionally report-first. It inventories, audits, explains, proposes, +and plans. It does not edit target repos. + +## Mental Model + +`devctl` has three layers: + +- **Catalogs define intent.** Laws, archetypes, contracts, and adjudications live + under `catalog/`. +- **Scanners gather evidence.** The CLI walks repos, records file:line evidence, + and never prints secret values. +- **Reports shape action.** Plans, packets, reports, and contract proposals group + findings into reviewable work. + +The public repo carries reusable standards machinery and neutral sample catalog +data. Real operator workspace truth belongs in ignored private overlays under +`catalog/local/` or an external `DEVCTL_CATALOG_HOME`. + +## Repo Map + +```text +. +|-- AGENTS.md # rules for agents changing this repo +|-- README.md # operator and contributor orientation +|-- Cargo.toml # Rust CLI crate +|-- docs/ +| `-- OPERATIONS.md # weekly standards loop runbook +|-- examples/ +| `-- local-catalog/ # safe private-overlay templates +|-- scripts/ +| `-- standards-loop.sh # repeatable local standards lane +|-- src/ +| |-- main.rs # thin binary entrypoint +| `-- lib.rs # CLI, scanners, catalogs, reports, tests +`-- catalog/ + |-- README.md # catalog model and editing guide + |-- workspace.toml # public sample workspace catalog + |-- laws.toml # standard definitions and maturity + |-- archetypes.toml # repo shapes and required capabilities + |-- adjudications.toml # reviewed finding decisions + `-- contracts/ # public sample repo contracts +``` + +Generated reports live under `reports/` and build outputs live under `target/`. +Both are ignored. + +## First Run + +Use the doctor commands before trusting plans: + +```bash +devctl doctor catalog ~/dev +devctl doctor privacy . +``` + +`doctor catalog` tells you whether a private overlay is loaded, whether the +active pilot catalog matches the selected workspace root, and how many contracts +are active. It reports counts and sanitized root labels, not private repo names. + +`doctor privacy` scans the public repo for absolute home paths, email addresses, +and optional local private patterns. + +Then inspect the workspace: + +```bash +devctl inventory ~/dev +devctl standards audit ~/dev --all +devctl standards plan ~/dev --all --risk P0,P1 +``` + +If `standards plan ~/dev --risk P0,P1` returns a zero-repo pilot warning, load a +private catalog overlay or use `--all` intentionally. + +For the repeatable local lane, run: + +```bash +./scripts/standards-loop.sh ~/dev +``` + +See `docs/OPERATIONS.md` for the triage loop. + +## Command Guide ```bash devctl inventory ~/dev --json +devctl repo explain ~/dev/sample-web-product + devctl standards audit ~/dev --pilot three-tier -devctl standards adjudication-template ~/dev --pilot three-tier --risk P0,P1 devctl standards audit ~/dev --all --json devctl standards contracts ~/dev --pilot three-tier devctl standards plan ~/dev --risk P0,P1 +devctl standards plan ~/dev --all --risk P0,P1 +devctl standards adjudication-template ~/dev --pilot three-tier --risk P0,P1 +devctl standards propose-contract ~/dev/sample-web-product devctl standards packet ~/dev --pilot three-tier --risk P0,P1 -devctl standards propose-contract ~/dev/sample-desktop-edge devctl standards report ~/dev --pilot three-tier -devctl repo explain ~/dev/sample-desktop-edge + +devctl doctor catalog ~/dev +devctl doctor privacy . ``` -## Standards loop - -V0.1 adds the review loop around the original read-only audit: - -- `catalog/archetypes.toml` defines the repo shapes that make standards - sensible. -- `catalog/contracts/` contains operator-owned repo contracts with typed command, - Cloudflare, release, token, and artifact records. Target repos are still - read-only. -- `catalog/laws.toml` declares the active laws and their maturity. -- `catalog/adjudications.toml` records explicit review decisions by finding - fingerprint. -- `standards contracts` compares typed repo contracts to observed repo reality. -- `standards adjudication-template` prints review stubs for unreviewed findings. -- `standards propose-contract` prints an inferred repo contract to stdout only. -- `standards plan` excludes findings adjudicated as `accepted-exception`, - `false-positive`, or `law-needs-work`, then groups remaining work by - repo/law/requirement so tranches are PR-sized. -- `standards packet` writes the pilot operating packet: contract proposals, - adjudication stubs, risk-scoped tranches, and ordered next actions. -- `standards report` writes JSON and Markdown snapshots under `reports/`. - -Generated reports are ignored by git. They are evidence artifacts, not source -policy. - -The repo development flow is the center of the system. Contracts and archetypes -define intent; `devctl` is the read-only instrument panel that observes drift, -proposes contracts, and packages repair work. +## Standards Loop + +The normal operating loop is: + +1. Run `doctor catalog` and `doctor privacy`. +2. Run `inventory` to confirm repo discovery. +3. Run `standards contracts` to compare declared intent with observed reality. +4. Run `standards audit` to collect file:line findings. +5. Run `standards adjudication-template` to review true positives and + exceptions. +6. Run `standards plan` or `standards packet` to produce PR-sized repair work. +7. Repair target repos manually in their own PRs. + +`devctl` stays read-only through this loop. The target repos carry the actual +repairs. + +## Catalogs + +The catalog is the source of intent: + +- `catalog/laws.toml` declares standards and maturity. +- `catalog/archetypes.toml` describes repo shapes and required capabilities. +- `catalog/contracts/` declares repo-specific expectations. +- `catalog/adjudications.toml` records reviewed finding decisions. +- `catalog/workspace.toml` selects pilot repos and status values. + +Private overlays can replace pilot lists and override matching repo statuses or +contracts without committing private repo names to the public repo. + +See `catalog/README.md` for the editing model and `examples/local-catalog/` for +safe templates. + +## Output Rules + +- JSON output uses `schema_version = "0.1.0"`. +- Findings include file:line evidence when available. +- Human output sorts actionable work by severity, repo, and law. +- Secret values are never printed. Key names, file paths, file modes, and line + numbers are acceptable evidence. +- Publication checks should run `doctor privacy` before pushing public branches. ## Verification @@ -56,4 +156,5 @@ proposes contracts, and packages repair work. cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace +cargo doc --workspace --no-deps ``` diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..b518e98 --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,94 @@ +# devctl Catalog + +The catalog is the policy layer for `devctl`. + +Scanners describe what exists. Catalogs describe what should exist. Findings are +useful only when those two views are kept separate. + +## Files + +```text +catalog/ +|-- workspace.toml # pilot repos, repo statuses, public sample workspace +|-- laws.toml # standards and maturity +|-- archetypes.toml # reusable repo shapes +|-- adjudications.toml # reviewed finding decisions +`-- contracts/ # repo-specific expectations +``` + +## Workspace Catalog + +`workspace.toml` defines the active pilot list and known repo status values. + +The public file uses neutral sample repo names. Real operator workspace names +belong in an ignored local overlay. + +## Laws + +`laws.toml` defines the standards that scanners can report against. A law should +be added with scanner behavior and tests in the same change. + +V0 laws cover: + +- Cloudflare mutation lanes +- token handling +- command and verification surfaces +- release proof +- artifact boundaries + +## Archetypes + +`archetypes.toml` defines reusable repo shapes such as Rust workspaces, +Cloudflare products, and generic active repos. + +Archetypes are intentionally small. They should express requirements that are +shared by more than one repo, not one-off local taste. + +## Contracts + +`contracts/*.toml` defines repo-specific expectations: + +- canonical commands +- Cloudflare posture and surfaces +- token policy +- release lanes and evidence +- artifact classifications + +Contract filenames must match the declared `repo` value. Public contracts should +use neutral sample repo names. Private contracts belong in local overlays. + +## Adjudications + +`adjudications.toml` records reviewed findings by fingerprint. Use it to mark a +finding as: + +- `true-positive` +- `accepted-exception` +- `false-positive` +- `law-needs-work` + +Do not hide findings without a reason. Exceptions should have an owner and an +expiry when possible. + +## Private Overlays + +Private operator catalog overlays can live in either location: + +```bash +catalog/local/ +DEVCTL_CATALOG_HOME=/path/to/private/catalog +``` + +Overlay behavior: + +- a local `workspace.toml` can replace the public pilot list +- local repo statuses override public repo statuses +- local contracts override public contracts for the same repo +- local overlays are ignored by git + +Use `devctl doctor catalog ` to confirm that the expected overlay is +loaded before trusting pilot plans. + +Neutral templates live under `examples/local-catalog/`. Copy them into +`catalog/local/` or an external private catalog directory, then replace the +sample repo names with local workspace truth in the ignored copy. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..640c855 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,93 @@ +# devctl Operations + +This runbook turns `devctl` into a repeatable standards loop. + +The goal is not to make `devctl` smarter than the repos. The goal is to make +repo drift visible, reviewable, and small enough to repair in normal PRs. + +## Weekly Lane + +Run the lane from the `devctl` repo: + +```bash +./scripts/standards-loop.sh ~/dev +``` + +The script runs: + +1. `doctor catalog` +2. `doctor privacy` +3. `inventory` +4. `standards contracts` +5. `standards audit` +6. `standards plan` +7. `standards packet` + +JSON snapshots go under `reports/operations/`, which is ignored by git. + +## Scope + +Default scope is `--all` because a sanitized public catalog may not contain real +pilot repo names. + +Use an explicit pilot after a private overlay is loaded: + +```bash +DEVCTL_SCOPE=pilot DEVCTL_PILOT=three-tier ./scripts/standards-loop.sh ~/dev +``` + +If `doctor catalog` says the pilot matched zero repos, do not trust pilot plans. +Load the private overlay or intentionally use `--all`. + +## Private Catalog Setup + +Public catalog files stay generic. Real workspace truth belongs in ignored local +catalogs: + +```bash +mkdir -p catalog/local/contracts +cp examples/local-catalog/workspace.toml catalog/local/workspace.toml +cp examples/local-catalog/contracts/product-web.toml catalog/local/contracts/product-web.toml +``` + +Then edit the ignored copies with real repo names, statuses, and contracts. + +Verify: + +```bash +devctl doctor catalog ~/dev +devctl standards contracts ~/dev --pilot three-tier +devctl standards plan ~/dev --pilot three-tier --risk P0,P1 +``` + +## Triage Order + +Handle findings in this order: + +1. P0 token or file-permission findings +2. missing repo contracts for active repos +3. release proof gaps +4. Cloudflare mutation posture gaps +5. command classification and artifact boundary gaps + +Every repair should be small enough to review as one target-repo PR. + +## Adjudication + +Do not suppress noisy findings informally. + +- True issue: fix it in the target repo. +- Intentional exception: record an adjudication with reason, owner, and expiry. +- Bad law behavior: adjust the law or scanner with a test. +- Missing intent: add or update the private contract. + +## PR Proof + +A standards repair PR should say: + +- which `devctl` finding IDs it addresses +- which command proves the fix +- whether any adjudication or contract changed +- which evidence directory or report snapshot was reviewed + +`devctl` remains read-only. Target repos carry the actual changes. diff --git a/examples/local-catalog/contracts/product-web.toml b/examples/local-catalog/contracts/product-web.toml new file mode 100644 index 0000000..c10527e --- /dev/null +++ b/examples/local-catalog/contracts/product-web.toml @@ -0,0 +1,42 @@ +schema_version = "0.1.0" +repo = "product-web" +archetype = "cloudflare-product" +status = "active-product" + +[[canonical_commands]] +id = "check" +command = "cargo check --workspace" +required = true + +[[canonical_commands]] +id = "test" +command = "cargo test --workspace" +required = true + +[cloudflare] +posture = "cfctl-aware-wrapper" + +[[cloudflare.surfaces]] +id = "worker" +kind = "worker" +wrangler_config = "workers/app/wrangler.toml" +required = true + +[cloudflare.token_policy] +parent_tokens = [] +parent_allowed_paths = [] +forbidden_sinks = ["CLOUDFLARE_API_TOKEN"] + +[release] +evidence_dirs = ["reports"] + +[[release.lanes]] +id = "deploy" +command = "scripts/deploy.sh" +preflight = "cargo test --workspace" +post_verify = "scripts/verify-prod.sh" +evidence = ["reports"] +mutates_cloudflare = true + +[artifacts.classifications] +reports = "generated evidence; ignored" diff --git a/examples/local-catalog/workspace.toml b/examples/local-catalog/workspace.toml new file mode 100644 index 0000000..f1aa98b --- /dev/null +++ b/examples/local-catalog/workspace.toml @@ -0,0 +1,20 @@ +schema_version = "0.1.0" +default_root = "~/dev" +pilot_three_tier = [ + "product-web", + "worker-product", + "desktop-edge", +] +status_values = [ + "active-product", + "active-control-plane", + "active-library", + "template", + "legacy", + "experiment", +] + +[repo_status] +product-web = "active-product" +worker-product = "active-product" +desktop-edge = "active-product" diff --git a/scripts/standards-loop.sh b/scripts/standards-loop.sh new file mode 100755 index 0000000..311b5b7 --- /dev/null +++ b/scripts/standards-loop.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace_root="${1:-${DEVCTL_WORKSPACE_ROOT:-$HOME/dev}}" +risk="${DEVCTL_RISK:-P0,P1}" +report_dir="${DEVCTL_REPORT_DIR:-reports/operations}" +scope_value="${DEVCTL_SCOPE:---all}" +pilot_value="${DEVCTL_PILOT:-three-tier}" + +case "$scope_value" in + --all|all) + scope_args=(--all) + scope_slug="all" + ;; + --pilot|pilot) + scope_args=(--pilot "$pilot_value") + scope_slug="pilot-$pilot_value" + ;; + "") + scope_args=() + scope_slug="default" + ;; + *) + echo "unknown DEVCTL_SCOPE: $scope_value" >&2 + echo "use DEVCTL_SCOPE=--all or DEVCTL_SCOPE=pilot" >&2 + exit 2 + ;; +esac + +stamp="$(date -u +%Y%m%dT%H%M%SZ)" +run=(cargo run --quiet --) +snapshot_dir="$report_dir/$stamp-$scope_slug" + +mkdir -p "$snapshot_dir" + +echo "devctl standards loop" +echo "workspace_root=$workspace_root" +echo "scope=${scope_args[*]:-default}" +echo "risk=$risk" +echo "snapshot_dir=$snapshot_dir" + +"${run[@]}" doctor catalog "$workspace_root" --json \ + > "$snapshot_dir/doctor-catalog.json" +"${run[@]}" doctor privacy . --json \ + > "$snapshot_dir/doctor-privacy.json" +"${run[@]}" inventory "$workspace_root" --json \ + > "$snapshot_dir/inventory.json" +"${run[@]}" standards contracts "$workspace_root" "${scope_args[@]}" --json \ + > "$snapshot_dir/contracts.json" +"${run[@]}" standards audit "$workspace_root" "${scope_args[@]}" --json \ + > "$snapshot_dir/audit.json" +"${run[@]}" standards plan "$workspace_root" "${scope_args[@]}" --risk "$risk" --json \ + > "$snapshot_dir/plan.json" +"${run[@]}" standards packet "$workspace_root" "${scope_args[@]}" --risk "$risk" --out "$report_dir" --json \ + > "$snapshot_dir/packet.json" + +echo "wrote $snapshot_dir" diff --git a/src/lib.rs b/src/lib.rs index e9c1764..f597eaf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,17 @@ +//! Read-only standards control-plane CLI for a local repo forest. +//! +//! `devctl` keeps operator intent in typed catalogs, gathers file:line evidence +//! from target repos, and turns findings into report-first repair plans. V0 is +//! deliberately read-only: commands may inspect sibling repos, but they do not +//! mutate them. + use camino::{Utf8Path, Utf8PathBuf}; use clap::{Args, Parser, Subcommand, ValueEnum}; use ignore::WalkBuilder; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::env; use std::error::Error; use std::fs; use std::os::unix::fs::PermissionsExt; @@ -35,6 +43,34 @@ enum Commands { Inventory(InventoryArgs), Standards(StandardsArgs), Repo(RepoArgs), + Doctor(DoctorArgs), +} + +#[derive(Args)] +struct DoctorArgs { + #[command(subcommand)] + command: DoctorCommand, +} + +#[derive(Subcommand)] +enum DoctorCommand { + Catalog(CatalogArgs), + Privacy(PrivacyArgs), +} + +#[derive(Args)] +struct CatalogArgs { + root: Option, + #[arg(long)] + json: bool, +} + +#[derive(Args)] +struct PrivacyArgs { + #[arg(default_value = ".")] + root: Utf8PathBuf, + #[arg(long)] + json: bool, } #[derive(Args)] @@ -82,6 +118,10 @@ enum Pilot { #[derive(Args)] struct PlanArgs { root: Utf8PathBuf, + #[arg(long, value_enum)] + pilot: Option, + #[arg(long)] + all: bool, #[arg(long, default_value = "P0,P1")] risk: String, #[arg(long)] @@ -167,7 +207,22 @@ struct ExplainArgs { #[derive(Debug, Deserialize)] struct WorkspaceCatalog { + #[serde(default)] pilot_three_tier: Vec, + #[serde(default)] + repo_status: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct WorkspaceCatalogFile { + schema_version: String, + #[serde(default)] + default_root: Option, + #[serde(default)] + pilot_three_tier: Vec, + #[serde(default)] + status_values: Vec, + #[serde(default)] repo_status: BTreeMap, } @@ -551,10 +606,28 @@ struct AuditOutput { struct PlanOutput { schema_version: &'static str, root: Utf8PathBuf, + scope: String, risk: Vec, + repos: usize, + findings: usize, + warnings: Vec, tranches: Vec, } +#[derive(Debug, Serialize)] +struct CatalogOutput { + schema_version: &'static str, + public_root: String, + local_root: Option, + local_overlay_loaded: bool, + pilot_repo_count: usize, + contract_count: usize, + root: Option, + discovered_repo_count: Option, + pilot_match_count: Option, + warnings: Vec, +} + #[derive(Debug, Serialize)] struct ContractsOutput { schema_version: &'static str, @@ -564,6 +637,25 @@ struct ContractsOutput { findings: Vec, } +#[derive(Debug, Serialize)] +struct PrivacyOutput { + schema_version: &'static str, + root: String, + scanned_files: usize, + findings: Vec, +} + +#[derive(Debug, Serialize)] +struct PrivacyFinding { + id: String, + file: String, + line: usize, + pattern: String, + message: String, + evidence: String, + recommendation: String, +} + #[derive(Debug, Serialize)] struct ReportOutput { schema_version: &'static str, @@ -760,6 +852,24 @@ pub fn run() -> DevResult<()> { } } }, + Commands::Doctor(args) => match args.command { + DoctorCommand::Catalog(args) => { + let output = catalog_doctor_command(&args)?; + if args.json { + print_json(&output)?; + } else { + print_catalog_human(&output); + } + } + DoctorCommand::Privacy(args) => { + let output = privacy_doctor_command(&args)?; + if args.json { + print_json(&output)?; + } else { + print_privacy_human(&output); + } + } + }, } Ok(()) } @@ -803,11 +913,16 @@ fn plan_command(args: &PlanArgs) -> DevResult { let risk = parse_risk(&args.risk)?; let audit = audit_command(&AuditArgs { root: args.root.clone(), - pilot: Some(Pilot::ThreeTier), - all: false, + pilot: args.pilot.clone(), + all: args.all, json: true, fail_on: None, })?; + let root = audit.root.clone(); + let scope = audit.scope.clone(); + let repos = audit.repos.len(); + let findings = audit.findings.len(); + let warnings = planning_warnings(&scope, repos, findings, &risk); let filtered: Vec = audit .findings .into_iter() @@ -816,12 +931,85 @@ fn plan_command(args: &PlanArgs) -> DevResult { let tranches = build_tranches(&filtered); Ok(PlanOutput { schema_version: SCHEMA_VERSION, - root: normalize_path(&args.root), + root, + scope, risk, + repos, + findings, + warnings, tranches, }) } +fn planning_warnings(scope: &str, repos: usize, findings: usize, risk: &[Severity]) -> Vec { + let mut warnings = Vec::new(); + if repos == 0 { + warnings.push(format!( + "Selected scope {scope:?} matched zero repos; load a private catalog overlay or use --all." + )); + } else if findings == 0 + && risk + .iter() + .any(|severity| matches!(severity, Severity::P0 | Severity::P1)) + { + warnings.push(format!( + "Selected scope {scope:?} matched repos but produced no findings for the requested risk." + )); + } + warnings +} + +fn catalog_doctor_command(args: &CatalogArgs) -> DevResult { + let public_root = catalog_public_root(); + let local_root = catalog_local_root(); + let catalog = load_workspace_catalog()?; + let contracts = load_contracts_catalog()?; + let mut warnings = Vec::new(); + if local_root.is_none() { + warnings.push( + "No private catalog overlay is loaded; pilot scope uses public sample catalog data." + .to_string(), + ); + } + if catalog.pilot_three_tier.is_empty() { + warnings.push("The active pilot catalog has no repos.".to_string()); + } + + let mut root = None; + let mut discovered_repo_count = None; + let mut pilot_match_count = None; + if let Some(input_root) = &args.root { + let normalized = normalize_path(input_root); + let repos = inventory(&normalized, &catalog)?; + let matches = repos + .iter() + .filter(|repo| catalog.pilot_three_tier.contains(&repo.name)) + .count(); + if !catalog.pilot_three_tier.is_empty() && matches == 0 { + warnings.push( + "The active pilot catalog matched zero discovered repos at the requested root." + .to_string(), + ); + } + root = Some(privacy_output_root(input_root, &normalized)); + discovered_repo_count = Some(repos.len()); + pilot_match_count = Some(matches); + } + + Ok(CatalogOutput { + schema_version: SCHEMA_VERSION, + public_root: catalog_root_label(&public_root), + local_root: local_root.as_ref().map(|path| catalog_root_label(path)), + local_overlay_loaded: local_root.is_some(), + pilot_repo_count: catalog.pilot_three_tier.len(), + contract_count: contracts.contracts.len(), + root, + discovered_repo_count, + pilot_match_count, + warnings, + }) +} + fn adjudication_template_command( args: &AdjudicationTemplateArgs, ) -> DevResult { @@ -1038,6 +1226,58 @@ fn report_command(args: &ReportArgs) -> DevResult { }) } +fn privacy_doctor_command(args: &PrivacyArgs) -> DevResult { + let root = normalize_path(&args.root); + let output_root = privacy_output_root(&args.root, &root); + let patterns = privacy_patterns()?; + let mut findings = Vec::new(); + let mut scanned_files = 0; + for entry in WalkBuilder::new(&root) + .standard_filters(true) + .hidden(false) + .filter_entry(|entry| !is_ignored_privacy_path(entry.path())) + .build() + { + let entry = entry?; + if !entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + { + continue; + } + let path = Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) + .map_err(|path| format!("non-utf8 privacy scan path: {}", path.display()))?; + let Ok(body) = fs::read_to_string(&path) else { + continue; + }; + scanned_files += 1; + for (index, line) in body.lines().enumerate() { + for pattern in &patterns { + if let Some(match_) = pattern.regex.find(line) { + findings.push(PrivacyFinding { + id: String::new(), + file: relative_display(&root, &path), + line: index + 1, + pattern: pattern.name.clone(), + message: pattern.message.clone(), + evidence: redact_privacy_evidence(match_.as_str()), + recommendation: pattern.recommendation.clone(), + }); + } + } + } + } + for (index, finding) in findings.iter_mut().enumerate() { + finding.id = format!("P{:04}", index + 1); + } + Ok(PrivacyOutput { + schema_version: SCHEMA_VERSION, + root: output_root, + scanned_files, + findings, + }) +} + fn parse_risk(value: &str) -> DevResult> { value .split(',') @@ -1047,28 +1287,75 @@ fn parse_risk(value: &str) -> DevResult> { } fn load_workspace_catalog() -> DevResult { - let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog/workspace.toml"); - let body = fs::read_to_string(path)?; - Ok(toml::from_str(&body)?) + load_workspace_catalog_from_roots(&catalog_public_root(), catalog_local_root().as_deref()) +} + +fn load_workspace_catalog_from_roots( + public_root: &Utf8Path, + local_root: Option<&Utf8Path>, +) -> DevResult { + let mut catalog = read_workspace_catalog_file(&public_root.join("workspace.toml"))?; + if let Some(local_root) = local_root { + let local_path = local_root.join("workspace.toml"); + if local_path.is_file() { + let local = read_workspace_catalog_file(&local_path)?; + if !local.pilot_three_tier.is_empty() { + catalog.pilot_three_tier = local.pilot_three_tier; + } + catalog.repo_status.extend(local.repo_status); + } + } + Ok(catalog) } fn load_laws_catalog() -> DevResult { - let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog/laws.toml"); + let path = catalog_public_root().join("laws.toml"); let body = fs::read_to_string(path)?; Ok(toml::from_str(&body)?) } fn load_archetypes_catalog() -> DevResult { - let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog/archetypes.toml"); + let path = catalog_public_root().join("archetypes.toml"); let body = fs::read_to_string(path)?; Ok(toml::from_str(&body)?) } fn load_contracts_catalog() -> DevResult { - let root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog/contracts"); + load_contracts_catalog_from_roots(&catalog_public_root(), catalog_local_root().as_deref()) +} + +fn load_contracts_catalog_from_roots( + public_root: &Utf8Path, + local_root: Option<&Utf8Path>, +) -> DevResult { let mut catalog = ContractsCatalog::default(); + load_contracts_from_dir(&public_root.join("contracts"), &mut catalog)?; + if let Some(local_root) = local_root { + load_contracts_from_dir(&local_root.join("contracts"), &mut catalog)?; + } + Ok(catalog) +} + +fn read_workspace_catalog_file(path: &Utf8Path) -> DevResult { + let body = fs::read_to_string(path)?; + let file: WorkspaceCatalogFile = toml::from_str(&body)?; + let _ = (&file.default_root, &file.status_values); + if file.schema_version != SCHEMA_VERSION { + return Err(format!( + "{} schema_version {} does not match {SCHEMA_VERSION}", + path, file.schema_version + ) + .into()); + } + Ok(WorkspaceCatalog { + pilot_three_tier: file.pilot_three_tier, + repo_status: file.repo_status, + }) +} + +fn load_contracts_from_dir(root: &Utf8Path, catalog: &mut ContractsCatalog) -> DevResult<()> { if !root.is_dir() { - return Ok(catalog); + return Ok(()); } for entry in fs::read_dir(root)? { let entry = entry?; @@ -1083,9 +1370,6 @@ fn load_contracts_catalog() -> DevResult { let body = fs::read_to_string(&path)?; let lines = toml_line_map(&body); let contract: RepoContract = toml::from_str(&body)?; - if catalog.contracts.contains_key(&contract.repo) { - return Err(format!("duplicate contract for repo {}", contract.repo).into()); - } catalog.contracts.insert( contract.repo.clone(), SourcedRepoContract { @@ -1095,11 +1379,11 @@ fn load_contracts_catalog() -> DevResult { }, ); } - Ok(catalog) + Ok(()) } fn load_adjudications_catalog() -> DevResult { - let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog/adjudications.toml"); + let path = catalog_public_root().join("adjudications.toml"); if !path.is_file() { return Ok(AdjudicationsCatalog::default()); } @@ -1107,6 +1391,29 @@ fn load_adjudications_catalog() -> DevResult { Ok(toml::from_str(&body)?) } +fn catalog_public_root() -> Utf8PathBuf { + Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("catalog") +} + +fn catalog_local_root() -> Option { + let local = catalog_local_root_candidate()?; + local.is_dir().then_some(local) +} + +fn catalog_local_root_candidate() -> Option { + if let Some(path) = env::var_os("DEVCTL_CATALOG_HOME") { + return Utf8PathBuf::from_path_buf(path.into()).ok(); + } + Some(catalog_public_root().join("local")) +} + +fn catalog_root_label(path: &Utf8Path) -> String { + let manifest_root = Utf8Path::new(env!("CARGO_MANIFEST_DIR")); + path.strip_prefix(manifest_root) + .map(|relative| relative.to_string()) + .unwrap_or_else(|_| "".to_string()) +} + fn validate_laws_catalog(catalog: &LawsCatalog) -> DevResult<()> { if catalog.schema_version != SCHEMA_VERSION { return Err(format!( @@ -1706,6 +2013,76 @@ fn is_ignored_cloudflare_discovery_path(path: &std::path::Path) -> bool { }) } +#[derive(Debug)] +struct PrivacyPattern { + name: String, + regex: Regex, + message: String, + recommendation: String, +} + +fn privacy_patterns() -> DevResult> { + let mut patterns = vec![ + PrivacyPattern { + name: "absolute-home-path".to_string(), + regex: Regex::new(concat!(r"(/Us", r"ers|/home)/[A-Za-z0-9._-]+"))?, + message: "Absolute user home path is checked into the repository".to_string(), + recommendation: "Use ~/dev, , or a documented environment variable instead." + .to_string(), + }, + PrivacyPattern { + name: "email-address".to_string(), + regex: Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")?, + message: "Email address is checked into the repository".to_string(), + recommendation: "Replace personal email addresses with neutral examples.".to_string(), + }, + ]; + if let Ok(raw) = env::var("DEVCTL_PRIVACY_PATTERNS") { + for (index, pattern) in raw.split(',').enumerate() { + let pattern = pattern.trim(); + if pattern.is_empty() { + continue; + } + patterns.push(PrivacyPattern { + name: format!("operator-pattern-{}", index + 1), + regex: Regex::new(pattern)?, + message: "Operator-provided private pattern is checked into the repository" + .to_string(), + recommendation: "Move this value to an ignored local overlay or neutral example." + .to_string(), + }); + } + } + Ok(patterns) +} + +fn is_ignored_privacy_path(path: &std::path::Path) -> bool { + path.components().any(|component| { + let value = component.as_os_str().to_string_lossy(); + matches!(value.as_ref(), ".git" | "target" | "node_modules") + }) +} + +fn redact_privacy_evidence(value: &str) -> String { + if let Some((prefix, _)) = value.rsplit_once('/') { + return format!("{prefix}/"); + } + if value.contains('@') { + return "".to_string(); + } + "".to_string() +} + +fn privacy_output_root(input: &Utf8Path, _normalized: &Utf8Path) -> String { + if input.as_str() == "." { + ".".to_string() + } else if input.is_relative() { + input.to_string() + } else { + "".to_string() + } +} + fn scan_release(path: &Utf8Path) -> DevResult { let mut deploy_scripts = Vec::new(); let mut release_scripts = Vec::new(); @@ -3143,6 +3520,7 @@ fn print_audit_human(output: &AuditOutput) { fn print_plan_human(output: &PlanOutput) { println!("devctl standards plan"); println!("root: {}", output.root); + println!("scope: {}", output.scope); println!( "risk: {}", output @@ -3152,6 +3530,11 @@ fn print_plan_human(output: &PlanOutput) { .collect::>() .join(",") ); + println!("repos: {}", output.repos); + println!("findings: {}", output.findings); + for warning in &output.warnings { + println!("warning: {warning}"); + } println!("tranches: {}", output.tranches.len()); for tranche in &output.tranches { println!( @@ -3164,6 +3547,30 @@ fn print_plan_human(output: &PlanOutput) { } } +fn print_catalog_human(output: &CatalogOutput) { + println!("devctl doctor catalog"); + println!("public_root: {}", output.public_root); + println!( + "local_root: {}", + output.local_root.as_deref().unwrap_or("not-loaded") + ); + println!("local_overlay_loaded: {}", output.local_overlay_loaded); + println!("pilot_repo_count: {}", output.pilot_repo_count); + println!("contract_count: {}", output.contract_count); + if let Some(root) = &output.root { + println!("root: {root}"); + } + if let Some(count) = output.discovered_repo_count { + println!("discovered_repo_count: {count}"); + } + if let Some(count) = output.pilot_match_count { + println!("pilot_match_count: {count}"); + } + for warning in &output.warnings { + println!("warning: {warning}"); + } +} + fn print_report_human(output: &ReportOutput) { println!("devctl standards report"); println!("tool_version: {}", output.tool_version); @@ -3203,6 +3610,19 @@ fn print_packet_human(output: &PacketOutput) { println!("packet_markdown: {}", output.packet_markdown); } +fn print_privacy_human(output: &PrivacyOutput) { + println!("devctl doctor privacy"); + println!("root: {}", output.root); + println!("scanned_files: {}", output.scanned_files); + println!("findings: {}", output.findings.len()); + for finding in &output.findings { + println!( + "{} {}:{} {} - {}", + finding.id, finding.file, finding.line, finding.pattern, finding.message + ); + } +} + fn print_contracts_human(output: &ContractsOutput) { println!("devctl standards contracts"); println!("root: {}", output.root); @@ -3382,6 +3802,98 @@ mod tests { assert!(json.contains("\"sample-web-product\"")); } + #[test] + fn local_workspace_overlay_replaces_pilot_and_overrides_status() { + let fixture = TestWorkspace::new(); + fixture.write( + "public/workspace.toml", + r#"schema_version = "0.1.0" +pilot_three_tier = ["public-repo"] + +[repo_status] +public-repo = "active-product" +shared-repo = "active-product" +"#, + ); + fixture.write( + "local/workspace.toml", + r#"schema_version = "0.1.0" +pilot_three_tier = ["private-repo"] + +[repo_status] +shared-repo = "legacy" +"#, + ); + + let catalog = load_workspace_catalog_from_roots( + &fixture.root.join("public"), + Some(&fixture.root.join("local")), + ) + .expect("workspace overlay loads"); + + assert_eq!(catalog.pilot_three_tier, vec!["private-repo"]); + assert_eq!( + catalog.repo_status.get("shared-repo"), + Some(&RepoStatus::Legacy) + ); + assert_eq!( + catalog.repo_status.get("public-repo"), + Some(&RepoStatus::ActiveProduct) + ); + } + + #[test] + fn local_contract_overlay_overrides_public_contract() { + let fixture = TestWorkspace::new(); + fixture.write( + "public/contracts/demo.toml", + &contract_fixture("demo", "cargo check --workspace"), + ); + fixture.write( + "local/contracts/demo.toml", + &contract_fixture("demo", "cargo test --workspace"), + ); + + let catalog = load_contracts_catalog_from_roots( + &fixture.root.join("public"), + Some(&fixture.root.join("local")), + ) + .expect("contract overlay loads"); + let sourced = catalog.contracts.get("demo").expect("demo contract exists"); + + assert_eq!( + command_contract_texts(&sourced.contract.canonical_commands), + vec!["cargo test --workspace"] + ); + assert!(sourced.path.starts_with(fixture.root.join("local"))); + } + + #[test] + fn privacy_doctor_redacts_operator_values_and_uses_relative_files() { + let fixture = TestWorkspace::new(); + let home_path = ["/Us", "ers/private/dev/devctl"].concat(); + let email = ["hello", "@", "example.com"].concat(); + fixture.write( + "README.md", + &format!("operator path {home_path} and email {email}\n"), + ); + fixture.write("target/generated.txt", &format!("{home_path}/ignored\n")); + + let output = privacy_doctor_command(&PrivacyArgs { + root: fixture.root.clone(), + json: true, + }) + .expect("privacy doctor runs"); + + assert_eq!(output.findings.len(), 2); + assert!(output.findings.iter().all(|finding| { + finding.file == "README.md" + && !finding.evidence.contains("private") + && !finding.evidence.contains(&email) + })); + assert!(!output.root.contains("private")); + } + #[test] fn plan_groups_findings_into_tranches() { let repo = RepoRecord { @@ -3414,6 +3926,51 @@ mod tests { assert_eq!(tranches[0].repos, vec!["demo"]); } + #[test] + fn plan_all_scope_does_not_silently_ignore_non_pilot_repos() { + let fixture = TestWorkspace::new(); + let repo = fixture.repo("non-pilot"); + repo.write(".env", "TOKEN=value\n"); + + let output = plan_command(&PlanArgs { + root: fixture.root.clone(), + pilot: None, + all: true, + risk: "P0,P1".to_string(), + json: true, + }) + .expect("plan runs"); + + assert_eq!(output.scope, "all"); + assert_eq!(output.repos, 1); + assert!(!output.tranches.is_empty()); + assert!(output.warnings.is_empty()); + } + + #[test] + fn plan_warns_when_selected_pilot_scope_matches_zero_repos() { + let fixture = TestWorkspace::new(); + fixture.repo("non-pilot").write("AGENTS.md", "repo\n"); + + let output = plan_command(&PlanArgs { + root: fixture.root.clone(), + pilot: None, + all: false, + risk: "P0,P1".to_string(), + json: true, + }) + .expect("plan runs"); + + assert_eq!(output.scope, "pilot:three-tier"); + assert_eq!(output.repos, 0); + assert!( + output + .warnings + .iter() + .any(|warning| warning.contains("matched zero repos")) + ); + } + #[test] fn accepted_exception_is_not_actionable() { let repo = test_repo_record("demo"); @@ -3833,6 +4390,15 @@ mod tests { } } + fn write(&self, relative: &str, body: &str) { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent created"); + } + let mut file = fs::File::create(path).expect("file created"); + file.write_all(body.as_bytes()).expect("fixture written"); + } + fn repo(&self, name: &str) -> TestRepo { let repo = TestRepo { root: self.root.join(name), @@ -3897,6 +4463,23 @@ mod tests { } } + fn contract_fixture(repo: &str, command: &str) -> String { + format!( + r#"schema_version = "{SCHEMA_VERSION}" +repo = "{repo}" +archetype = "rust-workspace" +status = "active-product" +canonical_commands = ["{command}"] + +[cloudflare] +posture = "none" + +[release] +evidence_dirs = [] +"# + ) + } + fn test_archetypes() -> ArchetypesCatalog { ArchetypesCatalog { schema_version: SCHEMA_VERSION.to_string(),