From 25adcea527aa35d5a8221503be5182d78e0baff9 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Sat, 15 Aug 2026 19:04:49 +0800 Subject: [PATCH 01/21] feat(runtime): converge language guards --- CHANGELOG.md | 2 + CONTRIBUTING.md | 29 +- eval/behavior/datasets/v1.jsonl | 15 + eval/behavior/requirements.json | 75 +++ eval/run_behavior_eval.py | 110 +++- eval/test_behavior_eval.py | 38 ++ guards/go/CLAUDE.md | 15 +- guards/go/check_defer_in_loop.sh | 142 +--- guards/go/check_error_handling.sh | 140 +--- guards/go/check_goroutine_leak.sh | 129 +--- guards/go/common.sh | 359 +--------- guards/go/runtime-shim.sh | 22 + guards/rust/CLAUDE.md | 16 +- .../rust/check_declaration_execution_gap.sh | 306 +-------- guards/rust/check_duplicate_types.sh | 106 +-- guards/rust/check_nested_locks.sh | 163 +---- guards/rust/check_semantic_effect.sh | 132 +--- guards/rust/check_single_source_of_truth.sh | 102 +-- guards/rust/check_taste_invariants.sh | 118 +--- guards/rust/check_unwrap_in_prod.sh | 543 +-------------- guards/rust/check_workspace_consistency.sh | 275 +------- guards/rust/common.sh | 309 +-------- guards/rust/runtime-shim.sh | 22 + guards/typescript/check_any_abuse.sh | 172 +---- .../typescript/check_component_duplication.sh | 152 +---- guards/typescript/check_console_residual.sh | 179 +---- .../typescript/check_duplicate_constants.sh | 137 +--- guards/typescript/common.sh | 360 +--------- guards/typescript/runtime-shim.sh | 22 + .../check-rust-test-path-classifier.sh | 7 +- tests/test_behavior_eval.sh | 2 +- tests/test_distribution_assets.sh | 34 +- tests/unit/test_baseline_scanning.sh | 298 +++++---- tests/unit/test_go_check_error_handling.sh | 23 +- ...st_rust_check_declaration_execution_gap.sh | 30 +- tests/unit/test_rust_check_duplicate_types.sh | 8 + tests/unit/test_rust_check_nested_locks.sh | 42 ++ tests/unit/test_rust_check_unwrap_in_prod.sh | 65 +- .../test_rust_check_workspace_consistency.sh | 61 ++ tests/unit/test_suppression.sh | 13 + tests/unit/test_ts_check_any_abuse.sh | 33 +- tests/unit/test_ts_check_console_residual.sh | 10 +- vibeguard-runtime/Cargo.lock | 2 +- vibeguard-runtime/Cargo.toml | 2 +- vibeguard-runtime/VERSION | 2 +- vibeguard-runtime/src/guard_scan/go.rs | 170 +++++ vibeguard-runtime/src/guard_scan/mod.rs | 50 ++ vibeguard-runtime/src/guard_scan/rust.rs | 424 ++++++++++++ .../src/guard_scan/rust_structural.rs | 579 ++++++++++++++++ vibeguard-runtime/src/guard_scan/shared.rs | 616 ++++++++++++++++++ .../src/guard_scan/typescript.rs | 373 +++++++++++ vibeguard-runtime/src/hook_checks/js.rs | 2 +- vibeguard-runtime/src/main.rs | 6 + 53 files changed, 3036 insertions(+), 4006 deletions(-) create mode 100755 guards/go/runtime-shim.sh create mode 100755 guards/rust/runtime-shim.sh create mode 100755 guards/typescript/runtime-shim.sh create mode 100644 vibeguard-runtime/src/guard_scan/go.rs create mode 100644 vibeguard-runtime/src/guard_scan/mod.rs create mode 100644 vibeguard-runtime/src/guard_scan/rust.rs create mode 100644 vibeguard-runtime/src/guard_scan/rust_structural.rs create mode 100644 vibeguard-runtime/src/guard_scan/shared.rs create mode 100644 vibeguard-runtime/src/guard_scan/typescript.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f5783b86..da6ed024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - W-21 "evidence must be provably executed, not merely cited": decisive claims need an out-of-session channel (transcript, filesystem, git, persisted exit codes/hashes), accusing the harness or hooks is a red flag, and two falsified root-cause theories in one investigation terminate the session (#687). +- `vibeguard-runtime scan ` now provides the canonical implementation for all 15 Rust, Go, and TypeScript shell guards, including staged and baseline-aware scanning (#752). ### Changed - W-01's debugging protocol now starts at step 0, a channel-trust check that rules out degraded reading before any filesystem, harness, or hook is blamed (#687). +- Rust, Go, and TypeScript `check_*.sh` files are now fail-closed runtime exec shims; behavior-eval fixtures pin every migrated rule, and `vibeguard-runtime` is now `1.1.17` (#752). ## [1.1.10] - 2026-07-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12a4a969..3cf0f546 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,7 +286,9 @@ guards/ └── typescript/ # TypeScript guards (TS-XX rules) ``` -Bash-based language directories (`rust/`, `go/`, `typescript/`) each contain a `common.sh` with shared utilities. Python guards are standalone scripts. +Rust, Go, and TypeScript detection lives in `vibeguard-runtime/src/guard_scan/`; +their Bash files are compatibility entrypoints. Python guards remain standalone +scripts. ### Step 1: Define the rule @@ -307,20 +309,17 @@ Canonical rule headings must use the format `## ID: Title (severity)`. Rules fol The file format differs by language: -**Bash guards (Rust, Go, TypeScript)** — create `guards//check_.sh` and start with: +**Rust, Go, and TypeScript guards** — implement the rule in +`vibeguard-runtime/src/guard_scan/`, register the `scan ` +command, then create `guards//check_.sh` as a compatibility +shim: ```bash #!/usr/bin/env bash -# VibeGuard Guard: () -# -# Usage: -# bash check_.sh [target_dir] -# bash check_.sh --strict [target_dir] # exit 1 on violations - set -euo pipefail -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard "$@" ``` **Python guards** — create `guards/python/check_.py` and parse arguments directly from `sys.argv`: @@ -355,10 +354,12 @@ if __name__ == "__main__": Every finding must follow this format because downstream tools consume it: ```text -[RS-14] path/to/file.rs:42 description. Fix: remediation hint +[RS-14] path/to/file.rs:42: description. Fix: remediation hint ``` -Use `TMPFILE=$(create_tmpfile)` from `common.sh` to buffer output in Bash guards. Print a clear summary and return `1` only when violations are present in `--strict` mode. +The runtime owns output buffering and exit semantics for Rust, Go, and +TypeScript guards. Print a clear summary and return `1` only when violations +are present in `--strict` mode. #### Exit codes @@ -403,8 +404,8 @@ If the change affects detection quality or scoring, also run `bash tests/run_pre ### Guard Quality Checklist - [ ] Starts with strict error handling (`set -euo pipefail` for Bash) -- [ ] Uses shared helpers where they already exist (`common.sh`, temp-file helpers, guard-path helpers) -- [ ] Output follows `[RULE-ID] file:line description. Fix: hint` +- [ ] Rust/Go/TypeScript shell entrypoints contain no detection or fallback logic +- [ ] Output follows `[RULE-ID] file:line: description. Fix: hint` - [ ] Handles expected exclusions and avoids obvious false positives - [ ] Supports `--strict` mode correctly - [ ] Has scope-appropriate regression coverage diff --git a/eval/behavior/datasets/v1.jsonl b/eval/behavior/datasets/v1.jsonl index 683a45a1..40edf08c 100644 --- a/eval/behavior/datasets/v1.jsonl +++ b/eval/behavior/datasets/v1.jsonl @@ -14,3 +14,18 @@ {"id": "codex-pre-write-existing-allow", "description": "Codex PreToolUse(Write) wrapper stays silent when overwriting an existing file", "platform": "codex", "hook": "pre-write-guard", "event": "PreToolUse", "profile": "default", "severity": "medium", "rule": "L1", "runner": "codex_wrapper", "script": "hooks/run-hook-codex.sh", "hook_name": "vibeguard-pre-write-guard.sh", "payload": {"hook_event_name": "PreToolUse", "tool_input": {"file_path": "README.md", "content": "# README"}}, "expect": {"exit_code": 0, "stdout_empty": true}} {"id": "claude-pre-bash-malformed-input-fail-closed", "description": "Claude PreToolUse(Bash) fails closed on a well-formed payload without tool_input.command and logs a shape diagnostic", "platform": "claude", "hook": "pre-bash-guard", "event": "PreToolUse", "profile": "default", "severity": "high", "rule": "U-29", "runner": "claude_hook", "script": "hooks/pre-bash-guard.sh", "payload": {"hook_event_name": "PreToolUse", "tool_name": "BashOutput", "tool_input": {"bash_id": "bg-1"}}, "expect": {"exit_code": 0, "json": [{"path": "decision", "equals": "block"}], "stdout_contains": ["invalid Bash hook input JSON"]}} {"id": "claude-pre-write-malformed-input-fail-closed", "description": "Claude PreToolUse(Write) fails closed on a payload missing tool_input.file_path and explains the validation failure", "platform": "claude", "hook": "pre-write-guard", "event": "PreToolUse", "profile": "default", "severity": "high", "rule": "U-29", "runner": "claude_hook", "script": "hooks/pre-write-guard.sh", "payload": {"hook_event_name": "PreToolUse", "tool_name": "NotebookEdit", "tool_input": {"notebook_path": "nb.ipynb"}}, "expect": {"exit_code": 0, "json": [{"path": "decision", "equals": "block"}], "stdout_contains": ["malformed PreToolUse(Write)"]}} +{"id":"guard-rust-unwrap-parity","description":"Rust runtime preserves RS-03 production unwrap detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-03","runner":"guard","script":"guards/rust/check_unwrap_in_prod.sh","payload":{"args":["--strict"],"files":{"src/main.rs":"fn main() { let _ = Some(1).unwrap(); }\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-03]","main.rs"]}} +{"id":"guard-rust-nested-locks-parity","description":"Rust runtime preserves RS-01 nested lock detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-01","runner":"guard","script":"guards/rust/check_nested_locks.sh","payload":{"args":["--strict"],"files":{"src/state.rs":"use std::sync::Mutex;\nstruct State { a: Mutex, b: Mutex }\nimpl State { fn update(&self) { let _a = self.a.lock(); let _b = self.b.lock(); } }\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-01]","update"]}} +{"id":"guard-rust-duplicate-types-parity","description":"Rust runtime preserves RS-05 duplicate type detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-05","runner":"guard","script":"guards/rust/check_duplicate_types.sh","payload":{"args":["--strict"],"files":{"src/a.rs":"pub struct SharedType;\n","src/b.rs":"pub struct SharedType;\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-05]","SharedType"]}} +{"id":"guard-rust-workspace-consistency-parity","description":"Rust runtime preserves RS-06 workspace configuration drift detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-06","runner":"guard","script":"guards/rust/check_workspace_consistency.sh","payload":{"args":["--strict"],"files":{"Cargo.toml":"[workspace]\nmembers = [\"server\", \"desktop\"]\n","server/Cargo.toml":"[package]\nname = \"server\"\nversion = \"0.1.0\"\n","server/src/main.rs":"fn main() { let _ = std::env::var(\"SERVER_DB_PATH\"); }\n","desktop/Cargo.toml":"[package]\nname = \"desktop\"\nversion = \"0.1.0\"\n","desktop/src/main.rs":"fn main() { let _ = std::env::var(\"DESKTOP_DB_PATH\"); }\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-06]","SERVER_DB_PATH","DESKTOP_DB_PATH"]}} +{"id":"guard-rust-ssot-parity","description":"Rust runtime preserves RS-12 dual task-system detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-12","runner":"guard","script":"guards/rust/check_single_source_of_truth.sh","payload":{"args":["--strict"],"files":{"src/tools.rs":"pub struct TodoWrite;\npub struct TaskDone;\nstatic TODO_STATE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());\nstatic TASK_STATE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-12]","dual task systems"]}} +{"id":"guard-rust-semantic-effect-parity","description":"Rust runtime preserves RS-13 missing side-effect detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-13","runner":"guard","script":"guards/rust/check_semantic_effect.sh","payload":{"args":["--strict"],"files":{"src/task/task_done.rs":"pub fn mark_done(task_id: &str) -> Result { Ok(format!(\"task {} done\", task_id)) }\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-13]","mark_done"]}} +{"id":"guard-rust-taste-parity","description":"Rust runtime preserves taste-invariant panic message detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"TASTE-PANIC-MSG","runner":"guard","script":"guards/rust/check_taste_invariants.sh","payload":{"args":["--strict"],"files":{"src/lib.rs":"pub fn fail() { panic!(\"\"); }\n"}},"expect":{"exit_code":1,"stdout_contains":["[TASTE-PANIC-MSG]","panic! lacks a meaningful message"]}} +{"id":"guard-rust-declaration-execution-parity","description":"Rust runtime preserves RS-14 Config default/load gap detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"RS-14","runner":"guard","script":"guards/rust/check_declaration_execution_gap.sh","payload":{"args":["--strict"],"files":{"src/config.rs":"pub struct AppConfig;\nimpl AppConfig { pub fn load() -> Self { Self } }\nimpl Default for AppConfig { fn default() -> Self { Self } }\n","src/main.rs":"mod config;\nfn main() { let _ = config::AppConfig::default(); }\n"}},"expect":{"exit_code":1,"stdout_contains":["[RS-14]","AppConfig::default()"]}} +{"id":"guard-go-error-handling-parity","description":"Rust runtime preserves GO-01 discarded error detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"GO-01","runner":"guard","script":"guards/go/check_error_handling.sh","payload":{"args":["--strict"],"files":{"main.go":"package main\nimport \"os\"\nfunc main() {\n _ = os.Remove(\"old\")\n}\n"}},"expect":{"exit_code":1,"stdout_contains":["[GO-01]","main.go"]}} +{"id":"guard-go-goroutine-parity","description":"Rust runtime preserves GO-02 goroutine leak detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"GO-02","runner":"guard","script":"guards/go/check_goroutine_leak.sh","payload":{"args":["--strict"],"files":{"worker.go":"package worker\nfunc start() {\n go func() {\n for { work() }\n }()\n}\nfunc work() {}\n"}},"expect":{"exit_code":1,"stdout_contains":["[GO-02]","worker.go"]}} +{"id":"guard-go-defer-loop-parity","description":"Rust runtime preserves GO-08 defer-in-loop detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"GO-08","runner":"guard","script":"guards/go/check_defer_in_loop.sh","payload":{"args":["--strict"],"files":{"main.go":"package main\nfunc run(items []string) {\n for _, item := range items {\n defer closeItem(item)\n }\n}\nfunc closeItem(string) {}\n"}},"expect":{"exit_code":1,"stdout_contains":["[GO-08]","defer closeItem"]}} +{"id":"guard-typescript-any-parity","description":"Rust runtime preserves TS-01 any-abuse detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"TS-01","runner":"guard","script":"guards/typescript/check_any_abuse.sh","payload":{"args":["--strict"],"files":{"src/value.ts":"export const value = input as any;\n"}},"expect":{"exit_code":1,"stdout_contains":["[TS-01]","value.ts"]}} +{"id":"guard-typescript-console-parity","description":"Rust runtime preserves TS-03 console residual detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"TS-03","runner":"guard","script":"guards/typescript/check_console_residual.sh","payload":{"args":["--strict"],"files":{"src/service.ts":"export function run(): void { console.log(\"debug\"); }\n"}},"expect":{"exit_code":1,"stdout_contains":["[TS-03]","service.ts"]}} +{"id":"guard-typescript-components-parity","description":"Rust runtime preserves TS-13 repeated component-style detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"TS-13","runner":"guard","script":"guards/typescript/check_component_duplication.sh","payload":{"args":["--strict"],"files":{"src/a.tsx":"export const A = () =>
;\n","src/b.tsx":"export const B = () =>
;\n"}},"expect":{"exit_code":1,"stdout_contains":["[TS-13]","Style string duplicated"]}} +{"id":"guard-typescript-constants-parity","description":"Rust runtime preserves duplicate exported constant detection","platform":"runtime","hook":"guard-scan","event":"GuardScan","profile":"default","severity":"high","rule":"DUP-CONST","runner":"guard","script":"guards/typescript/check_duplicate_constants.sh","payload":{"args":["--strict"],"files":{"src/a.ts":"export const API_URL = \"a\";\n","src/b.ts":"export const API_URL = \"b\";\n"}},"expect":{"exit_code":1,"stdout_contains":["[DUP-CONST]","API_URL"]}} diff --git a/eval/behavior/requirements.json b/eval/behavior/requirements.json index ee6f1bb0..8ae83795 100644 --- a/eval/behavior/requirements.json +++ b/eval/behavior/requirements.json @@ -34,5 +34,80 @@ "hook": "pre-write-guard", "profile": "default", "severity": "medium" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-03" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-01" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-05" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-06" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-12" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-13" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "TASTE-PANIC-MSG" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "RS-14" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "GO-01" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "GO-02" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "GO-08" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "TS-01" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "TS-03" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "TS-13" + }, + { + "platform": "runtime", + "hook": "guard-scan", + "rule": "DUP-CONST" } ] diff --git a/eval/run_behavior_eval.py b/eval/run_behavior_eval.py index 9687e7d9..7189980a 100644 --- a/eval/run_behavior_eval.py +++ b/eval/run_behavior_eval.py @@ -82,7 +82,7 @@ def validate_sample(sample: dict[str, Any], path: Path, line_number: int) -> Non missing = sorted(REQUIRED_SAMPLE_FIELDS - set(sample)) if missing: raise BehaviorDatasetError(f"{path}:{line_number}: missing fields: {', '.join(missing)}") - if sample["runner"] not in {"claude_hook", "codex_wrapper"}: + if sample["runner"] not in {"claude_hook", "codex_wrapper", "guard"}: raise BehaviorDatasetError(f"{path}:{line_number}: unsupported runner {sample['runner']!r}") if not isinstance(sample["payload"], dict): raise BehaviorDatasetError(f"{path}:{line_number}: payload must be an object") @@ -91,6 +91,21 @@ def validate_sample(sample: dict[str, Any], path: Path, line_number: int) -> Non raise BehaviorDatasetError(f"{path}:{line_number}: expect must be an object") if "json" in expect and not isinstance(expect["json"], list): raise BehaviorDatasetError(f"{path}:{line_number}: expect.json must be a list") + if sample["runner"] == "guard": + files = sample["payload"].get("files") + args = sample["payload"].get("args", []) + if not isinstance(files, dict) or not files: + raise BehaviorDatasetError( + f"{path}:{line_number}: guard payload.files must be a non-empty object" + ) + if not all(isinstance(name, str) and isinstance(content, str) for name, content in files.items()): + raise BehaviorDatasetError( + f"{path}:{line_number}: guard fixture paths and contents must be strings" + ) + if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): + raise BehaviorDatasetError( + f"{path}:{line_number}: guard payload.args must be a string array" + ) for field in ("id", "platform", "hook", "profile", "severity", "rule"): if not isinstance(sample[field], str) or not sample[field].strip(): raise BehaviorDatasetError(f"{path}:{line_number}: {field} must be a non-empty string") @@ -139,19 +154,43 @@ def evaluate_sample(sample: dict[str, Any], repo_root: Path, timeout_seconds: fl with tempfile.TemporaryDirectory(prefix=f"vibeguard-behavior-{sample['id']}-") as tmp: tmp_path = Path(tmp) env = build_env(sample, repo_root, tmp_path) - command = build_command(sample, repo_root) - payload = json.dumps(sample["payload"], ensure_ascii=False) + fixture_root = materialize_guard_fixture(sample, tmp_path) + command = build_command(sample, repo_root, fixture_root) + payload = ( + None + if sample["runner"] == "guard" + else json.dumps(sample["payload"], ensure_ascii=False) + ) try: completed = subprocess.run( command, input=payload, - cwd=repo_root, + cwd=fixture_root if sample["runner"] == "guard" else repo_root, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False, ) + direct = None + if sample["runner"] == "guard": + language, rule = guard_runtime_target(sample) + direct = subprocess.run( + [ + env["VIBEGUARD_RUNTIME"], + "scan", + language, + rule, + *sample["payload"].get("args", []), + str(fixture_root), + ], + cwd=fixture_root, + env=env, + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) except subprocess.TimeoutExpired as exc: return base_result(sample, started) | { "passed": False, @@ -163,6 +202,21 @@ def evaluate_sample(sample: dict[str, Any], repo_root: Path, timeout_seconds: fl } checks = evaluate_expectations(sample["expect"], completed.returncode, completed.stdout) + if direct is not None: + checks.extend([ + { + "name": "guard_runtime_exit_parity", + "passed": completed.returncode == direct.returncode, + "expected": direct.returncode, + "actual": completed.returncode, + }, + { + "name": "guard_runtime_stdout_parity", + "passed": completed.stdout == direct.stdout, + "expected": direct.stdout[:500], + "actual": completed.stdout[:500], + }, + ]) passed = all(check["passed"] for check in checks) return base_result(sample, started) | { "passed": passed, @@ -174,15 +228,50 @@ def evaluate_sample(sample: dict[str, Any], repo_root: Path, timeout_seconds: fl } -def build_command(sample: dict[str, Any], repo_root: Path) -> list[str]: +def build_command(sample: dict[str, Any], repo_root: Path, fixture_root: Path) -> list[str]: script_path = repo_root / sample["script"] if sample["runner"] == "claude_hook": return ["bash", str(script_path)] if sample["runner"] == "codex_wrapper": return ["bash", str(script_path), sample["hook_name"]] + if sample["runner"] == "guard": + return [ + "bash", + str(script_path), + *sample["payload"].get("args", []), + str(fixture_root), + ] raise BehaviorDatasetError(f"{sample['id']}: unsupported runner {sample['runner']!r}") +def materialize_guard_fixture(sample: dict[str, Any], tmp_path: Path) -> Path: + fixture_root = tmp_path / "project" + fixture_root.mkdir() + if sample["runner"] != "guard": + return fixture_root + for relative_name, content in sample["payload"]["files"].items(): + relative = Path(relative_name) + if relative.is_absolute() or ".." in relative.parts: + raise BehaviorDatasetError( + f"{sample['id']}: guard fixture path must stay relative: {relative_name!r}" + ) + destination = fixture_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + return fixture_root + + +def guard_runtime_target(sample: dict[str, Any]) -> tuple[str, str]: + script = Path(sample["script"]) + language = script.parent.name + stem = script.stem + if not stem.startswith("check_") or language not in {"rust", "go", "typescript"}: + raise BehaviorDatasetError( + f"{sample['id']}: guard script does not map to a runtime target: {script}" + ) + return language, stem.removeprefix("check_").replace("_", "-") + + def build_env(sample: dict[str, Any], repo_root: Path, tmp_path: Path) -> dict[str, str]: env = os.environ.copy() log_dir = tmp_path / "logs" @@ -198,6 +287,17 @@ def build_env(sample: dict[str, Any], repo_root: Path, tmp_path: Path) -> dict[s (repo_marker / "repo-path").write_text(str(repo_root), encoding="utf-8") (repo_marker / "execution-mode").write_text("dev-linked-repo\n", encoding="utf-8") env["HOME"] = str(home) + if sample["runner"] == "guard": + candidates = [ + repo_root / "vibeguard-runtime" / "target" / "debug" / "vibeguard-runtime", + repo_root / "vibeguard-runtime" / "target" / "release" / "vibeguard-runtime", + ] + runtime = next((candidate for candidate in candidates if candidate.is_file()), None) + if runtime is None: + raise BehaviorDatasetError( + "guard behavior eval requires a built vibeguard-runtime binary" + ) + env["VIBEGUARD_RUNTIME"] = str(runtime) return env diff --git a/eval/test_behavior_eval.py b/eval/test_behavior_eval.py index 7a964bff..c39bad6f 100644 --- a/eval/test_behavior_eval.py +++ b/eval/test_behavior_eval.py @@ -70,6 +70,44 @@ def test_timeout_stream_text_decodes_bytes(self) -> None: self.assertEqual(run_behavior_eval.timeout_stream_text(b"partial\n"), "partial\n") self.assertEqual(run_behavior_eval.timeout_stream_text(None), "") + def test_guard_fixture_runner_materializes_files_and_builds_command(self) -> None: + sample = { + "id": "guard-sample", + "runner": "guard", + "script": "guards/rust/check_unwrap_in_prod.sh", + "payload": { + "files": {"src/main.rs": "fn main() {}\n"}, + "args": ["--strict"], + }, + } + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + repo_root = tmp_path / "repo" + repo_root.mkdir() + fixture_root = run_behavior_eval.materialize_guard_fixture(sample, tmp_path) + command = run_behavior_eval.build_command(sample, repo_root, fixture_root) + + self.assertEqual( + (fixture_root / "src/main.rs").read_text(encoding="utf-8"), + "fn main() {}\n", + ) + self.assertEqual(command[-2:], ["--strict", str(fixture_root)]) + self.assertEqual( + run_behavior_eval.guard_runtime_target(sample), + ("rust", "unwrap-in-prod"), + ) + + def test_guard_fixture_rejects_parent_traversal(self) -> None: + sample = { + "id": "guard-traversal", + "runner": "guard", + "payload": {"files": {"../outside.rs": "fn main() {}\n"}}, + } + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(run_behavior_eval.BehaviorDatasetError): + run_behavior_eval.materialize_guard_fixture(sample, Path(tmp)) + def test_missing_required_coverage_reduces_score_and_fails(self) -> None: samples = [ { diff --git a/guards/go/CLAUDE.md b/guards/go/CLAUDE.md index 39a45204..fcef0576 100644 --- a/guards/go/CLAUDE.md +++ b/guards/go/CLAUDE.md @@ -1,6 +1,8 @@ # guards/go/ directory -Go language guard script to perform static mode detection on Go projects. +Go language guard compatibility scripts. Detection is implemented by +`vibeguard-runtime scan go `; each `check_*.sh` file is a thin +exec shim for existing callers. ## Script list @@ -10,15 +12,14 @@ Go language guard script to perform static mode detection on Go projects. | `check_goroutine_leak.sh` | GO-02 | Goroutine leak risk (go func without exit mechanism) | | `check_defer_in_loop.sh` | GO-08 | Defer in loop (resource leak) | -## common.sh usage +## Runtime shim -All scripts introduce shared functions through `source common.sh`: -- `list_go_files ` — List .go files (prefer git ls-files, exclude vendor/) -- `parse_guard_args "$@"` — parses --strict and target_dir -- `create_tmpfile` — Create an automatically cleaned temporary file +All scripts source `runtime-shim.sh` and call `run_runtime_guard`. Do not add +detection logic, diff parsing, or a fallback scanner to shell. `common.sh` +exists only as a deprecated compatibility entrypoint. ## Output format ``` -[GO-XX] file:line problem description. Repair: specific repair methods +[GO-XX] file:line: problem description. Repair: specific repair methods ``` diff --git a/guards/go/check_defer_in_loop.sh b/guards/go/check_defer_in_loop.sh index c47d3cfa..4c2a49bd 100755 --- a/guards/go/check_defer_in_loop.sh +++ b/guards/go/check_defer_in_loop.sh @@ -1,140 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Go Guard: detect defer inside loop (GO-08) -# -# defer within a loop is not executed at the end of each iteration, but when the function returns. -# This can lead to resource leaks (file handles, database connections, etc. are not released until the end of the loop). -# -# Usage: -# bash check_defer_in_loop.sh [target_dir] -# bash check_defer_in_loop.sh --strict [target_dir] -# -#Exclude: -# - *_test.go test file -# - vendor/ directory - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) - -# --- Baseline/diff filtering: only report problems on new lines (pre-commit or --baseline mode) --- -_LINEMAP="" -_IN_DIFF_MODE=false -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] || [[ -n "${BASELINE_COMMIT:-}" ]]; then - _IN_DIFF_MODE=true - _LINEMAP=$(create_tmpfile) - vg_build_diff_linemap "$_LINEMAP" '\.go$' -fi - -# Use awk to detect the defer in the for loop, and then perform linemap filtering on the results -# Output format (tab separated): [GO-08] filepath\tdefer_linenum\tfor_linenum\tcontent -_AWK_RAW=$(create_tmpfile) -list_go_files "${TARGET_DIR}" \ - | { grep -vE '(_test\.go$|/vendor/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - awk ' - BEGIN { total_depth=0; loop_depth=0; flit_depth=0 } - { - line = $0 - - # 1. Detect for loop start (POSIX [[:space:]] for BSD awk compat) - if (match(line, /^[[:space:]]*for([[:space:]]|$)/)) { - loop_depth++ - loop_starts[loop_depth] = NR - loop_brace_base[loop_depth] = total_depth - } - - # 2. Detect func literal inside loop (exclude defer-prefixed lines) - # go func() { ... } / func() { ... } -> safe scope for defer - # defer func() { ... }() -> defer is at loop level, NOT safe - is_flit = 0 - if (loop_depth > 0 && !match(line, /^[[:space:]]*defer[[:space:]]/)) { - if (match(line, /[^[:alnum:]_]func[[:space:]]*\(/) || match(line, /^[[:space:]]*func[[:space:]]*\(/)) { - is_flit = 1 - } - } - if (is_flit) { - flit_depth++ - flit_base[flit_depth] = total_depth - } - - # 3. Detect defer in loop but NOT inside func literal - if (match(line, /^[[:space:]]*defer[[:space:]]/) && loop_depth > 0 && flit_depth == 0) { - printf "[GO-08] %s\t%d\t%d\t%s\n", FILENAME, NR, loop_starts[loop_depth], line - } - - # 4. Count braces via gsub (handles multiple { } per line) - tmp = line; opens = gsub(/\{/, "", tmp) - tmp = line; closes = gsub(/\}/, "", tmp) - total_depth += opens - closes - - # 5. Exit func literals whose scope has closed - while (flit_depth > 0 && total_depth <= flit_base[flit_depth]) { - flit_depth-- - } - - # 6. Exit loops whose scope has closed - while (loop_depth > 0 && total_depth <= loop_brace_base[loop_depth]) { - loop_depth-- - } - } - ' "${f}" 2>/dev/null || true - fi - done \ - > "${_AWK_RAW}" || true - -# Linemap filtering: extract file:linenum and only keep new lines -# When _IN_DIFF_MODE=true and linemap is empty (only delete lines), pass silently instead of full scan. -# Check whether the defer line or the starting line of the for loop is a new line (capture the "existing defer is wrapped by a new for" situation). -if [[ "$_IN_DIFF_MODE" == true ]]; then - while IFS= read -r result_line; do - [[ -z "$result_line" ]] && continue - stripped="${result_line#\[GO-08\] }" - filepath=$(printf '%s' "$stripped" | cut -f1) - defer_linenum=$(printf '%s' "$stripped" | cut -f2) - for_linenum=$(printf '%s' "$stripped" | cut -f3) - content=$(printf '%s' "$stripped" | cut -f4-) - if [[ -n "$defer_linenum" ]] && [[ -n "$_LINEMAP" ]] && { - grep -qxF "${filepath}:${defer_linenum}" "$_LINEMAP" 2>/dev/null || \ - grep -qxF "${filepath}:${for_linenum}" "$_LINEMAP" 2>/dev/null - }; then - echo "[GO-08] ${filepath}:${defer_linenum} ${content}" - fi - done < "${_AWK_RAW}" > "${TMPFILE}" || true -else - while IFS= read -r result_line; do - [[ -z "$result_line" ]] && continue - stripped="${result_line#\[GO-08\] }" - filepath=$(printf '%s' "$stripped" | cut -f1) - defer_linenum=$(printf '%s' "$stripped" | cut -f2) - content=$(printf '%s' "$stripped" | cut -f4-) - echo "[GO-08] ${filepath}:${defer_linenum} ${content}" - done < "${_AWK_RAW}" > "${TMPFILE}" || true -fi - -apply_suppression_filter "${TMPFILE}" -cat "${TMPFILE}" -FOUND=$(wc -l < "${TMPFILE}" | tr -d ' ') - -echo "" -if [[ ${FOUND} -eq 0 ]]; then - echo "No defer-in-loop issues found." -else - echo "Found ${FOUND} defer-in-loop issue(s)." - echo "" - echo "Repair method:" - echo " 1. Extract the logic of defer into an independent function: " - echo " for _, item := range items {" - echo " if err := processItem(item); err != nil { ... }" - echo " }" - echo " func processItem(item Item) error {" - echo " f, err := os.Open(item.Path)" - echo " if err != nil { return err }" - echo " defer f.Close() // Correctly released at the end of the function" - echo " ..." - echo " }" - echo "2. Manually close resources at the end of each iteration (not recommended, easy to miss)" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical GO-08 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard go defer-in-loop "$@" diff --git a/guards/go/check_error_handling.sh b/guards/go/check_error_handling.sh index c793aa1d..34e70850 100755 --- a/guards/go/check_error_handling.sh +++ b/guards/go/check_error_handling.sh @@ -1,138 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Go Guard: Detect unchecked error return values (GO-01) -# -# Use ast-grep AST level scanning to accurately identify the `_ = func()` assignment statement. -# ast-grep automatically distinguishes the code structure and will not falsely report the _ variable in the for range clause. -# -# Usage: -# bash check_error_handling.sh [target_dir] -# bash check_error_handling.sh --strict [target_dir] -# -#Exclude: -# - *_test.go test file -# - vendor/ directory - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -RULES_DIR="${SCRIPT_DIR}/../ast-grep-rules" - -# --- Baseline/diff filtering: only report problems on new lines (pre-commit or --baseline mode) --- -_LINEMAP="" -_IN_DIFF_MODE=false -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] || [[ -n "${BASELINE_COMMIT:-}" ]]; then - _IN_DIFF_MODE=true - _LINEMAP=$(create_tmpfile) - vg_build_diff_linemap "$_LINEMAP" '\.go$' -fi - -_USE_GREP_FALLBACK=false - -if command -v ast-grep >/dev/null 2>&1; then - if ! command -v python3 >/dev/null 2>&1; then - echo "[GO-01] WARN: python3 is not available, use grep fallback" >&2 - _USE_GREP_FALLBACK=true - else - # staged mode: only scan staged Go files to avoid full warehouse scanning blocking irrelevant submissions - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - _ASG_TARGETS=() - while IFS= read -r _VG_TARGET; do - [[ -n "$_VG_TARGET" ]] && _ASG_TARGETS+=("$_VG_TARGET") - done < <(list_go_files "${TARGET_DIR}") - else - _ASG_TARGETS=("${TARGET_DIR}") - fi - - if [[ ${#_ASG_TARGETS[@]} -gt 0 ]]; then - _ASG_TMPOUT=$(create_tmpfile) - if ast-grep scan \ - --rule "${RULES_DIR}/go-01-error.yml" \ - --json \ - "${_ASG_TARGETS[@]}" > "${_ASG_TMPOUT}"; then - VG_DIFF_LINEMAP="$_LINEMAP" VG_IN_DIFF_MODE="$_IN_DIFF_MODE" python3 -c ' -import json, sys, re, os - -TEST_PATH = re.compile(r"(_test\.go$|(^|/)vendor/)") -linemap_path = os.environ.get("VG_DIFF_LINEMAP", "") -in_diff_mode = os.environ.get("VG_IN_DIFF_MODE", "false") == "true" -added_set = set() -if linemap_path and os.path.isfile(linemap_path): - with open(linemap_path) as lm: - for entry in lm: - added_set.add(entry.strip()) - -data = sys.stdin.read().strip() -if not data: - sys.exit(0) -try: - matches = json.loads(data) -except Exception as e: - print("[GO-01] WARN: ast-grep JSON parsing failed: " + str(e), file=sys.stderr) - sys.exit(1) -for m in matches: - f = m.get("file", "") - if TEST_PATH.search(f): - continue - line = m.get("range", {}).get("start", {}).get("line", 0) + 1 - # Baseline filtering: only report problems on new lines in diff. - # Use in_diff_mode instead of added_set non-empty to determine the diff mode. - # Avoid falling back to full scan when added_set is empty when only deleting rows. - if in_diff_mode and (f + ":" + str(line)) not in added_set: - continue - msg = m.get("message", "error return value is discarded") - print("[GO-01] " + f + ":" + str(line) + " " + msg) -' < "${_ASG_TMPOUT}" > "${TMPFILE}" || { - echo "[GO-01] WARN: python3 processing failed, use grep fallback" >&2 - _USE_GREP_FALLBACK=true - } - else - echo "[GO-01] WARN: ast-grep scan failed (the rule file may be missing), use grep fallback" >&2 - _USE_GREP_FALLBACK=true - fi - fi - fi -else - _USE_GREP_FALLBACK=true -fi - -if [[ "$_USE_GREP_FALLBACK" == true ]]; then - list_go_files "${TARGET_DIR}" \ - | { grep -vE '(_test\.go$|/vendor/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - grep -nE '^\s*_\s*(,\s*_)?\s*[:=]+' "${f}" 2>/dev/null \ - | grep -vE 'for\s+.*range' \ - | grep -vE ',\s*(ok|found|exists)\s*:?=' \ - | while IFS= read -r hit; do - LINE_NUM=$(echo "$hit" | cut -d: -f1) - # Baseline filtering: only report problems on new lines - if [[ "$_IN_DIFF_MODE" == true ]]; then - grep -qxF "${f}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null || continue - fi - echo "${f}:${hit}" - done - fi - done \ - | grep -v '^\s*//' \ - | awk '!/^[[:space:]]*\/\// { print "[GO-01] " $0 }' \ - > "${TMPFILE}" || true -fi - -apply_suppression_filter "${TMPFILE}" -sed 's/^\[GO-01\] /[GO-01] [auto-fix] [this-line] OBSERVATION: /' "${TMPFILE}" -FOUND=$(wc -l < "${TMPFILE}" | tr -d ' ') - -echo "" -if [[ ${FOUND} -eq 0 ]]; then - echo "No unchecked error returns found." -else - echo "Found ${FOUND} unchecked error return(s)." - echo "" - echo "SCOPE: this-line only — do not modify function signatures or upstream callers" - echo "ACTION: REVIEW" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical GO-01 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard go error-handling "$@" diff --git a/guards/go/check_goroutine_leak.sh b/guards/go/check_goroutine_leak.sh index d4a8c154..6659c800 100755 --- a/guards/go/check_goroutine_leak.sh +++ b/guards/go/check_goroutine_leak.sh @@ -1,127 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Go Guard: Detecting goroutine leak risks (GO-02) -# -# Scan the Go code for goroutines without exit mechanism. -# Usage: -# bash check_goroutine_leak.sh [target_dir] -# bash check_goroutine_leak.sh --strict [target_dir] -# -#Detection mode: -# - There is no select/context/return/break/ticker in go func() -# - for {} There is no exit condition in the infinite loop -# -#Exclude: -# - *_test.go test file -# - vendor/ directory - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) - -# --- Baseline/diff filtering: only report problems on new lines (pre-commit or --baseline mode) --- -_LINEMAP="" -_LINEMAP_DELETED="" -_IN_DIFF_MODE=false -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] || [[ -n "${BASELINE_COMMIT:-}" ]]; then - _IN_DIFF_MODE=true - _LINEMAP=$(create_tmpfile) - _LINEMAP_DELETED=$(create_tmpfile) - vg_build_diff_linemap "$_LINEMAP" '\.go$' "$_LINEMAP_DELETED" -fi - -# _in_diff_mode: Check whether it is in diff mode, does not rely on linemap being non-empty. -# When only deleting lines, the linemap is empty. In this case, it should pass silently instead of falling back to full scan. -_in_diff_mode() { - [[ "$_IN_DIFF_MODE" == true ]] -} - -list_go_files "${TARGET_DIR}" \ - | { grep -vE '(_test\.go$|/vendor/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - # Detect go func() startup, but exclude goroutine with exit mechanism - while IFS= read -r match; do - [[ -z "$match" ]] && continue - LINE_NUM=$(echo "$match" | cut -d: -f1) - # Baseline filtering: report if the goroutine startup line is new or the exit mechanism is deleted - if _in_diff_mode; then - if ! grep -qxF "${f}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null; then - _body_del=false - if [[ -s "$_LINEMAP_DELETED" ]]; then - while IFS= read -r _dl; do - case "$_dl" in - "${f}:"*) - _dl_num="${_dl#"${f}:"}" - if [[ "$_dl_num" -ge "$LINE_NUM" ]] && [[ "$_dl_num" -le "$((LINE_NUM+20))" ]]; then - _body_del=true; break - fi ;; - esac - done < "$_LINEMAP_DELETED" - fi - [[ "$_body_del" == true ]] || continue - fi - fi - # Read the last 20 lines of goroutine and check if there is an exit mechanism - HAS_EXIT=$(sed -n "${LINE_NUM},$((LINE_NUM+20))p" "${f}" 2>/dev/null \ - | grep -cE '(ctx\.Done|context\.WithCancel|wg\.(Add|Done|Wait)|errgroup|<-done|<-quit|<-stop|time\.After|ticker)' 2>/dev/null || true) - if [[ "${HAS_EXIT:-0}" -eq 0 ]]; then - echo "${f}:${match}" - fi - done < <(grep -nE '^\s*go\s+(func\s*\(|[a-zA-Z])' "${f}" 2>/dev/null || true) - fi - done \ - | awk '{ print "[GO-02] " $0 }' \ - > "${TMPFILE}" || true - -# Round 2: Detect for {} or for { infinite loop (high risk) -list_go_files "${TARGET_DIR}" \ - | { grep -vE '(_test\.go$|/vendor/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - while IFS= read -r match; do - [[ -z "$match" ]] && continue - LINE_NUM=$(echo "$match" | cut -d: -f1) - # Baseline filtering: report if the for{} line is new or the exit mechanism is deleted - if _in_diff_mode; then - if ! grep -qxF "${f}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null; then - _body_del=false - if [[ -s "$_LINEMAP_DELETED" ]]; then - while IFS= read -r _dl; do - case "$_dl" in - "${f}:"*) - _dl_num="${_dl#"${f}:"}" - if [[ "$_dl_num" -ge "$LINE_NUM" ]] && [[ "$_dl_num" -le "$((LINE_NUM+20))" ]]; then - _body_del=true; break - fi ;; - esac - done < "$_LINEMAP_DELETED" - fi - [[ "$_body_del" == true ]] || continue - fi - fi - echo "${f}:${match}" - done < <(grep -nE '^\s*for\s*\{' "${f}" 2>/dev/null || true) - fi - done \ - | awk '{ print "[GO-02/loop] " $0 }' \ - >> "${TMPFILE}" || true - -apply_suppression_filter "${TMPFILE}" -cat "${TMPFILE}" -FOUND=$(wc -l < "${TMPFILE}" | tr -d ' ') - -echo "" -if [[ ${FOUND} -eq 0 ]]; then - echo "No goroutine leak risks found." -else - echo "Found ${FOUND} goroutine launch/infinite loop site(s) to review." - echo "" - echo "Repair method:" - echo "1. Pass in context.Context and exit through <-ctx.Done()" - echo "2. Use errgroup.Group to manage goroutine life cycle" - echo "3. The for {} loop must have select + exit branch" - echo "4. Make sure each go func() has a clear exit path" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical GO-02 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard go goroutine-leak "$@" diff --git a/guards/go/common.sh b/guards/go/common.sh index f34931b5..3b97e8e1 100755 --- a/guards/go/common.sh +++ b/guards/go/common.sh @@ -1,359 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Go Guards — Shared function library -# -# All Go guard scripts are introduced through source common.sh to eliminate duplicate code. -# Provide: list_go_files, parameter parsing, temporary file management +# Deprecated compatibility entrypoint. Guard behavior lives in vibeguard-runtime. -set -euo pipefail - -# List .go source files -# Priority: VIBEGUARD_STAGED_FILES (pre-commit mode, only scan staged) > git ls-files > find -list_go_files() { - local dir="$1" - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - grep '\.go$' "${VIBEGUARD_STAGED_FILES}" || true - elif git -C "${dir}" rev-parse --is-inside-work-tree &>/dev/null; then - git -C "${dir}" ls-files '*.go' | while IFS= read -r f; do echo "${dir}/${f}"; done - else - find "${dir}" -name '*.go' -not -path '*/vendor/*' -not -path '*/.git/*' - fi -} - -# Parse --strict / --baseline flags and target_dir -# Usage: parse_guard_args "$@" -# Set variables: TARGET_DIR, STRICT, BASELINE_COMMIT -parse_guard_args() { - TARGET_DIR="." - STRICT=false - BASELINE_COMMIT="" - local positional_count=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --strict) - STRICT=true - ;; - --baseline) - shift - if [[ $# -eq 0 || -z "${1:-}" ]]; then - echo "Error: --baseline requires a commit argument" >&2 - return 1 - fi - BASELINE_COMMIT="$1" - ;; - --help|-h) - echo "Usage: $0 [--strict] [--baseline ] [target_dir]" >&2 - return 1 - ;; - --*) - echo "Unknown option: $1" >&2 - return 1 - ;; - *) - positional_count=$((positional_count + 1)) - if [[ ${positional_count} -gt 1 ]]; then - echo "Too many positional arguments: $*" >&2 - return 1 - fi - TARGET_DIR="$1" - ;; - esac - shift - done - # Resolve to absolute canonical path (disambiguation of . / relative path / macOS /var→/private/var symbolic link) - TARGET_DIR="$(cd "${TARGET_DIR}" 2>/dev/null && pwd -P || echo "${TARGET_DIR}")" - - # Verify that baseline commit exists to prevent invalid commits from causing empty linemaps and silently pass all checks - if [[ -n "$BASELINE_COMMIT" ]]; then - if ! git -C "${TARGET_DIR}" rev-parse --verify "${BASELINE_COMMIT}" >/dev/null 2>&1; then - echo "Error: --baseline '${BASELINE_COMMIT}' is not a valid commit in '${TARGET_DIR}'" >&2 - return 1 - fi - fi -} - -# vg_build_diff_linemap OUTPUT_FILE [EXT_FILTER] [OUT_DELETED] -# -# Build diff and add new line number index file (each line format: "filepath:linenum"). -# Used for baseline scanning: only new problems added to this diff will be reported, existing problems will not be reported. -# -# Optional third parameter OUT_DELETED: file path, write the new side line number near the deleted line (the format is the same as OUTPUT_FILE). -# Used to detect "delete exit mechanism" scenarios - goroutine lines remain unchanged but ctx.Done etc. are deleted. -# -# pre-commit mode (VIBEGUARD_STAGED_FILES is set): read git diff --cached -# baseline mode (BASELINE_COMMIT is set): read git diff BASELINE..HEAD -# -# Returns: 0 = success (linemap may be empty); 1 = not in any diff mode -vg_build_diff_linemap() { - local out="$1" - local ext_filter="${2:-}" - local out_deleted="${3:-}" - : > "$out" - [[ -n "$out_deleted" ]] && : > "$out_deleted" - - command -v python3 >/dev/null 2>&1 || return 1 - - local staged="${VIBEGUARD_STAGED_FILES:-}" - local baseline="${BASELINE_COMMIT:-}" - [[ -z "$staged" && -z "$baseline" ]] && return 1 - - VG_STAGED="$staged" VG_BASELINE="$baseline" VG_EXT="$ext_filter" VG_OUT="$out" VG_TARGET_DIR="${TARGET_DIR:-.}" VG_OUT_DELETED="${out_deleted}" \ - python3 -c ' -import sys, re, subprocess, os - -staged = os.environ.get("VG_STAGED", "") -baseline = os.environ.get("VG_BASELINE", "") -ext_filter = os.environ.get("VG_EXT", "") -out_path = os.environ.get("VG_OUT", "") -out_deleted = os.environ.get("VG_OUT_DELETED", "") -target_dir = os.environ.get("VG_TARGET_DIR", ".") - -EXIT_PATTERN = re.compile( - r"ctx\.Done|context\.WithCancel|wg\.(?:Add|Done|Wait)|errgroup|<-done|<-quit|<-stop|time\.After|ticker" -) - -_git_root_cache = {} - -def get_git_root(dirpath): - """Detect git root from a directory; returns canonical absolute path.""" - key = os.path.realpath(dirpath) - if key not in _git_root_cache: - r = subprocess.run( - ["git", "-C", key, "rev-parse", "--show-toplevel"], - capture_output=True, text=True - ) - _git_root_cache[key] = os.path.realpath(r.stdout.strip()) if r.returncode == 0 else "" - return _git_root_cache[key] - -_rename_cache = {} - -def rename_source(git_root, fpath): - """Map a renamed file to its old path. A pathspec-limited diff cannot pair - a rename (the old path is outside the pathspec), so without this a renamed - file shows as fully added and pre-existing lines look new.""" - key = (git_root, baseline) - if key not in _rename_cache: - if baseline: - cmd = ["git", "-C", git_root, "diff", "-M", "--name-status", "-z", baseline + "..HEAD"] - else: - cmd = ["git", "-C", git_root, "diff", "--cached", "-M", "--name-status", "-z"] - r = subprocess.run(cmd, capture_output=True) - mapping = {} - fields = r.stdout.split(b"\0") - i = 0 - while i + 1 < len(fields): - status = os.fsdecode(fields[i]) - first_path = os.fsdecode(fields[i + 1]) - i += 2 - if status.startswith(("R", "C")) and i < len(fields): - second_path = os.fsdecode(fields[i]) - i += 1 - if status.startswith("R"): - mapping[os.path.realpath(os.path.join(git_root, second_path))] = first_path - _rename_cache[key] = mapping - old = _rename_cache[key].get(os.path.realpath(fpath), "") - if old and _go_guard_path_state(os.path.join(git_root, old)) != _go_guard_path_state(fpath): - return "" - return old - -def _go_guard_path_state(path): - n = path.replace("\\", "/") - base = n.rsplit("/", 1)[-1] - normalized = "/" + n.strip("/") + "/" - return (base.endswith(".go"), base.endswith("_test.go"), "/vendor/" in normalized) - -def iter_files(): - if staged and os.path.isfile(staged): - with open(staged) as fh: - for line in fh: - p = os.path.realpath(line.strip()) - if p and (not ext_filter or re.search(ext_filter, p)): - yield p - elif baseline: - root = get_git_root(target_dir) - if not root: - return - result = subprocess.run( - ["git", "-C", root, "diff", "--name-only", baseline + "..HEAD"], - capture_output=True, text=True - ) - for fname in result.stdout.splitlines(): - if fname and (not ext_filter or re.search(ext_filter, fname)): - yield os.path.join(root, fname) - -def diff_linenos(fpath): - """Return (added_nums, deleted_exit_nums). - - added_nums: new-side line numbers that were added. - deleted_exit_nums: new-side positions where exit-mechanism lines were deleted. - """ - file_dir = os.path.dirname(fpath) or "." - git_root = get_git_root(file_dir) - if not git_root: - return [], [] - old = rename_source(git_root, fpath) - if baseline: - if old: - cmd = ["git", "-C", git_root, "diff", "-M", "-U0", baseline + "..HEAD", "--", old, fpath] - else: - cmd = ["git", "-C", git_root, "diff", "-U0", baseline + "..HEAD", "--", fpath] - else: - if old: - cmd = ["git", "-C", git_root, "diff", "--cached", "-M", "-U0", "--", old, fpath] - else: - cmd = ["git", "-C", git_root, "diff", "--cached", "-U0", "--", fpath] - result = subprocess.run(cmd, capture_output=True, text=True) - cur = 0 - added_nums = [] - deleted_exit_nums = [] - for line in result.stdout.splitlines(): - if line.startswith("@@"): - m = re.search(r"\+(\d+)(?:,(\d+))?", line) - if m: - cur = int(m.group(1)) - cnt = int(m.group(2)) if m.group(2) is not None else 1 - if cnt == 0: - cur = 0 - elif line.startswith("+++"): - continue - elif line.startswith("+"): - if cur > 0: - added_nums.append(cur) - cur += 1 - elif line.startswith("-"): - # Deleted line: cur is NOT incremented. - # Record new-side position if it matches exit pattern. - if cur > 0 and EXIT_PATTERN.search(line[1:]): - deleted_exit_nums.append(cur) - elif not line.startswith("\\\\"): - if cur > 0: - cur += 1 - return added_nums, deleted_exit_nums - -with open(out_path, "w") as out_f: - del_f = open(out_deleted, "w") if out_deleted else None - try: - for fpath in iter_files(): - if not os.path.isfile(fpath): - continue - added, deleted_exits = diff_linenos(fpath) - for n in added: - out_f.write(fpath + ":" + str(n) + "\n") - if del_f is not None: - for n in deleted_exits: - del_f.write(fpath + ":" + str(n) + "\n") - finally: - if del_f is not None: - del_f.close() -' - local _py_rc=$? - if [[ $_py_rc -ne 0 ]]; then - echo "Error: vg_build_diff_linemap failed (exit ${_py_rc})" >&2 - return $_py_rc - fi - return 0 -} - -# Temporary file cleaning directory: all guards share the same cleaning trap -_VG_TMPDIR="$(mktemp -d)" - -_vg_cleanup() { - [[ -n "$_VG_TMPDIR" && -d "$_VG_TMPDIR" ]] && rm -rf "$_VG_TMPDIR" || true -} -trap '_vg_cleanup' EXIT - -#Create temporary files and automatically clean them when the script exits -# Usage: TMPFILE=$(create_tmpfile) -create_tmpfile() { - mktemp "$_VG_TMPDIR/vg.XXXXXX" -} - -# --------------------------------------------------------------------------- -# Inline suppression: // vibeguard-disable-next-line [-- reason] -# --------------------------------------------------------------------------- - -# check_suppression FILE LINE_NUM RULE_ID -# Returns 0 (suppressed) if the line before LINE_NUM has a disable comment for RULE_ID. -# In pre-commit mode (VIBEGUARD_STAGED_FILES set) reads from staged content so that -# unstaged suppression comments cannot bypass checks on staged violations. -check_suppression() { - local file="$1" line_num="$2" rule_id="$3" - local prev=$((line_num - 1)) - [[ $prev -lt 1 ]] && return 1 - local prev_line - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]]; then - # Pre-commit mode: read from staged content, not the working tree. - # git show ":path" requires a path relative to the repo root. - # Use python3 realpath resolution to handle macOS /var→/private/var symlinks. - local rel_file="$file" - if [[ "$file" == /* ]]; then - local git_root - git_root=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [[ -n "$git_root" ]]; then - if command -v python3 >/dev/null 2>&1; then - rel_file=$(python3 -c "import os,sys; f=os.path.realpath(sys.argv[1]); r=os.path.realpath(sys.argv[2]); print(f[len(r)+1:] if f.startswith(r+os.sep) else sys.argv[1])" "$file" "$git_root" 2>/dev/null || echo "$file") - else - [[ "$file" == "$git_root/"* ]] && rel_file="${file#$git_root/}" - fi - fi - fi - prev_line=$(git show ":${rel_file}" 2>/dev/null | sed -n "${prev}p" || true) - else - [[ ! -f "$file" ]] && return 1 - prev_line=$(sed -n "${prev}p" "$file" 2>/dev/null || true) - fi - if printf '%s' "$prev_line" \ - | grep -qE "^[[:space:]]*//[[:space:]]*vibeguard-disable-next-line[[:space:]]+${rule_id}([[:space:]]|--|$)"; then - return 0 - fi - return 1 -} - -# apply_suppression_filter TMPFILE -# Reads findings from TMPFILE in format "[RULE-ID] file:line ..." and removes those -# suppressed by a vibeguard-disable-next-line comment on the preceding source line. -# Modifies TMPFILE in-place. -apply_suppression_filter() { - local tmpfile="$1" - [[ ! -s "$tmpfile" ]] && return 0 - - local filtered_file - filtered_file=$(create_tmpfile) - - while IFS= read -r finding; do - local rule_id - rule_id=$(printf '%s' "$finding" | sed -n 's/^\[\([^]]*\)\].*/\1/p') - - if [[ -z "$rule_id" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - local rest - rest="${finding#\[${rule_id}\] }" - - local line_num - line_num=$(printf '%s' "$rest" | grep -oE ':[0-9]+' | head -1 | tr -d ':' || true) - - if [[ -z "$line_num" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - local file_path - file_path=$(printf '%s' "$rest" | sed "s/:${line_num}.*$//") - - if [[ ! -f "$file_path" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - if check_suppression "$file_path" "$line_num" "$rule_id"; then - continue # suppressed — skip this finding - fi - - printf '%s\n' "$finding" >> "$filtered_file" - done < "$tmpfile" - - cp "$filtered_file" "$tmpfile" -} +source "$(dirname "${BASH_SOURCE[0]}")/runtime-shim.sh" diff --git a/guards/go/runtime-shim.sh b/guards/go/runtime-shim.sh new file mode 100755 index 00000000..49db14f3 --- /dev/null +++ b/guards/go/runtime-shim.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_runtime_guard() { + local language="$1" rule="$2" + shift 2 + local script_dir repo_dir candidate + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + repo_dir="$(cd "${script_dir}/../.." && pwd)" + for candidate in \ + "${VIBEGUARD_RUNTIME:-}" \ + "${repo_dir}/vibeguard-runtime/target/debug/vibeguard-runtime" \ + "${repo_dir}/vibeguard-runtime/target/release/vibeguard-runtime" \ + "${HOME:-}/.vibeguard/installed/bin/vibeguard-runtime"; do + if [[ -n "${candidate}" && -f "${candidate}" && -x "${candidate}" ]]; then + exec "${candidate}" scan "${language}" "${rule}" "$@" + fi + done + printf '%s\n' "VIBEGUARD ERROR: vibeguard-runtime not found. Run setup.sh or cargo build --release --manifest-path vibeguard-runtime/Cargo.toml." >&2 + exit 2 +} diff --git a/guards/rust/CLAUDE.md b/guards/rust/CLAUDE.md index f934d289..b2778a58 100644 --- a/guards/rust/CLAUDE.md +++ b/guards/rust/CLAUDE.md @@ -1,6 +1,8 @@ # guards/rust/ directory -Rust language guard script to perform static pattern detection on Rust projects. +Rust language guard compatibility scripts. Detection is implemented by +`vibeguard-runtime scan rust `; each `check_*.sh` file is a thin +exec shim for existing callers. ## Script list @@ -13,18 +15,18 @@ Rust language guard script to perform static pattern detection on Rust projects. | `check_single_source_of_truth.sh` | RS-12 | Task system dual-track coexistence/multi-state source splitting | | `check_semantic_effect.sh` | RS-13 | Action semantics and side effects are inconsistent | | `check_taste_invariants.sh` | TASTE-* | Harness style code taste constraints (ANSI hardcoded, async unwrap, panic no message) | +| `check_declaration_execution_gap.sh` | RS-14 | Config persistence declared but bypassed at startup | -## common.sh usage +## Runtime shim -All scripts introduce shared functions through `source common.sh`: -- `list_rs_files ` — List .rs files (prefer git ls-files) -- `parse_guard_args "$@"` — parses --strict and target_dir -- `create_tmpfile` — Create an automatically cleaned temporary file +All scripts source `runtime-shim.sh` and call `run_runtime_guard`. Do not add +detection logic, diff parsing, or a fallback scanner to shell. `common.sh` +exists only as a deprecated compatibility entrypoint. ## Output format ``` -[RS-XX] file:line problem description. Repair: specific repair methods +[RS-XX] file:line: problem description. Repair: specific repair methods ``` ## RS-03 Test Code Exclusion Strategy diff --git a/guards/rust/check_declaration_execution_gap.sh b/guards/rust/check_declaration_execution_gap.sh index 47e26210..ae20d6c9 100755 --- a/guards/rust/check_declaration_execution_gap.sh +++ b/guards/rust/check_declaration_execution_gap.sh @@ -1,304 +1,4 @@ #!/usr/bin/env bash -# RS-14: Statement-Perform Gap Detection (ast-grep version) -# -# Detect the case where the Config type is initialized through Default::default() instead of the load() method. -# Use ast-grep AST level scanning to eliminate the full false positive problem of previous grep versions. -# -# Usage: -# bash check_declaration_execution_gap.sh [--strict] [target_dir] - -set -euo pipefail - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" - -if ! command -v ast-grep >/dev/null 2>&1; then - echo "[RS-14] SKIP: ast-grep is not installed (installation method: brew install ast-grep)" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi - exit 0 -fi - -if ! command -v python3 >/dev/null 2>&1; then - echo "[RS-14] SKIP: python3 is not available" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi - exit 0 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RULES_DIR="${SCRIPT_DIR}/../ast-grep-rules" -TMPFILE=$(create_tmpfile) - -TEST_PATH_PATTERN='((^|/)tests[/._]|/test_|_test\.rs$|tests\.rs$|test_helpers\.rs$|(^|/)examples/|(^|/)benches/)' - -# Detect *Config::default() usage (exclude test paths) -# Only reported when the corresponding Config type has a load() method to avoid false positives of legal default-only Config -export VG_TARGET_DIR="${TARGET_DIR}" - -_ASG_TMPOUT=$(create_tmpfile) -if ! ast-grep scan \ - --rule "${RULES_DIR}/rs-14-config-default.yml" \ - --json \ - "${TARGET_DIR}" > "${_ASG_TMPOUT}"; then - echo "[RS-14] WARN: ast-grep scan failed (the rule file may be missing), skipping detection" >&2 - if [[ "${STRICT}" == true ]]; then - exit 1 - fi - exit 0 -fi - -python3 -c ' -import json, sys, re, subprocess, os - -TEST_PATH = re.compile(r"((^|/)tests[/._]|/test_|_test\.rs$|tests\.rs$|test_helpers\.rs$|(^|/)examples/|(^|/)benches/)") -target_dir = os.environ.get("VG_TARGET_DIR", ".") - -data = sys.stdin.read().strip() -if not data: - sys.exit(0) -try: - matches = json.loads(data) -except Exception as e: - print("[RS-14] WARN: ast-grep JSON parsing failed: " + str(e), file=sys.stderr) - sys.exit(1) - -load_cache = {} - -def has_load_method(full_type_path, search_dir): - """Check if the Config type has a load() method. - - full_type_path preserves module namespace (e.g. "config::AppConfig") so that - same-named Config types in different modules do not pollute each other cache - entries or impl-file search results. - """ - if full_type_path in load_cache: - return load_cache[full_type_path] - - bare_type = full_type_path.split("::")[-1] - module_parts = full_type_path.split("::")[:-1] - - try: - all_impl_files = subprocess.run( - ["grep", "-rEl", r"impl.*\b" + re.escape(bare_type) + r"\b", "--include=*.rs", search_dir], - capture_output=True, text=True - ).stdout.strip().splitlines() - - # Narrow to files whose path is consistent with the module namespace to - # avoid cross-module pollution when multiple modules define same-named - # Config types. Fall back to the full list only when filtering yields - # nothing (e.g. re-exported types with path aliases). - if module_parts: - module_suffix = os.path.join(*module_parts) - narrowed = [f for f in all_impl_files if module_suffix in f] - impl_files = narrowed if narrowed else all_impl_files - else: - impl_files = all_impl_files - - # Match impl blocks specifically for this Config type (inherent or trait impls). - # Brace-count to stay within the block, preventing false positives from - # other types defined in the same file. - # Match impl header line: allow { on same line, or where clause, or bare line-break. - # [^<>]*(?:<[^<>]*>[^<>]*)* handles one level of nested generics in the type params. - _nested_generic = r"[^<>]*(?:<[^<>]*>[^<>]*)*" - impl_pat = re.compile( - r"^\s*impl(?:<" + _nested_generic + r">)?\s+(?:[\w:]+(?:<" + _nested_generic + r">)?\s+for\s+)?(?:\w+::)*" - + re.escape(bare_type) + r"(?:<" + _nested_generic + r">)?\s*(?:\{|where\b|$)" - ) - load_pat = re.compile(r"\bfn\s+load\s*\(") - for impl_file in impl_files: - try: - with open(impl_file, "r", errors="ignore") as fh: - lines = fh.readlines() - i = 0 - while i < len(lines): - if impl_pat.search(lines[i]): - depth = lines[i].count("{") - lines[i].count("}") - j = i + 1 - # Handle where clause / line-broken brace: scan until we enter the block. - while j < len(lines) and depth <= 0: - depth += lines[j].count("{") - lines[j].count("}") - j += 1 - # Scan inside the impl block for fn load. - while j < len(lines) and depth > 0: - depth += lines[j].count("{") - lines[j].count("}") - if load_pat.search(lines[j]): - load_cache[full_type_path] = True - return True - j += 1 - i += 1 - except Exception: - pass - except Exception: - pass - load_cache[full_type_path] = False - return False - -for m in matches: - f = m.get("file", "") - if TEST_PATH.search(f): - continue - text = m.get("text", "").strip() - # Extract full qualified path before ::default(), preserving module namespace. - # Handles plain, path-qualified, and turbofish forms: - # AppConfig::default() - # config::AppConfig::default() - # AppConfig::::default() (turbofish pattern in yml) - # config::AppConfig::::default() - config_match = re.search(r"((?:\w+::)*\w+)::(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>::)?default\(\)\s*$", text) - if not config_match: - continue - full_type_path = config_match.group(1) # e.g. "config::AppConfig" or "AppConfig" - bare_type = full_type_path.split("::")[-1] - if not bare_type.endswith("Config"): - continue - if not has_load_method(full_type_path, target_dir): - continue - line = m.get("range", {}).get("start", {}).get("line", 0) + 1 - msg = m.get("message", "") - print("[RS-14] " + f + ":" + str(line) + " " + msg + " (" + text + ")") -' < "${_ASG_TMPOUT}" > "$TMPFILE" || { - echo "[RS-14] WARN: python3 processing failed, skipping detection" >&2 - if [[ "${STRICT}" == true ]]; then - exit 1 - fi - exit 0 -} - -# RS-14 persistence-method check: verify save/load/persist/restore are called at startup. -# Fix (Issue 3): scan src/bin/*.rs alongside main.rs/lib.rs; remove head -5 truncation -# so workspace members with >5 startup files or bin/ entrypoints are fully covered. -# Fix (Issue 1): per-(type,method) tracking — python3 extracts the impl-type name for -# each declaration so that an unrelated load( call on a different type cannot satisfy -# the check (false-negative where "anything named load" collapses to one _called flag). -# Fix (Issue 2): inline // comment stripping + string-literal exclusion in call-site -# grep so that `foo(); // load(` and `"load("` do not count as startup invocations. -_pm_startup_files=() -while IFS= read -r _f; do - [[ -f "${_f}" ]] && _pm_startup_files+=("${_f}") -done < <(find "${TARGET_DIR}" \ - \( -name 'main.rs' -o -name 'lib.rs' -o -path '*/bin/*.rs' \) \ - -not -path '*/target/*' \ - -not -path '*/examples/*' \ - -not -path '*/benches/*' \ - -not -path '*/tests/*' \ - 2>/dev/null) - -if [[ ${#_pm_startup_files[@]} -gt 0 ]]; then - for _method in save load persist restore; do - # Extract all impl-type names that declare fn <_method>( in production code. - # python3 tracks brace-depth to stay inside each impl block, preventing - # cross-type pollution when multiple types in the same file define the same - # method name. Only production (non-test, non-example) files are scanned. - _decl_types=$(python3 - "${_method}" "${TARGET_DIR}" <<'PYEOF' -import sys, re, subprocess - -method = sys.argv[1] -tgt_dir = sys.argv[2] - -TEST_PATH = re.compile(r'((^|/)tests[/._]|/test_|_test\.rs$|tests\.rs$|(^|/)examples/|(^|/)benches/)') - -try: - res = subprocess.run( - ["grep", "-rlE", r"fn\s+" + re.escape(method) + r"\s*\(", - "--include=*.rs", - "--exclude-dir=target", "--exclude-dir=examples", - "--exclude-dir=benches", "--exclude-dir=tests", - tgt_dir], - capture_output=True, text=True - ) - decl_files = [f for f in res.stdout.strip().splitlines() if not TEST_PATH.search(f)] -except Exception: - sys.exit(0) - -_ng = r"[^<>]*(?:<[^<>]*>[^<>]*)*" -impl_pat = re.compile( - r'^\s*impl(?:<' + _ng + r'>)?\s+' - r'(?:[\w:]+(?:<' + _ng + r'>)?\s+for\s+)?' - r'(\w+)(?:<' + _ng + r'>)?\s*(?:\{|where\b|$)' -) -method_pat = re.compile(r'\bfn\s+' + re.escape(method) + r'\s*\(') -comment_pat = re.compile(r'^\s*(//|/\*|\*)') - -types_found = set() -for filepath in decl_files: - try: - with open(filepath, 'r', errors='ignore') as fh: - lines = fh.readlines() - except Exception: - continue - i = 0 - while i < len(lines): - m = impl_pat.search(lines[i]) - if m: - type_name = m.group(1) - depth = lines[i].count('{') - lines[i].count('}') - j = i + 1 - while j < len(lines) and depth <= 0: - depth += lines[j].count('{') - lines[j].count('}') - j += 1 - while j < len(lines) and depth > 0: - depth += lines[j].count('{') - lines[j].count('}') - if method_pat.search(lines[j]) and not comment_pat.search(lines[j]): - types_found.add(type_name) - break - j += 1 - i = j - else: - i += 1 - -for t in sorted(types_found): - print(t) -PYEOF - ) - [[ -z "${_decl_types}" ]] && continue - - # For each declaring type, verify a call anchored to that specific type exists - # in at least one startup file. Five-stage filter pipeline per startup file: - # 1. initial grep: require Type:: or . prefix so the match is anchored to this type - # 2. drop full-line comment/doc lines (// /* *) - # 3. drop fn-declaration lines (false call — it's a definition, not invocation) - # 4. drop lines where the match falls inside an inline // comment - # 5. drop lines where the match is inside a double-quoted string literal - while IFS= read -r _type; do - [[ -z "${_type}" ]] && continue - _type_called=false - for _startup in "${_pm_startup_files[@]}"; do - if grep -E "(${_type}[[:space:]]*::|\.)[[:space:]]*${_method}[[:space:]]*\(" "${_startup}" 2>/dev/null \ - | grep -vE '^\s*(//|/\*|\*)' \ - | grep -vE '\bfn[[:space:]]+'"${_method}"'[[:space:]]*\(' \ - | grep -vE '//.*\b'"${_method}"'[[:space:]]*\(' \ - | grep -vE '"[^"]*\b'"${_method}"'[[:space:]]*\([^"]*"' \ - | grep -q .; then - _type_called=true - break - fi - done - if [[ "${_type_called}" == "false" ]]; then - echo "[RS-14] Persistence method '${_type}::${_method}()' is declared but not called at startup (main.rs / lib.rs / src/bin/*.rs). Fix: invoke ${_type}::${_method}() in the startup path, or add // vibeguard-disable-next-line RS-14 if intentional." >> "$TMPFILE" - fi - done <<< "${_decl_types}" - done -fi - -apply_suppression_filter "$TMPFILE" -FOUND=$(wc -l < "$TMPFILE" | tr -d ' ') - -if [[ $FOUND -eq 0 ]]; then - echo "[RS-14] PASS: Config statement-execution gap not detected" - exit 0 -fi - -cat "$TMPFILE" -echo "" -echo "Found ${FOUND} potential Config declaration-execution gap(s)." -echo "" -echo "Repair method:" -echo " 1. If Config has a load() method, Config::load() should be called during startup instead of Config::default()" -echo " 2. If Default::default() is indeed the expected behavior (such as testing or default configuration), add a comment" - -if [[ "${STRICT}" == true ]]; then - exit 1 -fi +# Compatibility entry point; the canonical RS-14 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust declaration-execution-gap "$@" diff --git a/guards/rust/check_duplicate_types.sh b/guards/rust/check_duplicate_types.sh index 672d5a5b..6d18f9e5 100755 --- a/guards/rust/check_duplicate_types.sh +++ b/guards/rust/check_duplicate_types.sh @@ -1,104 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect duplicate type definitions across files (RS-05) -# -# Scan the names of pub struct/enum and report when types with the same name appear in multiple files. -# Usage: -# bash check_duplicate_types.sh [target_dir] -# bash check_duplicate_types.sh --strict [target_dir] # If there are duplicates, exit code 1 -# -#Exclude: tests/ directory - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" - -# Allow list -ALLOWLIST_FILE="${TARGET_DIR}/.vibeguard-duplicate-types-allowlist" - -declare -A ALLOWLIST -if [[ -f "${ALLOWLIST_FILE}" ]]; then - while IFS= read -r name; do - [[ -z "${name}" || "${name}" == \#* ]] && continue - ALLOWLIST["${name}"]=1 - done < "${ALLOWLIST_FILE}" -fi - -TMPFILE=$(create_tmpfile) - -# Extraction: type name file path: line number (processed file by file, compatible with space paths and empty input) -# Use list_rs_prod_files to exclude test files and worktree copies. -list_rs_prod_files "${TARGET_DIR}" \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - # Exclude struct/enum within string literals (r#"..."# or "...") - grep -nE '^[[:space:]]*pub[[:space:]]+(struct|enum)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' "${f}" 2>/dev/null \ - | grep -v 'r#"' \ - | awk -v file="${f}" '{ - split($0, ln, ":") - s = $0 - sub(/^[0-9]+:.*pub[[:space:]]+(struct|enum)[[:space:]]+/, "", s) - sub(/[^A-Za-z0-9_].*/, "", s) - if (s != "") print s " " file ":" ln[1] - }' || true - fi - done \ - | sort \ - > "${TMPFILE}" - -# Construct allowed list parameters to awk -ALLOWLIST_AWK="" -for name in "${!ALLOWLIST[@]}"; do - ALLOWLIST_AWK="${ALLOWLIST_AWK}${name}\n" -done - -#Use awk single process to complete grouping, deduplication and reporting -RESULT=$(awk -v allowlist="${ALLOWLIST_AWK}" ' -BEGIN { - n = split(allowlist, arr, "\n") - for (i = 1; i <= n; i++) if (arr[i] != "") skip[arr[i]] = 1 -} -{ - name = $1; loc = $2 - split(loc, parts, ":") - file = parts[1] - if (!(name in first_file)) { - first_file[name] = file - seen[name, file] = 1 - locs[name] = loc - file_count[name] = 1 - } else if (!((name, file) in seen)) { - seen[name, file] = 1 - locs[name] = locs[name] ", " loc - file_count[name]++ - } -} -END { - found = 0 - for (name in file_count) { - if (file_count[name] > 1 && !(name in skip)) { - printf "[RS-05] Duplicate type: %s\n Locations: %s\n\n", name, locs[name] - found++ - } - } - if (found == 0) - print "No duplicate types found." - else - printf "Found %d duplicate type(s).\n", found - if (found > 0) { - print "" - print "Repair method:" - print "1. Extract to the shared module: move the type definition to core/ or shared/, and introduce pub use at each entrance" - print "2. If they have the same name but different synonyms: rename to distinguish semantics (such as ServerConfig vs DesktopConfig)" - print "3. If it is a false positive caused by re-export: add to .vibeguard-duplicate-types-allowlist" - } - print "EXIT_CODE=" (found > 0 ? "1" : "0") -} -' "${TMPFILE}") - -# Output the results (remove the last EXIT_CODE line) -echo "${RESULT}" | grep -v '^EXIT_CODE=' - -# Extract exit code -SHOULD_FAIL=$(echo "${RESULT}" | grep '^EXIT_CODE=' | cut -d= -f2) -if [[ "${STRICT}" == true && "${SHOULD_FAIL}" == "1" ]]; then - exit 1 -fi +# Compatibility entry point; the canonical RS-05 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust duplicate-types "$@" diff --git a/guards/rust/check_nested_locks.sh b/guards/rust/check_nested_locks.sh index ce953ddb..8197e4c7 100755 --- a/guards/rust/check_nested_locks.sh +++ b/guards/rust/check_nested_locks.sh @@ -1,161 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect nested lock acquisitions (RS-01) -# -# Detect the pattern of acquiring another lock while holding one lock guard in the same function. -# Only if two lock/read/write calls are at the same brace depth (not separated by {} blocks) -# Report only when required, excluding the safe mode of sequential acquisition (first acquire, then release, then acquire). -# -# Pre-commit mode: only check new lines in staged files -# Standalone mode: full scan -# -# Usage: -# bash check_nested_locks.sh [target_dir] -# bash check_nested_locks.sh --strict [target_dir] - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) - -# Pre-commit mode: only scan new lines in staged diff -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - STAGED_RS=$(grep '\.rs$' "${VIBEGUARD_STAGED_FILES}" \ - | grep -vE "${VIBEGUARD_EXCLUDE_PATHS}" \ - | grep -vE "${VIBEGUARD_TEST_FILE_PATTERN}") || STAGED_RS="" - - if [[ -n "${STAGED_RS}" ]]; then - # Load once in this shell: $(vg_staged_file_diff) would otherwise rebuild - # the rename map in a subshell on every staged file. - _vg_load_staged_rename_map - while IFS= read -r f; do - [[ -z "$f" || ! -f "$f" ]] && continue - _diff_tmp=$(create_tmpfile) - vg_staged_file_diff "${f}" > "${_diff_tmp}" - count=$(grep '^+' "${_diff_tmp}" | grep -v '^+++' \ - | grep -cE '\.(read|write|lock)[[:space:]]*\(') || count=0 - if [[ "$count" -gt 2 ]]; then - # Find first new-file line number of a lock acquisition so - # apply_suppression_filter can match vibeguard-disable-next-line. - first_line=$(awk ' - /^@@ / { - tmp = $0 - sub(/^.*\+/, "", tmp) - sub(/[^0-9].*/, "", tmp) - cur = tmp + 0 - 1 - } - /^\+[^+]/ { - cur++ - if ($0 ~ /\.(read|write|lock)[[:space:]]*\(/) { - print cur; exit - } - } - ' "${_diff_tmp}") - [[ -z "$first_line" ]] && first_line=1 - echo "[RS-01] ${f}:${first_line}: ${count} lock acquisitions in staged diff (review manually)" - fi - done <<< "${STAGED_RS}" - fi > "${TMPFILE}" || true - -# Standalone mode: full scan, improved nesting detection -else - list_rs_prod_files "${TARGET_DIR}" \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - grep -lE '\.(read|write|lock)[[:space:]]*\(' "${f}" 2>/dev/null || true - fi - done \ - | while IFS= read -r file; do - # Improved awk: Track block scope and only report when locks are acquired multiple times in the same scope. - #Reset the current scope's lock count when encountering a {} block boundary. - # Exclude the pattern of .read().await followed by } (scope drop) followed by a new .read(). - awk ' - /^[[:space:]]*(pub[[:space:]]+)?(async[[:space:]]+)?fn[[:space:]]+/ { - func_name = $0 - sub(/.*fn[[:space:]]+/, "", func_name) - sub(/\(.*/, "", func_name) - lock_count = 0 - active_locks = 0 - max_concurrent = 0 - func_line = NR - brace_depth = 0 - lock_idx = 0 - delete lock_depths - } - /{/ { - n = gsub(/{/, "{") - brace_depth += n - } - /\.(read|write|lock)[[:space:]]*\(/ { - # Count all lock acquisitions on this line using gsub so multiple calls on one - # line (e.g. the two .lock() calls in self.a.lock().map(|_a| { self.b.lock() })) - # are each tracked individually. - _tmp = $0 - gsub(/\/\/.*$/, "", _tmp) # strip line comments - gsub(/"[^"]*"/, "", _tmp) # strip simple string literals - _n = gsub(/\.(read|write|lock)[[:space:]]*\(/, "", _tmp) - # Do NOT use next when _n < 1: pattern fired on comment/string only, - # but the /}/ rule below must still run to keep brace_depth accurate. - if (_n >= 1) { - lock_count += _n - # A chained call like .lock().clone() / .lock().to_string() drops the guard - # immediately (value extracted, guard never bound to a variable). Only apply - # this exemption for known value-extraction methods; closure-passing methods - # like .lock().map(|g| { ... }) HOLD the guard through the closure body. - _value_chain = /\.(read|write|lock)[[:space:]]*\([^)]*\)\.(clone|to_owned|to_string|len|is_empty|contains)\(/ - if (!(_value_chain && _n == 1)) { - for (_k = 0; _k < _n; _k++) { - lock_depths[lock_idx] = brace_depth - lock_idx++ - active_locks++ - } - if (active_locks > max_concurrent) max_concurrent = active_locks - } - } - } - /}/ { - n = gsub(/}/, "}") - brace_depth -= n - # Release lock guards that went out of scope: their acquisition brace_depth - # is now greater than the current brace_depth, meaning their block closed. - # Iterate from newest to oldest lock to release in LIFO order. - for (i = lock_idx - 1; i >= 0; i--) { - if (lock_depths[i] > brace_depth) { - active_locks-- - delete lock_depths[i] - lock_idx-- - } - } - } - brace_depth == 0 && func_name != "" { - if (max_concurrent > 1) { - printf "[RS-01] %s:%d fn %s — %d concurrent lock acquisitions (of %d total)\n", FILENAME, func_line, func_name, max_concurrent, lock_count - } - func_name = "" - lock_count = 0 - active_locks = 0 - max_concurrent = 0 - lock_idx = 0 - delete lock_depths - } - ' "${file}" - done > "${TMPFILE}" -fi - -apply_suppression_filter "${TMPFILE}" -cat "${TMPFILE}" -FOUND=$(wc -l < "${TMPFILE}" | tr -d ' ') - -echo "" -if [[ "${FOUND}" -eq 0 ]]; then - echo "No nested lock patterns detected." -else - echo "Found ${FOUND} potential nested lock pattern(s)." - echo "" - echo "Repair method:" - echo " 1. Combine multiple locks into a single RwLock (eliminate nesting)" - echo "2. If multiple locks are necessary, unify the acquisition order (such as alphabetical order) to prevent ABBA deadlock" - echo "3. Reduce the lock scope: let value = lock.read().clone(); drop(lock); and then process" - echo "4. Use try_lock() / try_read() to avoid infinite waiting" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical RS-01 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust nested-locks "$@" diff --git a/guards/rust/check_semantic_effect.sh b/guards/rust/check_semantic_effect.sh index 4aa761f1..56fdd154 100755 --- a/guards/rust/check_semantic_effect.sh +++ b/guards/rust/check_semantic_effect.sh @@ -1,130 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect inconsistencies between action semantics and side effects (RS-13) -# -# Target question: -# - The function/tool name contains action semantics such as done/update/delete/remove/add/create/set etc. -# - But there is no visible state writing or event emission in the function body, which may be "semantic promise not fulfilled" -# -# Usage: -# bash check_semantic_effect.sh [target_dir] -# bash check_semantic_effect.sh --strict [target_dir] -# -# Allow list: -# Create .vibeguard-semantic-effect-allowlist in the root directory of the target warehouse -# One function name per line (such as mark_done) - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" - -ALLOWLIST_FILE="${TARGET_DIR}/.vibeguard-semantic-effect-allowlist" -ALLOWLIST_AWK="" -if [[ -f "${ALLOWLIST_FILE}" ]]; then - while IFS= read -r name; do - [[ -z "${name}" || "${name}" == \#* ]] && continue - ALLOWLIST_AWK="${ALLOWLIST_AWK}${name}\n" - done < "${ALLOWLIST_FILE}" -fi - -TMP_RS=$(create_tmpfile) -list_rs_files "${TARGET_DIR}" \ - | { grep -vE '(/tests/|/test_|_test\.rs$)' || true; } \ - > "${TMP_RS}" - -if [[ ! -s "${TMP_RS}" ]]; then - echo "No Rust source files found." - exit 0 -fi - -REPORT=$(while IFS= read -r f; do - [[ -f "${f}" ]] || continue - lower_path="$(printf '%s' "${f}" | tr '[:upper:]' '[:lower:]')" - # Focus on behavioral command-related modules to reduce false positives for pure functions - if [[ "${lower_path}" != *task* && "${lower_path}" != *todo* && "${lower_path}" != *tool* && "${lower_path}" != *command* ]]; then - continue - fi - - awk -v file="${f}" -v allowlist="${ALLOWLIST_AWK}" ' - BEGIN { - IGNORECASE = 1 - n = split(allowlist, arr, "\n") - for (i = 1; i <= n; i++) { - if (arr[i] != "") skip[arr[i]] = 1 - } - in_fn = 0 - brace_depth = 0 - } - - function is_action_name(name) { - lname = tolower(name) - if (lname ~ /(^|_)(mark_)?done$/) return 1 - if (lname ~ /^(update|delete|remove|add|create|set)_[a-z0-9_]+$/) return 1 - if (lname ~ /^(task|todo).*(done|update|delete|remove|add|create)$/) return 1 - return 0 - } - - { - line = $0 - - if (!in_fn) { - if (line ~ /fn[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) { - fn_name = line - sub(/^.*fn[[:space:]]+/, "", fn_name) - sub(/[^A-Za-z0-9_].*$/, "", fn_name) - if (is_action_name(fn_name) && !(fn_name in skip)) { - in_fn = 1 - start_line = NR - has_effect = 0 - has_result = 0 - brace_depth = 0 - } - } - } - - if (in_fn) { - lower = tolower(line) - # Visible status writing/event emission signal - if (lower ~ /\.(insert|push|remove|retain|update|replace|set)[[:space:]]*\(/ || - lower ~ /::(insert|push|remove|update|set|replace|write)[[:space:]]*\(/ || - lower ~ /(^|[^[:alnum:]_])(write|save|commit|emit|publish|dispatch|send|persist)([^[:alnum:]_]|$)/) { - has_effect = 1 - } - # Result construction signal: usually tool output text/Result - if (lower ~ /(ok\(|err\(|format!\(|json!\(|to_string\()/) { - has_result = 1 - } - - tmp = line - open_count = gsub(/\{/, "{", tmp) - tmp = line - close_count = gsub(/\}/, "}", tmp) - brace_depth += open_count - close_count - - if (brace_depth <= 0 && line ~ /\}/) { - if (!has_effect && has_result) { - printf "%s:%d:%s\n", file, start_line, fn_name - } - in_fn = 0 - } - } - } - ' "${f}" 2>/dev/null || true -done < "${TMP_RS}") - -if [[ -z "${REPORT}" ]]; then - echo "No semantic-effect mismatches detected." - exit 0 -fi - -COUNT=$(echo "${REPORT}" | sed '/^$/d' | wc -l | tr -d ' ') -echo "[RS-13] Found ${COUNT} action-like function(s) without visible side-effects:" -while IFS= read -r line; do - [[ -n "${line}" ]] && echo " - ${line}" -done <<< "${REPORT}" -echo "Repair:" -echo " 1. Action functions should explicitly write status or send events (insert/update/remove/emit, etc.)" -echo " 2. If the original intention of the function is only query/format, rename it to query/format/describe semantics" -echo " 3. Pure functions can be added to .vibeguard-semantic-effect-allowlist" - -if [[ "${STRICT}" == true ]]; then - exit 1 -fi +# Compatibility entry point; the canonical RS-13 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust semantic-effect "$@" diff --git a/guards/rust/check_single_source_of_truth.sh b/guards/rust/check_single_source_of_truth.sh index 47e5931f..7bc77961 100755 --- a/guards/rust/check_single_source_of_truth.sh +++ b/guards/rust/check_single_source_of_truth.sh @@ -1,100 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect mission system single source of truth corruption (RS-12) -# -# Target question: -# - Todo* and TaskManagement* two tool families coexist and appear in the tool registration link at the same time -# - There are too many global state containers related to task/todo, which may cause the state source to be split. -# -# Usage: -# bash check_single_source_of_truth.sh [target_dir] -# bash check_single_source_of_truth.sh --strict [target_dir] - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" - -# Fix RS-12: only match Claude Code-specific tool names, not generic data structures -# like TodoList, TodoItem, TodoTask which are common application types. -TODO_PATTERN='\b(TodoWrite|TodoRead)\b' -TASK_PATTERN='\b(ViewTasks?|AddTask|UpdateTask|ReorganizeTasks?|TaskDone|TaskList|TaskManagement)\b' - -TMP_RS=$(create_tmpfile) -list_rs_files "${TARGET_DIR}" \ - | { grep -vE '(/tests/|/test_|_test\.rs$)' || true; } \ - > "${TMP_RS}" - -if [[ ! -s "${TMP_RS}" ]]; then - echo "No Rust source files found." - exit 0 -fi - -TODO_HITS=$(while IFS= read -r f; do - [[ -f "${f}" ]] || continue - grep -nE "${TODO_PATTERN}" "${f}" 2>/dev/null | awk -v file="${f}" '{print file ":" $0}' || true -done < "${TMP_RS}" | head -20) - -TASK_HITS=$(while IFS= read -r f; do - [[ -f "${f}" ]] || continue - grep -nE "${TASK_PATTERN}" "${f}" 2>/dev/null | awk -v file="${f}" '{print file ":" $0}' || true -done < "${TMP_RS}" | head -20) - -STORE_HITS=$(while IFS= read -r f; do - [[ -f "${f}" ]] || continue - awk -v file="${f}" ' - { - line=tolower($0) - if (line !~ /(task|todo)/) next - if (line ~ /static[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) { - name = $0 - sub(/^.*static[[:space:]]+/, "", name) - sub(/[^A-Za-z0-9_].*$/, "", name) - if (name != "") - printf "%s %s:%d\n", name, file, NR - } else if (line ~ /:[[:space:]]*(arc<)?(mutex|rwlock|dashmap|hashmap|btreemap|vec)/) { - name = $0 - sub(/^[[:space:]]*/, "", name) - sub(/[[:space:]]*:.*$/, "", name) - if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) - printf "%s %s:%d\n", name, file, NR - } - } - ' "${f}" 2>/dev/null || true -done < "${TMP_RS}" | sort -u) - -FOUND=0 - -if [[ -n "${TODO_HITS}" && -n "${TASK_HITS}" ]]; then - echo "[RS-12] Potential dual task systems detected (Todo* + TaskManagement*)." - echo " Todo-family references:" - while IFS= read -r line; do - [[ -n "${line}" ]] && echo " - ${line}" - done <<< "${TODO_HITS}" - echo " Task-management references:" - while IFS= read -r line; do - [[ -n "${line}" ]] && echo " - ${line}" - done <<< "${TASK_HITS}" - echo "Fix: Convergence to a single task system (single tool family + single state source), avoiding parallel dual rails." - echo - FOUND=$((FOUND + 1)) -fi - -STORE_COUNT=$(echo "${STORE_HITS}" | sed '/^$/d' | wc -l | tr -d ' ') -if [[ "${STORE_COUNT}" -gt 1 ]]; then - echo "[RS-12] Multiple task/todo state stores detected (${STORE_COUNT})." - while IFS= read -r line; do - [[ -n "${line}" ]] && echo " - ${line}" - done <<< "${STORE_HITS}" - echo "Risk: Task status may be scattered across multiple containers, forming a non-single source of truth." - echo "Fixed: Extract unified state/repository, and only write one state entry for all task actions." - echo - FOUND=$((FOUND + 1)) -fi - -if [[ "${FOUND}" -eq 0 ]]; then - echo "No single-source-of-truth issues detected." - exit 0 -fi - -echo "Found ${FOUND} potential single-source-of-truth issue(s)." -if [[ "${STRICT}" == true ]]; then - exit 1 -fi +# Compatibility entry point; the canonical RS-12 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust single-source-of-truth "$@" diff --git a/guards/rust/check_taste_invariants.sh b/guards/rust/check_taste_invariants.sh index 8c61fdf0..37563d2d 100755 --- a/guards/rust/check_taste_invariants.sh +++ b/guards/rust/check_taste_invariants.sh @@ -1,116 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Harness Style Taste Invariants -# -# Detect Rust code taste constraints (benchmarked by OpenAI Harness Engineering). -# Do not use independent rule IDs to avoid conflicts with RS-01~RS-13. -# -# Detection items: -# - TASTE-ANSI: hardcoded ANSI escape sequences (colored/termcolor crate should be used) -# - TASTE-FOLD: Foldable single line if (can be simplified to then/map) -# - TASTE-ASYNC-UNWRAP: .unwrap() inside async fn (should use ?) -# - TASTE-PANIC-MSG: panic!() lacks meaningful message -# -# Usage: -# bash check_taste_invariants.sh [target_dir] -# bash check_taste_invariants.sh --strict [target_dir] - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) -TOTAL=0 - -# --- TASTE-ANSI: Hardcoded ANSI escape sequence --- -ANSI_TMP=$(create_tmpfile) -list_rs_files "${TARGET_DIR}" \ - | { grep -vE '(/tests/|/test_|_test\.rs$|/examples/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - grep -nE '\\x1b\[|\\033\[|\\e\[' "${f}" 2>/dev/null \ - | sed "s|^|${f}:|" || true - fi - done \ - | awk '!/^[[:space:]]*\/\// { print "[TASTE-ANSI] " $0 }' \ - > "${ANSI_TMP}" || true - -cat "${ANSI_TMP}" >> "${TMPFILE}" -ANSI_COUNT=$(wc -l < "${ANSI_TMP}" | tr -d ' ') -TOTAL=$((TOTAL + ANSI_COUNT)) - -# --- TASTE-ASYNC-UNWRAP: async fn within .unwrap() --- -# Fix: use awk to track async fn scope so we only flag unwrap() calls that are -# actually inside an async function body, not any unwrap() in a file that happens -# to contain an async fn somewhere else. -ASYNC_TMP=$(create_tmpfile) -list_rs_files "${TARGET_DIR}" \ - | { grep -vE '(/tests/|/test_|_test\.rs$|/examples/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - awk ' - # Detect start of async fn; wait for the opening brace - /async[[:space:]]+fn[[:space:]]+/ { pending_async = 1; brace_depth = 0; matched_open = 0 } - # Trait/interface method declarations end with ; and have no body — clear pending - # so the next real function'"'"'s { is not mistaken for this async fn'"'"'s body. - pending_async && /;/ && !/{/ { pending_async = 0; next } - pending_async && /{/ { - n = split($0, a, "{"); brace_depth += n - 1 - n = split($0, a, "}"); brace_depth -= n - 1 - matched_open = 1 - # Check for unwrap on this same line (single-line async fn or opening-brace - # on the same line as fn signature), before deciding in_async state. - if (/\.(unwrap|expect)\(/ && !/unwrap_or/ && !/^[[:space:]]*\/\//) - print NR ": " $0 - if (brace_depth <= 0) { pending_async = 0; in_async = 0 } - else in_async = 1 - next - } - in_async { - n = split($0, a, "{"); brace_depth += n - 1 - n = split($0, a, "}"); brace_depth -= n - 1 - if (brace_depth <= 0) { in_async = 0; pending_async = 0 } - if (/\.(unwrap|expect)\(/ && !/unwrap_or/ && !/^[[:space:]]*\/\//) - print NR ": " $0 - } - ' "${f}" | sed "s|^|${f}:|" || true - fi - done \ - | awk '{ print "[TASTE-ASYNC-UNWRAP] " $0 }' \ - > "${ASYNC_TMP}" || true - -cat "${ASYNC_TMP}" >> "${TMPFILE}" -ASYNC_COUNT=$(wc -l < "${ASYNC_TMP}" | tr -d ' ') -TOTAL=$((TOTAL + ASYNC_COUNT)) - -# --- TASTE-PANIC-MSG: panic!() lacks meaningful message --- -PANIC_TMP=$(create_tmpfile) -list_rs_files "${TARGET_DIR}" \ - | { grep -vE '(/tests/|/test_|_test\.rs$|/examples/)' || true; } \ - | while IFS= read -r f; do - if [[ -f "${f}" ]]; then - # Detect panic!() with no parameters or only empty string - grep -nE 'panic!\s*\(\s*\)|panic!\s*\(\s*""\s*\)' "${f}" 2>/dev/null \ - | sed "s|^|${f}:|" || true - fi - done \ - | awk '{ print "[TASTE-PANIC-MSG] " $0 }' \ - > "${PANIC_TMP}" || true - -cat "${PANIC_TMP}" >> "${TMPFILE}" -PANIC_COUNT=$(wc -l < "${PANIC_TMP}" | tr -d ' ') -TOTAL=$((TOTAL + PANIC_COUNT)) - -# --- Output summary --- -echo "" -cat "${TMPFILE}" -echo "" - -if [[ ${TOTAL} -eq 0 ]]; then - echo "Taste invariants check passed — no issues found." -else - echo "Found ${TOTAL} taste invariant violation(s):" - [[ ${ANSI_COUNT} -gt 0 ]] && echo " TASTE-ANSI: ${ANSI_COUNT} (hardcoded ANSI → use colored/termcolor crate)" - [[ ${ASYNC_COUNT} -gt 0 ]] && echo " TASTE-ASYNC-UNWRAP: ${ASYNC_COUNT} (unwrap → in async fn uses the ? operator)" - [[ ${PANIC_COUNT} -gt 0 ]] && echo " TASTE-PANIC-MSG: ${PANIC_COUNT} (panic no message → add context description)" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical TASTE implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust taste-invariants "$@" diff --git a/guards/rust/check_unwrap_in_prod.sh b/guards/rust/check_unwrap_in_prod.sh index fb85fc67..9d45b306 100755 --- a/guards/rust/check_unwrap_in_prod.sh +++ b/guards/rust/check_unwrap_in_prod.sh @@ -1,541 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect unwrap()/expect() in production code (RS-03) -# -# Two modes: -# Pre-commit mode (VIBEGUARD_STAGED_FILES is set): -# grep diff adds new lines (starting with +) and retains the original logic (diff is not a file and cannot be processed by ast-grep). -# -# Standalone mode (manual operation): -# Use ast-grep AST level scanning to eliminate false positives in comments and precisely exclude unwrap_or* variants. -# -# Usage: -# bash check_unwrap_in_prod.sh [target_dir] -# bash check_unwrap_in_prod.sh --strict [target_dir] -# -# Exclude (common to both modes): -# - tests/ directory, benches/ directory, examples/ directory -# - A file named tests.rs, test_helpers.rs, or a file containing test_ / _test -# - All code after the #[cfg(test)] line - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" -TMPFILE=$(create_tmpfile) - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -RULES_DIR="${SCRIPT_DIR}/../ast-grep-rules" - -# Stateful shell fallback used when ast-grep or its JSON parser is unavailable. -# Keep its lexical states aligned with the Python scanners below: braces inside -# strings/comments do not affect #[cfg(test)] scope, while lifetimes and labels -# remain ordinary Rust syntax rather than being mistaken for character literals. -scan_with_awk_fallback() { - local file="$1" - awk ' - function char_literal_end(line, start, end, close_pos, tail) { - end = start + 1 - if (end > length(line)) return 0 - if (substr(line, end, 1) == "\\") { - end++ - if (substr(line, end, 1) == "u" && substr(line, end + 1, 1) == "{") { - tail = substr(line, end + 2) - close_pos = index(tail, "}") - if (!close_pos) return 0 - end = end + 2 + close_pos - } else if (substr(line, end, 1) == "x") { - end += 3 - } else { - end++ - } - } else { - end++ - } - return substr(line, end, 1) == "\047" ? end : 0 - } - - function scan_line(line, n, i, c, pair, rest, pos, prefix_len, marker, hashes, j, char_end) { - code_line = "" - brace_delta = 0 - n = length(line) - i = 1 - while (i <= n) { - if (raw_string_end != "") { - rest = substr(line, i) - pos = index(rest, raw_string_end) - if (!pos) return brace_delta - i += pos - 1 + length(raw_string_end) - raw_string_end = "" - continue - } - c = substr(line, i, 1) - pair = substr(line, i, 2) - if (in_string) { - if (c == "\\") i += 2 - else if (c == "\"") { in_string = 0; i++ } - else i++ - continue - } - if (block_comment_depth) { - if (pair == "/*") { block_comment_depth++; i += 2 } - else if (pair == "*/") { block_comment_depth--; i += 2 } - else i++ - continue - } - if (pair == "//") break - if (pair == "/*") { block_comment_depth = 1; i += 2; continue } - - prefix_len = 0 - if (pair == "br" || pair == "cr") prefix_len = 2 - else if (c == "r") prefix_len = 1 - if (prefix_len && (i == 1 || substr(line, i - 1, 1) !~ /[[:alnum:]_]/)) { - marker = i + prefix_len - while (marker <= n && substr(line, marker, 1) == "#") marker++ - if (marker <= n && substr(line, marker, 1) == "\"") { - hashes = marker - i - prefix_len - raw_string_end = "\"" - for (j = 0; j < hashes; j++) raw_string_end = raw_string_end "#" - i = marker + 1 - continue - } - } - - if (c == "\"") { in_string = 1; i++; continue } - if (c == "\047") { - char_end = char_literal_end(line, i) - if (char_end) { i = char_end + 1; continue } - } - code_line = code_line c - if (c == "{") brace_delta++ - else if (c == "}") brace_delta-- - i++ - } - return brace_delta - } - - { - delta = scan_line($0) - if (code_line ~ /^[[:space:]]*#\[cfg\(test\)\]/) { - if (code_line ~ /(mod|fn|impl|struct|enum|type|trait)[[:space:]]/) { - in_test_mod = 1 - brace_depth = delta - if (brace_depth <= 0) in_test_mod = 0 - } else { - pending_test_attr = 1 - } - next - } - if (pending_test_attr && code_line ~ /^[[:space:]]*#\[/) next - if (pending_test_attr && code_line ~ /(mod|fn|impl|struct|enum|type|trait)[[:space:]]/) { - in_test_mod = 1 - pending_test_attr = 0 - brace_depth = delta - if (brace_depth <= 0) in_test_mod = 0 - next - } - if (pending_test_attr) pending_test_attr = 0 - if (in_test_mod) { - brace_depth += delta - if (brace_depth <= 0) in_test_mod = 0 - next - } - if (code_line ~ /\.(unwrap|expect)\(/) { - print "[RS-03] " FILENAME ":" NR ": " $0 - } - } - ' "${file}" -} - -# --- Pre-commit mode: grep diff new lines (ast-grep does not process diff text) --- -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - if ! grep -q '\.rs$' "${VIBEGUARD_STAGED_FILES}" 2>/dev/null; then - STAGED_RS="" - else - STAGED_RS=$(grep '\.rs$' "${VIBEGUARD_STAGED_FILES}" | filter_rs_prod_paths) - fi - - if [[ -n "${STAGED_RS}" ]]; then - _vg_load_staged_rename_map - while IFS= read -r f; do - [[ -z "$f" || ! -f "$f" ]] && continue - if command -v python3 >/dev/null 2>&1; then - # Parse hunk headers to include real line numbers so apply_suppression_filter - # can honour vibeguard-disable-next-line comments in the committed file. - # Save diff to a temp file so we can re-read it on Python failure. - _diff_tmp=$(create_tmpfile) - vg_staged_file_diff "${f}" > "${_diff_tmp}" - # Write Python script to temp file to avoid bash escaping issues with regex - _diff_py=$(create_tmpfile) - cat > "${_diff_py}" << 'DIFFPYEOF' -import sys, re - -fname = sys.argv[1] -danger_pat = re.compile(r'\.(unwrap\(|expect\()') -comment_pat = re.compile(r'^\s*//') -hunk_pat = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@') -_ITEM_KW = re.compile(r'\b(mod|fn|impl|struct|enum|type|trait)\b') - -# --- Build test_lines set from original file (reuse standalone logic) --- -_raw_string_end = None -_in_string = False -_block_comment_depth = 0 - -def _count_braces(s): - global _raw_string_end, _in_string, _block_comment_depth - depth = 0 - i = 0 - while i < len(s): - if _raw_string_end is not None: - end = s.find(_raw_string_end, i) - if end < 0: - return depth - i = end + len(_raw_string_end) - _raw_string_end = None - continue - if _in_string: - if s[i] == '\\': - i += 2 - elif s[i] == '"': - _in_string = False - i += 1 - else: - i += 1 - continue - if _block_comment_depth: - if s.startswith('/*', i): - _block_comment_depth += 1 - i += 2 - elif s.startswith('*/', i): - _block_comment_depth -= 1 - i += 2 - else: - i += 1 - continue - if s.startswith('//', i): - break - if s.startswith('/*', i): - _block_comment_depth = 1 - i += 2 - continue - - prefix_len = 0 - if s.startswith(('br', 'cr'), i): - prefix_len = 2 - elif s.startswith('r', i): - prefix_len = 1 - if prefix_len and (i == 0 or not (s[i - 1].isalnum() or s[i - 1] == '_')): - marker = i + prefix_len - while marker < len(s) and s[marker] == '#': - marker += 1 - if marker < len(s) and s[marker] == '"': - _raw_string_end = '"' + ('#' * (marker - i - prefix_len)) - i = marker + 1 - continue - - if s[i] == '"': - _in_string = True - i += 1 - continue - if s[i] == "'": - end = i + 1 - if end < len(s) and s[end] == '\\': - end += 1 - if end < len(s) and s[end] == 'u' and s.startswith('{', end + 1): - close = s.find('}', end + 2) - end = close + 1 if close >= 0 else len(s) - elif end < len(s) and s[end] == 'x': - end += 3 - else: - end += 1 - else: - end += 1 - if end < len(s) and s[end] == "'": - i = end + 1 - else: - # A lifetime or loop label is not a character literal. - i += 1 - continue - if s[i] == '{': - depth += 1 - elif s[i] == '}': - depth -= 1 - i += 1 - return depth - -test_lines = set() -try: - with open(fname) as _src: - _all = _src.readlines() - _pending = False; _in_mod = False; _depth = 0 - for _i, _ln in enumerate(_all, 1): - _s = _ln.strip() - if _s.startswith('#[cfg(test)]'): - test_lines.add(_i) - if _ITEM_KW.search(_s[len('#[cfg(test)]'):]): - _in_mod = True; _depth = _count_braces(_s) - if _depth <= 0: _in_mod = False - else: - _pending = True - continue - if _pending: - if _s.startswith('#['): - test_lines.add(_i); continue - _pending = False - if _ITEM_KW.search(_s): - _in_mod = True; _depth = _count_braces(_s); test_lines.add(_i) - if _depth <= 0: _in_mod = False - continue - if _in_mod: - test_lines.add(_i); _depth += _count_braces(_s) - if _depth <= 0: _in_mod = False -except Exception: - pass - -# --- Scan diff lines, skip test_lines --- -current_line = 0 -for raw in sys.stdin: - line = raw.rstrip('\n') - m = hunk_pat.match(line) - if m: - current_line = int(m.group(1)) - 1 - continue - if line.startswith('+++') or line.startswith('---'): - continue - if line.startswith('+'): - current_line += 1 - if current_line in test_lines: - continue - content = line[1:] - if danger_pat.search(content) and not comment_pat.match(content): - print('[RS-03] ' + fname + ':' + str(current_line) + ' ' + line) - elif not line.startswith('-'): - current_line += 1 -DIFFPYEOF - if ! python3 "${_diff_py}" "${f}" < "${_diff_tmp}" 2>/dev/null; then - echo "[RS-03] WARN: python3 failed to parse ${f}, use grep fallback" >&2 - <"${_diff_tmp}" grep '^+' \ - | grep -v '^+++' \ - | grep -E '\.(unwrap|expect)\(' \ - | grep -v '^\+[[:space:]]*//' \ - | while IFS= read -r line; do - echo "[RS-03] ${f}: ${line}" - done || true - fi - else - # Fallback when python3 is unavailable: no line numbers; suppression won't apply. - vg_staged_file_diff "${f}" \ - | grep '^+' \ - | grep -v '^+++' \ - | grep -E '\.(unwrap|expect)\(' \ - | grep -v '^\+[[:space:]]*//' \ - | while IFS= read -r line; do - echo "[RS-03] ${f}: ${line}" - done || true - fi - done <<< "${STAGED_RS}" - fi > "${TMPFILE}" || true - -# --- Standalone mode: ast-grep AST scan (accurately identify calling expressions, skip comments) --- -elif command -v ast-grep >/dev/null 2>&1; then - if ! command -v python3 >/dev/null 2>&1; then - echo "[RS-03] WARN: python3 is not available, use grep fallback" >&2 - list_rs_prod_files "${TARGET_DIR}" \ - | while IFS= read -r f; do - [[ -f "${f}" ]] && scan_with_awk_fallback "${f}" - done \ - > "${TMPFILE}" || true - else - _ASG_PER_FILE=$(create_tmpfile) - _PY_SCRIPT=$(create_tmpfile) - cat > "${_PY_SCRIPT}" << 'PYEOF' -import json, sys, re - -file_path = sys.argv[1] -test_lines = set() - -_raw_string_end = None -_in_string = False -_block_comment_depth = 0 - -def _count_braces(s): - global _raw_string_end, _in_string, _block_comment_depth - depth = 0 - i = 0 - while i < len(s): - if _raw_string_end is not None: - end = s.find(_raw_string_end, i) - if end < 0: - return depth - i = end + len(_raw_string_end) - _raw_string_end = None - continue - if _in_string: - if s[i] == '\\': - i += 2 - elif s[i] == '"': - _in_string = False - i += 1 - else: - i += 1 - continue - if _block_comment_depth: - if s.startswith('/*', i): - _block_comment_depth += 1 - i += 2 - elif s.startswith('*/', i): - _block_comment_depth -= 1 - i += 2 - else: - i += 1 - continue - if s.startswith('//', i): - break - if s.startswith('/*', i): - _block_comment_depth = 1 - i += 2 - continue - - prefix_len = 0 - if s.startswith(('br', 'cr'), i): - prefix_len = 2 - elif s.startswith('r', i): - prefix_len = 1 - if prefix_len and (i == 0 or not (s[i - 1].isalnum() or s[i - 1] == '_')): - marker = i + prefix_len - while marker < len(s) and s[marker] == '#': - marker += 1 - if marker < len(s) and s[marker] == '"': - _raw_string_end = '"' + ('#' * (marker - i - prefix_len)) - i = marker + 1 - continue - - if s[i] == '"': - _in_string = True - i += 1 - continue - if s[i] == "'": - end = i + 1 - if end < len(s) and s[end] == '\\': - end += 1 - if end < len(s) and s[end] == 'u' and s.startswith('{', end + 1): - close = s.find('}', end + 2) - end = close + 1 if close >= 0 else len(s) - elif end < len(s) and s[end] == 'x': - end += 3 - else: - end += 1 - else: - end += 1 - if end < len(s) and s[end] == "'": - i = end + 1 - else: - # A lifetime or loop label is not a character literal. - i += 1 - continue - if s[i] == '{': - depth += 1 - elif s[i] == '}': - depth -= 1 - i += 1 - return depth - -_ITEM_KW = re.compile(r'\b(mod|fn|impl|struct|enum|type|trait)\b') - -try: - with open(file_path) as _src: - _all = _src.readlines() - _pending = False - _in_mod = False - _depth = 0 - for _i, _ln in enumerate(_all, 1): - _s = _ln.strip() - if _s.startswith('#[cfg(test)]'): - test_lines.add(_i) - # Inline form: #[cfg(test)] mod tests { ... } on one line - if _ITEM_KW.search(_s[len('#[cfg(test)]'):]): - _in_mod = True - _depth = _count_braces(_s) - if _depth <= 0: - _in_mod = False - else: - _pending = True - continue - if _pending: - # Keep pending through additional attribute lines (#[allow(...)], #[tokio::test], etc.) - if _s.startswith('#['): - test_lines.add(_i) - continue - _pending = False - if _ITEM_KW.search(_s): - _in_mod = True - _depth = _count_braces(_s) - test_lines.add(_i) - if _depth <= 0: - _in_mod = False - continue - if _in_mod: - test_lines.add(_i) - _depth += _count_braces(_s) - if _depth <= 0: - _in_mod = False -except Exception: - pass - -data = sys.stdin.read().strip() -if not data: - sys.exit(0) -try: - matches = json.loads(data) -except Exception as e: - print('[RS-03] WARN: JSON parsing failed: ' + str(e), file=sys.stderr) - sys.exit(1) -for m in matches: - l = m.get('range', {}).get('start', {}).get('line', 0) + 1 - if l in test_lines: - continue - fname = m.get('file', '') - msg = m.get('message', '') - print('[RS-03] ' + fname + ':' + str(l) + ' ' + msg) -PYEOF - list_rs_prod_files "${TARGET_DIR}" \ - | while IFS= read -r f; do - [[ -f "${f}" ]] || continue - _ASG_FILE_OUT=$(create_tmpfile) - if ast-grep scan \ - --rule "${RULES_DIR}/rs-03-unwrap.yml" \ - --json "${f}" > "${_ASG_FILE_OUT}" 2>/dev/null; then - python3 "${_PY_SCRIPT}" "${f}" < "${_ASG_FILE_OUT}" >> "${_ASG_PER_FILE}" || { - echo "[RS-03] WARN: JSON parsing failed ${f}, use grep fallback" >&2 - scan_with_awk_fallback "${f}" >> "${_ASG_PER_FILE}" - } - else - echo "[RS-03] WARN: ast-grep scan failed ${f}, use grep fallback" >&2 - scan_with_awk_fallback "${f}" >> "${_ASG_PER_FILE}" - fi - done - cat "${_ASG_PER_FILE}" > "${TMPFILE}" || true - fi - -# --- Fallback: Use grep when ast-grep is not available --- -else - list_rs_prod_files "${TARGET_DIR}" \ - | while IFS= read -r f; do - [[ -f "${f}" ]] && scan_with_awk_fallback "${f}" - done \ - > "${TMPFILE}" || true -fi - -apply_suppression_filter "${TMPFILE}" -sed 's/^\[RS-03\] /[RS-03] [review] [this-edit] OBSERVATION: /' "${TMPFILE}" -FOUND=$(wc -l < "${TMPFILE}" | tr -d ' ') - -echo "" -if [[ ${FOUND} -eq 0 ]]; then - echo "No unwrap()/expect() in production code." -else - echo "Found ${FOUND} unwrap()/expect() call(s) in production code." - echo "" - echo "SCOPE: this-line only — do not fix other unwrap calls, add error types, or change function signatures" - echo "ACTION: REVIEW" - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical RS-03 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust unwrap "$@" diff --git a/guards/rust/check_workspace_consistency.sh b/guards/rust/check_workspace_consistency.sh index 5fea86ab..596fb22e 100755 --- a/guards/rust/check_workspace_consistency.sh +++ b/guards/rust/check_workspace_consistency.sh @@ -1,273 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guard: Detect workspace cross-entry configuration consistency (RS-06) -# -# Scan all entries (bin crates) in the Cargo workspace and report: -# 1. The environment variable name used by each entry (env::var / env::var_os / option_env!) -# 2. Hardcoded path suffix of each entry (.db / .sqlite / .json, etc.) -# 3. Whether the core library vs. each entry shares path construction logic -# -#Purpose: To prevent multiple binaries from using different paths/env var, causing data splitting -# -# Usage: -# bash check_workspace_consistency.sh [workspace_dir] -# bash check_workspace_consistency.sh --strict [workspace_dir] - -source "$(dirname "$0")/common.sh" -parse_guard_args "$@" - -CARGO_TOML="${TARGET_DIR}/Cargo.toml" -if [[ ! -f "${CARGO_TOML}" ]]; then - echo "Not a Cargo workspace: ${CARGO_TOML} not found." - exit 0 -fi - -# Check if it is a workspace (there is [workspace] or workspace.members) -if ! grep -qE '^\[workspace\]|^workspace\.members' "${CARGO_TOML}" 2>/dev/null; then - echo "Not a Cargo workspace (no [workspace] section). Skipping." - exit 0 -fi - -echo "======================================" -echo "VibeGuard RS-06: Workspace Consistency" -echo "Workspace: ${TARGET_DIR}" -echo "======================================" -echo - -# Extract workspace members (simple parsing, processing "members = [...]" format) -MEMBERS=() -in_members=false -while IFS= read -r line; do - # Skip comments - [[ "${line}" =~ ^[[:space:]]*# ]] && continue - - if [[ "${line}" =~ members[[:space:]]*= ]]; then - in_members=true - fi - - if [[ "${in_members}" == true ]]; then - #Extract the path in quotes - while [[ "${line}" =~ \"([^\"]+)\" ]]; do - MEMBERS+=("${BASH_REMATCH[1]}") - line="${line#*\"${BASH_REMATCH[1]}\"}" - done - # Check if the end of the array is reached - if [[ "${line}" =~ \] ]]; then - in_members=false - fi - fi -done < "${CARGO_TOML}" - -# Expand glob patterns (such as "crates/*" → crates/foo, crates/bar) -EXPANDED=() -for member in "${MEMBERS[@]}"; do - if [[ "${member}" == *"*"* || "${member}" == *"?"* ]]; then - for expanded in ${TARGET_DIR}/${member}; do - if [[ -d "${expanded}" && -f "${expanded}/Cargo.toml" ]]; then - EXPANDED+=("${expanded#${TARGET_DIR}/}") - fi - done - else - EXPANDED+=("${member}") - fi -done -MEMBERS=("${EXPANDED[@]}") - -if [[ ${#MEMBERS[@]} -eq 0 ]]; then - echo "No workspace members found." - exit 0 -fi - -echo "Workspace members: ${MEMBERS[*]}" -echo - -FOUND=0 - -# --- Check 1: Environment variable usage --- -echo "--- Environment Variables ---" -echo - -for member in "${MEMBERS[@]}"; do - member_dir="${TARGET_DIR}/${member}" - [[ -d "${member_dir}/src" ]] || continue - - member_name=$(basename "${member}") - envvars=$(grep -rnoE '(env::var|env::var_os|option_env!)\s*\(\s*"([^"]*)"' "${member_dir}/src/" 2>/dev/null \ - | sed -E 's/.*"([^"]*)".*/\1/' \ - | sort -u) || true - - if [[ -n "${envvars}" ]]; then - echo " [${member_name}]" - while IFS= read -r var; do - echo " - ${var}" - done <<< "${envvars}" - echo - fi -done - -# --- Check 2: Hardcoded file path --- -echo "--- Hardcoded File Paths ---" -echo - -for member in "${MEMBERS[@]}"; do - member_dir="${TARGET_DIR}/${member}" - [[ -d "${member_dir}/src" ]] || continue - - member_name=$(basename "${member}") - # Fix RS-06: exclude comment lines (// ...) and const/static definitions which - # are intentional named constants, not hardcoded paths. - # Use -n (no -o) so the full source line is available for the downstream filters; - # -o would strip context and make the const/static/comment exclusions ineffective. - paths=$(grep -rnE '"[^"]*\.(db|sqlite|json|toml|yaml|yml|log)"' "${member_dir}/src/" 2>/dev/null \ - | { grep -vE '(/tests/|/test_|_test\.rs:|^\s*//|:[[:space:]]*//)' || true; } \ - | { grep -vE '(const[[:space:]]|static[[:space:]])' || true; }) || true - - if [[ -n "${paths}" ]]; then - echo " [${member_name}]" - while IFS= read -r p; do - echo " ${p}" - done <<< "${paths}" - echo - fi -done - -# --- Check 3: Data directory construction method --- -echo "--- Data Directory Construction ---" -echo - -for member in "${MEMBERS[@]}"; do - member_dir="${TARGET_DIR}/${member}" - [[ -d "${member_dir}/src" ]] || continue - - member_name=$(basename "${member}") - dir_calls=$(grep -rnoE '(data_local_dir|data_dir|home_dir|config_dir|config_local_dir)\s*\(' "${member_dir}/src/" 2>/dev/null \ - | { grep -v '/tests/' || true; }) || true - - if [[ -n "${dir_calls}" ]]; then - echo " [${member_name}]" - while IFS= read -r d; do - echo " ${d}" - done <<< "${dir_calls}" - echo - fi -done - -# --- Check 4: Cross-entry consistency analysis --- -echo "--- Consistency Analysis ---" -echo - -# Collect env var of all entries -declare -A ENV_VAR_MEMBERS -for member in "${MEMBERS[@]}"; do - member_dir="${TARGET_DIR}/${member}" - [[ -d "${member_dir}/src" ]] || continue - - member_name=$(basename "${member}") - envvars=$(grep -rhoE '(env::var|env::var_os|option_env!)\s*\(\s*"([^"]*)"' "${member_dir}/src/" 2>/dev/null \ - | sed -E 's/.*"([^"]*)".*/\1/' \ - | sort -u) || true - - while IFS= read -r var; do - [[ -z "${var}" ]] && continue - if [[ -n "${ENV_VAR_MEMBERS[${var}]+x}" ]]; then - ENV_VAR_MEMBERS["${var}"]="${ENV_VAR_MEMBERS[${var}]}, ${member_name}" - else - ENV_VAR_MEMBERS["${var}"]="${member_name}" - fi - done <<< "${envvars}" -done - -# Find env vars with similar semantics but different names (such as *_DB_PATH, *_DATABASE_URL) -db_vars=() -port_vars=() -host_vars=() -for var in "${!ENV_VAR_MEMBERS[@]}"; do - lower_var=$(echo "${var}" | tr '[:upper:]' '[:lower:]') - if [[ "${lower_var}" =~ (db|database|sqlite|storage) ]]; then - db_vars+=("${var} (${ENV_VAR_MEMBERS[${var}]})") - elif [[ "${lower_var}" =~ (port|listen) ]]; then - port_vars+=("${var} (${ENV_VAR_MEMBERS[${var}]})") - elif [[ "${lower_var}" =~ (host|addr|bind|url) ]]; then - host_vars+=("${var} (${ENV_VAR_MEMBERS[${var}]})") - fi -done - -if [[ ${#db_vars[@]} -gt 1 ]]; then - echo "[RS-06] Multiple database-related env vars detected:" - for v in "${db_vars[@]}"; do - echo " - ${v}" - done - echo "Repair: Unify to a single env var (such as APP_DB_PATH), provide the resolve_db_path() public function in the core layer, and call this function at all entrances." - echo - FOUND=$((FOUND + 1)) -fi - -if [[ ${#port_vars[@]} -gt 1 ]]; then - echo "[RS-06] Multiple port-related env vars detected:" - for v in "${port_vars[@]}"; do - echo " - ${v}" - done - echo - FOUND=$((FOUND + 1)) -fi - -if [[ ${#host_vars[@]} -gt 1 ]]; then - echo "[RS-06] Multiple host/addr-related env vars detected:" - for v in "${host_vars[@]}"; do - echo " - ${v}" - done - echo - FOUND=$((FOUND + 1)) -fi - -# Check whether the hardcoded database file names are consistent -declare -A DB_FILE_MEMBERS -for member in "${MEMBERS[@]}"; do - member_dir="${TARGET_DIR}/${member}" - [[ -d "${member_dir}/src" ]] || continue - - member_name=$(basename "${member}") - # Fix RS-06: also exclude comment lines and const/static definitions. - # Filter on full source lines (no -o), then extract the string literal value. - # Using -o before filtering discards the line context that the exclusion - # patterns (const/static/comments) rely on, making those filters ineffective. - db_files=$(grep -rnE '"[^"]*\.(db|sqlite)"' "${member_dir}/src/" 2>/dev/null \ - | { grep -vE '(/tests/|:[[:space:]]*//)' || true; } \ - | { grep -vE '(const[[:space:]]|static[[:space:]])' || true; } \ - | grep -oE '"[^"]*\.(db|sqlite)"' \ - | tr -d '"' \ - | sort -u) || true - - while IFS= read -r dbf; do - [[ -z "${dbf}" ]] && continue - if [[ -n "${DB_FILE_MEMBERS[${dbf}]+x}" ]]; then - DB_FILE_MEMBERS["${dbf}"]="${DB_FILE_MEMBERS[${dbf}]}, ${member_name}" - else - DB_FILE_MEMBERS["${dbf}"]="${member_name}" - fi - done <<< "${db_files}" -done - -if [[ ${#DB_FILE_MEMBERS[@]} -gt 1 ]]; then - echo "[RS-06] Multiple database file names detected across members:" - for dbf in "${!DB_FILE_MEMBERS[@]}"; do - echo " - ${dbf} → ${DB_FILE_MEMBERS[${dbf}]}" - done - echo "Risk: Different binaries create their own database files, resulting in data fragmentation." - echo "Repair: Define default_db_path() in the core layer to return a unique path and call it uniformly for all entries. Refer to vibeguard/rules/universal.md U-11." - echo - FOUND=$((FOUND + 1)) -fi - -# --- Summarize --- -echo "======================================" -if [[ ${FOUND} -eq 0 ]]; then - echo "No cross-entry consistency issues detected." -else - echo "Found ${FOUND} potential consistency issue(s)." - echo "" - echo "Overall repair strategy: Create a unified configuration/path parsing function in core/shared library, and all entries call the same function." - echo "Environment variables use a unified prefix (such as APP_), and data paths use dirs::data_local_dir() to unify the base directory." - if [[ "${STRICT}" == true ]]; then - exit 1 - fi -fi +# Compatibility entry point; the canonical RS-06 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard rust workspace-consistency "$@" diff --git a/guards/rust/common.sh b/guards/rust/common.sh index 673a3cca..3b97e8e1 100755 --- a/guards/rust/common.sh +++ b/guards/rust/common.sh @@ -1,309 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Rust Guards — shared function library -# -# All Rust guard scripts are imported through source common.sh to eliminate duplicate code. -# Provide: list_rs_files, parameter parsing, temporary file management +# Deprecated compatibility entrypoint. Guard behavior lives in vibeguard-runtime. -set -euo pipefail - -# Paths to always exclude from scanning (worktrees, build artifacts, IDE caches). -VIBEGUARD_EXCLUDE_PATHS='(.harness/worktrees/|/target/|/.git/|/node_modules/)' - -# Fallback only. The authoritative test-path classifier is -# `vibeguard-runtime test-path-filter`; keep this for old/unbuilt runtime paths. -VIBEGUARD_TEST_FILE_PATTERN='((^|/)tests/|(^|/)test/|(^|/)__tests__/|(^|/)spec/|(^|/)fixtures/|(^|/)mocks/|(^|/)testdata/|(^|/)examples/|(^|/)benches/|(^|/)test_|_test\.rs$|_[tT][eE][sS][tT][sS]\.[rR][sS]$|tests\.rs$|test_helpers\.rs$)' - -vibeguard_rust_runtime_path() { - local script_dir repo_dir candidate - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - repo_dir="$(cd "${script_dir}/../.." && pwd)" - for candidate in \ - "${VIBEGUARD_RUNTIME:-}" \ - "${repo_dir}/vibeguard-runtime/target/release/vibeguard-runtime" \ - "${repo_dir}/vibeguard-runtime/target/debug/vibeguard-runtime" \ - "${HOME:-}/.vibeguard/installed/bin/vibeguard-runtime"; do - [[ -n "${candidate}" && -f "${candidate}" && -x "${candidate}" ]] || continue - printf '%s\n' "${candidate}" - return 0 - done - return 1 -} - -filter_rs_prod_paths() { - local tmp runtime_path - tmp=$(create_tmpfile) - cat > "${tmp}" - if runtime_path="$(vibeguard_rust_runtime_path 2>/dev/null)" \ - && "${runtime_path}" test-path-filter --prod < "${tmp}" 2>/dev/null; then - return 0 - fi - grep -vE "${VIBEGUARD_TEST_FILE_PATTERN}" "${tmp}" || true -} - -# List .rs source files -# Priority: VIBEGUARD_STAGED_FILES (pre-commit mode, only scan staged) > git ls-files > find -# Automatically exclude worktree copies and build directories. -list_rs_files() { - local dir="$1" - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - grep '\.rs$' "${VIBEGUARD_STAGED_FILES}" 2>/dev/null \ - | { grep -vE "${VIBEGUARD_EXCLUDE_PATHS}" || true; } \ - || true - elif git -C "${dir}" rev-parse --is-inside-work-tree &>/dev/null; then - git -C "${dir}" ls-files '*.rs' \ - | { grep -vE "${VIBEGUARD_EXCLUDE_PATHS}" || true; } \ - | while IFS= read -r f; do echo "${dir}/${f}"; done - else - find "${dir}" -name '*.rs' -not -path '*/target/*' -not -path '*/.git/*' -not -path '*/.harness/worktrees/*' - fi -} - -# List non-test .rs files (exclude test files based on list_rs_files) -list_rs_prod_files() { - list_rs_files "$1" | filter_rs_prod_paths -} - -# Parse --strict flag and target_dir -# Usage: parse_guard_args "$@" -# Set variables: TARGET_DIR, STRICT -parse_guard_args() { - TARGET_DIR="." - STRICT=false - local positional_count=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --strict) - STRICT=true - ;; - --help|-h) - echo "Usage: $0 [--strict] [target_dir]" >&2 - return 1 - ;; - --*) - echo "Unknown option: $1" >&2 - return 1 - ;; - *) - positional_count=$((positional_count + 1)) - if [[ ${positional_count} -gt 1 ]]; then - echo "Too many positional arguments: $*" >&2 - return 1 - fi - TARGET_DIR="$1" - ;; - esac - shift - done -} - -# Temporary file cleaning directory: all guards share the same cleaning trap -_VG_TMPDIR="$(mktemp -d)" - -_vg_cleanup() { - [[ -n "$_VG_TMPDIR" && -d "$_VG_TMPDIR" ]] && rm -rf "$_VG_TMPDIR" || true -} -trap '_vg_cleanup' EXIT - -#Create temporary files and automatically clean them when the script exits -# Usage: TMPFILE=$(create_tmpfile) -create_tmpfile() { - mktemp "$_VG_TMPDIR/vg.XXXXXX" -} - -# --------------------------------------------------------------------------- -# Rename-aware staged diff -# --------------------------------------------------------------------------- -# `git diff --cached -- ` cannot pair a staged rename because the -# old path falls outside the pathspec, so a renamed file shows up as fully -# added and every pre-existing line looks new. Resolve the rename source once -# per process and diff both sides of the pathspec instead. - -_VG_STAGED_RENAME_NEW=() -_VG_STAGED_RENAME_OLD=() -_VG_STAGED_RENAME_MAP_LOADED="" - -_vg_load_staged_rename_map() { - if [[ -z "${_VG_STAGED_RENAME_MAP_LOADED}" ]]; then - # PERF-OK: one rename-aware name-status diff per guard process. - local status first_path second_path - while IFS= read -r -d '' status && IFS= read -r -d '' first_path; do - if [[ "$status" == R* || "$status" == C* ]]; then - IFS= read -r -d '' second_path || break - if [[ "$status" == R* ]]; then - _VG_STAGED_RENAME_NEW+=("$second_path") - _VG_STAGED_RENAME_OLD+=("$first_path") - fi - fi - done < <(git diff --cached -M --name-status -z 2>/dev/null) - _VG_STAGED_RENAME_MAP_LOADED=1 - fi -} - -# vg_path_is_test PATH -# True when PATH is classified as test/fixture code. Avoid filter_rs_prod_paths -# here because its temp-file pipeline is unnecessary for a single path. -vg_path_is_test() { - local path="$1" runtime_path output - if runtime_path="$(vibeguard_rust_runtime_path 2>/dev/null)" \ - && output="$(printf '%s\n' "$path" | "${runtime_path}" test-path-filter --prod 2>/dev/null)"; then - [[ -z "$output" ]] - return - fi - printf '%s\n' "$path" | grep -qE "${VIBEGUARD_TEST_FILE_PATTERN}" -} - -# vg_path_enforcement_class PATH -# Print the path properties that determine whether Rust guards enforce a file. -# Rename pairing is safe only when every property is unchanged; otherwise the -# destination must be treated as newly governed code. -vg_path_enforcement_class() { - local path="$1" - local normalized="/${path#/}" - local source=0 excluded=0 test=0 - [[ "$path" == *.rs ]] && source=1 - printf '%s\n' "$normalized" | grep -qE "${VIBEGUARD_EXCLUDE_PATHS}" && excluded=1 - vg_path_is_test "$path" && test=1 - printf '%s:%s:%s\n' "$source" "$excluded" "$test" -} - -vg_staged_inline_test_count() { - git show "$1" 2>/dev/null | grep -cE '^[[:space:]]*#\[cfg\(test\)\]' || true -} - -# vg_staged_file_diff FILE -# Prints the staged -U0 diff for one file, pairing staged renames so that -# moved-but-unchanged lines do not appear as additions. Pairing is skipped when -# the rename crosses a test/production boundary (e.g. tests/helper.rs → src/helper.rs), -# because the destination is newly under production enforcement. -vg_staged_file_diff() { - local f="$1" - local git_root rel old old_class new_class old_inline_tests new_inline_tests i - rel="$f" - if [[ "$f" == /* ]]; then - git_root=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [[ -n "$git_root" ]]; then - if command -v python3 >/dev/null 2>&1; then - # realpath resolution handles macOS /var -> /private/var symlinks. - rel=$(python3 -c "import os,sys; f=os.path.realpath(sys.argv[1]); r=os.path.realpath(sys.argv[2]); print(f[len(r)+1:] if f.startswith(r+os.sep) else sys.argv[1])" "$f" "$git_root" 2>/dev/null || echo "$f") - else - [[ "$f" == "$git_root/"* ]] && rel="${f#$git_root/}" - fi - fi - fi - _vg_load_staged_rename_map - old="" - for ((i = 0; i < ${#_VG_STAGED_RENAME_NEW[@]}; i++)); do - if [[ "${_VG_STAGED_RENAME_NEW[$i]}" == "$rel" ]]; then - old="${_VG_STAGED_RENAME_OLD[$i]}" - break - fi - done - if [[ -n "$old" ]]; then - old_class=$(vg_path_enforcement_class "$old") - new_class=$(vg_path_enforcement_class "$rel") - old_inline_tests=$(vg_staged_inline_test_count "HEAD:${old}") - new_inline_tests=$(vg_staged_inline_test_count ":${rel}") - if [[ "$old_class" == "$new_class" && "$new_inline_tests" -ge "$old_inline_tests" ]]; then - git diff --cached -M -U0 -- ":(top)${old}" ":(top)${rel}" 2>/dev/null - else - git diff --cached -U0 -- "$f" 2>/dev/null - fi - else - git diff --cached -U0 -- "$f" 2>/dev/null - fi -} - -# --------------------------------------------------------------------------- -# Inline suppression: // vibeguard-disable-next-line [-- reason] -# --------------------------------------------------------------------------- - -# check_suppression FILE LINE_NUM RULE_ID -# Returns 0 (suppressed) if the line before LINE_NUM has a disable comment for RULE_ID. -# In pre-commit mode (VIBEGUARD_STAGED_FILES set) reads from staged content so that -# unstaged suppression comments cannot bypass checks on staged violations. -check_suppression() { - local file="$1" line_num="$2" rule_id="$3" - local prev=$((line_num - 1)) - [[ $prev -lt 1 ]] && return 1 - local prev_line - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]]; then - # Pre-commit mode: read from staged content, not the working tree. - # git show ":path" requires a path relative to the repo root. - # Use python3 realpath resolution to handle macOS /var→/private/var symlinks. - local rel_file="$file" - if [[ "$file" == /* ]]; then - local git_root - git_root=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [[ -n "$git_root" ]]; then - if command -v python3 >/dev/null 2>&1; then - rel_file=$(python3 -c "import os,sys; f=os.path.realpath(sys.argv[1]); r=os.path.realpath(sys.argv[2]); print(f[len(r)+1:] if f.startswith(r+os.sep) else sys.argv[1])" "$file" "$git_root" 2>/dev/null || echo "$file") - else - [[ "$file" == "$git_root/"* ]] && rel_file="${file#$git_root/}" - fi - fi - fi - prev_line=$(git show ":${rel_file}" 2>/dev/null | sed -n "${prev}p" || true) - else - [[ ! -f "$file" ]] && return 1 - prev_line=$(sed -n "${prev}p" "$file" 2>/dev/null || true) - fi - if printf '%s' "$prev_line" \ - | grep -qE "^[[:space:]]*//[[:space:]]*vibeguard-disable-next-line[[:space:]]+${rule_id}([[:space:]]|--|$)"; then - return 0 - fi - return 1 -} - -# apply_suppression_filter TMPFILE -# Reads findings from TMPFILE in format "[RULE-ID] file:line ..." and removes those -# suppressed by a vibeguard-disable-next-line comment on the preceding source line. -# Modifies TMPFILE in-place. -apply_suppression_filter() { - local tmpfile="$1" - [[ ! -s "$tmpfile" ]] && return 0 - - local filtered_file - filtered_file=$(create_tmpfile) - - while IFS= read -r finding; do - # Must start with [RULE-ID] to be a suppressible finding - local rule_id - rule_id=$(printf '%s' "$finding" | sed -n 's/^\[\([^]]*\)\].*/\1/p') - - if [[ -z "$rule_id" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - # Strip "[RULE-ID] " prefix to get "file:line ..." - local rest - rest="${finding#\[${rule_id}\] }" - - # Extract line number: first :digits sequence (file:line separator) - local line_num - line_num=$(printf '%s' "$rest" | grep -oE ':[0-9]+' | head -1 | tr -d ':' || true) - - if [[ -z "$line_num" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - # Extract file path: everything before :line_num - local file_path - file_path=$(printf '%s' "$rest" | sed "s/:${line_num}.*$//") - - if [[ ! -f "$file_path" ]]; then - printf '%s\n' "$finding" >> "$filtered_file" - continue - fi - - if check_suppression "$file_path" "$line_num" "$rule_id"; then - continue # suppressed — skip this finding - fi - - printf '%s\n' "$finding" >> "$filtered_file" - done < "$tmpfile" - - cp "$filtered_file" "$tmpfile" -} +source "$(dirname "${BASH_SOURCE[0]}")/runtime-shim.sh" diff --git a/guards/rust/runtime-shim.sh b/guards/rust/runtime-shim.sh new file mode 100755 index 00000000..49db14f3 --- /dev/null +++ b/guards/rust/runtime-shim.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_runtime_guard() { + local language="$1" rule="$2" + shift 2 + local script_dir repo_dir candidate + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + repo_dir="$(cd "${script_dir}/../.." && pwd)" + for candidate in \ + "${VIBEGUARD_RUNTIME:-}" \ + "${repo_dir}/vibeguard-runtime/target/debug/vibeguard-runtime" \ + "${repo_dir}/vibeguard-runtime/target/release/vibeguard-runtime" \ + "${HOME:-}/.vibeguard/installed/bin/vibeguard-runtime"; do + if [[ -n "${candidate}" && -f "${candidate}" && -x "${candidate}" ]]; then + exec "${candidate}" scan "${language}" "${rule}" "$@" + fi + done + printf '%s\n' "VIBEGUARD ERROR: vibeguard-runtime not found. Run setup.sh or cargo build --release --manifest-path vibeguard-runtime/Cargo.toml." >&2 + exit 2 +} diff --git a/guards/typescript/check_any_abuse.sh b/guards/typescript/check_any_abuse.sh index 325754dd..ec687092 100755 --- a/guards/typescript/check_any_abuse.sh +++ b/guards/typescript/check_any_abuse.sh @@ -1,170 +1,4 @@ #!/usr/bin/env bash -# VibeGuard TypeScript Guard — [TS-01] any type abuse detection / [TS-02] ts-ignore detection -# -# Use ast-grep to do AST level detection and eliminate comment/string false positives. -# When ast-grep is unavailable, fall back to grep detection. -# Detect `as any`, `: any` (TS-01) and `@ts-ignore`, `@ts-nocheck` (TS-02) in non-test files. -# -# Usage: -# bash check_any_abuse.sh [--strict] [target_dir] -# -# --strict mode: any violation exits with a non-zero exit code - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -RULES_DIR="${SCRIPT_DIR}/../ast-grep-rules" -source "${SCRIPT_DIR}/common.sh" -parse_guard_args "$@" - -RESULTS=$(create_tmpfile) - -# --- Baseline/diff filtering: only report problems on new lines (pre-commit or --baseline mode) --- -_LINEMAP="" -_IN_DIFF_MODE=false -if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] || [[ -n "${BASELINE_COMMIT:-}" ]]; then - _IN_DIFF_MODE=true - _LINEMAP=$(create_tmpfile) - vg_build_diff_linemap "$_LINEMAP" '\.(ts|tsx|js|jsx)$' -fi - -# --- TS-01: as any and : any type annotations --- -_USE_GREP_FALLBACK=false - -if command -v ast-grep >/dev/null 2>&1; then - if ! command -v python3 >/dev/null 2>&1; then - echo "[TS-01] WARN: python3 is not available, use grep fallback" >&2 - _USE_GREP_FALLBACK=true - else - # staged mode: only scan staged TS files to avoid full warehouse scanning blocking irrelevant submissions - if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]]; then - _ASG_TARGETS=() - while IFS= read -r _VG_TARGET; do - [[ -n "$_VG_TARGET" ]] && _ASG_TARGETS+=("$_VG_TARGET") - done < <(list_ts_files "${TARGET_DIR}") - else - _ASG_TARGETS=("${TARGET_DIR}") - fi - - if [[ ${#_ASG_TARGETS[@]} -gt 0 ]]; then - _ASG_TMPOUT=$(create_tmpfile) - if ast-grep scan \ - --rule "${RULES_DIR}/ts-01-any.yml" \ - --json \ - "${_ASG_TARGETS[@]}" > "${_ASG_TMPOUT}"; then - VG_DIFF_LINEMAP="$_LINEMAP" VG_IN_DIFF_MODE="$_IN_DIFF_MODE" python3 -c ' -import json, sys, re, os -TEST_PATTERN = re.compile(r"(\.(test|spec)\.(ts|tsx|js|jsx)$|(^|/)tests/|(^|/)__tests__/|(^|/)test/|(^|/)vendor/)") -linemap_path = os.environ.get("VG_DIFF_LINEMAP", "") -in_diff_mode = os.environ.get("VG_IN_DIFF_MODE", "false") == "true" -added_set = set() -if linemap_path and os.path.isfile(linemap_path): - with open(linemap_path) as lm: - for entry in lm: - added_set.add(entry.strip()) -data = sys.stdin.read().strip() -if not data: - sys.exit(0) -try: - matches = json.loads(data) -except Exception as e: - print("[TS-01] WARN: ast-grep JSON parsing failed: " + str(e), file=sys.stderr) - sys.exit(1) -for m in matches: - f = m.get("file", "") - if TEST_PATTERN.search(f): - continue - line = m.get("range", {}).get("start", {}).get("line", 0) + 1 - # Baseline filtering: only report problems on new lines in diff. - # Use in_diff_mode instead of added_set non-empty to determine the diff mode. - # Avoid falling back to full scan when added_set is empty when only deleting rows. - if in_diff_mode and (f + ":" + str(line)) not in added_set: - continue - msg = m.get("message", "'any' type usage") - print("[TS-01] " + f + ":" + str(line) + " [review] [this-line] OBSERVATION: " + msg) -' < "${_ASG_TMPOUT}" >> "$RESULTS" || { - echo "[TS-01] WARN: python3 processing failed, use grep fallback" >&2 - _USE_GREP_FALLBACK=true - } - else - echo "[TS-01] WARN: ast-grep scan failed (the rule file may be missing), use grep fallback" >&2 - _USE_GREP_FALLBACK=true - fi - fi - fi -else - _USE_GREP_FALLBACK=true -fi - -if [[ "$_USE_GREP_FALLBACK" == true ]]; then - list_ts_files "${TARGET_DIR}" \ - | filter_non_test \ - | while IFS= read -r f; do - [[ -f "$f" ]] || continue - grep -nE '(:\s*any\b|\bas\s+any\b)' "$f" 2>/dev/null \ - | grep -v '^\s*//' \ - | while IFS= read -r line_info; do - LINE_NUM=$(echo "$line_info" | cut -d: -f1) - # Baseline filtering: only report problems on new lines - if [[ "$_IN_DIFF_MODE" == true ]]; then - grep -qxF "${f}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null || continue - fi - echo "[TS-01] ${f}:${LINE_NUM} [review] [this-line] OBSERVATION: 'any' type usage" - done - done >> "$RESULTS" || true -fi - -# --- TS-02: @ts-ignore and @ts-nocheck (comment instructions, grep accuracy is sufficient) --- -while IFS= read -r file; do - [[ -z "$file" ]] && continue - [[ ! -f "$file" ]] && continue - - while IFS= read -r line_info; do - [[ -z "$line_info" ]] && continue - LINE_NUM=$(echo "$line_info" | cut -d: -f1) - # Baseline filtering: only report problems on new lines - if [[ "$_IN_DIFF_MODE" == true ]]; then - grep -qxF "${file}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null || continue - fi - echo "[TS-02] ${file}:${LINE_NUM} [review] [this-line] OBSERVATION: uses '@ts-ignore' to suppress type check" >> "$RESULTS" - done < <(grep -n '@ts-ignore' "$file" 2>/dev/null || true) - - while IFS= read -r line_info; do - [[ -z "$line_info" ]] && continue - LINE_NUM=$(echo "$line_info" | cut -d: -f1) - # Baseline filtering: only report problems on new lines - if [[ "$_IN_DIFF_MODE" == true ]]; then - grep -qxF "${file}:${LINE_NUM}" "$_LINEMAP" 2>/dev/null || continue - fi - echo "[TS-02] ${file}:${LINE_NUM} [review] [this-line] OBSERVATION: uses '@ts-nocheck' to disable type checking for entire file" >> "$RESULTS" - done < <(grep -n '@ts-nocheck' "$file" 2>/dev/null || true) - -done < <(list_ts_files "$TARGET_DIR" | filter_non_test) - -apply_suppression_filter "$RESULTS" -COUNT_01=$(grep -cE '^\[TS-01\]' "$RESULTS" || true) -COUNT_02=$(grep -cE '^\[TS-02\]' "$RESULTS" || true) -COUNT=$((COUNT_01 + COUNT_02)) - -if [[ "$COUNT" -eq 0 ]]; then - echo "[TS-01] PASS: no type abuse detected" - exit 0 -fi - -if [[ "$COUNT_01" -gt 0 ]]; then - echo "[TS-01] ${COUNT_01} 'any' type usage instance(s):" - grep -E '^\[TS-01\]' "$RESULTS" -fi - -if [[ "$COUNT_02" -gt 0 ]]; then - echo "[TS-02] ${COUNT_02} @ts-ignore/@ts-nocheck instance(s):" - grep -E '^\[TS-02\]' "$RESULTS" -fi - -echo "" -echo "SCOPE: this-line only — do not modify tsconfig.json, disable type checking globally, or broaden suppressions" -echo "ACTION: REVIEW" - -if [[ "$STRICT" == "true" ]]; then - exit 1 -fi +# Compatibility entry point; the canonical TS-01/TS-02 implementation lives in vibeguard-runtime. +source "$(dirname "$0")/runtime-shim.sh" +run_runtime_guard typescript any-abuse "$@" diff --git a/guards/typescript/check_component_duplication.sh b/guards/typescript/check_component_duplication.sh index 4aa010d8..0d3b2115 100755 --- a/guards/typescript/check_component_duplication.sh +++ b/guards/typescript/check_component_duplication.sh @@ -1,150 +1,4 @@ #!/usr/bin/env bash -# VibeGuard Guard: check_component_duplication.sh (TS-13) -# Detect function-level duplication of React components and Hooks (different names and same functions) -# -# Usage: -# bash check_component_duplication.sh [project_dir] -# bash check_component_duplication.sh --strict [project_dir] -# -# Detection rules: -# 1. Duplication of UI primitives: multiple files define the FormField pattern of