Skip to content

Commit 39d2ac1

Browse files
authored
Merge pull request #467 from aviavraham/feat/433-husky-support
feat: support Husky as equivalent pre-commit hook framework
2 parents cbbcc90 + 0ec0a07 commit 39d2ac1

3 files changed

Lines changed: 92 additions & 9 deletions

File tree

docs/attributes.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -789,10 +789,11 @@ Pre-commit hooks give immediate local feedback. They can be bypassed with `--no-
789789
The assessor scores on a 100-point scale:
790790

791791
- **`.pre-commit-config.yaml` present** (60 pts): pre-commit hooks configured
792+
- **`.husky` directory with hook scripts** (60 pts): Husky git hooks configured (e.g., pre-commit, commit-msg)
793+
- **`.husky` directory without hook scripts** (10 pts): Husky directory exists but no hooks defined
792794
- **`.claude/settings.json` with hooks** (30 pts): Claude Code hook configuration present
793-
- **`.husky` directory** (10 pts): Husky git hooks configured
794795

795-
**Pass threshold**: 60 points or higher. A `.pre-commit-config.yaml` alone is sufficient to pass.
796+
**Pass threshold**: 60 points or higher. Either `.pre-commit-config.yaml` or `.husky` with hook scripts is sufficient to pass.
796797

797798
#### Remediation
798799

src/agentready/assessors/testing.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,41 @@ def assess(self, repository: Repository) -> Finding:
590590
score += 10.0
591591
evidence.append(".claude/settings.json exists")
592592

593-
if husky_dir.exists():
594-
score += 10.0
595-
evidence.append(".husky directory found (git hooks)")
593+
if husky_dir.exists() and husky_dir.is_dir():
594+
valid_hook_names = {
595+
"applypatch-msg",
596+
"commit-msg",
597+
"post-applypatch",
598+
"post-checkout",
599+
"post-commit",
600+
"post-merge",
601+
"post-rewrite",
602+
"pre-applypatch",
603+
"pre-auto-gc",
604+
"pre-commit",
605+
"pre-merge-commit",
606+
"pre-push",
607+
"pre-rebase",
608+
"prepare-commit-msg",
609+
}
610+
try:
611+
hook_scripts = [
612+
f.name
613+
for f in husky_dir.iterdir()
614+
if f.is_file()
615+
and not f.name.startswith("_")
616+
and f.name in valid_hook_names
617+
]
618+
except OSError:
619+
hook_scripts = []
620+
evidence.append(".husky directory exists but could not be read")
621+
if hook_scripts:
622+
score += 60.0
623+
hooks_list = ", ".join(sorted(hook_scripts))
624+
evidence.append(f".husky directory found with hooks: {hooks_list}")
625+
else:
626+
score += 10.0
627+
evidence.append(".husky directory found but no hook scripts")
596628

597629
score = min(score, 100.0)
598630

@@ -636,14 +668,14 @@ def _create_remediation(self) -> Remediation:
636668
summary="Set up deterministic enforcement with hooks and lint rules",
637669
steps=[
638670
"Start with 2 hooks: auto-format on edit + block destructive operations",
639-
"Install pre-commit framework for git hooks",
671+
"Install pre-commit (Python) or Husky (Node.js) for git hooks",
640672
"Configure .claude/settings.json with agent hooks for team-wide sharing",
641673
"Add lint rules for import restrictions and architectural boundaries",
642674
],
643-
tools=["pre-commit"],
675+
tools=["pre-commit", "husky"],
644676
commands=[
645-
"pip install pre-commit",
646-
"pre-commit install",
677+
"pip install pre-commit && pre-commit install",
678+
"npx husky init",
647679
"mkdir -p .claude",
648680
],
649681
examples=[

tests/unit/test_assessors_testing.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,56 @@ def test_pre_commit_config_passes(self, tmp_path):
723723
assert finding.score >= 60.0
724724
assert any("pre-commit" in e.lower() for e in finding.evidence)
725725

726+
def test_husky_with_hooks_passes(self, tmp_path):
727+
"""Test that .husky directory with hook scripts passes."""
728+
husky_dir = tmp_path / ".husky"
729+
husky_dir.mkdir()
730+
(husky_dir / "pre-commit").write_text("#!/bin/sh\nnpx lint-staged\n")
731+
(husky_dir / "commit-msg").write_text("#!/bin/sh\nnpx commitlint --edit $1\n")
732+
733+
repo = _make_repo(tmp_path)
734+
assessor = DeterministicEnforcementAssessor()
735+
finding = assessor.assess(repo)
736+
737+
assert finding.status == "pass"
738+
assert finding.score >= 60.0
739+
assert any(".husky" in e for e in finding.evidence)
740+
assert any("pre-commit" in e for e in finding.evidence)
741+
assert any("commit-msg" in e for e in finding.evidence)
742+
743+
def test_husky_empty_dir_does_not_pass(self, tmp_path):
744+
"""Test that .husky directory without hook scripts only gives partial score."""
745+
husky_dir = tmp_path / ".husky"
746+
husky_dir.mkdir()
747+
748+
repo = _make_repo(tmp_path)
749+
assessor = DeterministicEnforcementAssessor()
750+
finding = assessor.assess(repo)
751+
752+
assert finding.status == "fail"
753+
assert finding.score == 10.0
754+
assert any("no hook scripts" in e for e in finding.evidence)
755+
756+
def test_husky_ignores_underscore_files(self, tmp_path):
757+
"""Test that underscore-prefixed files and non-hook files are ignored."""
758+
husky_dir = tmp_path / ".husky"
759+
husky_dir.mkdir()
760+
(husky_dir / "_").mkdir()
761+
(husky_dir / "_local").write_text("#!/bin/sh\necho local\n")
762+
(husky_dir / ".gitignore").write_text("_\n")
763+
(husky_dir / "README.md").write_text("# Hooks\n")
764+
(husky_dir / "pre-commit").write_text("#!/bin/sh\nnpx lint-staged\n")
765+
766+
repo = _make_repo(tmp_path)
767+
assessor = DeterministicEnforcementAssessor()
768+
finding = assessor.assess(repo)
769+
770+
assert finding.score >= 60.0
771+
evidence_str = " ".join(finding.evidence)
772+
assert "_local" not in evidence_str
773+
assert ".gitignore" not in evidence_str
774+
assert "README.md" not in evidence_str
775+
726776
def test_no_config_fails(self, tmp_path):
727777
"""Test fail when no enforcement config exists."""
728778
repo = _make_repo(tmp_path)

0 commit comments

Comments
 (0)