From 01778c278f358f3df12870009261f86a92506727 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 13 Jul 2026 07:50:28 -0700 Subject: [PATCH 1/2] feat: add diff regression guard --- .dev/guard.toml | 83 ++++++ .github/workflows/ci.yml | 30 +- CHANGELOG.md | 13 + Cargo.lock | 19 +- Cargo.toml | 2 +- README.md | 5 +- crates/dev/Cargo.toml | 4 +- crates/dev/src/cli.rs | 37 ++- crates/dev/src/commands/guard.rs | 25 ++ crates/dev/src/commands/mod.rs | 1 + crates/dev/src/dispatch.rs | 2 + crates/dev/src/guard.rs | 478 +++++++++++++++++++++++++++++++ crates/dev/src/lib.rs | 1 + crates/dev/tests/integration.rs | 106 +++++++ docs/USAGE.md | 33 ++- docs/spec.md | 8 +- scripts/install.sh | 2 +- 17 files changed, 833 insertions(+), 16 deletions(-) create mode 100644 .dev/guard.toml create mode 100644 crates/dev/src/commands/guard.rs create mode 100644 crates/dev/src/guard.rs diff --git a/.dev/guard.toml b/.dev/guard.toml new file mode 100644 index 0000000..79958b1 --- /dev/null +++ b/.dev/guard.toml @@ -0,0 +1,83 @@ +# Fast, diff-only regression tripwires for this repository. Rules intentionally +# favor precise skipped-work and failure-suppression signals over broad keywords. +version = 1 + +[[rules]] +id = "SKIP-001" +severity = "deny" +pattern = '(?i)\b(TODO|FIXME)\b' +message = "New unfinished-work marker" +guidance = "Complete the work or omit the incomplete path before submission." +include = ["crates/*/src/**", "src/**", "scripts/**"] + +[[rules]] +id = "SKIP-002" +severity = "deny" +pattern = '\b(todo!|unimplemented!)\s*\(' +message = "New unimplemented Rust path" +guidance = "Implement the path or return an explicit supported error." +include = ["**/*.rs"] + +[[rules]] +id = "SKIP-003" +severity = "deny" +pattern = '#\s*\[\s*ignore(?:\s*=|\s*\])|\b(?:describe|context|it|test)\.skip\s*\(' +message = "New skipped or ignored test" +guidance = "Keep the test active or remove the unsupported behavior with its test." +include = ["**/*.rs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] + +[[rules]] +id = "SKIP-004" +severity = "deny" +pattern = '(?i)\b(?:PLACEHOLDER|CHANGEME|NOT_IMPLEMENTED)\b' +message = "New placeholder value in production code" +guidance = "Replace the placeholder with implemented behavior before submission." +include = ["crates/*/src/**", "src/**", "scripts/**"] + +[[rules]] +id = "ERR-001" +severity = "warn" +pattern = 'let\s+_\s*=\s*[^;]*(?:insert|update|delete|save|store|grant|revoke|send|emit|write)' +message = "Possible discarded mutation or delivery failure" +guidance = "Propagate the error or retain explicit diagnostics for intentional best-effort work." +include = ["**/*.rs"] + +[[rules]] +id = "ERR-002" +severity = "deny" +pattern = '(?:catch\s*\([^)]*\)\s*\{\s*\}|\.catch\s*\([^=]*=>\s*(?:\{\s*\}|undefined)\s*\))' +message = "New empty error handler" +guidance = "Return a typed failure or record useful diagnostics instead of swallowing it." +include = ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] + +[[rules]] +id = "AUTH-001" +severity = "deny" +pattern = '(?:is_(?:blocked|banned|authorized|allowed)|has_(?:access|permission|grant))[^;]*\.unwrap_or\(false\)' +message = "Possible fail-open authorization lookup" +guidance = "Propagate lookup failure or deny access explicitly." +include = ["**/*.rs"] + +[[rules]] +id = "CI-001" +severity = "warn" +pattern = '(?:continue-on-error:\s*true|(?:^|\s)\|\|\s*true\b|--no-verify\b)' +message = "New CI or verification bypass" +guidance = "Keep required checks gating; narrowly document any best-effort command." +include = [".github/**", "scripts/**", "**/*.yml", "**/*.yaml", "**/*.sh"] + +[[rules]] +id = "SECRET-001" +severity = "deny" +pattern = '(?:^\s*set\s+-[^#]*x|^\s*#!\s*/bin/(?:ba)?sh\s+-[^\s]*x)' +message = "New shell tracing may expose secrets" +guidance = "Do not enable shell tracing in code that may handle credentials or key material." +include = ["**/*.sh", "**/*.yml", "**/*.yaml"] + +[[rules]] +id = "DATA-001" +severity = "warn" +pattern = '(?i)\b(?:INSERT\s+OR\s+REPLACE|REPLACE\s+INTO)\b' +message = "Replacement SQL can weaken ownership or cascade integrity" +guidance = "Prefer an ownership-bound update/upsert with explicit conflict and revision checks." +include = ["**/*.rs", "**/*.sql"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aec8a6a..81d777b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,34 @@ permissions: contents: read jobs: + guard: + name: Recent-change guard + runs-on: ubuntu-latest + if: > + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build guard + run: cargo build -p devkit-cli + - name: Check only recent changes + env: + PUSH_BASE: ${{ github.event.before }} + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + base="origin/$GITHUB_BASE_REF" + else + base="$PUSH_BASE" + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + base="HEAD^" + fi + fi + target/debug/dev guard --base "$base" --format github + fmt: name: Format runs-on: ubuntu-latest @@ -75,7 +103,7 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: [fmt, clippy, test, release-artifacts] + needs: [guard, fmt, clippy, test, release-artifacts] if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b478d..af8e5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 2026-07-13 - v0.5.0 + +### Added + +- Added `dev guard`, a fast diff-only regression check with repository-owned regex and path rules, compact GitHub annotations, and separate blocking and advisory severities. +- Added a low-noise default guard policy for skipped work, swallowed failures, fail-open authorization, verification bypasses, unsafe shell tracing, and replacement SQL. +- Added a required CI guard job that compares only recent commits with their merge base. + +### Changed + +- Guard policies are read from the base revision by default so a proposed change cannot weaken its own check. +- Bumped `devkit` and `devkit-cli` to 0.5.0. + ## 2026-06-12 - v0.4.0 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9efec02..524dd73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "devkit" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "devkit-cli", @@ -243,7 +243,7 @@ dependencies = [ [[package]] name = "devkit-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", @@ -251,7 +251,9 @@ dependencies = [ "chrono", "clap", "dirs", + "globset", "predicates", + "regex", "rust-embed", "semver", "serde", @@ -377,6 +379,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.15.5" diff --git a/Cargo.toml b/Cargo.toml index 628e42b..1966e40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devkit" -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index e9e6312..5473707 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It is a good fit for: - teams that want `.env` profile management, validation, templates, diffs, and sync helpers; - release flows that benefit from scripted branch, version, changelog, and release-PR commands; - machines that need repeatable developer setup; -- LLM-assisted code review where `dev review` and `dev walk` generate bounded Markdown context. +- LLM-assisted development where `dev guard` catches new regression markers and `dev review`/`dev walk` generate bounded context. For LLM tool loops that need compact summaries of noisy commands or detached agent runs, use [`agntctl`](https://crates.io/crates/agntctl) alongside `devkit`. `devkit` owns the project workflow surface; `agntctl` owns bounded command and agent reports. @@ -94,6 +94,7 @@ dev update --yes # Review and context reports dev review --main --output review.md +dev guard --base origin/main dev walk crates/dev -o manifest.md --extensions .rs .toml dev walk --stdout ``` @@ -116,7 +117,7 @@ Tagged releases publish a multi-arch Docker image for `linux/amd64` and `linux/a ```bash docker pull bakobiibizo/devkit-core:latest -docker pull bakobiibizo/devkit-core:v0.4.0 +docker pull bakobiibizo/devkit-core:v0.5.0 ``` The image is built from the NGC PyTorch base and includes the build toolchain, Git/Git LFS, `uv`, cache directories for Hugging Face/Torch/uv, `nvidia-ml-py` instead of the deprecated `pynvml` package, and patched `torchaudio`/`torchvision` installs for the CUDA PyTorch stack. It is intended for aarch64 inference hosts such as GB10 / DGX Spark class machines where the host GPU stack is already provisioned. diff --git a/crates/dev/Cargo.toml b/crates/dev/Cargo.toml index f98e369..29a64ca 100644 --- a/crates/dev/Cargo.toml +++ b/crates/dev/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devkit-cli" -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.85" license = "MIT" @@ -20,6 +20,8 @@ camino = "1.1" chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } clap = { version = "4.5", features = ["derive"] } rust-embed = "8.5" +globset = "0.4" +regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" diff --git a/crates/dev/src/cli.rs b/crates/dev/src/cli.rs index 0d607b7..dd1644f 100644 --- a/crates/dev/src/cli.rs +++ b/crates/dev/src/cli.rs @@ -11,7 +11,7 @@ use crate::cli_help::{dynamic_help, should_append_dynamic_help}; name = "dev", version, about = "Unified developer workflows", - long_about = "A single-binary developer workflow tool for configured tasks, language pipelines, git flows, setup, review reports, directory manifests, and environment management.", + long_about = "A single-binary developer workflow tool for configured tasks, language pipelines, git flows, setup, diff regression guards, review reports, directory manifests, and environment management.", after_help = "Examples:\n dev config generate\n dev list\n dev lint\n dev run all_check\n dev git branch-create feature/docs\n dev setup status" )] pub struct Cli { @@ -105,7 +105,7 @@ pub enum Command { }, /// Check for and install newer dev releases. #[command( - after_help = "Examples:\n dev update --check\n dev update --yes\n dev update --version v0.4.0 --install-dir ~/.local/bin" + after_help = "Examples:\n dev update --check\n dev update --yes\n dev update --version v0.5.0 --install-dir ~/.local/bin" )] Update(UpdateArgs), /// Environment variable helper commands backed by a `.env` file. @@ -150,6 +150,11 @@ pub enum Command { #[arg(long = "main")] main: bool, }, + /// Check newly added lines for configured failure-mode regressions. + #[command( + after_help = "Examples:\n dev guard\n dev guard --base origin/main\n dev guard --format github\n dev guard --format detailed" + )] + Guard(GuardArgs), /// Generate a directory structure map with file contents (for LLM context). #[command( after_help = "Examples:\n dev walk\n dev walk crates/dev -o manifest.md --extensions .rs .toml\n dev walk . --no-content --max-depth 4\n dev walk --stdout" @@ -197,6 +202,34 @@ pub enum Verb { Ci, } +#[derive(Args, Debug)] +pub struct GuardArgs { + /// Git revision to compare with HEAD. The merge base is used. + #[arg(long = "base", default_value = "origin/main")] + pub base: String, + /// Git revision containing the proposed changes. + #[arg(long = "head", default_value = "HEAD")] + pub head: String, + /// Rule configuration path, relative to the repository root by default. + #[arg(long = "config", default_value = ".dev/guard.toml")] + pub config: PathBuf, + /// Output style. Summary and GitHub output stay deliberately compact. + #[arg(long = "format", value_enum, default_value_t = GuardFormat::Summary)] + pub format: GuardFormat, + /// Load policy from the proposed worktree instead of the base revision. + /// Intended for developing a new policy before it has landed. + #[arg(long = "rules-from-worktree", default_value_t = false)] + pub rules_from_worktree: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum GuardFormat { + #[default] + Summary, + Github, + Detailed, +} + impl Verb { pub fn as_str(&self) -> &'static str { match self { diff --git a/crates/dev/src/commands/guard.rs b/crates/dev/src/commands/guard.rs new file mode 100644 index 0000000..dcc50b4 --- /dev/null +++ b/crates/dev/src/commands/guard.rs @@ -0,0 +1,25 @@ +use anyhow::Result; + +use crate::cli::GuardArgs; +use crate::dispatch::CliContext; +use crate::guard::{GuardOptions, run_guard}; + +pub(crate) fn handle(ctx: &CliContext, args: GuardArgs) -> Result<()> { + if ctx.dry_run { + println!( + "[dry-run] Check added lines in {}...{} with {}", + args.base, + args.head, + args.config.display() + ); + return Ok(()); + } + + run_guard(GuardOptions { + base: args.base, + head: args.head, + config: args.config, + format: args.format, + rules_from_worktree: args.rules_from_worktree, + }) +} diff --git a/crates/dev/src/commands/mod.rs b/crates/dev/src/commands/mod.rs index a4556db..9126d67 100644 --- a/crates/dev/src/commands/mod.rs +++ b/crates/dev/src/commands/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod config; pub(crate) mod env; pub(crate) mod git; +pub(crate) mod guard; pub(crate) mod language; pub(crate) mod review; pub(crate) mod setup; diff --git a/crates/dev/src/dispatch.rs b/crates/dev/src/dispatch.rs index 106c37a..cc7d138 100644 --- a/crates/dev/src/dispatch.rs +++ b/crates/dev/src/dispatch.rs @@ -121,6 +121,7 @@ pub fn run(cli: Cli) -> Result<()> { include_working, main, } => commands::review::handle(&ctx, output, include_working, main), + Command::Guard(args) => commands::guard::handle(&ctx, args), Command::Walk { directory, output, @@ -171,6 +172,7 @@ fn handle_with_state(state: &AppState, command: Command) -> Result<()> { Command::Config { .. } => unreachable!("config commands handled earlier"), Command::Setup { .. } => unreachable!("setup commands handled earlier"), Command::Review { .. } => unreachable!("review commands handled earlier"), + Command::Guard(_) => unreachable!("guard command handled earlier"), Command::Walk { .. } => unreachable!("walk commands handled earlier"), Command::External(extra) => { bail!("unknown command: {}", extra.join(" ")) diff --git a/crates/dev/src/guard.rs b/crates/dev/src/guard.rs new file mode 100644 index 0000000..c27e32e --- /dev/null +++ b/crates/dev/src/guard.rs @@ -0,0 +1,478 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use regex::Regex; +use serde::Deserialize; + +use crate::cli::GuardFormat; + +pub(crate) struct GuardOptions { + pub(crate) base: String, + pub(crate) head: String, + pub(crate) config: PathBuf, + pub(crate) format: GuardFormat, + pub(crate) rules_from_worktree: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct GuardConfig { + #[serde(default = "config_version")] + version: u32, + rules: Vec, +} + +const fn config_version() -> u32 { + 1 +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +enum Severity { + Deny, + Warn, +} + +impl Severity { + fn marker(self) -> &'static str { + match self { + Self::Deny => "error", + Self::Warn => "warn", + } + } + + fn github_level(self) -> &'static str { + match self { + Self::Deny => "error", + Self::Warn => "warning", + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleConfig { + id: String, + severity: Severity, + pattern: String, + message: String, + #[serde(default)] + guidance: Option, + #[serde(default)] + include: Vec, + #[serde(default)] + exclude: Vec, +} + +struct Rule { + id: String, + severity: Severity, + regex: Regex, + message: String, + guidance: Option, + include: GlobSet, + exclude: GlobSet, +} + +#[derive(Debug, Eq, PartialEq)] +struct AddedLine { + path: String, + number: usize, + content: String, +} + +struct Finding<'a> { + rule: &'a Rule, + line: &'a AddedLine, +} + +fn git(repo_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-c") + .arg("core.quotePath=false") + .args(args) + .current_dir(repo_root) + .output() + .with_context(|| format!("running git {}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git {} failed: {}", args.join(" "), stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn repo_root() -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("running git rev-parse --show-toplevel")?; + if !output.status.success() { + bail!("dev guard must run inside a git repository"); + } + Ok(PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim(), + )) +} + +fn parse_new_start(header: &str) -> Option { + let range = header + .split_whitespace() + .find(|part| part.starts_with('+'))?; + range + .trim_start_matches('+') + .split(',') + .next()? + .parse() + .ok() +} + +fn parse_added_lines(diff: &str) -> Vec { + let mut lines = Vec::new(); + let mut path: Option = None; + let mut next_line: Option = None; + + for raw in diff.lines() { + if let Some(value) = raw.strip_prefix("+++ b/") { + path = Some(value.to_owned()); + next_line = None; + continue; + } + if raw == "+++ /dev/null" { + path = None; + next_line = None; + continue; + } + if raw.starts_with("@@") { + next_line = parse_new_start(raw); + continue; + } + + let (Some(current_path), Some(number)) = (path.as_ref(), next_line) else { + continue; + }; + + if let Some(content) = raw.strip_prefix('+') { + lines.push(AddedLine { + path: current_path.clone(), + number, + content: content.to_owned(), + }); + next_line = Some(number + 1); + } else if raw.starts_with('-') || raw.starts_with("\\ No newline") { + // Removed lines and patch metadata do not advance the new-file cursor. + } else { + next_line = Some(number + 1); + } + } + + lines +} + +fn build_globs(patterns: &[String], default_all: bool, rule_id: &str) -> Result { + let mut builder = GlobSetBuilder::new(); + if patterns.is_empty() && default_all { + builder.add(Glob::new("**").expect("static glob is valid")); + } else { + for pattern in patterns { + builder.add( + Glob::new(pattern) + .with_context(|| format!("invalid path glob `{pattern}` in rule {rule_id}"))?, + ); + } + } + builder + .build() + .with_context(|| format!("building path globs for rule {rule_id}")) +} + +fn compile_rules(config: GuardConfig) -> Result> { + if config.version != 1 { + bail!( + "unsupported guard config version {}; expected 1", + config.version + ); + } + if config.rules.is_empty() { + bail!("guard config must define at least one [[rules]] entry"); + } + + let mut ids = HashSet::new(); + let mut rules = Vec::with_capacity(config.rules.len()); + for rule in config.rules { + if rule.id.trim().is_empty() { + bail!("guard rule IDs cannot be empty"); + } + if !ids.insert(rule.id.clone()) { + bail!("duplicate guard rule ID `{}`", rule.id); + } + if rule.message.trim().is_empty() { + bail!("guard rule {} must have a message", rule.id); + } + + rules.push(Rule { + regex: Regex::new(&rule.pattern) + .with_context(|| format!("invalid regex in rule {}", rule.id))?, + include: build_globs(&rule.include, true, &rule.id)?, + exclude: build_globs(&rule.exclude, false, &rule.id)?, + id: rule.id, + severity: rule.severity, + message: rule.message, + guidance: rule.guidance, + }); + } + Ok(rules) +} + +fn config_repo_path(config_path: &Path, repo_root: &Path) -> Result { + let absolute = if config_path.is_absolute() { + config_path.to_path_buf() + } else { + repo_root.join(config_path) + }; + let relative = absolute.strip_prefix(repo_root).map_err(|_| { + anyhow!( + "base-revision policy requires --config to be inside the repository; use --rules-from-worktree for an external config" + ) + })?; + relative + .to_str() + .map(|value| value.replace('\\', "/")) + .ok_or_else(|| anyhow!("guard config path must be valid UTF-8")) +} + +fn read_policy( + repo_root: &Path, + config_path: &Path, + merge_base: &str, + rules_from_worktree: bool, +) -> Result<(String, &'static str)> { + if !rules_from_worktree { + let relative = config_repo_path(config_path, repo_root)?; + let spec = format!("{merge_base}:{relative}"); + if let Ok(contents) = git(repo_root, &["show", &spec]) { + return Ok((contents, "base")); + } + } + + let absolute = if config_path.is_absolute() { + config_path.to_path_buf() + } else { + repo_root.join(config_path) + }; + let contents = std::fs::read_to_string(&absolute) + .with_context(|| format!("reading guard config {}", absolute.display()))?; + Ok((contents, "worktree")) +} + +fn escape_github(value: &str) -> String { + value + .replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +fn escape_github_property(value: &str) -> String { + escape_github(value).replace(':', "%3A").replace(',', "%2C") +} + +fn compact(value: &str, max_chars: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let prefix = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{prefix}…") + } else { + prefix + } +} + +fn render_finding(finding: &Finding<'_>, format: GuardFormat) -> String { + let rule = finding.rule; + let line = finding.line; + match format { + GuardFormat::Summary => format!( + "[{}] {} {}:{} {}", + rule.severity.marker(), + rule.id, + line.path, + line.number, + compact(&rule.message, 180) + ), + GuardFormat::Github => { + let detail = match rule.guidance.as_deref() { + Some(guidance) => format!("{} {}", rule.message, guidance), + None => rule.message.clone(), + }; + format!( + "::{} file={},line={},title=dev guard {}::{}", + rule.severity.github_level(), + escape_github_property(&line.path), + line.number, + escape_github_property(&rule.id), + escape_github(&compact(&detail, 300)) + ) + } + GuardFormat::Detailed => { + let mut rendered = format!( + "[{}] {} {}:{}\n {}\n > {}", + rule.severity.marker(), + rule.id, + line.path, + line.number, + rule.message, + line.content.trim() + ); + if let Some(guidance) = &rule.guidance { + rendered.push_str("\n Guidance: "); + rendered.push_str(guidance); + } + rendered + } + } +} + +pub(crate) fn run_guard(options: GuardOptions) -> Result<()> { + let root = repo_root()?; + git(&root, &["rev-parse", "--verify", &options.base])?; + git(&root, &["rev-parse", "--verify", &options.head])?; + let merge_base = git(&root, &["merge-base", &options.base, &options.head])? + .trim() + .to_owned(); + + let (policy_toml, policy_source) = read_policy( + &root, + &options.config, + &merge_base, + options.rules_from_worktree, + )?; + let config: GuardConfig = toml::from_str(&policy_toml).context("parsing guard config")?; + let rules = compile_rules(config)?; + + let diff = git( + &root, + &[ + "diff", + "--unified=0", + "--no-ext-diff", + "--no-renames", + "--diff-filter=ACMR", + &merge_base, + &options.head, + "--", + ], + )?; + let policy_path = config_repo_path(&options.config, &root).ok(); + let policy_changed = policy_path.as_ref().is_some_and(|path| { + let old_policy_header = format!("--- a/{path}"); + let new_policy_header = format!("+++ b/{path}"); + diff.lines() + .any(|line| line == old_policy_header || line == new_policy_header) + }); + let added_lines = parse_added_lines(&diff); + + let mut findings = Vec::new(); + for line in &added_lines { + for rule in &rules { + if rule.include.is_match(&line.path) + && !rule.exclude.is_match(&line.path) + && rule.regex.is_match(&line.content) + { + findings.push(Finding { rule, line }); + } + } + } + + findings.sort_by(|left, right| { + let left_priority = usize::from(left.rule.severity == Severity::Warn); + let right_priority = usize::from(right.rule.severity == Severity::Warn); + left_priority + .cmp(&right_priority) + .then(left.line.path.cmp(&right.line.path)) + .then(left.line.number.cmp(&right.line.number)) + .then(left.rule.id.cmp(&right.rule.id)) + }); + + let denied = findings + .iter() + .filter(|finding| finding.rule.severity == Severity::Deny) + .count(); + let warned = findings.len() - denied; + let range = format!("{}...{}", options.base, options.head); + + if findings.is_empty() { + if policy_changed { + println!( + "[warn] guard: {} added lines checked against {} rules; no code matches, but {} changed ({range}, {policy_source} policy).", + added_lines.len(), + rules.len(), + policy_path.as_deref().unwrap_or("external guard policy") + ); + println!("[warn] guard: review the policy change; it cannot affect this check."); + } else { + println!( + "[ok] guard: {} added lines checked against {} rules; no new matches ({range}, {policy_source} policy).", + added_lines.len(), + rules.len() + ); + } + return Ok(()); + } + + let marker = if denied > 0 { "error" } else { "warn" }; + println!( + "[{marker}] guard: {denied} blocking, {warned} warning matches in {} added lines ({range}, {policy_source} policy).", + added_lines.len() + ); + if policy_changed { + println!( + "[warn] guard: {} changed; review it separately because the base policy remains active.", + policy_path.as_deref().unwrap_or("external guard policy") + ); + } + for finding in &findings { + println!("{}", render_finding(finding, options.format)); + } + if options.format != GuardFormat::Detailed { + println!("[hint] rerun with --format detailed for matched source and guidance."); + } + + if denied > 0 { + bail!("guard rejected newly added failure-mode matches"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{AddedLine, parse_added_lines}; + + #[test] + fn parses_only_added_lines_with_new_file_numbers() { + let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,3 @@\n old\n+new\n last\n@@ -8 +9,2 @@\n-old again\n+replacement\n+tail\n"; + assert_eq!( + parse_added_lines(diff), + vec![ + AddedLine { + path: "src/lib.rs".into(), + number: 2, + content: "new".into(), + }, + AddedLine { + path: "src/lib.rs".into(), + number: 9, + content: "replacement".into(), + }, + AddedLine { + path: "src/lib.rs".into(), + number: 10, + content: "tail".into(), + }, + ] + ); + } +} diff --git a/crates/dev/src/lib.rs b/crates/dev/src/lib.rs index 0d63214..e1f8ddd 100644 --- a/crates/dev/src/lib.rs +++ b/crates/dev/src/lib.rs @@ -6,6 +6,7 @@ mod core; mod dispatch; mod envfile; mod gitops; +mod guard; mod logging; mod review; mod scaffold; diff --git a/crates/dev/tests/integration.rs b/crates/dev/tests/integration.rs index fa9efbd..09aa17e 100644 --- a/crates/dev/tests/integration.rs +++ b/crates/dev/tests/integration.rs @@ -348,3 +348,109 @@ edition = "2024" "version bump left a clean worktree" ); } + +#[test] +fn guard_scans_only_added_lines_and_uses_base_policy() { + let temp = TempDir::new().expect("tempdir"); + let repo = temp.path(); + init_repo(repo); + write_file( + &repo.join(".dev/guard.toml"), + r#"version = 1 + +[[rules]] +id = "SKIP-001" +severity = "deny" +pattern = '(?i)\bTODO\b' +message = "New unfinished work marker" +guidance = "Finish the work before submission." +include = ["src/**"] + +[[rules]] +id = "ERR-001" +severity = "warn" +pattern = 'let\s+_\s*=' +message = "New discarded result" +include = ["src/**"] +"#, + ); + write_file( + &repo.join("src/lib.rs"), + "// TODO: legacy marker must not be reported\npub fn existing() {}\n", + ); + run_git(repo, &["add", "."]); + run_git(repo, &["commit", "-m", "initial policy and legacy code"]); + run_git(repo, &["switch", "-c", "feature/guard-test"]); + + write_file( + &repo.join("src/lib.rs"), + "// TODO: legacy marker must not be reported\npub fn existing() {}\npub fn warning() { let _ = cleanup(); }\n", + ); + run_git(repo, &["add", "src/lib.rs"]); + run_git(repo, &["commit", "-m", "add warning candidate"]); + + dev() + .args(["-C", repo.to_str().unwrap(), "guard", "--base", "main"]) + .assert() + .success() + .stdout(predicates::str::contains("0 blocking, 1 warning")) + .stdout(predicates::str::contains("ERR-001 src/lib.rs:3")) + .stdout(predicates::str::contains("SKIP-001").not()) + .stdout(predicates::str::contains("legacy marker").not()); + + // Attempt to weaken the proposed branch's policy. The base policy must still win. + let weakened = fs::read_to_string(repo.join(".dev/guard.toml")) + .expect("read policy") + .replace("(?i)\\bTODO\\b", "THIS_PATTERN_CANNOT_MATCH"); + write_file(&repo.join(".dev/guard.toml"), &weakened); + write_file( + &repo.join("src/lib.rs"), + "// TODO: legacy marker must not be reported\npub fn existing() {}\npub fn warning() { let _ = cleanup(); }\n// TODO: new skipped work\n", + ); + run_git(repo, &["add", "."]); + run_git( + repo, + &["commit", "-m", "add blocking candidate and weaken policy"], + ); + + dev() + .args([ + "-C", + repo.to_str().unwrap(), + "guard", + "--base", + "main", + "--format", + "github", + ]) + .assert() + .failure() + .stdout(predicates::str::contains("1 blocking, 1 warning")) + .stdout(predicates::str::contains("base policy")) + .stdout(predicates::str::contains("review it separately")) + .stdout(predicates::str::contains("::error file=src/lib.rs,line=4")) + .stdout(predicates::str::contains( + "::warning file=src/lib.rs,line=3", + )) + .stdout(predicates::str::contains("legacy marker").not()) + .stderr(predicates::str::contains( + "guard rejected newly added failure-mode matches", + )); + + dev() + .args([ + "-C", + repo.to_str().unwrap(), + "guard", + "--base", + "main", + "--format", + "detailed", + ]) + .assert() + .failure() + .stdout(predicates::str::contains("TODO: new skipped work")) + .stdout(predicates::str::contains( + "Guidance: Finish the work before submission.", + )); +} diff --git a/docs/USAGE.md b/docs/USAGE.md index 428c05b..ef6d6cd 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -39,7 +39,7 @@ The CLI exposes the core devkit command families: - `git`, `version`, `update` - `env`, `config` - `setup` -- `review`, `walk` +- `review`, `guard`, `walk` The first-class verbs `fmt`, `lint`, `type`, `test`, `fix`, `check`, and `ci` are hidden clap commands normalized through the dynamic help layer and dispatch to language pipelines. @@ -130,7 +130,7 @@ dev version changelog --since v1.2.0 dev update --check dev update --yes -dev update --version v0.4.0 --install-dir ~/.local/bin +dev update --version v0.5.0 --install-dir ~/.local/bin ``` `dev update` checks GitHub releases, downloads the matching archive for the current platform, installs to the current binary directory or `~/.local/bin`, and keeps the previous binary as `dev.old`. @@ -155,23 +155,46 @@ Tagged releases publish the `bakobiibizo/devkit-core` Docker image for GPU-orien ```bash docker pull bakobiibizo/devkit-core:latest -docker pull bakobiibizo/devkit-core:v0.4.0 +docker pull bakobiibizo/devkit-core:v0.5.0 ``` -## Review And Walk +## Review, Guard, And Walk ```bash dev review dev review --main --output review.md dev review --include-working +dev guard --base origin/main +dev guard --base origin/main --format github +dev guard --base main --format detailed + dev walk dev walk crates/dev -o manifest.md --extensions .rs .toml dev walk . --no-content --max-depth 4 dev walk --stdout ``` -`dev review` produces a Markdown code-review overlay from staged diffs, working-tree diffs, or comparison to the main branch. `dev walk` creates an LLM-ready directory manifest and includes file contents by default. Use `dev walk --stdout` to print the manifest instead of writing `manifest.md`. +`dev review` produces a Markdown code-review overlay from staged diffs, working-tree diffs, or comparison to the main branch. `dev guard` loads regex and path-glob rules from `.dev/guard.toml`, then checks only added lines since the merge base. `deny` matches fail the command while `warn` matches remain advisory. CI-oriented output includes every finding as one concise annotation without source dumps; `--format detailed` adds matched source and guidance for local diagnosis. The rule policy comes from the base revision by default, preventing a proposed branch from weakening its own gate; policy-file changes are highlighted for review and cannot affect their own check. Use `--rules-from-worktree` only while bootstrapping or deliberately developing policy. + +Minimal policy: + +```toml +version = 1 + +[[rules]] +id = "SKIP-001" +severity = "deny" +pattern = '(?i)\b(TODO|FIXME)\b' +message = "New unfinished-work marker" +guidance = "Complete the work before submission." +include = ["src/**"] +exclude = ["src/fixtures/**"] +``` + +There is intentionally no inline source-code suppression syntax. Keep rules narrow with path globs and precise patterns so routine changes remain quiet. + +`dev walk` creates an LLM-ready directory manifest and includes file contents by default. Use `dev walk --stdout` to print the manifest instead of writing `manifest.md`. ## Verb Summaries diff --git a/docs/spec.md b/docs/spec.md index 755b241..f6157f4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -71,6 +71,8 @@ Commands: setup config review [--output ] [--include-working] [--main] + guard [--base ] [--head ] [--config ] [--format ] + [--rules-from-worktree] walk [DIR] [-o, --output ] [--stdout] [--format ] [--max-depth ] [--no-content] [--extensions ] [--include-hidden] @@ -213,10 +215,12 @@ Dependency rules: `setup inference ` clones or updates `https://github.com/bakobiibizo/dev-.git`, strips explicit Compose `container_name:` entries to avoid collisions, and runs `scripts/setup.sh`. -## Review And Walk +## Review, Guard, And Walk `review` generates Markdown code review reports from staged diffs, working tree diffs, or branch comparison to main. `walk` generates Markdown directory manifests with file contents by default and supports extension filtering, max-depth limits, hidden-file inclusion, and stdout output via `--stdout`. +`guard` scans only lines added between the merge base of `--base` (default `origin/main`) and `--head` (default `HEAD`). Repository-owned regex rules live in `.dev/guard.toml`, can be restricted by include/exclude globs, and use `deny` or `warn` severity. Summary and GitHub formats report every finding as one concise line; detailed output includes matched source and remediation guidance. By default the policy is loaded from the base revision so a proposed change cannot weaken its own gate. Policy-file changes are highlighted for review but evaluated with the base policy; `--rules-from-worktree` is reserved for bootstrapping or intentionally developing policy. + ## Verb Summaries First-class verbs run configured pipelines through the configured shell, capture bounded stdout/stderr, and print either an LLM-generated summary or a deterministic tail summary. @@ -237,6 +241,7 @@ crates/dev/ gitops.rs versioning.rs review.rs + guard.rs walk.rs templates.rs commands/ @@ -245,6 +250,7 @@ crates/dev/ git.rs language.rs review.rs + guard.rs setup.rs task.rs version.rs diff --git a/scripts/install.sh b/scripts/install.sh index 86efee3..4097544 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,7 +6,7 @@ set -eu # curl -fsSL https://raw.githubusercontent.com/bakobiibizo/devkit/main/scripts/install.sh | sh # Optional environment: # DEVKIT_REPO=bakobiibizo/devkit -# DEVKIT_VERSION=v0.4.0 # default: latest +# DEVKIT_VERSION=v0.5.0 # default: latest # DEVKIT_INSTALL_DIR=$HOME/.local/bin repo="${DEVKIT_REPO:-bakobiibizo/devkit}" From 183b698c7ee2688549bbf230d9aef7dbed49de50 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 13 Jul 2026 14:45:12 -0700 Subject: [PATCH 2/2] ci: use self-hosted runners --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81d777b..7e4f417 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: guard: name: Recent-change guard - runs-on: ubuntu-latest + runs-on: self-hosted if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) @@ -40,7 +40,7 @@ jobs: fmt: name: Format - runs-on: ubuntu-latest + runs-on: self-hosted if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) @@ -54,7 +54,7 @@ jobs: clippy: name: Clippy - runs-on: ubuntu-latest + runs-on: self-hosted if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) @@ -69,7 +69,7 @@ jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: self-hosted if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) @@ -82,7 +82,7 @@ jobs: release-artifacts: name: Release artifact checks - runs-on: ubuntu-latest + runs-on: self-hosted if: > github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) @@ -102,7 +102,7 @@ jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: self-hosted needs: [guard, fmt, clippy, test, release-artifacts] if: > github.event_name == 'push' ||