From 4e6b604bb85a06b215a994a1d3dee48a19d63c5f Mon Sep 17 00:00:00 2001 From: hun99999 <48903443+hun99999@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:05:24 +0900 Subject: [PATCH] ci: add guardrail verification and tool inventory --- .github/workflows/ci.yml | 34 ++ docs/agent-setup-playbook.md | 9 + .../2026-05-12-agent-stack-modernization.md | 4 +- .../2026-06-08-vibe-coding-guardrails.md | 2 +- docs/vibe-coding-guardrails.md | 23 ++ scripts/audit_agent_stack.py | 11 + scripts/check_private_paths.py | 126 +++++++ scripts/inventory_optional_tools.py | 323 ++++++++++++++++++ tests/test_agent_stack_audit.py | 13 + tests/test_ci_workflow.py | 27 ++ tests/test_claude_plugin.py | 4 + tests/test_optional_tool_inventory.py | 74 ++++ tests/test_private_path_scan.py | 39 +++ tests/test_vibe_coding_guardrails.py | 39 ++- 14 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/check_private_paths.py create mode 100644 scripts/inventory_optional_tools.py create mode 100644 tests/test_ci_workflow.py create mode 100644 tests/test_optional_tool_inventory.py create mode 100644 tests/test_private_path_scan.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5fea07 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run tests + run: python3 -m unittest discover -s tests -p 'test_*.py' + + - name: Audit repository-generated agent stack + run: python3 scripts/audit_agent_stack.py --repo-only + + - name: Inventory optional tools without installing + run: python3 scripts/inventory_optional_tools.py --json + + - name: Scan for private paths and secret assignments + run: python3 scripts/check_private_paths.py + + - name: Check whitespace + run: git diff --check diff --git a/docs/agent-setup-playbook.md b/docs/agent-setup-playbook.md index 0fc9873..86206c6 100644 --- a/docs/agent-setup-playbook.md +++ b/docs/agent-setup-playbook.md @@ -65,6 +65,15 @@ When two valid scopes could apply, prefer the smaller one and explain what is in Inventory tools before installing anything. +This repository includes a read-only optional tool inventory helper. It does not install tools. + +```bash +python3 scripts/inventory_optional_tools.py +python3 scripts/inventory_optional_tools.py --json +``` + +Use its output as evidence, not as permission to install. The agent must still classify each tool, explain the reason, and ask before installing anything. + For every relevant tool, report: - detected state: installed, missing, unknown, or not applicable diff --git a/docs/superpowers/plans/2026-05-12-agent-stack-modernization.md b/docs/superpowers/plans/2026-05-12-agent-stack-modernization.md index b85e2ab..2aacd81 100644 --- a/docs/superpowers/plans/2026-05-12-agent-stack-modernization.md +++ b/docs/superpowers/plans/2026-05-12-agent-stack-modernization.md @@ -831,7 +831,7 @@ python3 scripts/render_claude_plugin.py --partner-name "Hun" Expected: ```text -Rendered Claude plugin bundle at /Users/hooooonje/codex-dotfiles/plugins/process-first-agents +Rendered Claude plugin bundle at /plugins/process-first-agents ``` - [ ] **Step 6: Run focused tests** @@ -1032,7 +1032,7 @@ python3 scripts/render_claude_plugin.py --partner-name "Hun" Expected: ```text -Rendered Claude plugin bundle at /Users/hooooonje/codex-dotfiles/plugins/process-first-agents +Rendered Claude plugin bundle at /plugins/process-first-agents ``` - [ ] **Step 5: Run focused tests** diff --git a/docs/superpowers/plans/2026-06-08-vibe-coding-guardrails.md b/docs/superpowers/plans/2026-06-08-vibe-coding-guardrails.md index 40a9d64..33901ba 100644 --- a/docs/superpowers/plans/2026-06-08-vibe-coding-guardrails.md +++ b/docs/superpowers/plans/2026-06-08-vibe-coding-guardrails.md @@ -113,7 +113,7 @@ class VibeCodingGuardrailsDocsTests(unittest.TestCase): for heading in expected_headings: self.assertIn(heading, template) - self.assertNotIn("/Users/", template) + self.assertNotIn("/" + "Users/", template) self.assertNotIn("C:\\\\Users\\\\", template) def test_copy_paste_prompts_cover_apply_and_start_workflows(self) -> None: diff --git a/docs/vibe-coding-guardrails.md b/docs/vibe-coding-guardrails.md index e2c4e65..03dcbed 100644 --- a/docs/vibe-coding-guardrails.md +++ b/docs/vibe-coding-guardrails.md @@ -16,6 +16,29 @@ The fix is not one huge prompt. The fix is a repeatable operating loop: The loop should be boring. Boring is good here. +## Applied Checklist Mapping + +This section maps the original vibe-coding problem checklist to concrete artifacts in this repository. + +| Original issue or fix | Repository application | +| --- | --- | +| agent memory is not continuous | `docs/agent-setup-playbook.md` and `docs/local-project-knowledge-template.md` tell agents to build persistent project maps instead of rediscovering helpers, types, shapes, commands, and boundaries every session. | +| hidden coupling | `AGENTS.md`, this guide, and role prompts require pre-write checks for module boundaries, dependency direction, hidden imports, initialization order, global state, and side effects. | +| weak tests | `AGENTS.md` requires TDD; this guide requires behavior, failure-path, edge-case, and side-effect tests instead of string-only checks. | +| edge cases | The write gate requires empty input, null or missing values, boundary values, failure paths, side effects, and concurrency checks when relevant. | +| deep nesting | `AGENTS.md` tells agents to use guard clauses or early returns when nesting grows past two or three levels. | +| god functions | This guide tells agents to avoid god functions and god files, and the post-write review checks whether responsibility concentrated in one file. | +| circular dependencies | The pre-write lens asks agents to inspect dependency direction and likely cycle risks before editing. | +| re-export drift | The pre-write lens and post-write review both call out re-export and barrel files as coupling risks. | +| silent fallback | `AGENTS.md`, this guide, and the setup playbook all forbid swallowed errors and fallback behavior unless it is a documented requirement. | +| flat directories | `docs/local-project-knowledge-template.md` asks agents to record architecture maps, module boundaries, dependency direction, public APIs, and known hotspots so directory boundaries stay visible. | + +The support scripts reinforce those rules: + +- `scripts/inventory_optional_tools.py` gives agents a read-only way to classify Obsidian, Lumin Repo Lens, dependency lint, strict type checks, cycle detection, and complexity limits before asking to install anything. +- `scripts/check_private_paths.py` scans tracked files for concrete private paths and secret assignments. +- `scripts/audit_agent_stack.py` checks local harness state and generated Claude plugin drift; `--repo-only` keeps CI focused on repository artifacts. + ## Operating Loop ### Pre-write lens diff --git a/scripts/audit_agent_stack.py b/scripts/audit_agent_stack.py index 1ac1da4..ddd1eea 100644 --- a/scripts/audit_agent_stack.py +++ b/scripts/audit_agent_stack.py @@ -361,7 +361,11 @@ def run_audit( superpowers_path: Path, agents_home: Path, online: bool, + repo_only: bool = False, ) -> list[CheckResult]: + if repo_only: + return [check_claude_generated_bundle(repo_root)] + checks = [ check_cli(name, command, package_name, required, online) for name, command, package_name, required in CLI_CHECKS @@ -430,6 +434,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: action="store_true", help="Treat missing optional tools as failures.", ) + parser.add_argument( + "--repo-only", + action="store_true", + help="Skip local CLI and user-home checks; verify repository-generated artifacts only.", + ) return parser.parse_args(argv) @@ -445,6 +454,7 @@ def main(argv: list[str]) -> int: Path(args.superpowers_path), Path(args.agents_home), args.online, + args.repo_only, ) failed = should_fail(checks, args.strict) @@ -452,6 +462,7 @@ def main(argv: list[str]) -> int: payload = { "ok": not failed, "online": args.online, + "repo_only": args.repo_only, "checks": [asdict(check) for check in checks], } print(json.dumps(payload, indent=2, sort_keys=True)) diff --git a/scripts/check_private_paths.py b/scripts/check_private_paths.py new file mode 100644 index 0000000..88942e8 --- /dev/null +++ b/scripts/check_private_paths.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Scan tracked text files for concrete private paths and secret assignments.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +MAC_USER_PREFIX = "/" + "Users/" +WINDOWS_USER_PREFIX = "C:" + "\\Users\\" + +PRIVATE_PATTERNS = ( + ( + "private-user-path", + re.compile( + rf"({re.escape(MAC_USER_PREFIX)}[^\s`'\"<>]+|{re.escape(WINDOWS_USER_PREFIX)}[^\s`'\"<>]+)" + ), + ), + ( + "secret-assignment", + re.compile( + r"(?i)\b[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH_COOKIE)\b\s*[:=]\s*['\"]?[^'\"\s]+" + ), + ), +) +DEFAULT_EXCLUDED_SUFFIXES = { + ".pyc", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".pdf", + ".zip", +} + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + label: str + excerpt: str + + +def tracked_files(repo_root: Path) -> list[Path]: + result = subprocess.run( + ["git", "ls-files"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "git ls-files failed") + return [repo_root / line for line in result.stdout.splitlines() if line.strip()] + + +def should_scan(path: Path) -> bool: + if path.suffix.lower() in DEFAULT_EXCLUDED_SUFFIXES: + return False + return True + + +def scan_text(path: Path, text: str) -> list[Finding]: + findings: list[Finding] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + for label, pattern in PRIVATE_PATTERNS: + if pattern.search(line): + findings.append( + Finding( + path=str(path), + line=line_number, + label=label, + excerpt=line.strip()[:160], + ) + ) + return findings + + +def scan_paths(paths: list[Path]) -> list[Finding]: + findings: list[Finding] = [] + for path in paths: + if not should_scan(path): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + findings.extend(scan_text(path, text)) + return findings + + +def render_findings(findings: list[Finding]) -> str: + if not findings: + return "Private path scan: ok" + lines = ["Private path scan: findings"] + for finding in findings: + lines.append(f"- {finding.path}:{finding.line}: {finding.label}: {finding.excerpt}") + return "\n".join(lines) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + default=".", + help="Repository root. Defaults to the current directory.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + repo_root = Path(args.repo_root).expanduser().resolve() + findings = scan_paths(tracked_files(repo_root)) + print(render_findings(findings)) + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/inventory_optional_tools.py b/scripts/inventory_optional_tools.py new file mode 100644 index 0000000..9a297aa --- /dev/null +++ b/scripts/inventory_optional_tools.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Inventory optional guardrail tools without installing anything.""" + +from __future__ import annotations + +import argparse +import json +import platform +import shutil +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + + +SCHEMA = "agent-bootstrap.optional-tool-inventory.v1" +INSTALL_STATUS = "not installed by this script" +CommandLookup = Callable[[str], str | None] +PathExists = Callable[[Path], bool] + + +@dataclass(frozen=True) +class ToolInventory: + name: str + detected_state: str + decision: str + reason: str + evidence: list[str] + install_status: str = INSTALL_STATUS + + +def default_command_lookup(command: str) -> str | None: + return shutil.which(command) + + +def default_path_exists(path: Path) -> bool: + return path.expanduser().exists() + + +def load_package_json(repo_root: Path) -> dict[str, object]: + package_path = repo_root / "package.json" + if not package_path.exists(): + return {} + try: + data = json.loads(package_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + if not isinstance(data, dict): + return {} + return data + + +def read_package_sections(package: dict[str, object]) -> dict[str, str]: + values: dict[str, str] = {} + for section_name in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + section = package.get(section_name) + if isinstance(section, dict): + for key, value in section.items(): + values[str(key)] = str(value) + return values + + +def read_package_scripts(package: dict[str, object]) -> dict[str, str]: + scripts = package.get("scripts") + if not isinstance(scripts, dict): + return {} + return {str(key): str(value) for key, value in scripts.items()} + + +def repo_has_ts_js(repo_root: Path) -> bool: + if (repo_root / "package.json").exists() or (repo_root / "tsconfig.json").exists(): + return True + for pattern in ("*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs"): + if any(repo_root.rglob(pattern)): + return True + return False + + +def dependency_or_script_mentions( + package: dict[str, object], + names: tuple[str, ...], +) -> bool: + dependencies = read_package_sections(package) + scripts = read_package_scripts(package) + lowered_names = tuple(name.lower() for name in names) + + for dependency in dependencies: + if dependency.lower() in lowered_names: + return True + script_text = "\n".join(scripts.values()).lower() + return any(name in script_text for name in lowered_names) + + +def classify_obsidian( + command_lookup: CommandLookup, + path_exists: PathExists, + platform_name: str, +) -> ToolInventory: + evidence = [] + detected = False + + if command_lookup("obsidian") is not None: + detected = True + evidence.append("obsidian command is on PATH") + if platform_name == "Darwin" and path_exists(Path("/Applications/Obsidian.app")): + detected = True + evidence.append("/Applications/Obsidian.app exists") + + if not evidence: + evidence.append("No Obsidian command or documented app path was detected") + + return ToolInventory( + name="obsidian", + detected_state="installed" if detected else "missing", + decision="optional", + reason="Obsidian is useful for private cross-project knowledge, but it is not required for guardrails.", + evidence=evidence, + ) + + +def classify_lumin_repo_lens( + repo_root: Path, + command_lookup: CommandLookup, + path_exists: PathExists, +) -> ToolInventory: + evidence = [] + detected = False + ts_js = repo_has_ts_js(repo_root) + + if path_exists(Path("~/.codex/lumin-repo-lens")): + detected = True + evidence.append("~/.codex/lumin-repo-lens exists") + if command_lookup("lumin-repo-lens") is not None: + detected = True + evidence.append("lumin-repo-lens command is on PATH") + evidence.append("Target repository appears TS/JS-heavy" if ts_js else "No TS/JS repository signal detected") + + return ToolInventory( + name="lumin-repo-lens", + detected_state="installed" if detected else "missing", + decision="recommended" if ts_js else "skipped", + reason=( + "TS/JS structure evidence can help catch duplicate helpers, hidden coupling, re-export drift, and fan-in/fan-out hotspots." + if ts_js + else "Lumin Repo Lens is TS/JS-focused, and this repository does not show TS/JS signals." + ), + evidence=evidence, + ) + + +def classify_dependency_lint(repo_root: Path, package: dict[str, object]) -> ToolInventory: + ts_js = repo_has_ts_js(repo_root) + detected = dependency_or_script_mentions( + package, + ("dependency-cruiser", "eslint-plugin-boundaries", "madge"), + ) + evidence = [] + if detected: + evidence.append("package.json dependencies or scripts mention dependency boundary tooling") + else: + evidence.append("No package.json dependency boundary tooling signal detected") + + return ToolInventory( + name="dependency-lint", + detected_state="installed" if detected else "missing", + decision="recommended" if ts_js and detected else ("optional" if ts_js else "skipped"), + reason=( + "The repo already has dependency boundary tooling signals; tightening existing checks is low-risk." + if ts_js and detected + else "Dependency lint is useful, but adding new tooling requires user approval." + if ts_js + else "No TS/JS dependency graph signal was found." + ), + evidence=evidence, + ) + + +def classify_strict_type_checks(repo_root: Path, package: dict[str, object]) -> ToolInventory: + tsconfig_path = repo_root / "tsconfig.json" + evidence = [] + strict_enabled = False + if tsconfig_path.exists(): + try: + tsconfig = json.loads(tsconfig_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + tsconfig = {} + compiler_options = tsconfig.get("compilerOptions") if isinstance(tsconfig, dict) else None + strict_enabled = isinstance(compiler_options, dict) and compiler_options.get("strict") is True + evidence.append("tsconfig.json exists") + if strict_enabled: + evidence.append("compilerOptions.strict is true") + else: + evidence.append("No tsconfig.json detected") + + has_type_script = dependency_or_script_mentions(package, ("typescript", "tsc")) or tsconfig_path.exists() + return ToolInventory( + name="strict-type-checks", + detected_state="installed" if strict_enabled else ("missing" if has_type_script else "not applicable"), + decision="recommended" if strict_enabled else ("optional" if has_type_script else "skipped"), + reason=( + "Strict TypeScript checking is already enabled." + if strict_enabled + else "TypeScript appears present, but strict checking was not confirmed." + if has_type_script + else "No TypeScript type-checking signal was detected." + ), + evidence=evidence, + ) + + +def classify_cycle_detection(repo_root: Path, package: dict[str, object]) -> ToolInventory: + ts_js = repo_has_ts_js(repo_root) + detected = dependency_or_script_mentions(package, ("madge", "dependency-cruiser", "circular")) + evidence = [ + "package.json dependencies or scripts mention cycle detection" + if detected + else "No cycle detection signal detected" + ] + + return ToolInventory( + name="cycle-detection", + detected_state="installed" if detected else "missing", + decision="recommended" if ts_js and detected else ("optional" if ts_js else "skipped"), + reason=( + "Cycle detection appears available through existing repo tooling." + if ts_js and detected + else "Cycle detection could help, but adding new tooling requires user approval." + if ts_js + else "No TS/JS import graph signal was found." + ), + evidence=evidence, + ) + + +def classify_complexity_limits(repo_root: Path, package: dict[str, object]) -> ToolInventory: + ts_js = repo_has_ts_js(repo_root) + detected = dependency_or_script_mentions(package, ("eslint", "complexity", "max-depth", "max-lines")) + evidence = [ + "package.json dependencies or scripts mention lint or complexity-related tooling" + if detected + else "No complexity limit signal detected" + ] + + return ToolInventory( + name="complexity-limits", + detected_state="installed" if detected else "missing", + decision="recommended" if ts_js and detected else ("optional" if ts_js else "skipped"), + reason=( + "Existing lint tooling can host complexity, depth, and size limits." + if ts_js and detected + else "Complexity limits are useful, but adding new tooling requires user approval." + if ts_js + else "No relevant lint tooling signal was found." + ), + evidence=evidence, + ) + + +def build_inventory( + repo_root: Path, + command_lookup: CommandLookup = default_command_lookup, + path_exists: PathExists = default_path_exists, + platform_name: str | None = None, +) -> list[ToolInventory]: + root = repo_root.expanduser().resolve() + package = load_package_json(root) + current_platform = platform_name or platform.system() + return [ + classify_obsidian(command_lookup, path_exists, current_platform), + classify_lumin_repo_lens(root, command_lookup, path_exists), + classify_dependency_lint(root, package), + classify_strict_type_checks(root, package), + classify_cycle_detection(root, package), + classify_complexity_limits(root, package), + ] + + +def render_text(results: list[ToolInventory]) -> str: + lines = ["Optional tool inventory:"] + for result in results: + lines.append(f"- {result.name}: {result.decision} ({result.detected_state})") + lines.append(f" reason: {result.reason}") + lines.append(f" install: {result.install_status}") + for evidence in result.evidence: + lines.append(f" evidence: {evidence}") + return "\n".join(lines) + + +def render_json(results: list[ToolInventory]) -> str: + payload = { + "schema": SCHEMA, + "tools": [asdict(result) for result in results], + } + return json.dumps(payload, indent=2, sort_keys=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + default=".", + help="Repository root to inspect. Defaults to the current directory.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON instead of text.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + results = build_inventory(Path(args.repo_root)) + if args.json: + print(render_json(results)) + else: + print(render_text(results)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_agent_stack_audit.py b/tests/test_agent_stack_audit.py index b098693..0c97e8c 100644 --- a/tests/test_agent_stack_audit.py +++ b/tests/test_agent_stack_audit.py @@ -159,6 +159,19 @@ def test_claude_generated_bundle_matches_renderer(self) -> None: self.assertEqual(result.status, "ok", msg=result.detail) self.assertTrue(result.required) + def test_repo_only_audit_skips_local_cli_and_superpowers_checks(self) -> None: + audit = load_audit_module() + + checks = audit.run_audit( + REPO_ROOT, + Path("/missing/superpowers"), + Path("/missing/agents"), + online=False, + repo_only=True, + ) + + self.assertEqual([check.name for check in checks], ["claude-generated-bundle"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py new file mode 100644 index 0000000..473c916 --- /dev/null +++ b/tests/test_ci_workflow.py @@ -0,0 +1,27 @@ +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class CiWorkflowTests(unittest.TestCase): + def test_ci_workflow_runs_repository_verification_commands(self) -> None: + workflow_path = REPO_ROOT / ".github" / "workflows" / "ci.yml" + + self.assertTrue(workflow_path.exists(), f"missing workflow: {workflow_path}") + workflow = workflow_path.read_text(encoding="utf-8") + + expected_phrases = ( + "python3 -m unittest discover -s tests -p 'test_*.py'", + "python3 scripts/audit_agent_stack.py", + "python3 scripts/check_private_paths.py", + "python3 scripts/inventory_optional_tools.py --json", + "git diff --check", + ) + for phrase in expected_phrases: + self.assertIn(phrase, workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_claude_plugin.py b/tests/test_claude_plugin.py index e493a9c..1d94ddc 100644 --- a/tests/test_claude_plugin.py +++ b/tests/test_claude_plugin.py @@ -1,5 +1,6 @@ import importlib.util import json +import shutil import subprocess import tempfile import unittest @@ -93,6 +94,9 @@ def test_marketplace_points_at_plugin_package(self) -> None: ) def test_marketplace_manifest_passes_claude_validation(self) -> None: + if shutil.which("claude") is None: + self.skipTest("claude CLI is not installed") + result = self.run_claude_plugin_validate(MARKETPLACE_PATH) self.assertEqual( diff --git a/tests/test_optional_tool_inventory.py b/tests/test_optional_tool_inventory.py new file mode 100644 index 0000000..68ee671 --- /dev/null +++ b/tests/test_optional_tool_inventory.py @@ -0,0 +1,74 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from scripts import inventory_optional_tools + + +class OptionalToolInventoryTests(unittest.TestCase): + def test_inventory_classifies_ts_js_structure_tools_without_installing(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_root = Path(tmpdir) + (repo_root / "package.json").write_text( + json.dumps( + { + "scripts": { + "lint": "eslint .", + "deps": "dependency-cruiser src", + }, + "devDependencies": { + "dependency-cruiser": "^16.0.0", + "eslint": "^9.0.0", + }, + } + ), + encoding="utf-8", + ) + (repo_root / "tsconfig.json").write_text( + json.dumps({"compilerOptions": {"strict": True}}), + encoding="utf-8", + ) + (repo_root / "src").mkdir() + (repo_root / "src" / "index.ts").write_text("export const ok = true;\n", encoding="utf-8") + + results = inventory_optional_tools.build_inventory( + repo_root, + command_lookup=lambda command: None, + path_exists=lambda path: False, + platform_name="Darwin", + ) + by_name = {result.name: result for result in results} + + self.assertEqual(by_name["lumin-repo-lens"].decision, "recommended") + self.assertEqual(by_name["dependency-lint"].decision, "recommended") + self.assertEqual(by_name["strict-type-checks"].decision, "recommended") + self.assertEqual(by_name["cycle-detection"].decision, "recommended") + self.assertEqual(by_name["complexity-limits"].decision, "recommended") + self.assertEqual(by_name["obsidian"].decision, "optional") + for result in results: + self.assertEqual(result.install_status, "not installed by this script") + + def test_json_output_reports_required_decision_fields(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_root = Path(tmpdir) + results = inventory_optional_tools.build_inventory( + repo_root, + command_lookup=lambda command: None, + path_exists=lambda path: False, + platform_name="Linux", + ) + + payload = json.loads(inventory_optional_tools.render_json(results)) + first = payload["tools"][0] + + self.assertEqual(payload["schema"], "agent-bootstrap.optional-tool-inventory.v1") + self.assertIn("name", first) + self.assertIn("detected_state", first) + self.assertIn("decision", first) + self.assertIn("reason", first) + self.assertIn("install_status", first) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_private_path_scan.py b/tests/test_private_path_scan.py new file mode 100644 index 0000000..d890d9d --- /dev/null +++ b/tests/test_private_path_scan.py @@ -0,0 +1,39 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts import check_private_paths + + +class PrivatePathScanTests(unittest.TestCase): + def test_scan_reports_private_paths_and_secret_assignments(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + leaking = root / "leak.md" + leaking.write_text( + "vault=/" + "Users/hun/private-vault\n" + "API_" + "TOKEN" + "=secret-value\n", + encoding="utf-8", + ) + + findings = check_private_paths.scan_paths([leaking]) + + labels = {finding.label for finding in findings} + self.assertIn("private-user-path", labels) + self.assertIn("secret-assignment", labels) + + def test_scan_allows_policy_docs_without_concrete_private_values(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + safe = root / "policy.md" + safe.write_text( + "Do not commit private paths, credentials, MCP endpoints, or auth state.\n", + encoding="utf-8", + ) + + findings = check_private_paths.scan_paths([safe]) + + self.assertEqual(findings, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vibe_coding_guardrails.py b/tests/test_vibe_coding_guardrails.py index bbc8c3f..5eae126 100644 --- a/tests/test_vibe_coding_guardrails.py +++ b/tests/test_vibe_coding_guardrails.py @@ -103,7 +103,7 @@ def test_local_project_knowledge_template_captures_operational_index(self) -> No for heading in expected_headings: self.assertIn(heading, template) - self.assertNotIn("/Users/", template) + self.assertNotIn("/" + "Users/", template) self.assertNotIn("C:\\Users\\", template) def test_copy_paste_prompts_cover_apply_and_start_workflows(self) -> None: @@ -181,6 +181,43 @@ def test_readmes_point_master_prompt_at_agent_setup_playbook(self) -> None: self.assertIn("docs/agent-setup-playbook.md", master_prompt) + def test_playbook_points_agents_at_optional_tool_inventory_script(self) -> None: + playbook = (REPO_ROOT / "docs" / "agent-setup-playbook.md").read_text( + encoding="utf-8" + ) + + expected_phrases = ( + "python3 scripts/inventory_optional_tools.py", + "python3 scripts/inventory_optional_tools.py --json", + "read-only", + "does not install tools", + ) + for phrase in expected_phrases: + self.assertIn(phrase, playbook) + + def test_vibe_coding_guide_maps_original_problem_checklist_to_repo_artifacts(self) -> None: + guide = (REPO_ROOT / "docs" / "vibe-coding-guardrails.md").read_text( + encoding="utf-8" + ) + + expected_phrases = ( + "Applied Checklist Mapping", + "agent memory is not continuous", + "hidden coupling", + "weak tests", + "edge cases", + "god functions", + "circular dependencies", + "re-export", + "silent fallback", + "flat directories", + "docs/agent-setup-playbook.md", + "scripts/inventory_optional_tools.py", + "scripts/check_private_paths.py", + ) + for phrase in expected_phrases: + self.assertIn(phrase, guide) + if __name__ == "__main__": unittest.main()