Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
pull_request:
push:

permissions:
contents: read

jobs:
test:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install validation dependencies
run: python -m pip install --upgrade pip pytest ruff
- name: Test
run: python -m pytest -q
- name: Lint
run: python -m ruff check .
- name: Check formatting
run: python -m ruff format --check .
13 changes: 8 additions & 5 deletions hooks/lib/api_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ def extract_public_names(file_path: str) -> list[str]:

names = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if not node.name.startswith("_"):
names.append(node.name)
if isinstance(
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
) and not node.name.startswith("_"):
names.append(node.name)
return names


Expand All @@ -53,11 +54,12 @@ def validate_file_importable(file_path: str, project_root: str) -> tuple[bool, s

try:
import importlib

importlib.import_module(module_name)
return True, ""
except ImportError as e:
return False, f"import error: {e}"
except Exception:
except Exception: # noqa: BLE001 - imports may require arbitrary runtime setup
# Module has side effects or requires runtime setup -- treat as ok
return True, ""
finally:
Expand All @@ -72,10 +74,11 @@ def is_api_validation_enabled(project_root: str) -> bool:
return False
try:
import json

with open(config_path) as fh:
cfg = json.load(fh)
return bool(cfg.get("api_validation", False))
except Exception:
except Exception: # noqa: BLE001 - malformed optional configuration disables the feature
return False


Expand Down
20 changes: 15 additions & 5 deletions hooks/lib/complexity_scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@
import re

# Path-name signals (checked against the lowercased filename + directory components)
_PATH_HIGH = ("auth", "permission", "billing", "payment", "checkout", "invoice", "subscription")
_PATH_HIGH = (
"auth",
"permission",
"billing",
"payment",
"checkout",
"invoice",
"subscription",
)
_PATH_MED = ("admin", "upload", "delete", "remove", "purge", "migrate")

# Content keyword patterns
Expand All @@ -29,13 +37,16 @@
r"SELECT\s|INSERT\s|UPDATE\s|DELETE\s)",
re.IGNORECASE,
)
_BRANCH_PATTERN = re.compile(r"\b(if |elif |else:|match |case |switch\s*\()", re.MULTILINE)
_BRANCH_PATTERN = re.compile(
r"\b(if |elif |else:|match |case |switch\s*\()", re.MULTILINE
)
_PUBLIC_FUNC_PYTHON = re.compile(r"^def [a-z][a-z0-9_]*\(", re.MULTILINE)
_PUBLIC_FUNC_TS = re.compile(
r"(^export\s+(async\s+)?function\s+\w+|^\s*public\s+(async\s+)?\w+\s*\()", re.MULTILINE
r"(^export\s+(async\s+)?function\s+\w+|^\s*public\s+(async\s+)?\w+\s*\()",
re.MULTILINE,
)

_MAX_BRANCHES = 4 # cap contribution from branches
_MAX_BRANCHES = 4 # cap contribution from branches
_MAX_FUNCTIONS = 5 # cap contribution from public functions
_MAX_CONTENT_READ = 8000 # bytes -- avoid reading huge generated files

Expand Down Expand Up @@ -99,7 +110,6 @@ def score_file(file_path: str) -> tuple[int, str]:
score += func_hits
reasons.append(f"+{func_hits} function{'s' if func_hits > 1 else ''}")

depth, _ = score_to_depth(score)
reasoning = ""
if score >= 10 and reasons:
reasoning = f"{name_stem}: {' '.join(reasons)} = {score} scenarios"
Expand Down
71 changes: 54 additions & 17 deletions hooks/lib/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,52 @@

from __future__ import annotations

import json
import os
from typing import Optional

from hooks.lib.filter import RUNNER_REQUIRED_LANGUAGES, _norm
from hooks.lib.history_manager import format_history_context
from hooks.lib.last_failures_formatter import format_last_failures
from hooks.lib.session import load_session

_MAX_UNTRUSTED_JSON_CHARS = 3000
_MAX_CONTEXT_ITEMS = 5


def render_untrusted_file_data(entries: list[dict]) -> str:
"""Render repository-derived file metadata as bounded JSON data."""
payload = [
{
"path": entry.get("path", ""),
"status": entry.get("status", ""),
"hint": entry.get("hint", ""),
}
for entry in entries[:_MAX_CONTEXT_ITEMS]
if isinstance(entry, dict)
and isinstance(entry.get("path"), str)
and isinstance(entry.get("status", ""), str)
and isinstance(entry.get("hint", ""), str)
]
encoded = json.dumps(payload, ensure_ascii=True)
if len(encoded) <= _MAX_UNTRUSTED_JSON_CHARS:
return encoded
return json.dumps(
{
"item_count": len(entries),
"details_omitted": "untrusted file data exceeded the display budget",
},
ensure_ascii=True,
)


def get_test_file_path(
rel_path: str,
language: str,
runners: dict,
project_root: str,
) -> Optional[str]:
) -> str | None:
"""Return the absolute path of the expected test file for a source file."""
rel_path = _norm(rel_path)
runner_info = runners.get(language)
if not runner_info and runners and language not in RUNNER_REQUIRED_LANGUAGES:
runner_info = next(iter(runners.values()))
Expand All @@ -33,10 +63,10 @@ def get_test_file_path(
source_dir = os.path.dirname(rel_path)
test_filename = f"{basename}_test.go"
if source_dir:
return os.path.join(project_root, source_dir, test_filename)
return os.path.join(project_root, test_filename)
return _norm(os.path.join(project_root, source_dir, test_filename))
return _norm(os.path.join(project_root, test_filename))

test_location = runner_info.get("test_location", "tests/").rstrip("/")
test_location = runner_info.get("test_location", "tests/").rstrip("/\\")

if language == "python":
test_filename = f"test_{basename}.py"
Expand All @@ -59,17 +89,19 @@ def get_test_file_path(
for subdir in ("tests/Unit", "tests/Feature", "tests"):
candidate = os.path.join(project_root, subdir, test_filename)
if os.path.exists(candidate):
return candidate
return _norm(candidate)
is_feature = "/Http/" in rel_path or "/Controllers/" in rel_path
if is_feature:
feature_dir = runner_info.get("feature_test_dir", "tests/Feature").rstrip("/")
return os.path.join(project_root, feature_dir, test_filename)
unit_dir = runner_info.get("unit_test_dir", "tests/Unit").rstrip("/")
return os.path.join(project_root, unit_dir, test_filename)
feature_dir = runner_info.get("feature_test_dir", "tests/Feature").rstrip(
"/\\"
)
return _norm(os.path.join(project_root, feature_dir, test_filename))
unit_dir = runner_info.get("unit_test_dir", "tests/Unit").rstrip("/\\")
return _norm(os.path.join(project_root, unit_dir, test_filename))
else:
return None

return os.path.join(project_root, test_location, test_filename)
return _norm(os.path.join(project_root, test_location, test_filename))


def detect_framework_context(
Expand Down Expand Up @@ -117,11 +149,11 @@ def build_context_note(
language: str,
pending_count: int,
runners: dict,
project_root: Optional[str] = None,
existing_test_path: Optional[str] = None,
project_root: str | None = None,
existing_test_path: str | None = None,
) -> str:
"""Build the one-line context note for a new-file queued via Stop hook."""
runner_name: Optional[str] = None
runner_name: str | None = None
if language in runners:
runner_name = runners[language].get("command")
elif runners:
Expand Down Expand Up @@ -168,7 +200,7 @@ def build_context_note(
return ". ".join(parts) + "."


def build_bootstrap_note(runners: dict) -> Optional[str]:
def build_bootstrap_note(runners: dict) -> str | None:
"""Return a bootstrap instruction if any runner needs setup, else None."""
notes: list[str] = []
for lang, info in runners.items():
Expand Down Expand Up @@ -245,6 +277,7 @@ def build_startup_context(
lines.append(bootstrap)

from hooks.lib.style import build_style_context

style_ctx = build_style_context(project_root, runners)
if style_ctx:
lines.append("")
Expand Down Expand Up @@ -277,8 +310,12 @@ def build_compact_context(

if pending_files:
pending_paths = ", ".join(p["path"] for p in pending_files)
lines.append(f"tailtest: {len(pending_files)} file(s) pending from before compaction: {pending_paths}.")
lines.append("Read .tailtest/session.json and process pending files before responding to the user.")
lines.append(
f"tailtest: {len(pending_files)} file(s) pending from before compaction: {pending_paths}."
)
lines.append(
"Read .tailtest/session.json and process pending files before responding to the user."
)
if fix_attempts:
attempts_text = ", ".join(f"{k}: {v}" for k, v in fix_attempts.items())
lines.append(f"tailtest: fix attempts this session: {attempts_text}.")
Expand Down
Loading