#![cfg_attr(coverage_nightly, coverage(off))]
use super::hooks_command::HooksCommand;
use crate::services::configuration_service::{configuration, PmatConfig};
use anyhow::Result;
use std::fs;
use std::path::Path;
impl HooksCommand {
pub(super) fn normalize_hook_content(content: &str) -> String {
content
.lines()
.filter(|line| !line.contains("# Generated at:"))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn is_pmat_managed(&self, hook_path: &Path) -> Result<bool> {
if !hook_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(hook_path)?;
Ok(content.contains("auto-managed by PMAT") && content.contains("DO NOT EDIT"))
}
pub(super) async fn generate_hook_content(&self) -> Result<String> {
let config_service = configuration();
let config = config_service.get_config()?;
let header = self.generate_hook_header();
let env_vars = self.generate_env_vars(&config);
let checks = self.generate_quality_checks();
let hook_content = format!("{header}\n{env_vars}\n{checks}");
Ok(hook_content)
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub(crate) fn generate_hook_header(&self) -> String {
r#"#!/bin/bash
# Generated pre-commit hook (auto-managed by PMAT)
# DO NOT EDIT: This file is automatically generated
set -e
echo "🔍 PMAT Pre-commit Quality Gates"
echo "================================"
"#
.to_string()
}
fn generate_env_vars(&self, config: &PmatConfig) -> String {
format!(
r#"# Load current configuration dynamically
export PMAT_MAX_CYCLOMATIC_COMPLEXITY={}
export PMAT_MAX_COGNITIVE_COMPLEXITY={}
export PMAT_MIN_TEST_COVERAGE={}
export PMAT_MAX_SATD_COMMENTS=5
export PMAT_TASK_ID_PATTERN="PMAT-[0-9]{{4}}"
# GH-301: directories excluded from any hook filesystem scan.
# Override with: PMAT_PRECOMMIT_EXCLUDE_DIRS=".claude/worktrees target ..." git commit
export PMAT_PRECOMMIT_EXCLUDE_DIRS="${{PMAT_PRECOMMIT_EXCLUDE_DIRS:-.claude/worktrees .cursor/worktrees target node_modules .venv}}"
"#,
config.quality.max_complexity,
config.quality.max_cognitive_complexity,
config.quality.min_coverage as u32
)
}
fn generate_format_check() -> &'static str {
r#"# 0. Format check (Rust only — cargo fmt --check on staged .rs files)
STAGED_RS=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' 2>/dev/null)
if [ -n "$STAGED_RS" ] && command -v cargo &> /dev/null && [ -f Cargo.toml ]; then
echo -n " Format check... "
# --all matches the pre-push gate. Without it only the current package is
# checked, so an unformatted file in a workspace MEMBER passes pre-commit
# and then fails pre-push -- two gates disagreeing about the same repo.
FMT_OUTPUT=$(cargo fmt --all -- --check 2>&1)
if [ $? -eq 0 ]; then
echo "✅"
else
echo "❌"
echo " Unformatted Rust code detected. Run 'cargo fmt' before committing."
echo "$FMT_OUTPUT" | grep "^Diff in" | head -5
exit 1
fi
fi
"#
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub(crate) fn generate_quality_checks(&self) -> String {
let mut hook = String::from(
r#"# Check if pmat is available
if ! command -v pmat &> /dev/null; then
echo "⚠️ Warning: pmat not found in PATH"
echo " Install with: cargo install pmat"
exit 0 # Allow commit but warn
fi
echo "📊 Running quality gate checks..."
# GH-301: helper that strips PMAT_PRECOMMIT_EXCLUDE_DIRS prefixes from a stream
# of git-tracked paths. Never call `find` here — `find` traverses .gitignore'd
# paths (like .claude/worktrees/*) which blows up pre-commit time on monorepos
# with many agent worktrees and can even invoke nested Makefiles.
_pmat_filter_excluded() {
if [ -z "$PMAT_PRECOMMIT_EXCLUDE_DIRS" ]; then cat; return; fi
awk -v excl="$PMAT_PRECOMMIT_EXCLUDE_DIRS" '
BEGIN { n = split(excl, a, " ") }
{
skip = 0
for (i = 1; i <= n; i++) {
if (a[i] != "" && index($0, a[i] "/") == 1) { skip = 1; break }
}
if (!skip) print
}
'
}
# Detect if this repo has any source files at all (not just staged ones).
# Non-code repos (docs, configs, YAML, contracts) get a fast pass.
# Uses `git ls-files` (GH-301) so .gitignore'd directories — including
# .claude/worktrees/, target/, node_modules/ — are never scanned. Outside a
# git worktree we fall back to a bounded find with explicit prunes.
if git rev-parse --git-dir > /dev/null 2>&1; then
HAS_SOURCE_FILES=$(git ls-files -- \
'*.rs' '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' \
'*.go' '*.c' '*.cpp' '*.lua' '*.php' '*.swift' 2>/dev/null \
| _pmat_filter_excluded | head -n 1)
else
HAS_SOURCE_FILES=$(find . -maxdepth 4 -type f \( -name '*.rs' -o -name '*.py' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' -o -name '*.go' -o -name '*.c' -o -name '*.cpp' -o -name '*.lua' -o -name '*.php' -o -name '*.swift' \) -not -path './.git/*' -not -path '*/target/*' -not -path '*/node_modules/*' -not -path '*/.claude/worktrees/*' -not -path '*/.cursor/worktrees/*' -print -quit 2>/dev/null)
fi
if [ -z "$HAS_SOURCE_FILES" ]; then
echo " Project type: non-code (docs/configs/YAML)"
echo " Complexity check... ⏭️ (no source files in repo)"
echo " SATD check... ⏭️ (no source files in repo)"
echo ""
echo "✅ All quality gates passed!"
echo ""
exit 0
fi
"#,
);
hook.push_str(Self::generate_format_check());
hook.push_str(r#"
# 1. Complexity analysis (only staged source files, not entire project)
# Supports: Rust, Python, TypeScript, JavaScript, Go, C, C++, Lua, PHP, Swift
STAGED_SRC=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.go' '*.c' '*.cpp' '*.lua' '*.php' '*.swift' 2>/dev/null | head -20)
if [ -n "$STAGED_SRC" ]; then
echo -n " Complexity check... "
COMPLEXITY_FAILED=0
COMPLEXITY_DETAILS=""
for SRC_FILE in $STAGED_SRC; do
if [ -f "$SRC_FILE" ]; then
# ANSI codes MUST be stripped before matching. `pmat analyze
# complexity` colourises its summary, so the violation line is
# literally ESC[1;31mErrors:ESC[0m 2 -- there is no ": " sequence
# in it, and the previous pattern 'Errors.*: [1-9]' therefore NEVER
# matched. The gate silently passed every violation: a file
# measured at Cyclomatic 81 / Cognitive 120 against thresholds of
# 30/25 committed cleanly. Verified by hand:
# raw ^[[1;31mErrors:^[[0m 2 -> 'Errors.*: [1-9]' NO MATCH
# stripped Errors: 2 -> 'Errors: *[1-9]' MATCH
# NO_COLOR is set as well so the sed is belt-and-braces rather than
# the only line of defence.
FILE_OUTPUT=$(NO_COLOR=1 pmat analyze complexity --file "$SRC_FILE" --max-cyclomatic $PMAT_MAX_CYCLOMATIC_COMPLEXITY --max-cognitive $PMAT_MAX_COGNITIVE_COMPLEXITY 2>&1 | sed "s/$(printf '\\033')\[[0-9;]*m//g")
if echo "$FILE_OUTPUT" | grep -qE 'Errors: *[1-9]'; then
COMPLEXITY_FAILED=1
# Extract the offending file and functions
COMPLEXITY_DETAILS="${COMPLEXITY_DETAILS} ${SRC_FILE}:"$'\n'
FILE_VIOLATIONS=$(echo "$FILE_OUTPUT" | grep -E '^[0-9]+\. ' | grep -E 'Cyclomatic|Cognitive' | head -3)
COMPLEXITY_DETAILS="${COMPLEXITY_DETAILS}${FILE_VIOLATIONS}"$'\n'
fi
fi
done
if [ "$COMPLEXITY_FAILED" -eq 0 ]; then
echo "✅"
else
echo "❌"
echo "Issues Found:"
echo "$COMPLEXITY_DETAILS" | head -10
echo " Complexity exceeds thresholds (Cyclomatic: $PMAT_MAX_CYCLOMATIC_COMPLEXITY, Cognitive: $PMAT_MAX_COGNITIVE_COMPLEXITY)"
exit 1
fi
else
echo " Complexity check... ⏭️ (no source files staged)"
fi
# 2. SATD (Self-Admitted Quality Issues) check - informational only
echo -n " SATD check... "
# `pmat analyze satd` prints "Total violations: N". An older pattern looked
# for a phrase pmat never emitted, so the grep matched nothing and the
# `|| echo 0` fallback hardcoded the count to zero. Verified: a file with 3
# SATD markers reported "(0 SATD comments)".
# sed rather than a PCRE grep: PCRE support is GNU-only, and this hook also
# runs on macOS (BSD grep) where it fails and would silently restore the zero.
SATD_OUTPUT=$(pmat analyze satd 2>&1 | sed "s/$(printf '\033')\[[0-9;]*m//g")
SATD_COUNT=$(printf '%s' "$SATD_OUTPUT" | sed -n 's/.*Total violations:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1)
[ -n "$SATD_COUNT" ] || SATD_COUNT=0
if [ "$SATD_COUNT" -le "$PMAT_MAX_SATD_COMMENTS" ] 2>/dev/null; then
echo "✅ ($SATD_COUNT SATD comments)"
else
echo "⚠️ ($SATD_COUNT SATD comments, threshold: $PMAT_MAX_SATD_COMMENTS)"
fi
# 3. Documentation synchronization (only if docs structure exists)
if [ -d "docs/execution" ] || [ -f "CHANGELOG.md" ]; then
echo -n " Documentation check... "
if [ -f "docs/execution/roadmap.md" ] && [ -f "CHANGELOG.md" ]; then
echo "✅"
else
echo "⚠️ (docs/execution/roadmap.md or CHANGELOG.md missing)"
fi
fi
# 4. Task ID validation (if commit message available)
if [ -n "$1" ]; then
echo -n " Task ID check... "
if echo "$1" | grep -qE "$PMAT_TASK_ID_PATTERN"; then
echo "✅"
else
echo "⚠️"
echo " Warning: Commit message should contain task ID matching $PMAT_TASK_ID_PATTERN"
fi
fi
echo ""
echo "✅ All quality gates passed!"
echo ""
# Success
exit 0
"#);
hook
}
}
#[cfg(test)]
mod complexity_gate_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn complexity_gate_strips_ansi_before_matching() {
let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
let hook = cmd.generate_quality_checks();
assert!(
hook.contains("[0-9;]*m//g"),
"generated hook must pipe output through sed to strip ANSI codes"
);
assert!(
hook.contains("grep -qE 'Errors: *[1-9]'"),
"generated hook must use the ANSI-stripped pattern"
);
assert!(
!hook.contains("grep -q 'Errors.*: [1-9]'"),
"the ANSI-blind pattern must not come back: it never matches \
colourised output and silently passes all violations"
);
}
#[test]
fn satd_gate_matches_the_phrase_pmat_actually_prints() {
let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
let hook = cmd.generate_quality_checks();
assert!(
!hook.contains("Total SATD comments found:"),
"pmat never prints this phrase; matching it hardcodes SATD_COUNT to 0"
);
assert!(
hook.contains("Total violations:"),
"SATD gate must match pmat's real output line"
);
assert!(
!hook.contains("grep -oP"),
"grep -P is GNU-only; this hook also runs on macOS (BSD grep) where \
it fails and silently re-introduces a zero count"
);
}
#[test]
fn format_gate_uses_workspace_scope() {
let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
let hook = cmd.generate_quality_checks();
assert!(
hook.contains("cargo fmt --all -- --check"),
"pre-commit format scope must match pre-push (--all)"
);
}
#[test]
fn colourised_errors_line_has_no_literal_colon_space() {
let colourised = "\u{1b}[1;31mErrors:\u{1b}[0m 2";
assert!(
!colourised.contains("Errors: "),
"colourised output has no literal 'Errors: ' -- this is exactly \
why the old grep never fired"
);
let mut stripped = String::new();
let mut chars = colourised.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
stripped.push(c);
}
}
assert_eq!(stripped, "Errors: 2");
}
}