From c7d80a00d53364bddbc3db0e9a22c1afc13bbe81 Mon Sep 17 00:00:00 2001 From: Avi Avraham Date: Tue, 26 May 2026 17:23:02 +0300 Subject: [PATCH] feat: support Husky as equivalent pre-commit hook framework (#433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Husky (.husky/) is the most popular pre-commit hook tool for JavaScript/TypeScript/Java projects. Previously it scored only 10 points (always failing). Now .husky/ with hook scripts scores 60 points — same as .pre-commit-config.yaml — so projects using either framework can pass the deterministic enforcement attribute. Co-Authored-By: Claude Opus 4.6 --- docs/attributes.md | 5 ++-- src/agentready/assessors/testing.py | 24 +++++++++++----- tests/unit/test_assessors_testing.py | 43 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/docs/attributes.md b/docs/attributes.md index 54f09ed3..2bfc22c9 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -789,10 +789,11 @@ Pre-commit hooks give immediate local feedback. They can be bypassed with `--no- The assessor scores on a 100-point scale: - **`.pre-commit-config.yaml` present** (60 pts): pre-commit hooks configured +- **`.husky` directory with hook scripts** (60 pts): Husky git hooks configured (e.g., pre-commit, commit-msg) +- **`.husky` directory without hook scripts** (10 pts): Husky directory exists but no hooks defined - **`.claude/settings.json` with hooks** (30 pts): Claude Code hook configuration present -- **`.husky` directory** (10 pts): Husky git hooks configured -**Pass threshold**: 60 points or higher. A `.pre-commit-config.yaml` alone is sufficient to pass. +**Pass threshold**: 60 points or higher. Either `.pre-commit-config.yaml` or `.husky` with hook scripts is sufficient to pass. #### Remediation diff --git a/src/agentready/assessors/testing.py b/src/agentready/assessors/testing.py index d16a108f..330c3b43 100644 --- a/src/agentready/assessors/testing.py +++ b/src/agentready/assessors/testing.py @@ -588,9 +588,19 @@ def assess(self, repository: Repository) -> Finding: score += 10.0 evidence.append(".claude/settings.json exists") - if husky_dir.exists(): - score += 10.0 - evidence.append(".husky directory found (git hooks)") + if husky_dir.exists() and husky_dir.is_dir(): + hook_scripts = [ + f.name + for f in husky_dir.iterdir() + if f.is_file() and not f.name.startswith("_") + ] + if hook_scripts: + score += 60.0 + hooks_list = ", ".join(sorted(hook_scripts)) + evidence.append(f".husky directory found with hooks: {hooks_list}") + else: + score += 10.0 + evidence.append(".husky directory found but no hook scripts") score = min(score, 100.0) @@ -634,14 +644,14 @@ def _create_remediation(self) -> Remediation: summary="Set up deterministic enforcement with hooks and lint rules", steps=[ "Start with 2 hooks: auto-format on edit + block destructive operations", - "Install pre-commit framework for git hooks", + "Install pre-commit (Python) or Husky (Node.js) for git hooks", "Configure .claude/settings.json with agent hooks for team-wide sharing", "Add lint rules for import restrictions and architectural boundaries", ], - tools=["pre-commit"], + tools=["pre-commit", "husky"], commands=[ - "pip install pre-commit", - "pre-commit install", + "pip install pre-commit && pre-commit install", + "npx husky init", "mkdir -p .claude", ], examples=[ diff --git a/tests/unit/test_assessors_testing.py b/tests/unit/test_assessors_testing.py index 30c6ba84..c3901d80 100644 --- a/tests/unit/test_assessors_testing.py +++ b/tests/unit/test_assessors_testing.py @@ -561,6 +561,49 @@ def test_pre_commit_config_passes(self, tmp_path): assert finding.score >= 60.0 assert any("pre-commit" in e.lower() for e in finding.evidence) + def test_husky_with_hooks_passes(self, tmp_path): + """Test that .husky directory with hook scripts passes.""" + husky_dir = tmp_path / ".husky" + husky_dir.mkdir() + (husky_dir / "pre-commit").write_text("#!/bin/sh\nnpx lint-staged\n") + (husky_dir / "commit-msg").write_text("#!/bin/sh\nnpx commitlint --edit $1\n") + + repo = _make_repo(tmp_path) + assessor = DeterministicEnforcementAssessor() + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score >= 60.0 + assert any(".husky" in e for e in finding.evidence) + assert any("pre-commit" in e for e in finding.evidence) + assert any("commit-msg" in e for e in finding.evidence) + + def test_husky_empty_dir_does_not_pass(self, tmp_path): + """Test that .husky directory without hook scripts only gives partial score.""" + husky_dir = tmp_path / ".husky" + husky_dir.mkdir() + + repo = _make_repo(tmp_path) + assessor = DeterministicEnforcementAssessor() + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 10.0 + assert any("no hook scripts" in e for e in finding.evidence) + + def test_husky_ignores_underscore_files(self, tmp_path): + """Test that files starting with _ (like _/husky.sh) are ignored.""" + husky_dir = tmp_path / ".husky" + husky_dir.mkdir() + (husky_dir / "_").mkdir() + (husky_dir / "pre-commit").write_text("#!/bin/sh\nnpx lint-staged\n") + + repo = _make_repo(tmp_path) + assessor = DeterministicEnforcementAssessor() + finding = assessor.assess(repo) + + assert finding.score >= 60.0 + def test_no_config_fails(self, tmp_path): """Test fail when no enforcement config exists.""" repo = _make_repo(tmp_path)