-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Harness Engineering Hooks を追加(自動リント・設定保護・完了前テスト) #602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| #!/usr/bin/env python3 | ||
| """PreToolUse Hook: リンター・フォーマッター設定ファイルの編集をブロック | ||
|
|
||
| エージェントがリンターエラーに直面した場合、コードを修正する代わりに | ||
| リンター設定を緩和してテストをパスさせようとすることがある。 | ||
| このフックはそれを防止する。 | ||
|
|
||
| 参考: Harness Engineering ベストプラクティス | ||
| - 「コードを修正せよ、リンター設定を変更するな」 | ||
| """ | ||
| import sys | ||
| import json | ||
|
|
||
| data = json.load(sys.stdin) | ||
| tool_input = data.get("tool_input", {}) or {} | ||
| file_path = tool_input.get("file_path") or tool_input.get("path") or "" | ||
|
|
||
| if not file_path: | ||
| sys.exit(0) | ||
|
|
||
| # ── 保護対象のファイルパターン ────────────────────────────── | ||
| PROTECTED_BASENAMES = { | ||
| # ESLint | ||
| ".eslintrc", | ||
| ".eslintrc.js", | ||
| ".eslintrc.cjs", | ||
| ".eslintrc.json", | ||
| ".eslintrc.yml", | ||
| ".eslintrc.yaml", | ||
| "eslint.config.js", | ||
| "eslint.config.mjs", | ||
| "eslint.config.cjs", | ||
| "eslint.config.ts", | ||
| # Biome | ||
| "biome.json", | ||
| "biome.jsonc", | ||
| # Prettier | ||
| ".prettierrc", | ||
| ".prettierrc.js", | ||
| ".prettierrc.cjs", | ||
| ".prettierrc.json", | ||
| ".prettierrc.yml", | ||
| ".prettierrc.yaml", | ||
| ".prettierrc.toml", | ||
| "prettier.config.js", | ||
| "prettier.config.cjs", | ||
| "prettier.config.mjs", | ||
| # TypeScript | ||
| "tsconfig.json", | ||
| # Ruff (Python) | ||
| "ruff.toml", | ||
| # Lefthook / Husky | ||
| "lefthook.yml", | ||
| "lefthook-local.yml", | ||
| # golangci-lint | ||
| ".golangci.yml", | ||
| ".golangci.yaml", | ||
| # Clippy (Rust) - Cargo.toml の [lints] セクション | ||
| # Cargo.toml は汎用すぎるためここでは除外 | ||
| # SwiftLint | ||
| ".swiftlint.yml", | ||
| # ShellCheck | ||
| ".shellcheckrc", | ||
| # Pre-commit | ||
| ".pre-commit-config.yaml", | ||
| # Oxlint | ||
| ".oxlintrc.json", | ||
| } | ||
|
|
||
| # pyproject.toml は [tool.ruff] 等のリンター設定を含むが | ||
| # 汎用的すぎるためデフォルトでは保護しない | ||
|
|
||
| # ── パス判定 ─────────────────────────────────────────────── | ||
| from pathlib import PurePosixPath | ||
|
|
||
| basename = PurePosixPath(file_path).name | ||
|
|
||
| if basename in PROTECTED_BASENAMES: | ||
| msg = ( | ||
| f"BLOCKED: {basename} はリンター/フォーマッター設定ファイルです。\n" | ||
| f"コードを修正してください。設定を緩和してはいけません。\n" | ||
| f"FIX: リンターエラーの指示に従い、該当するソースコードを修正してください。" | ||
| ) | ||
| print(msg, file=sys.stderr) | ||
| sys.exit(2) | ||
|
|
||
| sys.exit(0) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| #!/usr/bin/env python3 | ||
| """PostToolUse Hook: ファイル編集後に自動フォーマット+リントを実行 | ||
|
|
||
| Write/Edit ツールでファイルが編集された後、対応するフォーマッター・リンターを | ||
| 自動実行し、残った違反を additionalContext として返す。 | ||
| エージェントは即座に自己修正できる。 | ||
|
|
||
| 対応言語: | ||
| - TypeScript/JavaScript: biome format + oxlint (フォールバック: prettier + eslint) | ||
| - Python: ruff check --fix + ruff format | ||
| - Shell: shellcheck (修正指示のみ) | ||
| """ | ||
| import sys | ||
| import json | ||
| import subprocess | ||
| import shutil | ||
| from pathlib import Path | ||
|
|
||
| data = json.load(sys.stdin) | ||
| tool_input = data.get("tool_input", {}) or {} | ||
| file_path = tool_input.get("file_path") or tool_input.get("path") or "" | ||
|
|
||
| if not file_path: | ||
| sys.exit(0) | ||
|
|
||
| path = Path(file_path) | ||
| if not path.exists(): | ||
| sys.exit(0) | ||
|
|
||
| suffix = path.suffix.lower() | ||
|
|
||
| # ── 言語判定 ────────────────────────────────────────────── | ||
| TS_JS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} | ||
| PYTHON = {".py"} | ||
| SHELL = {".sh", ".bash"} | ||
|
|
||
| if suffix not in TS_JS | PYTHON | SHELL: | ||
| sys.exit(0) | ||
|
|
||
|
|
||
| def run_silent(cmd: list[str]) -> None: | ||
| """コマンドをサイレント実行(失敗しても無視)""" | ||
| try: | ||
| subprocess.run(cmd, capture_output=True, timeout=30) | ||
| except (subprocess.TimeoutExpired, FileNotFoundError): | ||
| pass | ||
|
|
||
|
|
||
| def run_capture(cmd: list[str], max_lines: int = 20) -> str: | ||
| """コマンドを実行し出力を取得""" | ||
| try: | ||
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) | ||
| output = (result.stdout or "") + (result.stderr or "") | ||
| lines = output.strip().splitlines() | ||
| if len(lines) > max_lines: | ||
| lines = lines[:max_lines] + [f"... ({len(lines) - max_lines} more lines)"] | ||
| return "\n".join(lines) | ||
| except (subprocess.TimeoutExpired, FileNotFoundError): | ||
| return "" | ||
|
|
||
|
|
||
| diagnostics = "" | ||
| file_str = str(file_path) | ||
|
|
||
| if suffix in TS_JS: | ||
| # Phase 1: 自動修正(サイレント) | ||
| if shutil.which("biome"): | ||
| run_silent(["biome", "format", "--write", file_str]) | ||
| run_silent(["biome", "check", "--fix", file_str]) | ||
| elif shutil.which("prettier"): | ||
| run_silent(["prettier", "--write", file_str]) | ||
|
|
||
| if shutil.which("oxlint"): | ||
| run_silent(["oxlint", "--fix", file_str]) | ||
| elif shutil.which("npx"): | ||
| run_silent(["npx", "--yes", "oxlint", "--fix", file_str]) | ||
|
|
||
| # Phase 2: 残った違反を取得 | ||
| if shutil.which("oxlint"): | ||
| diagnostics = run_capture(["oxlint", file_str]) | ||
| elif shutil.which("npx"): | ||
| diagnostics = run_capture(["npx", "--yes", "oxlint", file_str]) | ||
|
|
||
| elif suffix in PYTHON: | ||
| if shutil.which("ruff"): | ||
| run_silent(["ruff", "check", "--fix", file_str]) | ||
| run_silent(["ruff", "format", file_str]) | ||
| diagnostics = run_capture(["ruff", "check", file_str]) | ||
|
|
||
| elif suffix in SHELL: | ||
| if shutil.which("shellcheck"): | ||
| diagnostics = run_capture(["shellcheck", "-f", "gcc", file_str]) | ||
|
|
||
| # ── 結果を返す ──────────────────────────────────────────── | ||
| # 問題がない場合はスキップ(oxlint の "Found 0 warnings and 0 errors" 等) | ||
| if diagnostics: | ||
| lower = diagnostics.lower() | ||
| if "0 warnings and 0 errors" in lower or "found 0 " in lower: | ||
| diagnostics = "" | ||
| # ruff: "All checks passed!" をスキップ | ||
| if "all checks passed" in lower: | ||
| diagnostics = "" | ||
|
|
||
| if diagnostics and diagnostics.strip(): | ||
| output = { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "PostToolUse", | ||
| "additionalContext": ( | ||
| f"[auto-lint] {path.name} に以下の問題が残っています。修正してください:\n" | ||
| f"{diagnostics}" | ||
| ), | ||
| } | ||
| } | ||
| json.dump(output, sys.stdout) | ||
|
|
||
| sys.exit(0) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| #!/usr/bin/env python3 | ||
| """Stop Hook: エージェント完了前にテストを実行して検証 | ||
|
|
||
| エージェントが作業完了を宣言する前に、利用可能なテストスイートを | ||
| 自動実行し、失敗があればエージェントに修正を促す。 | ||
|
|
||
| 無限ループ防止: STOP_HOOK_ACTIVE 環境変数で再帰実行を防ぐ。 | ||
| Git 変更がない場合はスキップ(新規セッションでの空実行を防止)。 | ||
| """ | ||
| import sys | ||
| import json | ||
| import subprocess | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| # 無限ループ防止 | ||
| if os.environ.get("STOP_HOOK_ACTIVE") == "1": | ||
| sys.exit(0) | ||
|
|
||
| os.environ["STOP_HOOK_ACTIVE"] = "1" | ||
|
|
||
| # Git リポジトリのルートを取得 | ||
| try: | ||
| result = subprocess.run( | ||
| ["git", "rev-parse", "--show-toplevel"], | ||
| capture_output=True, text=True, timeout=10 | ||
| ) | ||
| if result.returncode != 0: | ||
| sys.exit(0) | ||
| repo_root = Path(result.stdout.strip()) | ||
| except (subprocess.TimeoutExpired, FileNotFoundError): | ||
| sys.exit(0) | ||
|
|
||
| # ── Git 変更の有無を確認 ────────────────────────────────── | ||
| # ステージング済み or 未ステージングの変更がなければスキップ | ||
| try: | ||
| diff_result = subprocess.run( | ||
| ["git", "diff", "--name-only", "HEAD"], | ||
| capture_output=True, text=True, timeout=10, cwd=repo_root | ||
| ) | ||
| staged_result = subprocess.run( | ||
| ["git", "diff", "--cached", "--name-only"], | ||
| capture_output=True, text=True, timeout=10, cwd=repo_root | ||
| ) | ||
| untracked_result = subprocess.run( | ||
| ["git", "ls-files", "--others", "--exclude-standard"], | ||
| capture_output=True, text=True, timeout=10, cwd=repo_root | ||
| ) | ||
| has_changes = bool( | ||
| (diff_result.stdout or "").strip() | ||
| or (staged_result.stdout or "").strip() | ||
| or (untracked_result.stdout or "").strip() | ||
| ) | ||
| if not has_changes: | ||
| sys.exit(0) | ||
| except (subprocess.TimeoutExpired, FileNotFoundError): | ||
| sys.exit(0) | ||
|
|
||
| # ── package.json からテストスクリプトを検出 ──────────────── | ||
| pkg_json = repo_root / "package.json" | ||
| if not pkg_json.exists(): | ||
| sys.exit(0) | ||
|
|
||
| try: | ||
| with open(pkg_json) as f: | ||
| pkg = json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| sys.exit(0) | ||
|
|
||
| scripts = pkg.get("scripts", {}) | ||
|
|
||
| # パッケージマネージャーを判定 | ||
| PM = "npm" | ||
| if (repo_root / "pnpm-lock.yaml").exists(): | ||
| PM = "pnpm" | ||
| elif (repo_root / "yarn.lock").exists(): | ||
| PM = "yarn" | ||
| elif (repo_root / "bun.lockb").exists() or (repo_root / "bun.lock").exists(): | ||
| PM = "bun" | ||
|
|
||
| # ── テスト実行 ──────────────────────────────────────────── | ||
| TEST_SCRIPTS = ["test", "test:unit"] | ||
| test_script = None | ||
| for s in TEST_SCRIPTS: | ||
| if s in scripts: | ||
| test_script = s | ||
| break | ||
|
|
||
| if not test_script: | ||
| sys.exit(0) | ||
|
|
||
| try: | ||
| result = subprocess.run( | ||
| [PM, "run", test_script], | ||
| capture_output=True, text=True, timeout=300, cwd=repo_root, | ||
| env={**os.environ, "CI": "true", "STOP_HOOK_ACTIVE": "1"}, | ||
| ) | ||
| except subprocess.TimeoutExpired: | ||
|
Comment on lines
+94
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This block only handles Useful? React with 👍 / 👎. |
||
| output = { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "Stop", | ||
| "additionalContext": ( | ||
| "[stop-verification] テストがタイムアウトしました(5分)。\n" | ||
| "テストを確認してから再度完了してください。" | ||
| ), | ||
| } | ||
| } | ||
| json.dump(output, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| if result.returncode != 0: | ||
| # テスト失敗 → エージェントにフィードバック | ||
| stderr_lines = (result.stderr or "").strip().splitlines() | ||
| stdout_lines = (result.stdout or "").strip().splitlines() | ||
| # 最後の 30 行を取得 | ||
| all_lines = stdout_lines + stderr_lines | ||
| tail = all_lines[-30:] if len(all_lines) > 30 else all_lines | ||
| failure_output = "\n".join(tail) | ||
|
|
||
| output = { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "Stop", | ||
| "additionalContext": ( | ||
| "[stop-verification] テストが失敗しています。修正してから再度完了してください:\n" | ||
| f"{failure_output}" | ||
| ), | ||
| } | ||
| } | ||
| json.dump(output, sys.stdout) | ||
|
|
||
| sys.exit(0) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The success filter
"found 0 "is too broad and clears diagnostics even when errors remain (for example, summaries likeFound 0 warnings and N errors). In that caseadditionalContextis never emitted, so the auto-fix loop silently misses real lint failures and can allow completion with unresolved issues.Useful? React with 👍 / 👎.