chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
"""Shared helpers for changelog.d/ fragment-name validation.
Used by the blocking ``check-changelog-fragments`` hook and the advisory
``recommend-release-notes`` hook so the fragment grammar and category
list can't drift between them.
"""
import re
import tomllib
from pathlib import Path
# changelog.d/<id>.<category>[.<n>].md or changelog.d/+<slug>.<category>[.<n>].md
# - <id>: integer PR/issue number
# - +<slug>: orphan fragment with no PR/issue, slug is [A-Za-z0-9_-]+
# - .<n>: optional integer counter suffix for multiple fragments of the
# same (id, category) — towncrier renders each as a separate bullet,
# all linked back to the same PR/issue.
FRAGMENT_RE = re.compile(
r"^(?:\d+|\+[A-Za-z0-9_-]+)\.(?P<category>[a-z]+)(?:\.\d+)?\.md$"
)
def load_categories(pyproject_path):
"""Read ``[[tool.towncrier.type]].directory`` entries from pyproject.toml.
Raises on an unreadable file or a missing/empty type table. The
advisory hook catches and falls back to a default list; the blocking
hook lets the error propagate so a broken towncrier config fails
loud instead of validating against a guessed category list.
"""
with Path(pyproject_path).open("rb") as fh:
cfg = tomllib.load(fh)
types = cfg["tool"]["towncrier"]["type"]
cats = tuple(t["directory"] for t in types if "directory" in t)
if not cats:
raise ValueError(
f"no [[tool.towncrier.type]] entries in {pyproject_path}"
)
return cats
def classify_fragment(name, categories):
"""Classify a fragment filename against the grammar and *categories*.
Returns ``("ok", category)`` for a valid fragment, ``("bad-category",
category)`` for a well-formed name whose category isn't declared, or
``("bad-name", None)`` for a filename that doesn't match the fragment
pattern at all.
"""
m = FRAGMENT_RE.match(name)
if not m:
return "bad-name", None
category = m.group("category")
if category not in categories:
return "bad-category", category
return "ok", category
+232
View File
@@ -0,0 +1,232 @@
"""Shared utility module for pre-commit hooks analyzing staged files."""
import subprocess
from dataclasses import dataclass, field
from pathlib import PurePosixPath
@dataclass
class StagedFileInfo:
path: str
added: int
removed: int
is_new: bool
category: str
@dataclass
class CommitAnalysis:
source_files: list[StagedFileInfo] = field(default_factory=list)
test_files: list[StagedFileInfo] = field(default_factory=list)
doc_files: list[StagedFileInfo] = field(default_factory=list)
@property
def new_source_files(self):
return [f for f in self.source_files if f.is_new]
@property
def total_source_added(self):
return sum(f.added for f in self.source_files)
@property
def has_tests(self):
return len(self.test_files) > 0
@property
def has_docs(self):
return len(self.doc_files) > 0
# Directories/patterns excluded from "source" classification
_EXCLUDED_DIRS = {
"migrations",
"alembic",
"config",
"settings",
"scripts",
".pre-commit-hooks",
"examples",
"docs",
}
_EXCLUDED_BASENAMES = {"__init__.py", "conftest.py"}
def _run_git(args):
"""Run a git command and return stdout lines.
Returns empty list if git command fails (e.g., not in a git repo).
"""
result = subprocess.run(
["git"] + args,
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return result.stdout.strip().splitlines() if result.stdout.strip() else []
def _parse_numstat(lines):
"""Parse git diff --numstat output into {filepath: (added, removed)}."""
stats = {}
for line in lines:
parts = line.split("\t")
if len(parts) != 3:
continue
added_str, removed_str, filepath = parts
# Binary files show '-' for counts
added = int(added_str) if added_str != "-" else 0
removed = int(removed_str) if removed_str != "-" else 0
stats[filepath] = (added, removed)
return stats
def classify_file(path):
"""Classify a file path into a category string.
Returns one of: source, test, doc, init, conftest, migration, config,
hook, script, other
"""
p = PurePosixPath(path)
basename = p.name
parts_set = set(p.parts)
# Doc files
if p.suffix == ".md":
return "doc"
# JavaScript files
if p.suffix == ".js":
# Vitest test files: tests/js/**/*.test.js
if "tests" in parts_set and basename.endswith(".test.js"):
return "test"
# Source: under src/.../static/js/
if "static" in parts_set and "js" in parts_set and parts_set & {"src"}:
# Skip vendored / third-party
if "vendor" in parts_set or "lib" in parts_set:
return "other"
return "source"
return "other"
# Non-Python files are "other"
if p.suffix != ".py":
return "other"
# Specific basename exclusions
if basename == "__init__.py":
return "init"
if basename == "conftest.py":
return "conftest"
# Test files: under tests/ or matching test_*.py / *_test.py
if (
"tests" in parts_set
or basename.startswith("test_")
or basename.endswith("_test.py")
):
return "test"
# Check excluded directory patterns
for part in p.parts:
if part.lower() in _EXCLUDED_DIRS:
return _dir_to_category(part.lower())
# Source files: under src/
if len(p.parts) > 0 and p.parts[0] == "src":
return "source"
return "other"
def _dir_to_category(dirname):
"""Map excluded directory names to categories."""
mapping = {
"migrations": "migration",
"alembic": "migration",
"config": "config",
"settings": "config",
"scripts": "script",
".pre-commit-hooks": "hook",
"examples": "other",
"docs": "doc",
}
return mapping.get(dirname, "other")
def suggest_test_path(source_path):
"""Suggest a test file path for a given source file.
Python: src/local_deep_research/web/api.py -> tests/web/test_api.py
JS: src/local_deep_research/web/static/js/components/foo.js
-> tests/js/components/foo.test.js
"""
p = PurePosixPath(source_path)
# JavaScript: mirror tests/js/<subpath under static/js/>
if p.suffix == ".js":
parts = list(p.parts)
if "static" in parts and "js" in parts:
js_idx = parts.index("js", parts.index("static"))
sub_parts = parts[js_idx + 1 :]
if sub_parts:
stem = PurePosixPath(sub_parts[-1]).stem
# Convert snake_case → kebab-case to match existing test naming
stem = stem.replace("_", "-")
test_name = f"{stem}.test.js"
test_parts = ["tests", "js"] + sub_parts[:-1] + [test_name]
return str(PurePosixPath(*test_parts))
return f"tests/js/{p.stem.replace('_', '-')}.test.js"
parts = list(p.parts)
# Strip leading src/ and package name (e.g., src/local_deep_research/)
if parts and parts[0] == "src":
parts = parts[1:]
if parts and not parts[0].startswith("test"):
parts = parts[1:] # Remove package name like local_deep_research
# Build test path
if parts:
test_name = f"test_{parts[-1]}"
test_parts = ["tests"] + parts[:-1] + [test_name]
else:
test_parts = ["tests", f"test_{p.name}"]
return str(PurePosixPath(*test_parts))
def analyze_commit():
"""Analyze staged files and return a CommitAnalysis."""
# Get list of staged files with their status
status_lines = _run_git(["diff", "--cached", "--name-status"])
new_files = set()
for line in status_lines:
parts = line.split("\t")
if len(parts) >= 2 and parts[0].startswith("A"):
new_files.add(parts[-1])
# Get line counts
numstat_lines = _run_git(["diff", "--cached", "--numstat"])
stats = _parse_numstat(numstat_lines)
analysis = CommitAnalysis()
for filepath, (added, removed) in stats.items():
category = classify_file(filepath)
info = StagedFileInfo(
path=filepath,
added=added,
removed=removed,
is_new=filepath in new_files,
category=category,
)
if category == "source":
analysis.source_files.append(info)
elif category == "test":
analysis.test_files.append(info)
elif category == "doc":
analysis.doc_files.append(info)
return analysis
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
Pre-commit hook to prevent absolute module paths in the codebase.
Module path configuration values MUST use relative imports (starting with ".")
instead of absolute paths (starting with "local_deep_research.").
The module whitelist security boundary requires relative paths for search engines,
and absolute paths elsewhere are a packaging anti-pattern.
See: src/local_deep_research/security/module_whitelist.py
"""
import ast
import json
import sys
from pathlib import Path
# The absolute base that must NOT appear as a module path prefix
ABSOLUTE_BASE = "local_deep_research"
# Known legitimate uses of absolute package references that are NOT module_path
# config values. These are used for importlib.resources, package= kwargs, and
# normalization constants where the full package name is required by Python APIs.
LEGITIMATE_ABSOLUTE_REFS = frozenset(
{
# importlib.resources.files() requires the full package name
"local_deep_research.defaults.settings",
# importlib.import_module() to avoid circular imports
"local_deep_research.utilities",
# package= kwarg for relative import resolution
"local_deep_research.llm.providers",
# package= kwarg for relative import resolution in module_whitelist.py
"local_deep_research.web_search_engines",
}
)
# Directory patterns where absolute paths are allowed (matched as path components)
ALLOWED_DIR_PATTERNS = {
"tests", # Tests may verify rejection of absolute paths
".pre-commit-hooks", # Hook files themselves
}
# Specific file suffixes where absolute paths are allowed
ALLOWED_FILE_SUFFIXES = {
# Use os.sep-independent matching via PurePosixPath
"security/module_whitelist.py", # Defines the security boundary itself
}
# Basename patterns for test files
ALLOWED_BASENAME_PREFIXES = {"test_"} # test_*.py
ALLOWED_BASENAME_SUFFIXES = {"_test.py"} # *_test.py
class AbsoluteModulePathChecker(ast.NodeVisitor):
"""AST visitor to detect absolute module paths in string literals."""
def __init__(self, filename: str):
self.filename = filename
self.errors = []
def visit_Constant(self, node):
"""Check string constants for absolute module paths."""
if (
isinstance(node.value, str)
and node.value.startswith(ABSOLUTE_BASE + ".")
and node.value not in LEGITIMATE_ABSOLUTE_REFS
):
self.errors.append(
(
node.lineno,
f'Absolute module path "{node.value}" found. '
f"Use a relative path (starting with '.') instead.",
)
)
self.generic_visit(node)
def _is_file_allowed(filename: str) -> bool:
"""Check if this file is allowed to contain absolute module paths."""
p = Path(filename)
normalized = p.as_posix()
parts = p.parts
# Check directory patterns as path components
for pattern in ALLOWED_DIR_PATTERNS:
if pattern in parts:
return True
# Check specific file suffixes
for suffix in ALLOWED_FILE_SUFFIXES:
if normalized.endswith(suffix):
return True
# Check basename patterns
basename = p.name
for prefix in ALLOWED_BASENAME_PREFIXES:
if basename.startswith(prefix):
return True
for suffix in ALLOWED_BASENAME_SUFFIXES:
if basename.endswith(suffix):
return True
return False
def check_python_file(filename: str) -> bool:
"""Check a Python file for absolute module paths in string literals."""
if _is_file_allowed(filename):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
checker = AbsoluteModulePathChecker(filename)
checker.visit(tree)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors (let other tools handle that)
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def check_json_file(filename: str) -> bool:
"""Check a JSON config file for absolute module paths in module_path keys."""
if _is_file_allowed(filename):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
errors = []
def _check_value(obj, path=""):
if isinstance(obj, dict):
for key, value in obj.items():
current_path = f"{path}.{key}" if path else key
# Only check values under module_path keys
if key in ("module_path", "full_search_module") and isinstance(
value, str
):
if (
value.startswith(ABSOLUTE_BASE + ".")
and value not in LEGITIMATE_ABSOLUTE_REFS
):
errors.append((current_path, value))
elif isinstance(value, (dict, list)):
_check_value(value, current_path)
elif isinstance(obj, list):
for i, item in enumerate(obj):
_check_value(item, f"{path}[{i}]")
_check_value(data)
if errors:
print(f"\n{filename}:")
for key_path, value in errors:
print(
f' Key "{key_path}": Absolute module path "{value}" found. '
f"Use a relative path (starting with '.') instead."
)
return False
return True
def main():
"""Check all provided files for absolute module paths."""
if len(sys.argv) < 2:
print("Usage: check-absolute-module-paths.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if filename.endswith(".py"):
if not check_python_file(filename):
has_errors = True
elif filename.endswith(".json"):
if not check_json_file(filename):
has_errors = True
if has_errors:
print("\n" + "=" * 70)
print("Module Path Security: Absolute module paths detected!")
print("=" * 70)
print("\nModule paths must use relative imports (starting with '.')")
print("instead of absolute paths starting with 'local_deep_research.'.")
print("\nExamples:")
print(
' BAD: "local_deep_research.web_search_engines'
'.engines.search_engine_local"'
)
print(' GOOD: ".engines.search_engine_local"')
print("\nSee: src/local_deep_research/security/module_whitelist.py")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Guard ADR numbering under docs/decisions/.
Fails when two ADRs share a ``NNNN`` prefix — the exact accident of two
``0006-*`` records landing from separate PRs cut off main at different times.
Runs in CI via pre-commit, so a duplicate number can't merge silently.
The scan is deliberately loose about *what* counts as an ADR file: it matches
any file whose name starts with four digits and a separator (``-`` or ``_``),
in any subdirectory and with any extension. A number collision must be caught
even if a file is named slightly off-convention (uppercase slug, ``.rst``) or
tucked into an archive subfolder — a strict ``NNNN-slug.md`` match would let
those slip past and give false confidence.
"""
import re
import sys
from pathlib import Path
ADR_FILE_RE = re.compile(r"^(\d{4})[-_]")
def main() -> int:
decisions_dir = Path(__file__).parent.parent / "docs" / "decisions"
if not decisions_dir.is_dir():
return 0
seen: dict[str, str] = {}
errors: list[str] = []
for path in sorted(decisions_dir.rglob("*")):
if not path.is_file():
continue
match = ADR_FILE_RE.match(path.name)
if not match:
continue
number = match.group(1)
rel = str(path.relative_to(decisions_dir))
if number in seen:
errors.append(
f"Duplicate ADR number {number}: {rel} collides with "
f"{seen[number]}. Renumber one of them."
)
else:
seen[number] = rel
if errors:
print("ADR numbering check failed:\n")
for err in errors:
print(f" - {err}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Author identity consistency check.
Ensures every commit attributed to a project author declared in
``pyproject.toml`` (the ``authors`` list) uses an acceptable email identity:
either a GitHub ``@users.noreply.github.com`` address (always allowed — these
are privacy-preserving), or that author's own declared address. This keeps
contributor attribution consistent and prevents a declared author's commits
from going out under an unintended personal address.
It inspects commit *metadata* (author, committer, and ``Co-authored-by``
trailers) rather than file contents, so it runs once per invocation
(``always_run: true`` / ``pass_filenames: false``):
- In CI on a pull request, it checks every commit the PR adds (``merge-base..head``),
and reads the allow-list from the *base* ref so a PR cannot authorize an
address by editing ``pyproject.toml`` in the same change.
- Locally at the pre-commit stage, it checks the commit about to be made.
The allow-list is read from ``pyproject.toml`` at runtime — nothing is
hard-coded here. A mismatching address is never printed (so it can't end up in
logs); messages name only the declared author. Range-resolution failures fail
*closed* (the check fails rather than silently passing).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
NOREPLY_SUFFIX = "@users.noreply.github.com"
def _git(*args: str) -> tuple[int, str]:
"""Run git; return (returncode, stdout). stderr is captured and discarded."""
proc = subprocess.run(["git", *args], capture_output=True, text=True)
return proc.returncode, proc.stdout
def parse_identities(text: str) -> dict[str, set[str]]:
"""Map declared author name (lower-cased) -> set of declared emails (lower).
Tolerant of key order and surrounding whitespace; anchored to a top-level
``authors = [`` line so an unrelated ``*authors`` key can't hijack it.
"""
identities: dict[str, set[str]] = {}
block = re.search(r"(?m)^authors\s*=\s*\[(.*?)\]", text, re.S)
if not block:
return identities
for entry in re.findall(r"\{([^}]*)\}", block.group(1)):
name = re.search(r'name\s*=\s*"([^"]+)"', entry)
email = re.search(r'email\s*=\s*"([^"]+)"', entry)
if name and email:
identities.setdefault(name.group(1).strip().lower(), set()).add(
email.group(1).strip().lower()
)
return identities
def _is_noreply(email: str) -> bool:
return email.endswith(NOREPLY_SUFFIX)
def _mismatch(kind: str, name: str, email: str, declared: dict[str, set[str]]):
"""Return a message if (name, email) is a disallowed identity, else None.
The offending email is intentionally NOT included in the message.
"""
raw_name = (name or "").strip()
name_lc = raw_name.lower()
email = (email or "").strip().lower()
allowed = declared.get(name_lc)
if not allowed:
return None # not a declared author -> not enforced
if _is_noreply(email):
return None # any GitHub noreply is privacy-safe and allowed
if email in allowed:
return None # the author's own declared address
return (
f'{kind} "{raw_name}" is a declared author but is using a non-noreply '
f"address that is not its declared identity"
)
_CO_AUTHOR = re.compile(
r"^\s*Co-authored-by:\s*(?P<name>.*?)\s*<(?P<email>[^>]+)>\s*$", re.I | re.M
)
def _check(record, declared) -> list[str]:
sha, an, ae, cn, ce, body = record
out = []
for kind, name, email in (("author", an, ae), ("committer", cn, ce)):
msg = _mismatch(kind, name, email, declared)
if msg:
out.append(f" {sha[:9]}: {msg}")
for m in _CO_AUTHOR.finditer(body or ""):
msg = _mismatch(
"Co-authored-by", m.group("name"), m.group("email"), declared
)
if msg:
out.append(f" {sha[:9]}: {msg}")
return out
def _parse_log(raw: str) -> list[tuple]:
records = []
for chunk in raw.split("\x1e"):
chunk = chunk.strip("\n")
if not chunk:
continue
fields = chunk.split("\x00")
if len(fields) < 6:
# A control char (\x1e / \x00) injected into the author/committer
# name or body corrupts the framing. Never silently skip a commit
# -> fail closed.
raise RuntimeError("malformed commit record in git log output")
# body may itself contain NULs -> rejoin the tail
records.append(
(
fields[0],
fields[1],
fields[2],
fields[3],
fields[4],
"\x00".join(fields[5:]),
)
)
return records
def _resolve_merge_base(base: str, head: str) -> str:
"""Return merge-base(base, head). Raise on failure.
Resolve from locally-available history FIRST and fetch only as a fallback.
This hook runs inside ``pre-commit run``, which treats any working-tree or
git-state change a hook makes as a hook failure. The workflow checks out
full history (``fetch-depth: 0``) precisely so the PR-CI path resolves the
range with no fetch -- mutating nothing. The fetch fallback exists only for
a shallow checkout and should not run in the PR-CI path.
"""
rc, mb = _git("merge-base", base, head)
if rc == 0 and mb.strip():
return mb.strip()
# Endpoints not present locally (shallow clone): fetch, then retry.
subprocess.run(
["git", "fetch", "--quiet", "--depth=1000", "origin", head, base],
check=False,
)
rc, mb = _git("merge-base", base, head)
if rc != 0 or not mb.strip():
# Diverged further than the shallow window — get full history, retry.
subprocess.run(
["git", "fetch", "--quiet", "--unshallow", "origin"], check=False
)
rc, mb = _git("merge-base", base, head)
if rc != 0 or not mb.strip():
raise RuntimeError("could not resolve the PR commit range")
return mb.strip()
def _pr_context():
"""In PR CI: return (records, base_pyproject_text). None if not PR CI.
Raises RuntimeError on an unresolvable range (caller fails closed).
"""
is_pr_event = os.environ.get("GITHUB_EVENT_NAME", "") in (
"pull_request",
"pull_request_target",
)
def give_up(reason: str):
# On a genuine PR event an unusable payload must FAIL CLOSED, never drop
# to the local no-op path. Off a PR event, this simply isn't PR CI.
if is_pr_event:
raise RuntimeError(reason)
return
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not (event_path and Path(event_path).exists()):
return give_up("pull_request event but no event payload")
try:
with open(event_path, encoding="utf-8") as fh:
payload = json.load(fh)
except (OSError, ValueError):
return give_up("could not read the PR event payload")
pr = payload.get("pull_request") if isinstance(payload, dict) else None
if not isinstance(pr, dict):
return give_up("pull_request event missing its payload")
base = (pr.get("base") or {}).get("sha")
head = (pr.get("head") or {}).get("sha")
if not (base and head):
return give_up("pull_request event missing base/head sha")
merge_base = _resolve_merge_base(base, head) # may raise -> fail closed
rc, raw = _git(
"log",
f"{merge_base}..{head}",
"--format=%H%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x1e",
)
if rc != 0:
raise RuntimeError("git log failed for the PR commit range")
rc, base_pyproject = _git("show", f"{base}:pyproject.toml")
if rc != 0:
raise RuntimeError("could not read pyproject.toml from the base ref")
return _parse_log(raw), base_pyproject
def _pending_records() -> list[tuple]:
"""Identity of the commit about to be created (local pre-commit stage)."""
def parse(ident: str):
m = re.match(r"^(.*)<([^>]+)>", ident)
return (m.group(1).strip(), m.group(2).strip()) if m else ("", "")
an, ae = parse(_git("var", "GIT_AUTHOR_IDENT")[1])
cn, ce = parse(_git("var", "GIT_COMMITTER_IDENT")[1])
return [("pending00", an, ae, cn, ce, "")]
def main() -> int:
try:
ctx = _pr_context()
except RuntimeError as exc:
# Fail CLOSED: never silently pass a required check we couldn't run.
print(f"author-identity: {exc}; failing closed.", file=sys.stderr)
return 1
if ctx is not None:
records, pyproject_text = ctx
declared = parse_identities(pyproject_text)
if not declared and re.search(
r"(?m)^\s*(authors\s*=|\[\[[^\]]*authors\s*\]\])", pyproject_text
):
# The base pyproject declares authors but we parsed none (e.g. the
# table format changed). Fail CLOSED rather than silently disabling
# the check -- a loud red is the whole point of this guard.
print(
"author-identity: base pyproject.toml declares authors but none "
"could be parsed (unrecognized format?); failing closed.",
file=sys.stderr,
)
return 1
else:
try:
declared = parse_identities(
Path("pyproject.toml").read_text(encoding="utf-8")
)
except OSError:
declared = {}
records = _pending_records()
if not declared:
return 0
errors = []
for rec in records:
errors.extend(_check(rec, declared))
if errors:
print("Author identity check failed:\n", file=sys.stderr)
print("\n".join(errors), file=sys.stderr)
print(
"\nA commit is attributed to a declared author but uses a personal "
"(non-noreply) address.\nUse your GitHub `@users.noreply.github.com` "
"address, e.g. re-author with:\n"
" git commit --amend --reset-author # latest commit\n"
"and ensure your git user.email is your noreply address.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Pre-commit guard: Bearer ``bearer:disable`` directives must be well-formed.
Bearer SILENTLY ignores a suppression directive (the finding stays open) unless
it is written exactly as:
* a line comment (``#`` in Python, ``//`` in JavaScript) on its OWN line,
directly above the statement — a *same-line* trailing directive
(``code() # bearer:disable rule``) is ignored; and
* the bare rule id(s) only, with NO trailing prose — ``# bearer:disable rule
-- why`` is ignored. Put the rationale on a separate comment line above the
bare directive; and
* NOT inside a block comment — Bearer ignores ``/* bearer:disable rule */``
and JSDoc ``* bearer:disable rule`` too.
Each failure mode is silent, so a malformed directive looks like protection
while suppressing nothing. This hook fails the commit when it finds one.
Implementation notes:
* Python uses ``tokenize`` — directives mentioned inside docstrings or string
literals are not flagged, only real ``#`` comments.
* JavaScript uses a small char scanner that tracks strings/templates and
block comments, so a ``//`` inside a string (e.g. a URL) is not mistaken
for a comment. Not special-cased (rare edges, accepted): regex literals,
and code inside template ``${...}`` interpolations (a directive written
inside ``${...}`` is treated as template text and would be missed) — no
real directive is written in either place.
"""
from __future__ import annotations
import io
import re
import sys
import tokenize
# A Bearer rule id is always namespaced with underscores, e.g.
# python_lang_sql_injection / javascript_lang_dangerous_insert_html — never a
# bare English word. Requiring an underscore stops lowercase prose
# ("because reasons") from masquerading as a rule id.
_RULE = r"[a-z][a-z0-9]*(?:_[a-z0-9]+)+"
# After `bearer:disable`: one or more rule ids, comma-separated only (Bearer's
# documented multi-rule syntax), and nothing else.
_VALID_AFTER = re.compile(rf"^[ \t]+{_RULE}(?:[ \t]*,[ \t]*{_RULE})*[ \t]*$")
# A Python `#` comment whose content begins with the directive.
_DIRECTIVE_START = re.compile(r"^#[ \t]*bearer:disable\b(.*)$")
# An embedded `# bearer:disable` (a directive trailing another comment).
_EMBEDDED = re.compile(r"#[ \t]*bearer:disable\b")
# A `// bearer:disable ...` line comment (used to read the bit after the rule).
_JS_LINE_DIRECTIVE = re.compile(r"^//[ \t]*bearer:disable\b(.*)$")
# A block-comment line that STARTS with the directive (after `/*` / JSDoc `*`),
# vs. one that merely mentions it in prose.
_BLOCK_DIRECTIVE_LINE = re.compile(
r"^[ \t]*(?:/\*+|\*+)?[ \t]*bearer:disable\b"
)
_SAME_LINE_MSG = (
"same-line `bearer:disable` is silently ignored by Bearer — put the bare "
"directive on its own line directly above the statement"
)
_TRAILING_MSG = (
"trailing text after the rule id is silently ignored by Bearer — keep the "
"directive line as the bare rule id and move the rationale to a separate "
"comment line above it"
)
_BLOCK_MSG = (
"`bearer:disable` in a block comment is silently ignored by Bearer — use a "
"line comment (// or #) on its own line directly above the statement"
)
_MISSING_MSG = "`bearer:disable` is missing a rule id"
def _after_violation(after: str) -> str | None:
"""Validate the text that follows ``bearer:disable``."""
if not after.strip():
return _MISSING_MSG
if not _VALID_AFTER.match(after):
return _TRAILING_MSG
return None
def _check_python(content: str) -> list[tuple[int, str]]:
errors: list[tuple[int, str]] = []
lines = content.splitlines()
try:
tokens = list(tokenize.generate_tokens(io.StringIO(content).readline))
except (tokenize.TokenError, IndentationError, SyntaxError):
return errors # malformed files are caught by other tools
for tok in tokens:
if tok.type != tokenize.COMMENT or "bearer:disable" not in tok.string:
continue
row, col = tok.start
code_before = (
lines[row - 1][:col].strip() if row - 1 < len(lines) else ""
)
m = _DIRECTIVE_START.match(tok.string)
if m:
if code_before:
errors.append((row, _SAME_LINE_MSG))
else:
msg = _after_violation(m.group(1))
if msg:
errors.append((row, msg))
elif code_before and _EMBEDDED.search(tok.string):
# A real directive trailing another comment on a code line, e.g.
# `run(q) # noqa: S608 # bearer:disable rule`.
errors.append((row, _SAME_LINE_MSG))
# else: a comment that only mentions the text in prose — not a directive.
return errors
def _classify_js_line_comment(
lineno: int, code_before: str, comment: str, errors: list[tuple[int, str]]
) -> None:
m = _JS_LINE_DIRECTIVE.match(comment)
if not m:
return # `// other prose ... bearer:disable ...` — not a directive
if code_before.strip():
errors.append((lineno, _SAME_LINE_MSG))
else:
msg = _after_violation(m.group(1))
if msg:
errors.append((lineno, msg))
def _check_js(content: str) -> list[tuple[int, str]]:
"""Char scanner: only `bearer:disable` reached as a real comment counts."""
errors: list[tuple[int, str]] = []
n = len(content)
i = 0
line = 1
line_start = 0
state = "code" # code | block | sq | dq | tmpl
block_start_i = 0
block_start_line = 0
def flag_block(text: str, start_line: int) -> None:
# Flag only a block line that STARTS with the directive (a real
# block-comment suppression), not prose that mentions it.
for off, ln in enumerate(text.splitlines()):
if _BLOCK_DIRECTIVE_LINE.match(ln):
errors.append((start_line + off, _BLOCK_MSG))
return
while i < n:
ch = content[i]
nxt = content[i + 1] if i + 1 < n else ""
if ch == "\n":
line += 1
line_start = i + 1
i += 1
continue
if state == "code":
if ch == "/" and nxt == "/":
eol = content.find("\n", i)
if eol == -1:
eol = n
_classify_js_line_comment(
line, content[line_start:i], content[i:eol], errors
)
i = eol
elif ch == "/" and nxt == "*":
state, block_start_i, block_start_line = "block", i, line
i += 2
elif ch == '"':
state = "dq"
i += 1
elif ch == "'":
state = "sq"
i += 1
elif ch == "`":
state = "tmpl"
i += 1
else:
i += 1
elif state == "block":
if ch == "*" and nxt == "/":
flag_block(content[block_start_i : i + 2], block_start_line)
state = "code"
i += 2
else:
i += 1
else: # sq | dq | tmpl
quote = {"sq": "'", "dq": '"', "tmpl": "`"}[state]
if ch == "\\":
i += 2
elif ch == quote:
state = "code"
i += 1
else:
i += 1
if state == "block": # unterminated block comment
flag_block(content[block_start_i:n], block_start_line)
return errors
def check_file(filename: str) -> list[tuple[int, str]]:
try:
# utf-8-sig strips a leading BOM so it is not mistaken for code.
with open(filename, "r", encoding="utf-8-sig") as fh:
content = fh.read()
except (UnicodeDecodeError, OSError):
return []
if filename.endswith(".py"):
return _check_python(content)
if filename.endswith(".js"):
return _check_js(content)
return []
def main(argv: list[str]) -> int:
failed = False
for filename in argv:
errors = check_file(filename)
if errors:
failed = True
print(f"\n{filename}:")
for line_num, msg in sorted(errors):
print(f" Line {line_num}: {msg}")
if failed:
print(
"\n❌ Malformed `bearer:disable` directive(s). Bearer only honors a "
"directive that is\n the bare rule id on its own line directly "
"above the statement:\n"
" # bearer:disable python_lang_sql_injection\n"
" <statement>\n"
" Put any rationale on separate comment line(s) above it."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Fail on invalid changelog.d/ fragment filenames.
towncrier only renders fragments whose category is declared in
``[[tool.towncrier.type]]``; anything else is silently dropped from the
release notes (and ``towncrier check`` errors on it). #5023's fragment
shipped as ``.refactor.md`` — an undeclared category — and would have
vanished from the next release; the advisory reminder hook printed a
warning but nothing failed. This hook is the blocking counterpart: it
validates every file in changelog.d/ (not just staged ones, so CI's
``pre-commit run --all-files`` enforces it on the whole tree) and exits
non-zero on any problem.
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _changelog_fragments import classify_fragment, load_categories
REPO_ROOT = Path(__file__).resolve().parent.parent
def validate_directory(changelog_dir, categories):
"""Return one problem string per invalid file in *changelog_dir*.
Every regular file except README.md must be a valid fragment.
Dotfiles are skipped so local editor/OS droppings (e.g. .DS_Store)
don't fail commits that never stage them.
"""
problems = []
for path in sorted(Path(changelog_dir).iterdir()):
if not path.is_file():
continue
if path.name == "README.md" or path.name.startswith("."):
continue
kind, category = classify_fragment(path.name, categories)
if kind == "bad-name":
problems.append(
f"{path.name} — does not match `<id>.<category>.md` or "
f"`+<slug>.<category>.md`"
)
elif kind == "bad-category":
problems.append(
f"{path.name} — unknown category `{category}`; towncrier "
f"silently drops it from the release notes. Use one of: "
f"{', '.join(categories)}"
)
return problems
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"changelog_dir",
nargs="?",
default=str(REPO_ROOT / "changelog.d"),
help="Directory to validate (defaults to the repo's changelog.d/)",
)
parser.add_argument(
"--pyproject",
default=str(REPO_ROOT / "pyproject.toml"),
help="pyproject.toml declaring [[tool.towncrier.type]] categories",
)
args = parser.parse_args(argv)
categories = load_categories(args.pyproject)
problems = validate_directory(args.changelog_dir, categories)
if problems:
print("Invalid changelog.d/ fragment(s):")
for problem in problems:
print(f" - {problem}")
print("See changelog.d/README.md for the naming convention.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Check that the hardcoded CODEOWNERS list in pr-triage.yml matches
the global owners line in .github/CODEOWNERS."""
import re
import sys
from pathlib import Path
def main():
root = Path(__file__).parent.parent
codeowners_file = root / ".github" / "CODEOWNERS"
workflow_file = root / ".github" / "workflows" / "pr-triage.yml"
if not codeowners_file.exists() or not workflow_file.exists():
return 0
try:
codeowners_text = codeowners_file.read_text(encoding="utf-8")
workflow_text = workflow_file.read_text(encoding="utf-8")
except OSError as e:
print(f"ERROR: Could not read file: {e}")
return 1
global_owners = None
for line in codeowners_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("* "):
global_owners = re.findall(r"@([A-Za-z0-9][A-Za-z0-9-]*)", stripped)
break
if not global_owners:
print(
"ERROR: Could not find a '* @owner...' global owners line "
"in .github/CODEOWNERS"
)
return 1
match = re.search(
r"const\s+CODEOWNERS\s*=\s*\[([^\]]*)\]",
workflow_text,
)
if match is None:
print(
"ERROR: Could not find 'const CODEOWNERS = [...]' "
"in .github/workflows/pr-triage.yml"
)
return 1
js_owners = re.findall(
r"['\"]([A-Za-z0-9][A-Za-z0-9-]*)['\"]", match.group(1)
)
co_set = {u.lower() for u in global_owners}
js_set = {u.lower() for u in js_owners}
if co_set != js_set:
print("ERROR: CODEOWNERS list mismatch between files.")
print(f" .github/CODEOWNERS global owners: {sorted(global_owners)}")
print(f" pr-triage.yml CODEOWNERS const: {sorted(js_owners)}")
only_in_co = co_set - js_set
only_in_js = js_set - co_set
if only_in_co:
print(f" Only in CODEOWNERS: {sorted(only_in_co)}")
if only_in_js:
print(f" Only in pr-triage.yml: {sorted(only_in_js)}")
print(
"Update both lists so they share the same maintainers "
"(comments in each file note this requirement)."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""
Pre-commit hook to ensure all LDR-specific CSS class names are prefixed with 'ldr-'.
This prevents CSS class name conflicts when Vite bundles CSS from dependencies.
"""
import re
import sys
from pathlib import Path
from typing import List, Tuple
# Allowed patterns - ONLY third-party framework classes
# Everything else MUST have ldr- prefix
ALLOWED_PATTERNS = {
# Already prefixed with ldr-
r"^ldr-",
# Bootstrap 5 specific classes (no wildcards unless necessary)
# Layout
r"^(container|container-fluid|container-sm|container-md|container-lg|container-xl|container-xxl)$",
r"^row$",
r"^col$",
r"^col-(1|2|3|4|5|6|7|8|9|10|11|12)$",
r"^col-(sm|md|lg|xl|xxl)-(1|2|3|4|5|6|7|8|9|10|11|12)$",
# Bootstrap components (with specific prefixes)
r"^btn(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-link|-outline-primary|-outline-secondary|-outline-success|-outline-danger|-outline-warning|-outline-info|-outline-light|-outline-dark|-sm|-lg|-block|-close|-close-white)?$",
r"^alert(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-dismissible)?$",
r"^badge(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-pill)?$",
r"^card(-body|-header|-footer|-title|-subtitle|-text|-link|-img-top|-img-bottom)?$",
r"^navbar(-brand|-nav|-toggler|-collapse|-expand|-expand-sm|-expand-md|-expand-lg|-expand-xl|-dark|-light)?$",
r"^nav(-link|-item|-pills|-tabs|-fill|-justified)?$",
r"^dropdown(-toggle|-menu|-menu-end|-menu-start|-item|-divider|-header)?$",
r"^modal(-dialog|-dialog-centered|-dialog-scrollable|-content|-header|-body|-footer|-title|-backdrop|-static|-sm|-lg|-xl|-fullscreen)?$",
r"^form(-control|-label|-text|-check|-check-input|-check-label|-check-inline|-switch|-select|-range|-floating|-group|-row)?$",
r"^input-group(-text|-prepend|-append)?$",
r"^list-group(-item|-item-action|-flush)?$",
r"^table(-dark|-striped|-bordered|-borderless|-hover|-sm|-responsive)?$",
# Bootstrap utilities with exact values
r"^text-(start|end|center|primary|secondary|success|danger|warning|info|light|dark|body|muted|white|black-50|white-50)$",
r"^text-(lowercase|uppercase|capitalize|nowrap|truncate|break|monospace)$",
r"^bg-(primary|secondary|success|danger|warning|info|light|dark|body|white|transparent)$",
r"^border(-top|-end|-bottom|-start|-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-white)?$",
r"^border-(0|1|2|3|4|5)$",
r"^rounded(-top|-end|-bottom|-start|-circle|-pill|-0|1|2|3)?$",
r"^shadow(-sm|-lg|-none)?$",
# Display utilities
r"^d-(none|inline|inline-block|block|table|table-row|table-cell|flex|inline-flex|grid)$",
r"^d-(sm|md|lg|xl|xxl)-(none|inline|inline-block|block|table|table-row|table-cell|flex|inline-flex|grid)$",
# Flexbox utilities
r"^flex-(row|row-reverse|column|column-reverse|wrap|nowrap|wrap-reverse)$",
r"^justify-content-(start|end|center|between|around|evenly)$",
r"^align-items-(start|end|center|baseline|stretch)$",
r"^align-self-(start|end|center|baseline|stretch)$",
r"^flex-(fill|grow-0|grow-1|shrink-0|shrink-1)$",
# Spacing utilities (margins and padding)
r"^m-(0|1|2|3|4|5|auto)$",
r"^mt-(0|1|2|3|4|5|auto)$",
r"^mb-(0|1|2|3|4|5|auto)$",
r"^ms-(0|1|2|3|4|5|auto)$",
r"^me-(0|1|2|3|4|5|auto)$",
r"^mx-(0|1|2|3|4|5|auto)$",
r"^my-(0|1|2|3|4|5|auto)$",
r"^p-(0|1|2|3|4|5)$",
r"^pt-(0|1|2|3|4|5)$",
r"^pb-(0|1|2|3|4|5)$",
r"^ps-(0|1|2|3|4|5)$",
r"^pe-(0|1|2|3|4|5)$",
r"^px-(0|1|2|3|4|5)$",
r"^py-(0|1|2|3|4|5)$",
# Sizing utilities
r"^w-(25|50|75|100|auto)$",
r"^h-(25|50|75|100|auto)$",
r"^mw-100$",
r"^mh-100$",
# Position utilities
r"^position-(static|relative|absolute|fixed|sticky)$",
r"^top-(0|50|100)$",
r"^bottom-(0|50|100)$",
r"^start-(0|50|100)$",
r"^end-(0|50|100)$",
# Typography utilities
r"^fs-(1|2|3|4|5|6)$",
r"^fw-(light|lighter|normal|bold|bolder)$",
r"^fst-(normal|italic)$",
r"^lh-(1|sm|base|lg)$",
r"^font-monospace$",
r"^text-decoration-(none|underline|line-through)$",
# Visibility utilities
r"^(visible|invisible)$",
r"^visually-hidden$",
r"^visually-hidden-focusable$",
r"^overflow-(auto|hidden|visible|scroll)$",
r"^user-select-(all|auto|none)$",
# Interactive utilities
r"^pe-(none|auto)$",
r"^(active|disabled|show|hide|collapse|collapsed|fade|collapsing|close)$",
# Form validation states (Bootstrap 5)
r"^(is-valid|is-invalid|valid-feedback|invalid-feedback|valid-tooltip|invalid-tooltip)$",
# Toast component (Bootstrap 5)
r"^toast(-header|-body)?$",
# Progress bars (Bootstrap 5)
r"^progress(-bar|-bar-striped|-bar-animated|-stacked)?$",
r"^btn-group(-vertical|-sm|-lg)?$",
# Image utilities (Bootstrap 5)
r"^img-(fluid|thumbnail)$",
# Other Bootstrap utilities
r"^(clearfix|sr-only|sr-only-focusable|small)$",
r"^spinner-(border|border-sm|grow|grow-sm)$",
r"^placeholder(-glow|-wave)?$",
# Bootstrap Icons (bi-*)
r"^bi(-[a-z0-9-]+)?$",
# Font Awesome icons
r"^(fa|fas|far|fab|fal|fad)$",
r"^fa-[a-z0-9-]+$",
# KaTeX math rendering
r"^katex(-[a-z0-9-]+)?$",
}
def is_allowed_class(class_name: str) -> bool:
"""Check if a class name is allowed without ldr- prefix."""
# Remove any leading/trailing whitespace
class_name = class_name.strip()
# Empty class names are invalid
if not class_name:
return False
# Check against allowed patterns
for pattern in ALLOWED_PATTERNS:
if re.match(pattern, class_name, re.IGNORECASE):
return True
return False
def check_css_file(file_path: Path) -> List[Tuple[int, str, str]]:
"""Check CSS file for non-prefixed class definitions."""
errors = []
try:
content = file_path.read_text(encoding="utf-8")
lines = content.split("\n")
# Pattern to match CSS class selectors, including compound
# selectors like .class1.class2
# First match the leading dot (not preceded by a word char or /)
# then capture everything including subsequent .class segments
class_pattern = re.compile(
r"(?<![\w/])\.([a-zA-Z][a-zA-Z0-9\-_]*(?:\.[a-zA-Z][a-zA-Z0-9\-_]*)*)"
)
for line_num, line in enumerate(lines, 1):
# Skip comments
if "/*" in line or "*/" in line or line.strip().startswith("//"):
continue
# Skip @import statements
if "@import" in line:
continue
matches = class_pattern.findall(line)
for match in matches:
# Split compound selectors like "class1.class2" into
# individual class names for separate validation
for class_name in match.split("."):
if not class_name:
continue
if not is_allowed_class(class_name):
errors.append(
(
line_num,
class_name,
f"CSS class '.{class_name}' should be prefixed with 'ldr-'",
)
)
except Exception as e:
print(f"Error reading {file_path}: {e}", file=sys.stderr)
return errors
def check_html_file(file_path: Path) -> List[Tuple[int, str, str]]:
"""Check HTML file for non-prefixed class usage and CSS definitions in style tags."""
errors = []
try:
content = file_path.read_text(encoding="utf-8")
lines = content.split("\n")
# Pattern to match class attributes in HTML
# Simplified pattern to avoid exponential backtracking
# Matches: class="..." or class='...'
# Uses two separate patterns to handle each quote type correctly
class_attr_patterns = [
re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE),
re.compile(r"class\s*=\s*'([^']*)'", re.IGNORECASE),
]
# Pattern to match CSS class definitions (same as in check_css_file)
css_class_pattern = re.compile(
r"(?<![\w/])\.([a-zA-Z][a-zA-Z0-9\-_]*(?:\.[a-zA-Z][a-zA-Z0-9\-_]*)*)"
)
in_style_tag = False
for line_num, line in enumerate(lines, 1):
# Skip HTML comments
if "<!--" in line or "-->" in line:
continue
# Skip Jinja2 template comments
if "{#" in line or "#}" in line:
continue
# Check for style tag boundaries
if "<style" in line.lower():
in_style_tag = True
if "</style>" in line.lower():
in_style_tag = False
# If we're inside a style tag, check for CSS class definitions
if in_style_tag:
# Skip CSS comments
if (
"/*" in line
or "*/" in line
or line.strip().startswith("//")
):
continue
# Check for CSS class definitions
css_matches = css_class_pattern.findall(line)
for match in css_matches:
# Split compound selectors like "class1.class2"
for class_name in match.split("."):
if not class_name:
continue
if not is_allowed_class(class_name):
errors.append(
(
line_num,
class_name,
f"CSS class '.{class_name}' in style tag should be prefixed with 'ldr-'",
)
)
continue # Skip HTML class checking when in style tag
# Try both patterns to handle different quote types
matches = []
for pattern in class_attr_patterns:
matches.extend(pattern.findall(line))
for class_attr in matches:
# Skip if this is a JavaScript template literal (contains ${...})
if "${" in class_attr:
continue
# Skip if the entire class attribute contains Jinja2 template variables
# This handles cases like class="alert alert-{{ category }}"
if "{{" in class_attr or "{%" in class_attr:
# Extract only the static class names (those not part of Jinja2 expressions)
# Remove the Jinja2 parts and check remaining static classes
# Remove Jinja2 expressions but keep the rest
cleaned_attr = re.sub(
r"\{\{[^}]*\}\}|\{%[^%]*%\}", "", class_attr
)
classes = cleaned_attr.split()
else:
# No Jinja2 in this attribute, check all classes
classes = class_attr.split()
for class_name in classes:
# Additional safety check for individual class names
if (
"{{" in class_name
or "{%" in class_name
or "{" in class_name
or "}" in class_name
):
continue
# Skip JavaScript template literals and variables
if any(
char in class_name
for char in [
"$",
"=",
"!",
"<",
">",
"(",
")",
"[",
"]",
"||",
"&&",
"?",
":",
]
):
continue
# Skip common Jinja2 patterns
if class_name in [
"if",
"else",
"endif",
"for",
"endfor",
"block",
"endblock",
"include",
"extends",
]:
continue
# Skip Bootstrap icon classes
if class_name.startswith("bi-") or class_name == "bi":
continue
if not is_allowed_class(class_name):
errors.append(
(
line_num,
class_name,
f"HTML class '{class_name}' should be prefixed with 'ldr-'",
)
)
except Exception as e:
print(f"Error reading {file_path}: {e}", file=sys.stderr)
return errors
def check_js_file(file_path: Path) -> List[Tuple[int, str, str]]:
"""Check JavaScript file for non-prefixed class usage."""
errors = []
try:
content = file_path.read_text(encoding="utf-8")
lines = content.split("\n")
# Patterns to match class usage in JavaScript (excluding querySelector/jQuery)
patterns = [
# classList.add('classname')
re.compile(
r'classList\.(add|remove|toggle|contains)\s*\(\s*["\']([^"\']+)["\']'
),
# className = 'classname' or className: 'classname'
re.compile(r'className\s*[:=]\s*["\']([^"\']+)["\']'),
# getElementsByClassName('classname')
re.compile(r'getElementsByClassName\s*\(\s*["\']([^"\']+)["\']'),
# hasClass('classname'), addClass('classname'), removeClass('classname')
re.compile(
r'\.(hasClass|addClass|removeClass|toggleClass)\s*\(\s*["\']([^"\']+)["\']'
),
]
# Pattern for template literal className assignments:
# className = `some-class ${dynamic}` or className: `some-class`
template_literal_pattern = re.compile(r"className\s*[:=]\s*`([^`]+)`")
# Special patterns for querySelector and jQuery that need different handling
querySelector_pattern = re.compile(
r'querySelector(?:All)?\s*\(\s*["\']([^"\']+)["\']'
)
jquery_pattern = re.compile(r'\$\s*\(\s*["\']([^"\']+)["\']')
for line_num, line in enumerate(lines, 1):
# Skip comments
if "//" in line or "/*" in line or "*/" in line:
# Simple comment detection (not perfect but good enough)
comment_start = line.find("//")
if comment_start >= 0:
line = line[:comment_start]
# Handle querySelector and jQuery selectors specially
for selector_match in querySelector_pattern.findall(line):
# Extract class names from CSS selectors (e.g., '.class1 .class2', '.class1.class2')
# Only match class selectors (starting with .)
class_matches = re.findall(
r"\.([a-zA-Z0-9_-]+)", selector_match
)
for cls in class_matches:
if not is_allowed_class(cls):
errors.append(
(
line_num,
cls,
f"JavaScript class '.{cls}' should be prefixed with 'ldr-'",
)
)
for jquery_match in jquery_pattern.findall(line):
# Extract class names from jQuery selectors
class_matches = re.findall(r"\.([a-zA-Z0-9_-]+)", jquery_match)
for cls in class_matches:
if not is_allowed_class(cls):
errors.append(
(
line_num,
cls,
f"JavaScript class '.{cls}' should be prefixed with 'ldr-'",
)
)
# Handle template literal className assignments
for tl_match in template_literal_pattern.findall(line):
# Extract static class tokens by removing ${...} expressions
static_part = re.sub(r"\$\{[^}]*\}", " ", tl_match)
for cls in static_part.split():
# Skip partial tokens that are fragments of dynamic
# class names (e.g. "alert-" from "alert-${type}")
if cls.endswith("-") or cls.startswith("-"):
continue
if not is_allowed_class(cls):
errors.append(
(
line_num,
cls,
f"JavaScript class '{cls}' should be prefixed with 'ldr-'",
)
)
# Handle other patterns
for pattern in patterns:
matches = pattern.findall(line)
for match in matches:
# Handle different capture groups
if isinstance(match, tuple):
# For patterns with multiple groups, get the class name
class_name = match[-1] if len(match) > 1 else match[0]
else:
class_name = match
# Split multiple classes if present
classes = class_name.split()
for cls in classes:
if not is_allowed_class(cls):
errors.append(
(
line_num,
cls,
f"JavaScript class '{cls}' should be prefixed with 'ldr-'",
)
)
except Exception as e:
print(f"Error reading {file_path}: {e}", file=sys.stderr)
return errors
def main():
"""Main function to check files passed as arguments."""
if len(sys.argv) < 2:
print("No files to check")
return 0
has_errors = False
for file_arg in sys.argv[1:]:
file_path = Path(file_arg)
if not file_path.exists():
continue
# Match path components, not substrings: "lib" must be an actual
# directory segment, not a fragment of "library.html" etc.
if any(
vendor in file_path.parts
for vendor in [
"vendor",
"dist",
"build",
"lib",
"libs",
"node_modules",
]
):
continue
errors = []
# Check based on file extension
if file_path.suffix == ".css":
errors = check_css_file(file_path)
elif file_path.suffix in [".html", ".htm"]:
errors = check_html_file(file_path)
elif file_path.suffix in [".js", ".jsx", ".ts", ".tsx", ".mjs"]:
errors = check_js_file(file_path)
if errors:
has_errors = True
print(f"\n❌ CSS class prefix errors in {file_path}:")
for line_num, class_name, message in errors:
print(f" Line {line_num}: {message}")
if has_errors:
print("\n" + "=" * 60)
print("CSS Class Naming Convention:")
print(" All LDR-specific CSS classes must be prefixed with 'ldr-'")
print(
" This prevents conflicts when Vite bundles CSS from dependencies"
)
print(" Example: .custom-button → .ldr-custom-button")
print("=" * 60)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Pre-commit hook to check for usage of deprecated database connection methods.
Ensures code uses per-user database connections instead of the deprecated shared database.
"""
import sys
import re
import os
# Set environment variable for pre-commit hooks to allow unencrypted databases
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
def check_file(filepath):
"""Check a single file for deprecated database usage."""
issues = []
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
# Pattern to detect get_db_connection usage
db_connection_pattern = re.compile(r"\bget_db_connection\s*\(")
# Pattern to detect direct imports of get_db_connection
import_pattern = re.compile(
r"from\s+[\w.]+\s+import\s+.*\bget_db_connection\b"
)
# Pattern to detect db_manager.get_session() — returns a raw QueuePool
# session that must be closed by the caller. Safe only inside
# inject_current_user / ensure_db_session (stored in g.db_session and
# cleaned up by teardown_appcontext). All other call sites should use
# get_user_db_session() context manager instead.
raw_session_pattern = re.compile(r"\bdb_manager\.get_session\s*\(")
# Check for usage
for i, line in enumerate(lines, 1):
# Skip comment and docstring lines — they can legitimately reference
# deprecated APIs (TODOs, migration notes, inline examples).
stripped = line.lstrip()
if stripped.startswith(("#", '"""', "'''")):
continue
if db_connection_pattern.search(line):
issues.append(
f"{filepath}:{i}: Usage of deprecated get_db_connection()"
)
if import_pattern.search(line):
issues.append(
f"{filepath}:{i}: Import of deprecated get_db_connection"
)
if (
raw_session_pattern.search(line)
and "# noqa: raw-session" not in line
):
issues.append(
f"{filepath}:{i}: Direct db_manager.get_session() call — "
"returns an unmanaged QueuePool session that leaks FDs if not closed. "
"Use 'with get_user_db_session(username) as session:' instead, "
"or add '# noqa: raw-session' if this is intentional (e.g. stored in g.db_session)"
)
# (The previous file-level "from ..web.models.database import get_db_connection"
# substring check was removed: it was redundant with the per-line import_pattern
# above, and its file-level scope also fired on comments mentioning the import.)
# Check for SQLite connections to shared database.
#
# Previously the exemption was "get_user_db_session not in content" — a
# *file-level* check. One correct get_user_db_session() call anywhere in the
# file silently allowed raw sqlite3.connect("ldr.db") calls elsewhere in the
# same file. Gate per-line on a "# noqa: shared-db" sentinel instead.
shared_db_pattern = re.compile(r"sqlite3\.connect\s*\([^)]*ldr\.db")
for i, line in enumerate(lines, 1):
if line.lstrip().startswith(("#", '"""', "'''")):
continue
if shared_db_pattern.search(line) and "# noqa: shared-db" not in line:
issues.append(
f"{filepath}:{i}: Direct SQLite connection to shared database - use get_user_db_session() instead"
)
return issues
def main():
"""Main function to check all provided files."""
if len(sys.argv) < 2:
print("No files to check")
return 0
all_issues = []
for filepath in sys.argv[1:]:
# Skip the database.py file itself (it contains the deprecated function definition)
if "web/models/database.py" in filepath:
continue
# Skip files that legitimately manage raw sessions (store in g.db_session
# or define the deprecated helpers themselves)
if any(
skip in filepath
for skip in [
"session_context.py", # ensure_db_session stores in g.db_session
"web/auth/decorators.py", # inject_current_user stores in g.db_session
"encrypted_db.py", # defines get_session()
"db_utils.py", # defines get_db_session() wrapper
]
):
continue
# Skip migration scripts and test files that might legitimately need shared DB access
if any(
skip in filepath
for skip in ["migrations/", "tests/", "test_", ".pre-commit-hooks/"]
):
continue
issues = check_file(filepath)
all_issues.extend(issues)
if all_issues:
print("❌ Deprecated or unsafe database access detected!\n")
print("The shared database (get_db_connection) is deprecated.")
print(
"Direct db_manager.get_session() leaks QueuePool connections (FDs)."
)
print(
"Please use get_user_db_session(username) for per-user database access.\n"
)
print("Issues found:")
for issue in all_issues:
print(f" - {issue}")
print("\nExample fix:")
print(" # Old (deprecated):")
print(" conn = get_db_connection()")
print(" cursor = conn.cursor()")
print(" # ... SQL query execution ...")
print()
print(" # New (correct):")
print(" from flask import session")
print(" username = session.get('username', 'anonymous')")
print(" with get_user_db_session(username) as db_session:")
print(" results = db_session.query(Model).filter(...).all()")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Pre-commit hook to warn about usage of deprecated get_setting_from_db_main_thread wrapper.
This function is deprecated because it's redundant - use the SettingsManager directly
with proper session context management instead.
NOTE: This hook currently only warns about usage to allow gradual migration.
"""
import sys
from pathlib import Path
from typing import List, Tuple
def check_file(filepath: Path) -> List[Tuple[int, str]]:
"""Check a single Python file for deprecated wrapper usage.
Args:
filepath: Path to the Python file to check
Returns:
List of (line_number, error_message) tuples
"""
errors = []
try:
content = filepath.read_text(encoding="utf-8")
# Short-circuit: no mention at all → nothing to check.
if "get_setting_from_db_main_thread" not in content:
return errors
# Per-line scan. Previously this routine also walked the AST and
# appended a second error per call site, producing duplicate
# diagnostics on every hit. The AST walk (ast.Name match) is
# strictly weaker than this line scan — any bare identifier
# reference also appears on the line that contains it — so it
# is safe to remove.
for i, line in enumerate(content.split("\n"), 1):
# Skip comment and docstring lines — they can legitimately
# mention the deprecated name without invoking it.
stripped = line.strip()
if stripped.startswith(("#", '"""', "'''")):
continue
if "get_setting_from_db_main_thread" not in line:
continue
if "from" in line and "import" in line:
errors.append(
(
i,
"Importing deprecated get_setting_from_db_main_thread - use SettingsManager with proper session context",
)
)
else:
errors.append(
(
i,
"Using deprecated get_setting_from_db_main_thread - use SettingsManager with get_user_db_session context manager",
)
)
except Exception as e:
print(f"Error checking {filepath}: {e}", file=sys.stderr)
return errors
def main():
"""Main entry point for the pre-commit hook."""
files_to_check = sys.argv[1:]
if not files_to_check:
print("No files to check")
return 0
all_errors = []
for filepath_str in files_to_check:
filepath = Path(filepath_str)
# Skip non-Python files
if filepath.suffix != ".py":
continue
# Skip the file that defines the function (db_utils.py), this hook itself,
# and test files that test the deprecated function
if filepath.name in [
"db_utils.py",
"check-deprecated-settings-wrapper.py",
"test_db_utils.py",
"test_db_utils_deep_coverage.py",
]:
continue
errors = check_file(filepath)
if errors:
all_errors.append((filepath, errors))
if all_errors:
print(
"\n⚠️ Warning: Found usage of deprecated get_setting_from_db_main_thread wrapper:\n"
)
print(
"This function is deprecated and will be removed in a future version."
)
print(
"For Flask routes/views, use SettingsManager with proper session context:\n"
)
print(
" from local_deep_research.database.session_context import get_user_db_session"
)
print(
" from local_deep_research.utilities.db_utils import get_settings_manager"
)
print("")
print(" with get_user_db_session(username) as db_session:")
print(
" settings_manager = get_settings_manager(db_session, username)"
)
print(" value = settings_manager.get_setting(key, default)")
print("\nFor background threads, use settings_snapshot pattern.")
print("\nFiles with deprecated usage:")
for filepath, errors in all_errors:
print(f"\n {filepath}:")
for line_num, error_msg in errors:
print(f" Line {line_num}: {error_msg}")
# Return 1 to fail and enforce migration
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect double-escaping in JavaScript files.
Catches patterns where escapeHtml() is called on values that are then
passed to functions which already escape internally (showError, showSuccess,
showInfo, showAlert).
Known limitations:
- Multi-line function calls are not detected (line-by-line processing).
- Variable indirection bypasses detection
(e.g., ``const safe = escapeHtml(msg); showError(safe);``).
"""
import re
import sys
# Functions that escape their arguments internally
ESCAPING_FUNCTIONS = ["showError", "showSuccess", "showInfo", "showAlert"]
# Build pattern: showError(...escapeHtml(...)...)
PATTERN = re.compile(
r"\b(" + "|".join(ESCAPING_FUNCTIONS) + r")\s*\("
r".*?"
r"(?:escapeHtml|XSSProtection\.escapeHtml)\s*\("
)
def strip_js_comments(line, in_block_comment):
"""Strip JavaScript comments from a line, respecting string literals.
Handles ``//`` line comments, ``/* */`` block comments (including
multi-line), and correctly ignores comment-like sequences inside
single-quoted, double-quoted, and template-literal strings.
Returns ``(code_without_comments, still_in_block_comment)``.
"""
result = []
i = 0
in_single_quote = False
in_double_quote = False
in_template = False
while i < len(line):
ch = line[i]
# Inside a block comment — scan for */
if in_block_comment:
if ch == "*" and i + 1 < len(line) and line[i + 1] == "/":
in_block_comment = False
i += 2
else:
i += 1
continue
# Handle escape sequences inside strings
if (in_single_quote or in_double_quote or in_template) and ch == "\\":
result.append(ch)
if i + 1 < len(line):
result.append(line[i + 1])
i += 2
else:
i += 1
continue
# Track string state
if ch == "'" and not in_double_quote and not in_template:
in_single_quote = not in_single_quote
elif ch == '"' and not in_single_quote and not in_template:
in_double_quote = not in_double_quote
elif ch == "`" and not in_single_quote and not in_double_quote:
in_template = not in_template
elif not in_single_quote and not in_double_quote and not in_template:
# Outside strings — check for comments
if ch == "/" and i + 1 < len(line):
if line[i + 1] == "/":
break # Line comment — rest of line is comment
if line[i + 1] == "*":
in_block_comment = True
i += 2
continue
result.append(ch)
i += 1
return "".join(result), in_block_comment
def check_file(file_path):
"""Check a JavaScript file for double-escaping patterns."""
issues = []
try:
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
except (UnicodeDecodeError, IOError):
# Skip binary files or files we can't read
return []
in_block_comment = False
for line_num, line in enumerate(lines, 1):
code, in_block_comment = strip_js_comments(line, in_block_comment)
if PATTERN.search(code):
issues.append(
f"{file_path}:{line_num}: Possible double-escaping — "
f"escapeHtml() inside a function that already escapes: "
f"{line.strip()}"
)
return issues
def main():
"""Main function to check all provided files."""
if len(sys.argv) < 2:
print("No files to check")
return 0
all_issues = []
for file_path in sys.argv[1:]:
if not file_path.endswith(".js"):
continue
try:
issues = check_file(file_path)
all_issues.extend(issues)
except Exception as e:
print(f"Error checking {file_path}: {e}")
continue
if all_issues:
print("Double-escaping detected:")
print("-" * 60)
for issue in all_issues:
print(f" {issue}")
print("-" * 60)
print("\nThese functions already escape their arguments internally.")
print("Pass raw text instead of pre-escaping with escapeHtml().")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
Simple pre-commit hook to check for direct os.environ usage.
This is a lightweight check - comprehensive validation happens in CI.
"""
import ast
import sys
from pathlib import Path
# Allowlist entries, partitioned by matching strategy.
#
# Previously this was a single ALLOWED_PATTERNS set checked with
# `pattern in self.filename` — a bare substring match. That incorrectly
# exempted production files whose path happened to contain an allowlist
# substring: e.g. "test_" matched protest_handler.py, "settings/" matched
# foo_settings_override.py, and so on. Partition the list instead so each
# entry matches at the right granularity.
# Directory subtrees where env-var access is allowed (settings / tests /
# scripts / examples / migrations / the hooks themselves).
ALLOWED_PATH_SEGMENTS = {
"settings",
"config",
"tests",
"scripts",
"examples",
"migrations",
".pre-commit-hooks",
}
# Filename prefixes for test files.
ALLOWED_NAME_PREFIXES = ("test_",)
# Filename suffixes for Go-style *_test.py convention.
ALLOWED_NAME_SUFFIXES = ("_test.py",)
# Exact basenames for bootstrap / infrastructure modules that run before
# SettingsManager is initialized.
ALLOWED_NAMES = {
"log_utils.py", # Logger init before DB/SettingsManager
"server_config.py", # Fail-closed security validation for LDR_APP_ALLOW_REGISTRATIONS
"sqlcipher_utils.py", # Encryption init needs LDR_TEST_MODE before SettingsManager
}
# Path-anchored entries where the basename alone is too generic to match
# reliably.
# Leading "/" anchors the path segment so endswith() cannot match lookalike
# paths (e.g. "notsecurity/secure_logging.py").
ALLOWED_PATH_ENDINGS = (
"/security/rate_limiter.py", # Module-level RATE_LIMIT_FAIL_CLOSED at decorator time
"/security/secure_logging.py", # Diagnose-mode gate must work before/without SettingsManager (same rationale as log_utils.py)
)
# System environment variables that are always allowed
SYSTEM_VARS = {
"PATH",
"HOME",
"USER",
"PYTHONPATH",
"TMPDIR",
"TEMP",
"TZ", # Standard POSIX timezone variable
"CI",
"GITHUB_ACTIONS",
"TESTING", # External testing flag
"PYTEST_CURRENT_TEST", # Pytest test detection
"WERKZEUG_RUN_MAIN", # Flask/Werkzeug debug reloader detection
}
class EnvVarChecker(ast.NodeVisitor):
def __init__(self, filename: str):
self.filename = filename
self.errors = []
def visit_ImportFrom(self, node):
"""Check for 'from os import environ/getenv' direct imports."""
if not self._is_file_allowed() and node.module == "os" and node.names:
for alias in node.names:
if alias.name in ("environ", "getenv"):
self.errors.append(
(
node.lineno,
f"Direct import 'from os import {alias.name}' — use SettingsManager instead of direct env var access",
)
)
self.generic_visit(node)
def visit_Assign(self, node):
"""Check for aliasing: env = os.environ."""
if not self._is_file_allowed():
# Check the right-hand side for os.environ attribute access
if (
isinstance(node.value, ast.Attribute)
and node.value.attr == "environ"
and isinstance(node.value.value, ast.Name)
and node.value.value.id == "os"
):
self.errors.append(
(
node.lineno,
"Aliasing os.environ to a local variable — use SettingsManager instead",
)
)
self.generic_visit(node)
def visit_Call(self, node):
# Check for os.environ.get() or os.getenv()
is_environ_get = False
env_var_name = None
# Pattern 1: os.environ.get("VAR_NAME") or os.environ.get(variable)
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "get"
and isinstance(node.func.value, ast.Attribute)
and node.func.value.attr == "environ"
and isinstance(node.func.value.value, ast.Name)
and node.func.value.value.id == "os"
):
is_environ_get = True
if node.args and isinstance(node.args[0], ast.Constant):
env_var_name = node.args[0].value
# Pattern 2: os.getenv("VAR_NAME") or os.getenv(variable)
elif (
isinstance(node.func, ast.Attribute)
and node.func.attr == "getenv"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "os"
):
is_environ_get = True
if node.args and isinstance(node.args[0], ast.Constant):
env_var_name = node.args[0].value
# Pattern 3: os.environ.pop() / .setdefault() / .update()
if (
isinstance(node.func, ast.Attribute)
and node.func.attr in ("pop", "setdefault", "update")
and isinstance(node.func.value, ast.Attribute)
and node.func.value.attr == "environ"
and isinstance(node.func.value.value, ast.Name)
and node.func.value.value.id == "os"
):
if not self._is_file_allowed():
self.errors.append(
(
node.lineno,
f"Direct os.environ.{node.func.attr}() call — use SettingsManager instead",
)
)
if is_environ_get:
if env_var_name:
# Known constant key — allow system vars
if env_var_name in SYSTEM_VARS:
return self.generic_visit(node)
if not self._is_file_allowed():
if env_var_name.startswith("LDR_"):
self.errors.append(
(
node.lineno,
f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ",
)
)
else:
self.errors.append(
(
node.lineno,
f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager",
)
)
else:
# Dynamic key (variable, not a string literal)
if not self._is_file_allowed():
self.errors.append(
(
node.lineno,
"Dynamic environment variable access (variable key) — use SettingsManager instead",
)
)
self.generic_visit(node)
def visit_Subscript(self, node):
# Check for os.environ["VAR_NAME"] pattern
if (
isinstance(node.value, ast.Attribute)
and node.value.attr == "environ"
and isinstance(node.value.value, ast.Name)
and node.value.value.id == "os"
):
if isinstance(node.slice, ast.Constant):
env_var_name = node.slice.value
# Allow system vars
if env_var_name in SYSTEM_VARS:
return self.generic_visit(node)
if not self._is_file_allowed():
if env_var_name.startswith("LDR_"):
self.errors.append(
(
node.lineno,
f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ",
)
)
else:
self.errors.append(
(
node.lineno,
f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager",
)
)
else:
# Dynamic subscript: os.environ[variable]
if not self._is_file_allowed():
self.errors.append(
(
node.lineno,
"Dynamic os.environ[variable] access — use SettingsManager instead",
)
)
self.generic_visit(node)
def visit_Compare(self, node):
"""Check for 'KEY in os.environ' containment checks."""
if not self._is_file_allowed():
for comparator in node.comparators:
if (
isinstance(comparator, ast.Attribute)
and comparator.attr == "environ"
and isinstance(comparator.value, ast.Name)
and comparator.value.id == "os"
):
# Check if the left side is a system var
if (
isinstance(node.left, ast.Constant)
and node.left.value in SYSTEM_VARS
):
continue
self.errors.append(
(
node.lineno,
"'... in os.environ' check — use SettingsManager instead of direct env var access",
)
)
self.generic_visit(node)
def _is_file_allowed(self) -> bool:
"""Check if this file is allowed to use os.environ directly."""
p = Path(self.filename)
if p.name in ALLOWED_NAMES:
return True
if p.name.startswith(ALLOWED_NAME_PREFIXES):
return True
if p.name.endswith(ALLOWED_NAME_SUFFIXES):
return True
if ALLOWED_PATH_SEGMENTS.intersection(p.parts):
return True
for ending in ALLOWED_PATH_ENDINGS:
if self.filename.endswith(ending):
return True
return False
def check_file(filename: str) -> bool:
"""Check a single Python file for direct env var access."""
if not filename.endswith(".py"):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
checker = EnvVarChecker(filename)
checker.visit(tree)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all staged Python files."""
if len(sys.argv) < 2:
print("Usage: check-env-vars.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n⚠️ Direct environment variable access detected!")
print("\nFor LDR_ variables, use SettingsManager instead of os.environ")
print("See issue #598 for migration details")
print("\nNote: Full validation runs in CI")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Pre-commit hook to check for external CDN and resource references.
Ensures all resources are served locally from vendor directories.
"""
import re
import sys
from pathlib import Path
from typing import List, Tuple
# Patterns that indicate external resources
EXTERNAL_PATTERNS = [
# CDN URLs (with or without protocol)
r"(https?://)?cdn\.(cloudflare|jsdelivr|unpkg|jspm|skypack)",
r"(https?://)?cdnjs\.(cloudflare\.com|com)",
r"(https?://)?unpkg\.(com|\.)",
r"(https?://)?jsdelivr\.(net|com)",
r"(https?://)?ajax\.googleapis\.",
r"(https?://)?code\.jquery\.",
r"(https?://)?maxcdn\.",
r"(https?://)?stackpath\.",
# Common external libraries
r"https?://.*\/(jquery|bootstrap|react|vue|angular|fontawesome|font-awesome)[\-\.]",
# Script/link tags with external sources (excluding vendor paths)
r'<script[^>]+src=["\']https?://(?!localhost|127\.0\.0\.1)',
r'<link[^>]+href=["\']https?://(?!localhost|127\.0\.0\.1)',
# Integrity attributes (often used with CDNs)
r'integrity=["\']sha(256|384|512)-',
r'crossorigin=["\']anonymous["\']',
]
# Allowed external resources (APIs, documentation, etc)
ALLOWED_PATTERNS = [
# API endpoints
r"https?://api\.",
r"https?://.*\.api\.",
r"openrouter\.ai/api",
# Documentation and source links
r"https?://github\.com",
r"https?://docs\.",
r"https?://.*\.readthedocs\.",
r"https?://npmjs\.com",
r"https?://pypi\.org",
# Example/placeholder URLs
r"https?://example\.",
r"https?://localhost",
r"https?://127\.0\.0\.1",
r"https?://0\.0\.0\.0",
# Common in comments or documentation
r"#.*https?://",
r"//.*https?://",
r"\*.*https?://",
]
def check_file(filepath: Path) -> List[Tuple[int, str, str]]:
"""
Check a file for external resource references.
Returns list of (line_number, line_content, pattern_matched) tuples.
"""
violations = []
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
except (UnicodeDecodeError, IOError):
# Skip binary files or files we can't read
return violations
for line_num, line in enumerate(lines, 1):
# Check if line contains external patterns
for pattern in EXTERNAL_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
# Check if it's an allowed exception
is_allowed = False
for allowed in ALLOWED_PATTERNS:
if re.search(allowed, line, re.IGNORECASE):
is_allowed = True
break
if not is_allowed:
violations.append((line_num, line.strip(), pattern))
break # Only report first matching pattern per line
return violations
def main():
"""Main entry point for the pre-commit hook."""
# Get list of files to check from command line arguments
files = sys.argv[1:] if len(sys.argv) > 1 else []
if not files:
# If no files specified, check all relevant files
patterns = ["**/*.html", "**/*.js", "**/*.css", "**/*.py"]
files = []
for pattern in patterns:
files.extend(Path(".").glob(pattern))
else:
files = [Path(f) for f in files]
all_violations = []
for filepath in files:
# Skip vendor directories and node_modules
if any(
part in filepath.parts
for part in [
"vendor",
"node_modules",
".venv",
"venv",
"__pycache__",
]
):
continue
violations = check_file(filepath)
if violations:
all_violations.append((filepath, violations))
if all_violations:
print("\n❌ External resource references found!\n")
print(
"All resources should be served locally from the vendor directory."
)
print("Found the following external references:\n")
for filepath, violations in all_violations:
print(f"📄 {filepath}")
for line_num, line_content, pattern in violations:
print(f" Line {line_num}: {line_content[:100]}...")
print(f" Matched pattern: {pattern}\n")
print("\nTo fix this:")
print("1. Add the library to package.json as a dependency")
print("2. Run 'npm install' to download it")
print("3. Update the reference to use the local node_modules path")
print("4. Use a build process to bundle the assets")
print(
"\nAll external libraries should be managed through npm for security and version tracking."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Pre-commit hook to warn when test files redefine root-conftest fixtures.
The root tests/conftest.py provides `app`, `client`, and `authenticated_client`
fixtures with security-relevant setup (CSRF, auth DB init, temp data dir).
Full-app redefinitions (those calling `create_app()`) often skip that setup,
which is the real harm. Fixtures that build a minimal `Flask(__name__)` app
for blueprint isolation are intentionally different and are NOT flagged.
Class-level fixtures are allowed because they cannot easily inherit from
conftest. Module-level redefinitions produce a WARNING for allowlisted
files (exit 0) and an ERROR for new occurrences (exit 1).
"""
import ast
import os
import sys
# Fixture names defined in tests/conftest.py that should not be redefined
# at module level in individual test files when they use create_app().
PROTECTED_FIXTURES = {"app", "client", "authenticated_client"}
# Existing violations — these files already redefine the fixtures using
# create_app(). They emit a soft warning (exit 0) instead of blocking.
# Remove entries as files are migrated to use the shared conftest fixtures.
ALLOWLIST: dict[str, set[str]] = {
"tests/auth_tests/test_auth_integration.py": {"app", "client"},
"tests/auth_tests/test_auth_routes.py": {"app", "client"},
"tests/security/test_cookie_security.py": {"app", "client"},
"tests/web/test_error_handler_behavior.py": {"app", "client"},
"tests/web/test_teardown_cleanup.py": {"app"},
"tests/web/test_websocket_middleware.py": {"app", "client"},
}
def _normalize(filepath: str) -> str:
"""Normalize path to use forward slashes and strip a leading './'."""
path = filepath.replace(os.sep, "/")
if path.startswith("./"):
path = path[2:]
return path
def _is_fixture_decorator(decorator: ast.expr) -> bool:
"""Check if an AST decorator node is @pytest.fixture (with or without args)."""
# @pytest.fixture
if isinstance(decorator, ast.Attribute):
return (
isinstance(decorator.value, ast.Name)
and decorator.value.id == "pytest"
and decorator.attr == "fixture"
)
# @pytest.fixture() or @pytest.fixture(scope=...)
if isinstance(decorator, ast.Call):
return _is_fixture_decorator(decorator.func)
return False
def _calls_create_app(func_node: ast.AST) -> bool:
"""Return True if the fixture body contains a call to `create_app`.
Matches `create_app(...)` (bare) and `module.create_app(...)` (attribute).
A fixture that only uses `Flask(__name__)` for blueprint isolation does
not trigger this and is treated as intentional, not a violation.
"""
for node in ast.walk(func_node):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Name) and func.id == "create_app":
return True
if isinstance(func, ast.Attribute) and func.attr == "create_app":
return True
return False
def find_module_level_fixture_redefinitions(
filepath: str,
) -> list[tuple[int, str]]:
"""Find module-level pytest fixtures that shadow root conftest definitions.
Only full-app redefinitions (fixtures that call create_app) are reported.
Returns list of (line_number, fixture_name) tuples.
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
source = f.read()
except (OSError, UnicodeDecodeError) as exc:
print(f"WARNING: could not read {filepath}: {exc}", file=sys.stderr)
return []
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError as exc:
print(f"WARNING: could not parse {filepath}: {exc}", file=sys.stderr)
return []
# A `client`/`authenticated_client` fixture is flagged only when the file
# also has a local `app` fixture that uses create_app — because the client
# then inherits the problematic full-app without conftest security setup.
# If no such `app` exists (file uses a minimal Flask inside the client
# fixture itself), the client is flagged only if it calls create_app directly.
local_app_uses_create_app = False
for node in ast.iter_child_nodes(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "app"
and any(_is_fixture_decorator(d) for d in node.decorator_list)
and _calls_create_app(node)
):
local_app_uses_create_app = True
break
violations = []
for node in ast.iter_child_nodes(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if node.name not in PROTECTED_FIXTURES:
continue
if not any(_is_fixture_decorator(d) for d in node.decorator_list):
continue
if node.name == "app":
if _calls_create_app(node):
violations.append((node.lineno, node.name))
else:
# client / authenticated_client
if _calls_create_app(node) or local_app_uses_create_app:
violations.append((node.lineno, node.name))
return violations
def main() -> int:
if len(sys.argv) < 2:
return 0
new_violations: list[str] = []
allowlisted_warnings: list[str] = []
for filepath in sys.argv[1:]:
norm = _normalize(filepath)
# Only check files under tests/
if not norm.startswith("tests/"):
continue
# Skip the root conftest itself — it's the canonical source
if norm == "tests/conftest.py":
continue
violations = find_module_level_fixture_redefinitions(filepath)
if not violations:
continue
allowed = ALLOWLIST.get(norm, set())
for lineno, fixture_name in violations:
msg = f"{norm}:{lineno}: module-level redefinition of `{fixture_name}` fixture (uses create_app)"
if fixture_name in allowed:
allowlisted_warnings.append(msg)
else:
new_violations.append(msg)
if allowlisted_warnings:
print(
"WARNING: The following files redefine root-conftest fixtures "
"(allowlisted, but please migrate):"
)
for w in allowlisted_warnings:
print(f" {w}")
print()
if new_violations:
print(
"ERROR: New module-level redefinitions of root-conftest fixtures detected!\n"
)
print(
"The root tests/conftest.py provides `app`, `client`, and "
"`authenticated_client` fixtures with security-relevant setup "
"(CSRF disable, auth DB init, temp data dir)."
)
print(
"Redefining these at module level with `create_app()` skips that "
"setup and may create subtle test-environment differences.\n"
)
print("New violations:")
for v in new_violations:
print(f" {v}")
print(
"\nTo fix: remove the local fixture and use the shared one from "
"tests/conftest.py."
)
print(
"If this is intentional (e.g., testing a different app factory "
"configuration), add the file to the ALLOWLIST in "
".pre-commit-hooks/check-fixture-duplication.py\n"
)
print(
"Note: minimal `Flask(__name__)` fixtures for blueprint isolation "
"are NOT flagged — only fixtures calling create_app() are.\n"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Check that golden master settings are updated when default settings change."""
import subprocess
import sys
def _find_venv_python():
"""Find the project's venv Python interpreter."""
import pathlib
for candidate in [
pathlib.Path(".venv/bin/python"),
pathlib.Path("venv/bin/python"),
]:
if candidate.exists():
return str(candidate)
return sys.executable
def _golden_master_is_stale():
"""Regenerate golden master in-place and check if git sees a diff."""
golden_master = "tests/settings/golden_master_settings.json"
python = _find_venv_python()
try:
result = subprocess.run(
[python, "scripts/dev/regenerate_golden_master.py"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
return True
# Check if regeneration changed the file
diff = subprocess.run(
["git", "diff", "--quiet", golden_master],
capture_output=True,
)
# Restore original
subprocess.run(
["git", "checkout", "--", golden_master],
capture_output=True,
)
return diff.returncode != 0
except Exception:
return True
def main():
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Warning: Could not check staged files (git error)")
return 0
staged_files = result.stdout.strip().splitlines()
defaults_changed = any(
f.startswith("src/local_deep_research/defaults/")
and f.endswith(".json")
for f in staged_files
)
if not defaults_changed:
return 0
golden_master = "tests/settings/golden_master_settings.json"
if golden_master in staged_files:
return 0
# Only fail if the golden master is actually out of date
if not _golden_master_is_stale():
return 0
print("ERROR: Default settings changed but golden master not updated!")
print()
print(" Staged files that affect settings:")
for f in staged_files:
if f.startswith("src/local_deep_research/defaults/") and f.endswith(
".json"
):
print(f" - {f}")
print()
print(" To regenerate the golden master:")
print(" 1. Run: python scripts/dev/regenerate_golden_master.py")
print(f" 2. Stage the updated {golden_master}")
return 1
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# Pre-commit hook to check for unpinned Docker images
# Prevents commits with unpinned images, providing immediate feedback
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
VIOLATIONS=0
echo "🔍 Checking Docker image pinning..."
# Check Dockerfiles
if git diff --cached --name-only | grep -qE '^Dockerfile|/Dockerfile'; then
echo " Checking Dockerfiles..."
for file in $(git diff --cached --name-only | grep -E '^Dockerfile|/Dockerfile'); do
if [ -f "$file" ]; then
# Check FROM statements without @sha256
if grep -n "^FROM.*:.*[^@]$" "$file" | grep -v "@sha256:" >/dev/null 2>&1; then
echo -e "${RED}$file: FROM statement missing SHA digest${NC}"
grep -n "^FROM" "$file" | grep -v "@sha256:"
VIOLATIONS=$((VIOLATIONS + 1))
fi
fi
done
fi
# Check docker-compose files
if git diff --cached --name-only | grep -qE 'docker-compose.*\.ya?ml'; then
echo " Checking docker-compose files..."
for file in $(git diff --cached --name-only | grep -E 'docker-compose.*\.ya?ml'); do
if [ -f "$file" ]; then
# Skip cookiecutter templates and examples
if [[ "$file" =~ cookiecutter-docker/ ]] || [[ "$file" =~ examples/ ]]; then
continue
fi
# Check image: lines without @sha256 (excluding own image)
if grep -n "image:.*:.*[^@]$" "$file" 2>/dev/null | \
grep -v "localdeepresearch/local-deep-research" | \
grep -v "@sha256:" >/dev/null 2>&1; then
echo -e "${RED}$file: image reference missing SHA digest${NC}"
grep -n "image:" "$file" | grep -v "@sha256:" | grep -v "localdeepresearch/local-deep-research"
VIOLATIONS=$((VIOLATIONS + 1))
fi
fi
done
fi
# Check workflow files (basic check - detailed validation happens in CI)
if git diff --cached --name-only | grep -qE '^\.github/workflows/.*\.ya?ml'; then
echo " Checking workflow files..."
for file in $(git diff --cached --name-only | grep -E '^\.github/workflows/.*\.ya?ml'); do
if [ -f "$file" ]; then
# Look for image: lines in services or container blocks
if grep -B2 "image:" "$file" 2>/dev/null | grep -E "services:|container:" >/dev/null 2>&1; then
# Check if any image lines lack @sha256
if grep -A2 -B2 "image:" "$file" 2>/dev/null | \
grep "image:" | grep -v "@sha256:" | grep -qE "image:.*:"; then
echo -e "${YELLOW} ⚠️ $file: May have unpinned service containers${NC}"
echo " (Full validation will run in CI)"
fi
fi
fi
done
fi
echo ""
if [ $VIOLATIONS -gt 0 ]; then
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${RED}❌ Found $VIOLATIONS unpinned images${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Images must use SHA256 digests for supply chain security."
echo ""
echo "To fix:"
echo " 1. Pull: docker pull <image:tag>"
echo " 2. Get digest: docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
echo " 3. Update: image: <image:tag>@sha256:..."
echo ""
echo "Example:"
echo -e " ${RED}# Bad${NC}"
echo " FROM python:3.13-slim"
echo ""
echo -e " ${GREEN}# Good${NC}"
echo " FROM python:3.13-slim@sha256:326df678c20c78d..."
echo ""
exit 1
fi
echo -e "${GREEN}✅ All Docker images properly pinned${NC}"
exit 0
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Pre-commit hook: enforce that journal_quality.db is opened read-only.
The compiled journal-quality DB has exactly one writer — `build_db()` in
`src/local_deep_research/journal_quality/db.py`. Every other consumer
must open the file with SQLite URI flag `mode=ro` (and ideally also
`immutable=1`).
This hook scans staged Python files for opens of `journal_quality.db`
or the legacy `journal_reference.db` and fails the commit if any of
them is missing the `mode=ro` flag, OR if the open lives outside the
single allowed writer module.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ALLOWED_WRITER = "src/local_deep_research/journal_quality/db.py"
DB_NAME_PATTERN = re.compile(r"journal_(quality|reference)\.db")
MODE_RO_PATTERN = re.compile(r"mode\s*=\s*ro", re.IGNORECASE)
def check_file(path: Path) -> list[str]:
"""Return list of human-readable error messages for `path`."""
errors: list[str] = []
if str(path).endswith(ALLOWED_WRITER):
return errors # the writer module is allowed to open writable
if not path.suffix == ".py":
return errors
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return errors
for lineno, line in enumerate(content.splitlines(), start=1):
if not DB_NAME_PATTERN.search(line):
continue
# Skip comments and string-literal references that aren't opens
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Skip lines that are just naming the file (e.g. f-strings used
# for log messages, dict keys, file existence checks)
is_open_call = any(
tok in line
for tok in (
"sqlite3.connect",
"create_engine",
".connect(",
"open(",
)
)
if not is_open_call:
continue
if not MODE_RO_PATTERN.search(line):
errors.append(
f"{path}:{lineno}: opens journal_quality.db without "
f"mode=ro — only `journal_quality/db.py::build_db` may "
f"open the file writable."
)
return errors
def main(argv: list[str]) -> int:
files = [Path(p) for p in argv[1:]]
if not files:
return 0
all_errors: list[str] = []
for f in files:
all_errors.extend(check_file(f))
if all_errors:
print(
"ERROR: journal_quality.db read-only invariant violated:",
file=sys.stderr,
)
for err in all_errors:
print(f" {err}", file=sys.stderr)
print(
"\nThe compiled journal-quality DB is read-only at runtime."
" The only writer is `build_db()` in "
f"{ALLOWED_WRITER}. Open the file with "
'sqlite3.connect(f"file:{path}?mode=ro&immutable=1", uri=True)'
" everywhere else.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Pre-commit hook to enforce that infrastructure packages do not import from web/.
Packages like settings/, utilities/, security/, and config/ should not depend
on web/ — this breaks CLI, MCP, and programmatic API usage.
"""
import re
import sys
# Infrastructure packages that must not import from web/
PROTECTED_PACKAGES = {
"settings",
"utilities",
"security",
"config",
}
# Existing violations that are allowlisted (file path substring -> allowed import patterns)
ALLOWLIST = {
"settings/manager.py": [
"web.models.settings",
"web.themes",
"web.services.socket_service",
],
"utilities/log_utils.py": [
"web.services.socket_service",
],
"security/rate_limiter.py": [
"web.server_config",
],
}
# Match import statements that reference .web. or from ..web
WEB_IMPORT_PATTERN = re.compile(
r"^\s*(?:from\s+(?:\.+)?(?:local_deep_research\.)?web\b|"
r"import\s+(?:local_deep_research\.)?web\b)"
)
def get_package(filepath):
"""Extract the package name from file path."""
parts = filepath.replace("\\", "/").split("/")
try:
ldr_idx = parts.index("local_deep_research")
except ValueError:
return None
if ldr_idx + 1 < len(parts) and parts[ldr_idx + 1] in PROTECTED_PACKAGES:
return parts[ldr_idx + 1]
return None
def is_allowlisted(filepath, line):
"""Check if a specific import is in the allowlist."""
for path_substr, allowed_imports in ALLOWLIST.items():
if path_substr in filepath:
for allowed in allowed_imports:
if allowed in line:
return True
return False
def is_type_checking_block(lines, lineno):
"""Simple heuristic: check if line is inside a TYPE_CHECKING block."""
for i in range(lineno - 1, max(0, lineno - 20), -1):
stripped = lines[i].strip()
if stripped.startswith("if TYPE_CHECKING"):
return True
if (
stripped
and not stripped.startswith("#")
and not stripped.startswith("from")
and not stripped.startswith("import")
):
break
return False
def check_file(filepath):
errors = []
try:
with open(filepath, encoding="utf-8") as f:
lines = f.readlines()
except (OSError, UnicodeDecodeError):
return errors
pkg = get_package(filepath)
if not pkg:
return errors
for lineno, line in enumerate(lines):
if WEB_IMPORT_PATTERN.search(line):
if is_allowlisted(filepath, line):
continue
if is_type_checking_block(lines, lineno):
continue
errors.append((lineno + 1, line.rstrip()))
return errors
def main():
exit_code = 0
for filepath in sys.argv[1:]:
if not filepath.endswith(".py"):
continue
errors = check_file(filepath)
for lineno, line in errors:
pkg = get_package(filepath)
print(
f"{filepath}:{lineno}: {pkg}/ must not import from web/ — "
f"this breaks CLI/MCP/API usage"
)
print(f" {line.strip()}")
exit_code = 1
if exit_code:
print(
"\nInfrastructure packages (settings/, utilities/, security/, config/) "
"should not depend on the web layer."
)
print(
"Move shared types to a common module or use dependency injection."
)
return exit_code
if __name__ == "__main__":
sys.exit(main())
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Pre-commit hook to prevent usage of ldr.db (shared database).
All data should be stored in per-user encrypted databases.
"""
import sys
import re
import os
from pathlib import Path
# Set environment variable for pre-commit hooks to allow unencrypted databases
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
def check_file_for_ldr_db(file_path):
"""Check if a file contains references to ldr.db."""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except (UnicodeDecodeError, IOError):
# Skip binary files or files we can't read
return []
# Pattern to find ldr.db references
pattern = r"ldr\.db"
matches = []
for line_num, line in enumerate(content.splitlines(), 1):
if re.search(pattern, line, re.IGNORECASE):
# Skip comments and documentation
stripped = line.strip()
if not (
stripped.startswith("#")
or stripped.startswith("//")
or stripped.startswith("*")
or stripped.startswith('"""')
or stripped.startswith("'''")
):
matches.append((line_num, line.strip()))
return matches
def main():
"""Main function to check all Python files for ldr.db usage."""
# Get all Python files from command line arguments
files_to_check = sys.argv[1:] if len(sys.argv) > 1 else []
if not files_to_check:
# If no files specified, check all Python files
src_dir = Path(__file__).parent.parent / "src"
files_to_check = list(src_dir.rglob("*.py"))
violations = []
for file_path in files_to_check:
file_path = Path(file_path)
# Only skip this hook file itself
if file_path.name == "check-ldr-db.py":
continue
matches = check_file_for_ldr_db(file_path)
if matches:
violations.append((file_path, matches))
if violations:
print("❌ DEPRECATED ldr.db USAGE DETECTED!")
print("=" * 60)
print("The shared ldr.db database is deprecated.")
print("All data must be stored in per-user encrypted databases.")
print("=" * 60)
for file_path, matches in violations:
print(f"\n📄 {file_path}")
for line_num, line in matches:
print(f" Line {line_num}: {line}")
print("\n" + "=" * 60)
print("MIGRATION REQUIRED:")
print("1. Store user-specific data in encrypted per-user databases")
print("2. Use get_user_db_session() instead of shared database access")
print("3. See migration guide in documentation")
print("=" * 60)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Pre-commit hook to prevent stdlib printf-style formatting in direct loguru calls.
loguru uses brace formatting (`{}`), not stdlib logging placeholders like
`%s` or `%d`. Mixing the two leaves placeholders unrendered in runtime logs.
Applies to files importing either raw loguru or the project's diagnose-gated
``security.secure_logging`` wrapper (which delegates formatting to loguru).
The pytest guardian ``tests/utilities/test_loguru_placeholder_formatting.py``
imports this module so hook and guardian cannot diverge.
"""
import ast
from pathlib import Path
import re
import sys
PRINTF_PLACEHOLDER_RE = re.compile(
r"%(?:\([^)]+\))?[#0 +\-]*\d*(?:\.\d+)?[sdfr]"
)
LOGURU_METHODS = {
"trace",
"debug",
"info",
"success",
"warning",
"error",
"critical",
"exception",
"log",
}
# The secure_logging wrapper import, matched exactly (module + level) like
# check-sensitive-logging.py does — never by suffix.
WRAPPER_MODULE_RELATIVE = "security.secure_logging"
WRAPPER_MODULE_ABSOLUTE = "local_deep_research.security.secure_logging"
# SecureLogger.bind()/.patch() re-wrap and keep loguru brace formatting, so
# chained calls need the same placeholder check as direct ones.
WRAPPER_CHAIN_METHODS = {"bind", "patch"}
def imports_project_logger(tree: ast.AST) -> bool:
"""True if the module imports ``logger`` from loguru or the wrapper.
Walks the whole tree so function-local imports are detected too.
"""
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
if not any(alias.name == "logger" for alias in node.names):
continue
if node.level == 0 and node.module in (
"loguru",
WRAPPER_MODULE_ABSOLUTE,
):
return True
if node.level > 0 and node.module == WRAPPER_MODULE_RELATIVE:
return True
return False
def _is_logger_receiver(expr: ast.AST) -> bool:
"""True for ``logger`` or a bind()/patch() chain rooted at ``logger``."""
while (
isinstance(expr, ast.Call)
and isinstance(expr.func, ast.Attribute)
and expr.func.attr in WRAPPER_CHAIN_METHODS
):
expr = expr.func.value
return isinstance(expr, ast.Name) and expr.id == "logger"
def find_printf_violations(tree: ast.AST) -> list[tuple[int, str]]:
"""Return (lineno, message) for logger calls using printf placeholders."""
violations = []
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr in LOGURU_METHODS
and _is_logger_receiver(node.func.value)
):
continue
method_name = node.func.attr
message_index = 1 if method_name == "log" else 0
min_args = 3 if method_name == "log" else 2
if len(node.args) < min_args:
continue
message_arg = node.args[message_index]
if not (
isinstance(message_arg, ast.Constant)
and isinstance(message_arg.value, str)
and PRINTF_PLACEHOLDER_RE.search(message_arg.value)
):
continue
violations.append(
(
node.lineno,
message_arg.value,
)
)
return violations
def check_file(file_path: str) -> list[tuple[int, str]]:
path = Path(file_path)
if path.suffix != ".py":
return []
try:
source = path.read_text(encoding="utf-8")
except Exception as exc:
return [(0, f"failed to read file: {exc}")]
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return []
if not imports_project_logger(tree):
return []
return find_printf_violations(tree)
def main() -> int:
exit_code = 0
for file_path in sys.argv[1:]:
violations = check_file(file_path)
for lineno, message in violations:
print(
f"{file_path}:{lineno}: loguru logger call uses printf-style placeholders"
)
print(f" {message!r}")
exit_code = 1
if exit_code:
print()
print(
"Hint: use loguru brace formatting, e.g. logger.info('value: {}', x)"
)
return exit_code
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Pre-commit hook to enforce explicit ``encoding=`` on text-mode file I/O calls.
On Windows the default encoding is the system locale (often cp1252), not UTF-8.
Omitting ``encoding`` causes silent failures or ``UnicodeDecodeError`` when
reading/writing UTF-8 files. See issue #3743.
Detects:
* Bare ``open(...)`` — second positional arg is the mode.
* ``<expr>.open(<mode>, ...)`` — only when the first positional arg is a
constant string that looks like a real file mode (avoids false positives
on ``tarfile.open`` / ``zipfile.open`` etc., which take a path first).
* ``<expr>.read_text(...)`` and ``<expr>.write_text(...)`` — these are
effectively pathlib-only, so any bare call without ``encoding=`` is flagged.
"""
import ast
import sys
from pathlib import Path
_FILE_MODE_CHARS = frozenset("rwxabt+")
def _looks_like_file_mode(value: object) -> bool:
"""True if value is a string that plausibly is a file ``open`` mode."""
return (
isinstance(value, str)
and 0 < len(value) <= 3
and set(value) <= _FILE_MODE_CHARS
)
def _has_encoding_keyword(call: ast.Call) -> bool:
"""True if the call has an explicit ``encoding=`` kwarg.
Treats ``**kwargs`` spreads as "may contain encoding" to avoid false
positives — we can't statically prove the spread doesn't supply it.
"""
for kw in call.keywords:
if kw.arg is None: # **kwargs spread
return True
if kw.arg == "encoding":
return True
return False
def _get_mode_arg(call: ast.Call, positional_index: int) -> ast.expr | None:
"""Return the mode AST node, looking at both positional and ``mode=`` kwarg.
Returns None if no mode was supplied (caller defaults to text mode).
"""
if len(call.args) > positional_index:
return call.args[positional_index]
for kw in call.keywords:
if kw.arg == "mode":
return kw.value
return None
def _is_text_mode_at(call: ast.Call, mode_arg_index: int) -> bool | None:
"""Return True if the mode argument is text mode.
Inspects both the positional slot and the ``mode=`` kwarg, so calls like
``open(f, mode="rb")`` are correctly classified as binary even though the
positional slot is empty.
Returns ``None`` (falsy) when the mode can't be determined statically —
e.g. ``open(filepath, mode)`` where ``mode`` is a variable.
"""
mode_arg = _get_mode_arg(call, mode_arg_index)
if mode_arg is None:
return True # default mode is "r" (text)
if not isinstance(mode_arg, ast.Constant):
return None
return "b" not in str(mode_arg.value)
def _violations_for_call(node: ast.Call) -> list[tuple[int, str]]:
func = node.func
# Bare open(...) — second positional arg is mode.
if isinstance(func, ast.Name) and func.id == "open":
if _is_text_mode_at(node, 1) and not _has_encoding_keyword(node):
return [(node.lineno, "open() called without explicit encoding=")]
return []
if not isinstance(func, ast.Attribute):
return []
attr = func.attr
# Path.read_text() / Path.write_text() — encoding= is the only safe option.
if attr in {"read_text", "write_text"}:
if not _has_encoding_keyword(node):
return [
(
node.lineno,
f".{attr}() called without explicit encoding=",
)
]
return []
# <expr>.open(<mode>, ...) — first positional arg is the mode (or mode= kwarg).
# When the mode looks like a file mode (or is omitted entirely, defaulting
# to "r"), flag missing encoding. The mode-shape filter avoids false
# positives on tarfile.open("foo.tar") / zipfile.ZipFile.open("inner") etc.
# — bare ``.open()`` is the same gap that bare ``open(f)`` already catches.
if attr == "open":
mode_arg = _get_mode_arg(node, 0)
flag = False
if mode_arg is None:
flag = True # defaults to "r" — same risk as bare open(f)
elif isinstance(mode_arg, ast.Constant) and _looks_like_file_mode(
mode_arg.value
):
flag = "b" not in mode_arg.value
if flag and not _has_encoding_keyword(node):
return [
(
node.lineno,
".open() called without explicit encoding=",
)
]
return []
def check_file(file_path: str) -> list[tuple[int, str]]:
path = Path(file_path)
if path.suffix != ".py":
return []
try:
source = path.read_text(encoding="utf-8")
except Exception:
return []
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return []
violations: list[tuple[int, str]] = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
violations.extend(_violations_for_call(node))
return violations
def main() -> int:
exit_code = 0
for file_path in sys.argv[1:]:
violations = check_file(file_path)
for lineno, message in violations:
print(f"{file_path}:{lineno}: {message}")
exit_code = 1
if exit_code:
print()
print(
"Hint: add encoding='utf-8' (or 'utf-8-sig' for JSON config files)"
)
print(
" to all text-mode open() / Path.open() / read_text() / write_text() calls."
)
print(" See issue #3743.")
return exit_code
if __name__ == "__main__":
sys.exit(main())
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Pre-commit hook to enforce using pathlib.Path instead of os.path.
This hook checks for os.path usage in Python files and suggests
using pathlib.Path instead for better cross-platform compatibility
and more modern Python code.
"""
import argparse
import ast
import sys
from pathlib import Path
class OsPathChecker(ast.NodeVisitor):
"""AST visitor to find os.path usage."""
def __init__(self, filename: str):
self.filename = filename
self.violations: list[tuple[int, str]] = []
# Local names bound to the ``os`` module. Includes aliases from
# ``import os as <alias>`` so they cannot bypass the check.
self.os_module_names: set[str] = set()
self.has_os_path_import = False
def visit_Import(self, node: ast.Import) -> None:
"""Check for 'import os' statements (incl. ``import os as <alias>``)."""
for alias in node.names:
if alias.name == "os":
self.os_module_names.add(alias.asname or "os")
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Check for 'from os import path' or 'from os.path import ...' statements."""
if node.module == "os" and any(
alias.name == "path" for alias in node.names
):
self.has_os_path_import = True
self.violations.append(
(
node.lineno,
"Found 'from os import path' - use 'from pathlib import Path' instead",
)
)
elif node.module == "os.path":
self.has_os_path_import = True
imported_names = [alias.name for alias in node.names]
self.violations.append(
(
node.lineno,
f"Found 'from os.path import {', '.join(imported_names)}' - use pathlib.Path methods instead",
)
)
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None:
"""Check for os.path.* usage."""
if (
isinstance(node.value, ast.Name)
and node.value.id in self.os_module_names
and node.attr == "path"
):
# This is os.path usage
# Try to get the specific method being called
parent = getattr(node, "parent", None)
if parent and isinstance(parent, ast.Attribute):
method = parent.attr
# Skip os.path.expandvars as it has no pathlib equivalent
if method == "expandvars":
return
suggestion = get_pathlib_equivalent(f"os.path.{method}")
else:
suggestion = "Use pathlib.Path instead"
self.violations.append(
(node.lineno, f"Found os.path usage - {suggestion}")
)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Check for direct calls to os.path functions."""
if isinstance(node.func, ast.Attribute):
# Store parent reference for better context
node.func.parent = node
# Check for os.path.* calls
if (
isinstance(node.func.value, ast.Attribute)
and isinstance(node.func.value.value, ast.Name)
and node.func.value.value.id in self.os_module_names
and node.func.value.attr == "path"
):
method = node.func.attr
# Skip os.path.expandvars as it has no pathlib equivalent
if method == "expandvars":
return
suggestion = get_pathlib_equivalent(f"os.path.{method}")
self.violations.append(
(node.lineno, f"Found os.path.{method}() - {suggestion}")
)
self.generic_visit(node)
def get_pathlib_equivalent(os_path_call: str) -> str:
"""Get the pathlib equivalent for common os.path operations."""
equivalents = {
"os.path.join": "Use Path() / 'subpath' or Path().joinpath()",
"os.path.exists": "Use Path().exists()",
"os.path.isfile": "Use Path().is_file()",
"os.path.isdir": "Use Path().is_dir()",
"os.path.dirname": "Use Path().parent",
"os.path.basename": "Use Path().name",
"os.path.abspath": "Use Path().resolve()",
"os.path.realpath": "Use Path().resolve()",
"os.path.expanduser": "Use Path().expanduser()",
"os.path.split": "Use Path().parent and Path().name",
"os.path.splitext": "Use Path().stem and Path().suffix",
"os.path.getsize": "Use Path().stat().st_size",
"os.path.getmtime": "Use Path().stat().st_mtime",
"os.path.normpath": "Use Path() - it normalizes automatically",
# Note: os.path.expandvars has no pathlib equivalent and is allowed
"os.path.expandvars": "(No pathlib equivalent - allowed)",
}
return equivalents.get(os_path_call, "Use pathlib.Path equivalent method")
def check_file(
filepath: Path, allow_legacy: bool = False
) -> list[tuple[str, int, str]]:
"""
Check a Python file for os.path usage.
Args:
filepath: Path to the Python file to check
allow_legacy: If True, only check modified lines (not implemented yet)
Returns:
List of (filename, line_number, violation_message) tuples
"""
try:
content = filepath.read_text(encoding="utf-8")
tree = ast.parse(content, filename=str(filepath))
except SyntaxError as e:
print(f"Syntax error in {filepath}: {e}", file=sys.stderr)
return []
except Exception as e:
print(f"Error reading {filepath}: {e}", file=sys.stderr)
return []
checker = OsPathChecker(str(filepath))
checker.visit(tree)
return [(str(filepath), line, msg) for line, msg in checker.violations]
def main() -> int:
"""Main entry point for the pre-commit hook."""
parser = argparse.ArgumentParser(
description="Check for os.path usage and suggest pathlib alternatives"
)
parser.add_argument(
"filenames",
nargs="*",
help="Python files to check",
)
parser.add_argument(
"--allow-legacy",
action="store_true",
help="Allow os.path in existing code (only check new/modified lines)",
)
args = parser.parse_args()
# List of files that are allowed to use os.path (legacy or special cases)
ALLOWED_FILES = {
"src/local_deep_research/utilities/log_utils.py", # May need os.path for low-level operations
"src/local_deep_research/config/paths.py", # Already migrated but may have legacy code
".pre-commit-hooks/check-pathlib-usage.py", # This file itself
}
violations = []
for filename in args.filenames:
filepath = Path(filename)
# Skip non-Python files
if not filename.endswith(".py"):
continue
# Skip allowed files
if any(filename.endswith(allowed) for allowed in ALLOWED_FILES):
continue
file_violations = check_file(filepath, args.allow_legacy)
violations.extend(file_violations)
if violations:
print("\n❌ Found os.path usage - please use pathlib.Path instead:\n")
for filename, line, message in violations:
print(f" {filename}:{line}: {message}")
print(
"\n💡 Tip: pathlib.Path provides a more modern and cross-platform API."
)
print(
" Example: Path('dir') / 'file.txt' instead of os.path.join('dir', 'file.txt')"
)
print(
"\n📚 See https://docs.python.org/3/library/pathlib.html for more information.\n"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Check if pdm.lock is in sync with pyproject.toml
set -e
echo "Checking pdm.lock is up-to-date..."
if ! pdm lock --check 2>/dev/null; then
echo ""
echo "ERROR: pdm.lock is out of sync with pyproject.toml!"
echo ""
echo "Run this to fix:"
echo " pdm lock"
echo " git add pdm.lock"
echo ""
exit 1
fi
echo "pdm.lock is up-to-date."
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect raw console.* calls in JavaScript files.
All JS logging should use SafeLogger (from security/safe-logger.js) which
sanitises output and prevents accidental leakage of sensitive data.
Allowed exceptions:
- safe-logger.js itself (it wraps console.*)
- Comment lines (// or *)
- Test files (excluded via pre-commit config)
"""
import re
import sys
from pathlib import Path
RAW_CONSOLE_RE = re.compile(r"\bconsole\.(log|error|warn|info|debug)\b")
# Files where raw console.* is legitimate
ALLOWED_FILES = {"safe-logger.js"}
def check_file(file_path):
"""Return list of (line_number, line_text) for violations."""
if Path(file_path).name in ALLOWED_FILES:
return []
violations = []
with open(file_path, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
stripped = line.lstrip()
# Skip comment lines
if stripped.startswith("//") or stripped.startswith("*"):
continue
if RAW_CONSOLE_RE.search(line):
violations.append((lineno, line.rstrip()))
return violations
def main():
exit_code = 0
for file_path in sys.argv[1:]:
violations = check_file(file_path)
for lineno, line in violations:
print(
f"{file_path}:{lineno}: Use SafeLogger instead of raw console.*"
)
print(f" {line}")
exit_code = 1
if exit_code:
print()
print(
"Hint: import SafeLogger and use SafeLogger.log/error/warn/info/debug"
)
return exit_code
if __name__ == "__main__":
sys.exit(main())
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Pre-commit hook: keep README.md claims verifiable against the repo.
README.md is the project storefront, and it drifts: files move, headings
get renamed, CLI entry points change, and example snippets keep naming
things that no longer exist. A 2026-07 audit found a benchmark command
that had never been runnable, a `reset` example missing its required
flag, and an MCP example querying a search engine that was never
implemented. This hook catches those drift classes at commit time, in
README.md, SECURITY.md, CONTRIBUTING.md, and every markdown file under
docs/:
1. Relative markdown links must point at files/directories that exist.
2. Anchor fragments (``docs/faq.md#some-heading`` or same-file
``#-benchmarks``) must match a real heading in the target file,
using GitHub's slug rules. ``#L10``/``#L10-L20`` line anchors must
be within the target file's line count.
3. ``python -m local_deep_research...`` examples must reference a real,
runnable module: a ``<module>.py`` file or a package directory
containing ``__main__.py`` under ``src/``.
4. ``engine="<name>"`` examples must name an engine registered in
``ENGINE_REGISTRY`` (parsed from engine_registry.py).
Links inside fenced code blocks are exempt from check 1/2 -- GitHub does
not render them, and they are usually example markup (e.g. runtime
``/static/...`` paths in an HTML snippet). Checks 3/4 deliberately DO
look inside code blocks; that is where the examples live.
External (http/https/mailto) links are not checked -- no network access
at commit time.
"""
import argparse
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
REPO_ROOT = Path(__file__).resolve().parent.parent
# Fixed files whose internal references this hook validates; every
# markdown file under docs/ is added at runtime in main().
CHECKED_FILES = ["README.md", "SECURITY.md", "CONTRIBUTING.md"]
ENGINE_REGISTRY_PATH = Path(
"src/local_deep_research/web_search_engines/engine_registry.py"
)
# Inline markdown links/images: [text](target) / ![alt](target "title").
# The target group stops at whitespace or ')' so optional "title" parts
# are excluded.
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(\s*<?([^)<>\s]+)>?[^)]*\)")
# Relative href/src in inline HTML.
HTML_REF = re.compile(r"""(?:href|src)=["']([^"']+)["']""")
# `python -m some.module` (also matches python3).
PYTHON_M = re.compile(r"python3?\s+-m\s+([A-Za-z_][\w.]*)")
# engine="name" in example snippets.
ENGINE_KWARG = re.compile(r"""engine=["']([\w\-]+)["']""")
# GitHub file line anchors: #L10 or #L10-L20.
LINE_ANCHOR = re.compile(r"^L(\d+)(?:-L(\d+))?$")
# Registry entries: ` "name": EngineEntry(`.
REGISTRY_KEY = re.compile(r"^\s+\"([\w\-]+)\":\s*EngineEntry\(", re.M)
FENCE = re.compile(r"^\s*(```|~~~)")
def github_slug(heading: str) -> str:
"""Approximate GitHub's heading-to-anchor slug algorithm.
Lowercase, strip markdown link syntax, drop everything that is not a
letter/digit/space/hyphen/underscore (this removes emoji and other
punctuation), then turn spaces into hyphens.
"""
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading).strip().lower()
kept = [c for c in text if c.isalnum() or c in "-_ "]
return "".join(kept).replace(" ", "-")
def iter_headings(md_text: str):
"""Yield ATX heading texts, skipping fenced code blocks."""
in_fence = False
for line in md_text.splitlines():
if FENCE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
match = re.match(r"^#{1,6}\s+(.*)$", line)
if match:
yield match.group(1)
def heading_slugs(md_text: str) -> set:
return {github_slug(h) for h in iter_headings(md_text)}
def strip_fences(md_text: str) -> str:
"""Blank out fenced code blocks; GitHub renders no links inside them."""
out, in_fence = [], False
for line in md_text.splitlines():
if FENCE.match(line):
in_fence = not in_fence
out.append("")
continue
out.append("" if in_fence else line)
return "\n".join(out)
def check_anchor(target: Path, fragment: str) -> str:
"""Return an error message for a bad anchor, or '' if it resolves."""
line_match = LINE_ANCHOR.match(fragment)
if line_match:
last = int(line_match.group(2) or line_match.group(1))
lines = len(target.read_text(encoding="utf-8").splitlines())
if last > lines:
return (
f"line anchor #{fragment} exceeds file length ({lines} lines)"
)
return ""
if target.suffix.lower() not in (".md", ".markdown"):
# GitHub only generates heading anchors for rendered markdown.
return f"anchor #{fragment} on non-markdown file cannot be verified"
slugs = heading_slugs(target.read_text(encoding="utf-8"))
slug = fragment.lower()
if slug in slugs:
return ""
# GitHub dedupes duplicate headings by appending -1, -2, ...
base = re.sub(r"-\d+$", "", slug)
if base != slug and base in slugs:
return ""
return f"no heading matches anchor #{fragment}"
def check_links(md_path: Path, text: str) -> list:
errors = []
targets = MARKDOWN_LINK.findall(text) + HTML_REF.findall(text)
for raw_target in targets:
parsed = urlparse(raw_target)
if parsed.scheme or raw_target.startswith("//"):
continue # external; not checkable offline
rel_path, fragment = unquote(parsed.path), parsed.fragment
target = (md_path.parent / rel_path if rel_path else md_path).resolve()
if not target.exists():
errors.append(f"broken link: {raw_target} (file not found)")
continue
if fragment and target.is_file():
anchor_error = check_anchor(target, fragment)
if anchor_error:
errors.append(f"broken link: {raw_target} ({anchor_error})")
return errors
def check_python_modules(root: Path, text: str) -> list:
errors = []
for module in PYTHON_M.findall(text):
if not module.startswith("local_deep_research"):
continue
base = root / "src" / Path(*module.split("."))
if base.with_suffix(".py").is_file():
continue
if (base / "__main__.py").is_file():
continue
errors.append(
f"`python -m {module}` is not runnable: expected "
f"src/{'/'.join(module.split('.'))}.py or .../__main__.py"
)
return errors
def check_engine_names(root: Path, text: str) -> list:
engines = ENGINE_KWARG.findall(text)
if not engines:
return []
registry_file = root / ENGINE_REGISTRY_PATH
if not registry_file.is_file():
return [f"cannot verify engine names: {ENGINE_REGISTRY_PATH} missing"]
registered = set(
REGISTRY_KEY.findall(registry_file.read_text(encoding="utf-8"))
)
if not registered:
# The registry format changed; failing loudly beats silently
# skipping the check.
return [
f"could not parse any engine names from {ENGINE_REGISTRY_PATH}; "
"update REGISTRY_KEY in this hook"
]
return [
f'engine="{name}" is not a registered search engine '
f"(see {ENGINE_REGISTRY_PATH})"
for name in engines
if name not in registered
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
type=Path,
default=REPO_ROOT,
help="Repository root to check against (test seam)",
)
args = parser.parse_args()
root = args.root.resolve()
files = [root / name for name in CHECKED_FILES] + sorted(
root.glob("docs/**/*.md")
)
all_errors = []
for md_path in files:
name = str(md_path.relative_to(root))
if not md_path.is_file():
all_errors.append((name, "file is missing"))
continue
text = md_path.read_text(encoding="utf-8")
for error in (
check_links(md_path, strip_fences(text))
+ check_python_modules(root, text)
+ check_engine_names(root, text)
):
all_errors.append((name, error))
if all_errors:
print("❌ DOCUMENTATION REFERENCES SOMETHING THAT DOES NOT EXIST")
print("=" * 60)
for name, error in all_errors:
print(f"📄 {name}: {error}")
print("=" * 60)
print("FIX: update the link/example to match the repo, or fix the")
print("hook if GitHub's anchor rules are approximated incorrectly")
print("(.pre-commit-hooks/check-readme-links.py). Links inside")
print("fenced code blocks are already exempt.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Pre-commit hook: keep release-gate.yml's two needs lists in sync.
release-gate.yml has TWO jobs that must wait on every security scan which
uploads a SARIF report to GitHub code scanning:
- ``release-gate-summary`` — fails the gate if any scan job failed.
- ``check-code-scanning-alerts`` — waits for the SARIF uploads to be
indexed, then fails the gate if any open critical/high/medium alert exists.
Both ``needs:`` lists are maintained by hand, with nothing keeping them in
sync. That drift is a real, shipped bug: ``grype-scan`` was in the summary's
needs but was omitted from ``check-code-scanning-alerts`` for ~1.5 years
(fixed in #4817). Because Grype runs ``fail-build: false`` (a findings-only run
"succeeds"), its findings silently bypassed the alert gate the whole time — the
alert query could run before Grype's SARIF was even uploaded.
This hook recomputes, from source, the set of release-gate jobs that upload a
SARIF to code scanning, and fails if any of them is missing from EITHER needs
list. So the next scanner added to one list but forgotten in the other is
caught at commit time instead of silently not gating a release.
A job is treated as a SARIF uploader if a ``SARIF_UPLOAD_MARKERS`` substring
appears in either its OWN inline steps or the LOCAL reusable workflow it calls
(``uses: ./…``). Limits worth knowing: a uploader pulled in via a REMOTE
reusable workflow (``uses: org/repo/…@ref``) cannot be inspected here, and a
new upload mechanism needs a new entry in ``SARIF_UPLOAD_MARKERS``. None exist
today; revisit this hook if either changes.
"""
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
RELEASE_GATE = REPO_ROOT / ".github" / "workflows" / "release-gate.yml"
# Substrings whose presence means a job uploads a SARIF report to GitHub code
# scanning (so its findings become gating alerts).
SARIF_UPLOAD_MARKERS = (
"codeql-action/upload-sarif", # grype, trivy, bearer, semgrep, devskim, …
"codeql-action/analyze", # codeql (uploads internally)
"zizmor-action", # zizmor (uploads internally)
)
# The two jobs that must each `needs:` every SARIF-uploading scan.
CONSUMER_JOBS = ("check-code-scanning-alerts", "release-gate-summary")
def text_uploads_sarif(text: str) -> bool:
return any(marker in text for marker in SARIF_UPLOAD_MARKERS)
def job_uploads_sarif(job: dict, errors: list[str]) -> bool:
"""True if a job uploads SARIF via its own inline steps or a local
reusable workflow it calls.
A ``uses: ./…`` reference to a file that does not exist is recorded as a
loud error (appended to ``errors``) rather than silently treated as a
non-uploader — a typo'd workflow reference should fail the hook, not slip
a scanner past it.
"""
# Inline steps defined directly on the job.
if text_uploads_sarif(yaml.safe_dump(job)):
return True
# Local reusable workflow the job calls.
uses = job.get("uses")
if isinstance(uses, str) and uses.startswith("./"):
workflow = REPO_ROOT / uses[2:] # strip leading "./"
if not workflow.is_file():
errors.append(
f"missing reusable workflow referenced by a job: {uses}"
)
return False
return text_uploads_sarif(workflow.read_text(encoding="utf-8"))
return False
def needs_of(job: object) -> set[str]:
"""Normalize a job's `needs` (str | list | missing) to a set."""
if not isinstance(job, dict):
return set()
needs = job.get("needs", [])
if isinstance(needs, str):
return {needs}
if isinstance(needs, list):
return set(needs)
return set()
def main() -> int:
try:
gate = yaml.safe_load(RELEASE_GATE.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
print(f"❌ Could not parse {RELEASE_GATE}: {exc}")
return 1
jobs = gate.get("jobs", {}) if isinstance(gate, dict) else {}
# SARIF-uploading jobs = release-gate jobs that upload SARIF (inline or via
# the local reusable workflow they call).
errors: list[str] = []
sarif_jobs: dict[str, str] = {}
for job_id, job in jobs.items():
if not isinstance(job, dict):
continue
if job_uploads_sarif(job, errors):
uses = job.get("uses")
sarif_jobs[job_id] = (
uses[2:]
if isinstance(uses, str) and uses.startswith("./")
else "inline steps"
)
if errors:
print(
"❌ release-gate.yml references a workflow file that does not exist"
)
print("=" * 64)
for err in errors:
print(f" - {err}")
print("=" * 64)
print(
"FIX: correct the `uses:` path in .github/workflows/release-gate.yml"
)
return 1
if not sarif_jobs:
print("❌ No SARIF-uploading jobs detected in release-gate.yml.")
print(" The detector is likely broken — check SARIF_UPLOAD_MARKERS.")
return 1
violations: list[tuple[str, str, str]] = [] # (consumer, job, source)
for consumer in CONSUMER_JOBS:
needs = needs_of(jobs.get(consumer, {}))
for job_id, source in sorted(sarif_jobs.items()):
if job_id not in needs:
violations.append((consumer, job_id, source))
if violations:
print("❌ SARIF SCANNER MISSING FROM A release-gate.yml needs LIST")
print("=" * 64)
print("Every scan that uploads a SARIF to code scanning must be in the")
print("`needs:` of BOTH check-code-scanning-alerts and")
print("release-gate-summary. A job missing from check-code-scanning-")
print("alerts can let its findings race the indexing query and bypass")
print(
"the gate (this silently happened to grype-scan for ~1.5y, #4817)."
)
print("=" * 64)
for consumer, job_id, source in violations:
print(f"\n job '{job_id}' ({source}) uploads SARIF")
print(f" but is NOT in {consumer}.needs")
print("\n" + "=" * 64)
print("FIX: add the job id to that job's `needs:` list in")
print(" .github/workflows/release-gate.yml")
print("=" * 64)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Pre-commit hook to check for incorrect research_id type hints.
Research IDs are UUIDs and should always be treated as strings, never as integers.
"""
import sys
import re
import os
from pathlib import Path
# Set environment variable for pre-commit hooks to allow unencrypted databases
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
def check_file(filepath):
"""Check a single file for incorrect research_id patterns."""
errors = []
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
# Patterns to check for
patterns = [
# Flask route with int type
(
r"<int:research_id>",
"Flask route uses <int:research_id> - should be <string:research_id>",
),
# Type hints with int
(
r"research_id:\s*int",
"Type hint uses research_id: int - should be research_id: str",
),
# Function parameters with int conversion
(
r"int\(research_id\)",
"Converting research_id to int - research IDs are UUIDs/strings",
),
# Integer comparison patterns
(
r"research_id\s*==\s*\d+",
"Comparing research_id to integer - research IDs are UUIDs/strings",
),
]
for line_num, line in enumerate(lines, 1):
# Skip comment and docstring lines — comments like
# "# Flask route: <int:research_id> (old API)" should not fire.
if line.lstrip().startswith(("#", '"""', "'''")):
continue
for pattern, message in patterns:
if re.search(pattern, line):
errors.append(f"{filepath}:{line_num}: {message}")
errors.append(f" {line.strip()}")
return errors
def main():
"""Main entry point."""
# Get files to check from command line arguments
files_to_check = sys.argv[1:]
if not files_to_check:
print("No files to check")
return 0
all_errors = []
for filepath in files_to_check:
# Skip non-Python files
if not filepath.endswith(".py"):
continue
# Skip test files, migration files, and pre-commit hooks (they might have legitimate int usage).
#
# Previously this used `"test_" in filepath` (bare substring) — that
# matched production files like protest_handler.py and missed the
# *_test.py convention and files under a /tests/ directory. Mirror
# the guard pattern from _is_raw_sql_exempt in custom-checks.py.
p = Path(filepath)
if (
p.name.startswith("test_")
or p.name.endswith("_test.py")
or "tests" in p.parts
or "migration" in filepath.lower()
or ".pre-commit-hooks" in filepath
):
continue
errors = check_file(filepath)
all_errors.extend(errors)
if all_errors:
print("Research ID type errors found:")
print("-" * 80)
for error in all_errors:
print(error)
print("-" * 80)
print(
f"Total errors: {len([e for e in all_errors if not e.startswith(' ')])}"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""
Pre-commit hook to enforce safe_requests usage for SSRF protection.
This prevents direct usage of requests.get/post/Session which bypasses
SSRF validation. Use safe_get/safe_post/SafeSession from the security module.
See: https://cwe.mitre.org/data/definitions/918.html (CWE-918: SSRF)
"""
import ast
import sys
# Files/patterns where direct requests usage is allowed
ALLOWED_PATTERNS = {
"security/safe_requests.py", # The wrapper itself must use raw requests
"tests/", # Test files may need to mock/test raw requests
"test_", # Test files
"_test.py", # Test files
"examples/", # Example scripts run client-side, not server-side
}
# HTTP methods that should use safe_get/safe_post
UNSAFE_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
class RequestsChecker(ast.NodeVisitor):
"""AST visitor to detect unsafe requests usage."""
def __init__(self, filename: str):
self.filename = filename
self.errors = []
def visit_Call(self, node):
# Skip if file is in allowed list
if self._is_file_allowed():
return self.generic_visit(node)
# Pattern 1: requests.get(), requests.post(), etc.
if (
isinstance(node.func, ast.Attribute)
and node.func.attr in UNSAFE_METHODS
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "requests"
):
method = node.func.attr
if method in ("get", "post"):
safe_method = f"safe_{method}"
suggestion = (
f"Use {safe_method}() from security module instead."
)
else:
suggestion = (
"Use SafeSession() from security module for HTTP requests."
)
self.errors.append(
(
node.lineno,
f"Direct requests.{method}() bypasses SSRF protection. "
f"{suggestion}",
)
)
# Pattern 2: requests.Session()
elif (
isinstance(node.func, ast.Attribute)
and node.func.attr == "Session"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "requests"
):
self.errors.append(
(
node.lineno,
"Direct requests.Session() bypasses SSRF protection. "
"Use SafeSession() from security module instead.",
)
)
self.generic_visit(node)
def _is_file_allowed(self) -> bool:
"""Check if this file is allowed to use requests directly."""
for pattern in ALLOWED_PATTERNS:
if pattern in self.filename:
return True
return False
def check_file(filename: str) -> bool:
"""Check a single Python file for unsafe requests usage."""
if not filename.endswith(".py"):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
checker = RequestsChecker(filename)
checker.visit(tree)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors (let other tools handle that)
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all provided Python files."""
if len(sys.argv) < 2:
print("Usage: check-safe-requests.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n" + "=" * 70)
print("SSRF Protection: Unsafe requests usage detected!")
print("=" * 70)
print("\nTo fix, replace direct requests calls with safe alternatives:")
print(" - requests.get() -> safe_get()")
print(" - requests.post() -> safe_post()")
print(" - requests.Session() -> SafeSession()")
print("\nImport from security module:")
print(" from ...security import safe_get, safe_post, SafeSession")
print("\nFor localhost/internal services, use:")
print(" safe_get(url, allow_localhost=True)")
print(" safe_get(url, allow_private_ips=True)")
print("\nSee: https://cwe.mitre.org/data/definitions/918.html")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+499
View File
@@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect resources (services, database sessions) that are
instantiated without context managers.
Services like DownloadService, LibraryRAGService, and LocalEmbeddingManager
hold resources (file handles, connections, models) that need to be released.
They should be used with context managers (with statements) to ensure cleanup.
Functions like get_auth_db_session() return raw sessions that require manual
cleanup. Use the auth_db_session() context manager instead.
Exceptions:
- Factory functions that return services (caller is responsible for cleanup)
- Services passed to other objects (dependency injection)
- try/finally with explicit close()
"""
import ast
import sys
from typing import List, Optional, Set, Tuple
# Services (classes) that require context manager usage
SERVICES_REQUIRING_CONTEXT = {
"DownloadService",
"LibraryRAGService",
"LocalEmbeddingManager",
}
# Functions returning resources that need cleanup
# Maps function name -> suggested context manager replacement
FUNCTIONS_REQUIRING_CONTEXT = {
"get_auth_db_session": "auth_db_session()",
"get_db_session": "get_user_db_session(username)",
}
# Files/patterns where direct instantiation is allowed
ALLOWED_PATTERNS = {
"tests/", # Test files often mock or need special handling
"test_", # Test files
"_test.py", # Test files
# The service implementations themselves
"download_service.py",
"library_rag_service.py",
"search_engine_local.py",
# Database implementation files
"auth_db.py",
"encrypted_db.py",
"db_utils.py", # Defines get_db_session itself
"session_context.py", # Stores session in g.db_session (cleaned by teardown)
"web/auth/decorators.py", # inject_current_user stores in g.db_session
}
def is_file_allowed(filename: str) -> bool:
"""Check if this file is allowed to use services without context managers."""
for pattern in ALLOWED_PATTERNS:
if pattern in filename:
return True
return False
def get_resource_name(node: ast.expr) -> Optional[Tuple[str, Optional[str]]]:
"""
Check if an expression is a resource that requires cleanup.
Returns:
Tuple of (resource_name, suggested_replacement) or None if not a resource.
For services, suggested_replacement is None (use 'with ServiceName(...) as var:').
For functions, suggested_replacement is the context manager to use instead.
"""
if not isinstance(node, ast.Call):
return None
func_name = None
# Check for Name() pattern (e.g., ServiceName() or get_auth_db_session())
if isinstance(node.func, ast.Name):
func_name = node.func.id
# Check for module.Name() pattern (e.g., module.ServiceName())
elif isinstance(node.func, ast.Attribute):
func_name = node.func.attr
if func_name is None:
return None
# Check if it's a service class
if func_name in SERVICES_REQUIRING_CONTEXT:
return (func_name, None)
# Check if it's a function returning a resource
if func_name in FUNCTIONS_REQUIRING_CONTEXT:
return (func_name, FUNCTIONS_REQUIRING_CONTEXT[func_name])
return None
class FunctionScopeAnalyzer(ast.NodeVisitor):
"""Analyze patterns within a function scope."""
def __init__(self):
self.close_vars: Set[str] = set() # Variables with try/finally close()
self.safe_with_lines: Set[int] = set() # Lines in with context
self.returned_vars: Set[str] = set() # Variables that are returned
self.passed_to_args_vars: Set[str] = (
set()
) # Variables passed as arguments
def visit_Try(self, node: ast.Try) -> None:
"""Find variables that are closed in finally blocks within this scope."""
if node.finalbody:
self._find_close_calls(node.finalbody)
self.generic_visit(node)
def _find_close_calls(self, stmts: List) -> None:
"""Recursively find .close() calls in a list of statements."""
for stmt in stmts:
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
call = stmt.value
if (
isinstance(call.func, ast.Attribute)
and call.func.attr == "close"
and isinstance(call.func.value, ast.Name)
):
self.close_vars.add(call.func.value.id)
# Recursively check nested try blocks in finally
elif isinstance(stmt, ast.Try):
self._find_close_calls(stmt.body)
for handler in stmt.handlers:
self._find_close_calls(handler.body)
self._find_close_calls(stmt.finalbody)
elif isinstance(stmt, ast.If):
self._find_close_calls(stmt.body)
self._find_close_calls(stmt.orelse)
def visit_With(self, node: ast.With) -> None:
"""Mark with statements that use resources as context managers."""
for item in node.items:
resource_info = get_resource_name(item.context_expr)
if resource_info:
self.safe_with_lines.add(item.context_expr.lineno)
self.generic_visit(node)
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
"""Mark async with statements that use resources as context managers."""
for item in node.items:
resource_info = get_resource_name(item.context_expr)
if resource_info:
self.safe_with_lines.add(item.context_expr.lineno)
self.generic_visit(node)
def visit_Return(self, node: ast.Return) -> None:
"""Track variables that are returned (factory function pattern)."""
if node.value and isinstance(node.value, ast.Name):
self.returned_vars.add(node.value.id)
# Also handle direct resource returns
if node.value and get_resource_name(node.value):
# This is a direct return of a resource - mark the line as safe
self.safe_with_lines.add(node.value.lineno)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Track variables passed as arguments to other functions."""
# Check all arguments
for arg in node.args:
if isinstance(arg, ast.Name):
self.passed_to_args_vars.add(arg.id)
# Check keyword arguments
for kwarg in node.keywords:
if isinstance(kwarg.value, ast.Name):
self.passed_to_args_vars.add(kwarg.value.id)
self.generic_visit(node)
class ServiceContextChecker(ast.NodeVisitor):
"""Check for service instantiations that aren't properly managed."""
def __init__(self):
self.errors: List[Tuple[int, str]] = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
"""Analyze a function for improper service usage."""
self._check_function_body(node)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
"""Analyze an async function for improper service usage."""
self._check_function_body(node)
self.generic_visit(node)
def visit_Module(self, node: ast.Module) -> None:
"""Also check module-level code."""
# Create a fake function body from module-level statements
# that aren't inside functions
module_stmts = []
for stmt in node.body:
if not isinstance(
stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
):
module_stmts.append(stmt)
if module_stmts:
self._check_statements(module_stmts)
self.generic_visit(node)
def _check_function_body(self, func_node) -> None:
"""Check a function body for improper service usage."""
self._check_statements(func_node.body)
def _check_statements(self, statements: List[ast.stmt]) -> None:
"""Check a list of statements for improper service usage."""
# First, analyze this scope for patterns
analyzer = FunctionScopeAnalyzer()
for stmt in statements:
analyzer.visit(stmt)
# Now check each statement for service assignments
self._check_statements_recursive(
statements,
analyzer.close_vars,
analyzer.safe_with_lines,
analyzer.returned_vars,
analyzer.passed_to_args_vars,
)
def _check_statements_recursive(
self,
statements: List[ast.stmt],
close_vars: Set[str],
safe_with_lines: Set[int],
returned_vars: Set[str],
passed_to_args_vars: Set[str],
) -> None:
"""Recursively check statements for service assignments."""
for stmt in statements:
if isinstance(stmt, ast.Assign):
self._check_assign(
stmt,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.With):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.AsyncWith):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.If):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.For):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.While):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.Try):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
for handler in stmt.handlers:
self._check_statements_recursive(
handler.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.finalbody,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
def _check_assign(
self,
node: ast.Assign,
close_vars: Set[str],
safe_with_lines: Set[int],
returned_vars: Set[str],
passed_to_args_vars: Set[str],
) -> None:
"""Check an assignment for improper resource usage."""
resource_info = get_resource_name(node.value)
if not resource_info:
return
resource_name, suggested_replacement = resource_info
# Get the variable name being assigned
var_name = None
if node.targets and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
# Check if this is a safe instantiation
if node.value.lineno in safe_with_lines:
# Inside a with statement as context manager - safe
return
if var_name and var_name in close_vars:
# Has explicit try/finally with close() in the same function - acceptable
return
if var_name and var_name in returned_vars:
# Variable is returned (factory function pattern) - caller responsible
return
if var_name and var_name in passed_to_args_vars:
# Variable is passed to another function (dependency injection) - receiver responsible
return
# Not safe - flag this
if suggested_replacement:
# Function with a known replacement context manager
self.errors.append(
(
node.lineno,
f"{resource_name}() returns a resource that needs cleanup. "
f"Use 'with {suggested_replacement} as var:' instead.",
)
)
else:
# Service class
self.errors.append(
(
node.lineno,
f"{resource_name} should be used with a context manager "
f"('with {resource_name}(...) as var:') to ensure proper cleanup.",
)
)
def check_file(filename: str) -> bool:
"""Check a single Python file for service context manager usage."""
if not filename.endswith(".py"):
return True
if is_file_allowed(filename):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
# Check for service instantiations
checker = ServiceContextChecker()
checker.visit(tree)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors (let other tools handle that)
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all provided Python files."""
if len(sys.argv) < 2:
print("Usage: check-service-context-managers.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n" + "=" * 70)
print("Resource Leak Prevention: Use context managers for cleanup!")
print("=" * 70)
print("\n--- Services ---")
print("Services like DownloadService, LibraryRAGService, and")
print("LocalEmbeddingManager hold resources that need cleanup.")
print("\nTo fix, use context managers:")
print(" # Before (leaks resources):")
print(" service = DownloadService()")
print(" result = service.download(...)")
print()
print(" # After (proper cleanup):")
print(" with DownloadService() as service:")
print(" result = service.download(...)")
print("\n--- Database Sessions ---")
print("get_auth_db_session() returns a raw session that needs cleanup.")
print("Use auth_db_session() context manager instead:")
print()
print(" # Before (may leak on exception):")
print(" session = get_auth_db_session()")
print(" user = session.query(User).first()")
print(" session.close()")
print()
print(" # After (proper cleanup):")
print(" with auth_db_session() as session:")
print(" user = session.query(User).first()")
print()
print("get_db_session() is deprecated and leaks QueuePool connections.")
print("Use get_user_db_session(username) context manager instead:")
print()
print(" # Before (leaks pool connection — never closed):")
print(" session = get_db_session(username=username)")
print()
print(" # After (proper cleanup):")
print(" with get_user_db_session(username) as session:")
print(" settings = SettingsManager(session)")
print("\n--- Alternative: try/finally ---")
print(" service = DownloadService()")
print(" try:")
print(" result = service.download(...)")
print(" finally:")
print(" service.close()")
print("\nNote: Factory functions that return resources are allowed,")
print("as the caller is responsible for cleanup.")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect try/finally session patterns and suggest context managers.
This hook checks for SQLAlchemy session management patterns that use try/finally
blocks and suggests replacing them with context managers for better resource
management and cleaner code.
"""
import ast
import sys
from pathlib import Path
from typing import List, Tuple
class SessionPatternChecker(ast.NodeVisitor):
"""AST visitor to detect try/finally session patterns."""
def __init__(self, filename: str):
self.filename = filename
self.issues: List[Tuple[int, str]] = []
self.functions_and_methods = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
"""Visit function definitions to check for session patterns."""
self._check_function_for_pattern(node)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
"""Visit async function definitions to check for session patterns."""
self._check_function_for_pattern(node)
self.generic_visit(node)
def _check_function_for_pattern(self, func_node) -> None:
"""Check a function body for try/finally session patterns."""
# Look for session = Session() followed by try/finally
for i, stmt in enumerate(func_node.body):
# Check if this is a session assignment
if isinstance(stmt, ast.Assign):
session_var = self._get_session_var_from_assign(stmt)
if session_var:
# Look for a try/finally block that follows
for next_stmt in func_node.body[
i + 1 : i + 3
]: # Check next 2 statements
if (
isinstance(next_stmt, ast.Try)
and next_stmt.finalbody
):
# Check if finally has session.close()
if self._has_session_close_in_finally(
next_stmt.finalbody, session_var
):
self.issues.append(
(
stmt.lineno,
f"Found try/finally session pattern. Consider using 'with self.Session() as {session_var}:' instead",
)
)
break
def _get_session_var_from_assign(self, assign_node: ast.Assign) -> str:
"""Check if an assignment is creating a session and return the variable name."""
if isinstance(assign_node.value, ast.Call) and self._is_session_call(
assign_node.value
):
if assign_node.targets and isinstance(
assign_node.targets[0], ast.Name
):
return assign_node.targets[0].id
return None
def _is_session_call(self, call_node: ast.Call) -> bool:
"""Check if a call node is creating a SQLAlchemy session."""
# Check for self.Session() pattern
if isinstance(call_node.func, ast.Attribute):
if call_node.func.attr in (
"Session",
"get_session",
"create_session",
):
return True
# Check for Session() pattern
elif isinstance(call_node.func, ast.Name):
if call_node.func.id in (
"Session",
"get_session",
"create_session",
):
return True
return False
def _has_session_close_in_finally(
self, finalbody: List[ast.stmt], session_var: str
) -> bool:
"""Check if finally block contains session.close()."""
for stmt in finalbody:
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
# Check for session.close() pattern
if (
isinstance(stmt.value.func, ast.Attribute)
and stmt.value.func.attr == "close"
):
# Check if it's our session variable
if (
isinstance(stmt.value.func.value, ast.Name)
and stmt.value.func.value.id == session_var
):
return True
return False
def check_file(filepath: Path) -> List[Tuple[str, int, str]]:
"""Check a single Python file for try/finally session patterns."""
issues = []
try:
content = filepath.read_text(encoding="utf-8")
tree = ast.parse(content, filename=str(filepath))
checker = SessionPatternChecker(str(filepath))
checker.visit(tree)
for line_no, message in checker.issues:
issues.append((str(filepath), line_no, message))
except SyntaxError as e:
# Skip files with syntax errors
print(f"Syntax error in {filepath}: {e}", file=sys.stderr)
except Exception as e:
print(f"Error checking {filepath}: {e}", file=sys.stderr)
return issues
def main():
"""Main entry point for the pre-commit hook."""
# Get list of files to check from command line arguments
files_to_check = sys.argv[1:] if len(sys.argv) > 1 else []
if not files_to_check:
print("No files to check")
return 0
all_issues = []
for filepath_str in files_to_check:
filepath = Path(filepath_str)
# Skip non-Python files
if not filepath.suffix == ".py":
continue
# Skip test files and migration files
if "test" in filepath.parts or "migration" in filepath.parts:
continue
issues = check_file(filepath)
all_issues.extend(issues)
# Report issues
if all_issues:
print(
"\n❌ Found try/finally session patterns that should use context managers:\n"
)
for filepath, line_no, message in all_issues:
print(f" {filepath}:{line_no}: {message}")
print("\n💡 Tip: Replace try/finally blocks with context managers:")
print(" Before:")
print(" session = self.Session()")
print(" try:")
print(" # operations")
print(" session.commit()")
print(" finally:")
print(" session.close()")
print("\n After:")
print(" with self.Session() as session:")
print(" # operations")
print(" session.commit()")
print("\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
Pre-commit hook to validate hardcoded settings key strings against the
allow/block lists defined in settings_routes.py.
Catches new settings keys that would be rejected by the runtime namespace
gate before they reach the release server.
"""
import ast
import re
import sys
from pathlib import Path
SETTINGS_ROUTES = (
Path(__file__).resolve().parent.parent
/ "src"
/ "local_deep_research"
/ "web"
/ "routes"
/ "settings_routes.py"
)
WRITE_FUNC_NAMES = {"set_setting"}
# Regex patterns for JS files
JS_SAVE_SETTING_RE = re.compile(r"saveSetting\s*\(\s*['\"]([^'\"]+)['\"]")
JS_SETTINGS_API_URL_RE = re.compile(
r"['\"]\/settings\/api\/([a-z][a-z0-9_]*\.[a-z0-9_.]+)['\"]"
)
JS_INLINE_COMMENT_RE = re.compile(r"/\*.*?\*/")
# Excluded paths/directories
SKIP_PATH_SEGMENTS = {
"tests",
"test",
".pre-commit-hooks",
"migrations",
"node_modules",
"dist",
"build",
"vendor",
"defaults", # JSON schema files define keys — not write call sites
}
SKIP_NAME_PREFIXES = ("test_", "conftest")
SKIP_NAME_SUFFIXES = ("_test.py", "_test.js", ".min.js")
SKIP_EXACT_NAMES = {"settings_routes.py"}
def load_prefixes():
"""Parse ALLOWED_SETTING_PREFIXES and BLOCKED_SETTING_PREFIXES
from settings_routes.py using AST."""
try:
source = SETTINGS_ROUTES.read_text(encoding="utf-8")
except FileNotFoundError:
print(
f"FATAL: Cannot find settings_routes.py at {SETTINGS_ROUTES}\n"
"The hook cannot validate settings keys without it."
)
sys.exit(1)
tree = ast.parse(source, filename=str(SETTINGS_ROUTES))
allowed = None
blocked = None
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if not isinstance(target, ast.Name):
continue
if target.id == "ALLOWED_SETTING_PREFIXES":
allowed = _extract_frozenset_strings(node.value)
elif target.id == "BLOCKED_SETTING_PREFIXES":
blocked = _extract_frozenset_strings(node.value)
if allowed is None or blocked is None:
print(
"FATAL: Could not parse ALLOWED/BLOCKED_SETTING_PREFIXES "
f"from {SETTINGS_ROUTES}"
)
sys.exit(1)
return allowed, blocked
def _extract_frozenset_strings(node):
"""Extract string values from frozenset({...}) or frozenset((...,))."""
if not isinstance(node, ast.Call):
return None
if not (isinstance(node.func, ast.Name) and node.func.id == "frozenset"):
return None
if not node.args:
return None
collection = node.args[0]
strings = set()
if isinstance(collection, (ast.Set, ast.Tuple, ast.List)):
for elt in collection.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
strings.add(elt.value)
return strings if strings else None
def is_key_allowed(key, allowed, blocked):
"""Mirrors _is_allowed_new_setting_key from settings_routes.py."""
if not isinstance(key, str) or not key or ".." in key:
return False
key_lower = key.lower()
for prefix in blocked:
if key_lower.startswith(prefix):
return False
for prefix in allowed:
if key_lower.startswith(prefix):
return True
return False
def should_skip(filepath):
"""Check if a file should be skipped."""
p = Path(filepath)
name = p.name
if name in SKIP_EXACT_NAMES:
return True
if name.startswith(SKIP_NAME_PREFIXES):
return True
if name.endswith(SKIP_NAME_SUFFIXES):
return True
if SKIP_PATH_SEGMENTS.intersection(p.parts):
return True
return False
class SettingsKeyChecker(ast.NodeVisitor):
"""AST visitor for Python files — detects set_setting("key", ...) calls."""
def __init__(self, filename, allowed, blocked):
self.filename = filename
self.allowed = allowed
self.blocked = blocked
self.errors = []
def visit_Call(self, node):
key = self._extract_write_key(node)
if key is not None and not is_key_allowed(
key, self.allowed, self.blocked
):
self.errors.append((node.lineno, key))
self.generic_visit(node)
def _extract_write_key(self, node):
"""Extract a hardcoded settings key from a set_setting call.
Returns the key string if found, or None if the call is not
applicable or uses a dynamic key.
"""
# Pattern 1: set_setting("key", ...)
if isinstance(node.func, ast.Name) and node.func.id in WRITE_FUNC_NAMES:
return self._first_string_arg(node)
# Pattern 2: obj.set_setting("key", ...)
if (
isinstance(node.func, ast.Attribute)
and node.func.attr in WRITE_FUNC_NAMES
):
return self._first_string_arg(node)
return None
@staticmethod
def _first_string_arg(node):
"""Return the first positional arg if it's a settings-key-like string.
A settings key contains at least one dot (e.g. "llm.provider") or
underscore (e.g. "local_search_embedding_model"). Bare words like
"value" or "config" are not settings keys and are skipped to avoid
false positives on non-settings call sites.
"""
if node.args and isinstance(node.args[0], ast.Constant):
val = node.args[0].value
if (
isinstance(val, str)
and len(val) > 2
and ("." in val or "_" in val)
):
return val
return None
def check_python_file(filepath, allowed, blocked):
"""Check a Python file for settings key namespace violations."""
try:
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
return []
try:
tree = ast.parse(content, filename=filepath)
except SyntaxError:
return []
checker = SettingsKeyChecker(filepath, allowed, blocked)
checker.visit(tree)
return checker.errors
def check_js_file(filepath, allowed, blocked):
"""Check a JS file for settings key namespace violations."""
errors = []
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
except Exception:
return []
for i, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("//") or stripped.startswith("*"):
continue
# Strip inline /* ... */ comments to avoid false positives
line = JS_INLINE_COMMENT_RE.sub("", line)
# saveSetting('key', ...)
for m in JS_SAVE_SETTING_RE.finditer(line):
key = m.group(1)
if not is_key_allowed(key, allowed, blocked):
errors.append((i, key))
# '/settings/api/key' literal URLs
for m in JS_SETTINGS_API_URL_RE.finditer(line):
key = m.group(1)
if not is_key_allowed(key, allowed, blocked):
errors.append((i, key))
return errors
def main():
if len(sys.argv) < 2:
print("Usage: check-settings-key-namespace.py <file1> <file2> ...")
sys.exit(1)
allowed, blocked = load_prefixes()
all_errors = []
for filepath in sys.argv[1:]:
if should_skip(filepath):
continue
if filepath.endswith(".py"):
errors = check_python_file(filepath, allowed, blocked)
elif filepath.endswith((".js", ".mjs")):
errors = check_js_file(filepath, allowed, blocked)
else:
continue
if errors:
all_errors.append((filepath, errors))
if all_errors:
allowed_sorted = sorted(allowed)
print("\nSettings key namespace violations found:\n")
for filepath, errors in all_errors:
print(f"{filepath}:")
for line_num, key in errors:
print(
f" Line {line_num}: '{key}' does not match any allowed prefix"
)
print(
f"\nAllowed prefixes: {', '.join(allowed_sorted)}\n"
"Fix: Add the prefix to ALLOWED_SETTING_PREFIXES in "
"settings_routes.py, or rename the key."
)
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect get_settings_manager() calls without db_session
in code that runs in background threads.
Background threads have no Flask app context, so get_settings_manager()
without an explicit db_session falls back to JSON defaults only. Settings
that have no JSON defaults (e.g. local_search_* embedding keys) silently
return None, causing user-configured values to be ignored. See #3453.
Scope and limitations:
- Only direct calls inside a thread function are inspected. Indirect
calls via a helper function (thread fn -> helper -> get_settings_manager)
are not caught; that would require cross-function call-graph analysis.
- Thread detection relies on the @thread_cleanup decorator or
_background_/_auto_/*_worker naming conventions. Other thread targets
(e.g. threading.Thread(target=self._monitor_resources)) are not matched.
Prefer decorating those functions with @thread_cleanup if they need
to call get_settings_manager().
- Test files are excluded via .pre-commit-config.yaml ``exclude: ^tests/``.
"""
import ast
import sys
from pathlib import Path
from typing import List, Tuple
# Decorators that mark a function as running in a background thread
THREAD_DECORATORS = frozenset({"thread_cleanup"})
# Function name patterns that indicate background-thread execution
THREAD_FUNCTION_PREFIXES = ("_background_", "_auto_")
THREAD_FUNCTION_SUFFIXES = ("_worker",)
class SettingsManagerThreadSafetyChecker(ast.NodeVisitor):
"""AST visitor to detect unsafe get_settings_manager() calls in thread code."""
def __init__(self, filename: str):
self.filename = filename
self.issues: List[Tuple[int, str]] = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if self._is_thread_function(node):
self._check_body_for_unsafe_calls(node)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
if self._is_thread_function(node):
self._check_body_for_unsafe_calls(node)
self.generic_visit(node)
# ------------------------------------------------------------------
def _is_thread_function(self, node) -> bool:
"""Return True if the function is likely executed in a background thread."""
# Check decorators
for decorator in node.decorator_list:
name = None
if isinstance(decorator, ast.Name):
name = decorator.id
elif isinstance(decorator, ast.Attribute):
name = decorator.attr
elif isinstance(decorator, ast.Call):
if isinstance(decorator.func, ast.Name):
name = decorator.func.id
elif isinstance(decorator.func, ast.Attribute):
name = decorator.func.attr
if name and name in THREAD_DECORATORS:
return True
# Check function name conventions
fname = node.name
if any(fname.startswith(p) for p in THREAD_FUNCTION_PREFIXES):
return True
if any(fname.endswith(s) for s in THREAD_FUNCTION_SUFFIXES):
return True
return False
def _check_body_for_unsafe_calls(self, node) -> None:
"""Walk the function body looking for get_settings_manager() without db_session.
Stops at nested function-def boundaries: any nested function is
visited separately by ``generic_visit`` -> ``visit_FunctionDef``,
so we must not descend into it here or we would double-report.
"""
for child in self._iter_non_nested(node):
if not isinstance(child, ast.Call):
continue
if not self._is_get_settings_manager_call(child):
continue
if not self._has_db_session_arg(child):
self.issues.append(
(
child.lineno,
"get_settings_manager() called without db_session= "
"in a background-thread function. In threads without "
"Flask app context the DB session will be None and "
"settings fall back to JSON defaults only. "
"Pass an explicit db_session from get_user_db_session().",
),
)
@classmethod
def _iter_non_nested(cls, node):
"""Yield descendants of ``node``, stopping at nested function defs."""
for child in ast.iter_child_nodes(node):
if isinstance(
child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
):
continue
yield child
yield from cls._iter_non_nested(child)
@staticmethod
def _is_get_settings_manager_call(call_node: ast.Call) -> bool:
func = call_node.func
if isinstance(func, ast.Name) and func.id == "get_settings_manager":
return True
if (
isinstance(func, ast.Attribute)
and func.attr == "get_settings_manager"
):
return True
return False
@staticmethod
def _has_db_session_arg(call_node: ast.Call) -> bool:
# db_session is the first positional param of get_settings_manager,
# so any positional argument satisfies the safety contract.
if call_node.args:
return True
return any(kw.arg == "db_session" for kw in call_node.keywords)
def check_file(filepath: Path) -> List[Tuple[str, int, str]]:
"""Check a single Python file."""
issues = []
try:
content = filepath.read_text(encoding="utf-8")
tree = ast.parse(content, filename=str(filepath))
checker = SettingsManagerThreadSafetyChecker(str(filepath))
checker.visit(tree)
for line_no, message in checker.issues:
issues.append((str(filepath), line_no, message))
except SyntaxError as e:
print(f"Syntax error in {filepath}: {e}", file=sys.stderr)
except Exception as e:
print(f"Error checking {filepath}: {e}", file=sys.stderr)
return issues
def main():
files_to_check = sys.argv[1:] if len(sys.argv) > 1 else []
if not files_to_check:
print("No files to check")
return 0
all_issues = []
for filepath_str in files_to_check:
filepath = Path(filepath_str)
if not filepath.suffix == ".py":
continue
issues = check_file(filepath)
all_issues.extend(issues)
if all_issues:
print(
"\n\u274c get_settings_manager() called without db_session "
"in background-thread code:\n"
)
for filepath, line_no, message in all_issues:
print(f" {filepath}:{line_no}: {message}")
print(
"\n\U0001f4a1 Tip: Use get_user_db_session() to obtain a session "
"and pass it explicitly:\n"
)
print(
" with get_user_db_session(username, db_password) as db_session:"
)
print(
" settings = get_settings_manager("
"db_session=db_session, username=username)"
)
print()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Block "shadow tests": test modules that never import the SUT.
A shadow test imports nothing from ``local_deep_research`` and therefore
exercises no production code. Such files test inline reimplementations,
pure stdlib behaviour, or locally-built dicts — inflating the test count
and coverage metrics while catching zero real regressions. PRs #4239,
#4242, #4243 removed ~80 such files; this hook stops them creeping back.
Detection (AST-based): a file whose basename matches the test-module
pattern (``test_*.py`` / ``*_test.py``) is flagged when none of its
``import`` / ``from ... import`` statements reference ``local_deep_research``
(or ``src.local_deep_research``). Imports under ``if TYPE_CHECKING:`` don't
run, so they don't count as exercising the SUT.
Opt-out: a small number of files legitimately have no SUT import — e.g.
guardian tests that assert on repository structure or CI workflow files.
Add a marker line to exempt one::
# allow: no-sut-import — <why this test has no local_deep_research import>
The reason text is required so the exemption is self-documenting.
"""
import ast
import re
import sys
from pathlib import Path
# Marker that exempts a file, mirroring the `# allow: unmarked-sleep`
# convention used by check-unmarked-sleep.py. A trailing reason is required.
ALLOW_RE = re.compile(r"#\s*allow:\s*no-sut-import\b\s*[-—:]\s*\S+")
SUT_ROOTS = ("local_deep_research", "src.local_deep_research")
# Only emit ANSI colour when stdout is a TTY (matches recommend-release-notes.py);
# CI log viewers and Windows consoles then get clean plain text.
_USE_COLOR = sys.stdout.isatty()
_RED = "\033[31m" if _USE_COLOR else ""
_RESET = "\033[0m" if _USE_COLOR else ""
def is_test_module(path: str) -> bool:
"""True for pytest test modules (not conftest/__init__/helpers)."""
# Path().name handles both / and \ so the hook behaves the same
# if it's ever invoked outside pre-commit (which normalizes to POSIX).
name = Path(path).name
if not name.endswith(".py"):
return False
return name.startswith("test_") or name.endswith("_test.py")
def _references_sut(name: str | None) -> bool:
if not name:
return False
return any(
name == root or name.startswith(root + ".") for root in SUT_ROOTS
)
def _is_type_checking_guard(node: ast.If) -> bool:
"""True for ``if TYPE_CHECKING:`` / ``if typing.TYPE_CHECKING:``."""
test = node.test
if isinstance(test, ast.Name):
return test.id == "TYPE_CHECKING"
if isinstance(test, ast.Attribute):
return test.attr == "TYPE_CHECKING"
return False
def imports_sut(tree: ast.AST) -> bool:
"""True if any *runtime* import statement pulls from the SUT package.
Imports under ``if TYPE_CHECKING:`` don't execute, so a test whose only
SUT reference is a type-hint import still exercises no production code —
those guard bodies are skipped (but the runtime ``else`` branch is not).
"""
stack: list[ast.AST] = [tree]
while stack:
node = stack.pop()
if isinstance(node, ast.Import):
if any(_references_sut(alias.name) for alias in node.names):
return True
continue
if isinstance(node, ast.ImportFrom):
# Absolute import from the package (level 0); relative imports
# inside tests/ never reach the installed package, so ignore.
if node.level == 0 and _references_sut(node.module):
return True
continue
if isinstance(node, ast.If) and _is_type_checking_guard(node):
# Skip the type-only body; the else branch runs at runtime.
stack.extend(node.orelse)
continue
stack.extend(ast.iter_child_nodes(node))
return False
def check_file(path: str) -> bool:
"""Return True if `path` is an unmarked shadow test."""
if not is_test_module(path):
return False
try:
with open(path, encoding="utf-8") as fh:
source = fh.read()
except (OSError, UnicodeDecodeError):
return False
if ALLOW_RE.search(source):
return False
try:
tree = ast.parse(source, filename=path)
except SyntaxError:
# Let ruff / other hooks report syntax errors.
return False
return not imports_sut(tree)
def main(argv: list[str]) -> int:
shadow = [p for p in argv if check_file(p)]
if not shadow:
return 0
print(f"{_RED}Shadow tests detected{_RESET}")
print("=" * 60)
print(
"These test modules import nothing from `local_deep_research`, so\n"
"they exercise no production code (see CONTRIBUTING.md → Testing):\n"
)
for path in shadow:
print(f" - {path}")
print()
print("Fix one of these ways:")
print(" 1. Import and exercise the real code under test, or")
print(" 2. If the file legitimately has no SUT import (e.g. a guardian")
print(" test), add a marker line stating why:")
print(" # allow: no-sut-import — <reason>")
print()
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Pre-commit hook to flag unsafe cleanup patterns.
Check 1 — Silent close() methods:
Detects `def close(self)` methods that contain bare `except Exception: pass`
or `except: pass` — cleanup failures should be logged via safe_close() from
utilities.resource_utils, or with explicit logger calls.
Check 2 — Bare .close() in finally/except blocks:
Detects `obj.close()` calls inside finally/except blocks that are not wrapped
in safe_close(). If .close() raises, it masks the original exception.
"""
import ast
import sys
# Files/patterns that are excluded from this check
ALLOWED_PATTERNS = {
"tests/",
"test_",
"_test.py",
# LLM wrapper close() methods delegate to _close_base_llm which has
# selective httpx-client introspection logic — not a simple .close() call.
"config/llm_config.py",
"rate_limiting/llm/wrapper.py",
}
class SilentCleanupChecker(ast.NodeVisitor):
"""AST visitor to detect silent exceptions in close() methods."""
def __init__(self, filename: str):
self.filename = filename
self.errors = []
def visit_FunctionDef(self, node):
# Only inspect `def close(self, ...):` methods
if node.name != "close":
self.generic_visit(node)
return
# Walk the close() body looking for try/except
for child in ast.walk(node):
if not isinstance(child, ast.ExceptHandler):
continue
# Check if the handler is "except Exception:" or bare "except:"
if child.type is not None and not (
isinstance(child.type, ast.Name)
and child.type.id == "Exception"
):
continue
# Check if handler body is *only* `pass` (no logging, no raise)
if self._is_silent_handler(child):
self.errors.append(
(
child.lineno,
"Silent exception in close() method — use "
"safe_close() from utilities.resource_utils "
"or add explicit logging so cleanup failures "
"are visible.",
)
)
self.generic_visit(node)
@staticmethod
def _is_silent_handler(handler: ast.ExceptHandler) -> bool:
"""Return True if handler body is only `pass` with no logging/raise."""
for stmt in handler.body:
# pass statement — continue checking
if isinstance(stmt, ast.Pass):
continue
# raise — not silent
if isinstance(stmt, ast.Raise):
return False
# Any expression that looks like logger.something(...)
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
func = stmt.value.func
if isinstance(func, ast.Attribute) and isinstance(
func.value, ast.Name
):
if func.value.id == "logger":
return False
# Any other statement means it's not just `pass`
return False
# If we got here, every statement was `pass`
return True
def _is_file_allowed(self) -> bool:
for pattern in ALLOWED_PATTERNS:
if pattern in self.filename:
return True
return False
class BareCloseInFinallyChecker(ast.NodeVisitor):
"""AST visitor to detect bare .close() calls in finally/except blocks.
A bare `obj.close()` in a finally or except block can mask the original
exception if .close() itself raises. Use safe_close() instead.
"""
def __init__(self, filename: str):
self.filename = filename
self.errors = []
def visit_Try(self, node):
# Check finally body
for stmt in node.finalbody:
self._check_block(stmt)
# Check except handlers
for handler in node.handlers:
for stmt in handler.body:
self._check_block(stmt)
self.generic_visit(node)
def _check_block(self, node):
"""Check a single statement for bare .close() calls.
Recurses into if/elif/else bodies but stops at nested try blocks
(which would provide their own exception protection).
"""
# Direct .close() call as a statement
if self._is_bare_close(node):
self.errors.append(
(
node.lineno,
"Bare .close() in finally/except block — if "
".close() raises, it masks the original exception. "
"Use safe_close() from utilities.resource_utils.",
)
)
return
# Recurse into if/elif/else bodies (common pattern: if x is not None: x.close())
if isinstance(node, ast.If):
for stmt in node.body:
self._check_block(stmt)
for stmt in node.orelse:
self._check_block(stmt)
# Names that are not resource handles — .close() on these is safe
_SAFE_CLOSE_NAMES = {"plt", "figure", "fig", "ax"}
@classmethod
def _is_bare_close(cls, node) -> bool:
"""Return True if node is an expression statement calling .close()."""
if not isinstance(node, ast.Expr):
return False
if not isinstance(node.value, ast.Call):
return False
call = node.value
if not (
isinstance(call.func, ast.Attribute)
and call.func.attr == "close"
and not call.args
and not call.keywords
):
return False
# Skip known-safe names (e.g. plt.close(), cursor.close())
if isinstance(call.func.value, ast.Name):
if call.func.value.id in cls._SAFE_CLOSE_NAMES:
return False
return True
def check_file(filename: str) -> bool:
"""Check a single Python file for silent cleanup patterns."""
if not filename.endswith(".py"):
return True
# Skip allowed files
for pattern in ALLOWED_PATTERNS:
if pattern in filename:
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
checker = SilentCleanupChecker(filename)
checker.visit(tree)
bare_checker = BareCloseInFinallyChecker(filename)
bare_checker.visit(tree)
all_errors = checker.errors + bare_checker.errors
if all_errors:
print(f"\n{filename}:")
for line_num, error in sorted(all_errors):
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all provided Python files."""
if len(sys.argv) < 2:
print("Usage: check-silent-cleanup.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n" + "=" * 70)
print("Silent Cleanup: Unsafe close() patterns detected!")
print("=" * 70)
print("\nTo fix, replace silent try/except blocks with safe_close():")
print(" from ...utilities.resource_utils import safe_close")
print(' safe_close(self.client, "client name")')
print("\nOr add explicit logging:")
print(' logger.warning("Failed to close resource", exc_info=True)')
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect silent exception swallowing.
Flags ``except Exception: pass`` and ``except: pass`` patterns where no
logging, re-raise, or meaningful handling occurs. At minimum a
``logger.debug()`` should be present so failures are traceable.
Legitimate suppression (e.g. optional cleanup, best-effort parsing)
should use an inline ``# noqa: silent-exception`` comment to opt out.
"""
import ast
import sys
NOQA_MARKER = "noqa: silent-exception"
class SilentExceptionChecker(ast.NodeVisitor):
"""AST visitor that flags except handlers whose body is only ``pass``."""
def __init__(self, filename: str, lines: list[str]):
self.filename = filename
self.lines = lines
self.issues: list[tuple[int, str]] = []
def visit_ExceptHandler(self, node: ast.ExceptHandler):
# Only flag broad catches: bare ``except:`` or ``except Exception:``
if node.type is not None and not (
isinstance(node.type, ast.Name) and node.type.id == "Exception"
):
self.generic_visit(node)
return
if self._is_silent(node) and not self._has_noqa(node):
kind = "except:" if node.type is None else "except Exception:"
self.issues.append(
(
node.lineno,
f"Silent `{kind} pass` — add at least "
f"`logger.debug(...)` or `# {NOQA_MARKER}` to suppress",
)
)
self.generic_visit(node)
# ------------------------------------------------------------------
@staticmethod
def _is_silent(handler: ast.ExceptHandler) -> bool:
"""True when the handler body contains only ``pass`` statements."""
for stmt in handler.body:
if isinstance(stmt, ast.Pass):
continue
# Any raise, call, assignment, etc. counts as handling
return False
return True
def _has_noqa(self, handler: ast.ExceptHandler) -> bool:
"""True when the ``except`` or handler body lines have a noqa comment."""
# Check the except line itself and all body lines
for node in [handler] + handler.body:
idx = node.lineno - 1
if 0 <= idx < len(self.lines):
if NOQA_MARKER in self.lines[idx]:
return True
return False
def check_file(filepath: str) -> list[tuple[int, str]]:
try:
with open(filepath, encoding="utf-8") as f:
source = f.read()
except Exception as exc:
return [(0, f"Cannot read file: {exc}")]
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError:
return []
lines = source.splitlines()
checker = SilentExceptionChecker(filepath, lines)
checker.visit(tree)
return checker.issues
def main() -> int:
exit_code = 0
for filepath in sys.argv[1:]:
for lineno, msg in check_file(filepath):
print(f"{filepath}:{lineno}: {msg}")
exit_code = 1
if exit_code:
print()
print(
"Hint: add logging (logger.debug/warning) or "
f"suppress with `# {NOQA_MARKER}`"
)
return exit_code
if __name__ == "__main__":
sys.exit(main())
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Pre-commit hook to enforce rel="noopener noreferrer" on every
<a target="_blank"> that points at an external (cross-origin) URL.
Without rel="noopener", the opened page can access window.opener and
perform tabnabbing attacks. Without rel="noreferrer", the Referer header
leaks the LDR URL to the destination.
The check is "flag unless proven internal": a new-tab link must carry the
rel unless we can statically prove it stays same-origin. This intentionally
also covers dynamic hrefs — JS ${...} and Jinja {{ ... }} expressions that
are not url_for() — since those are exactly where past regressions lived.
Provably same-origin anchors (href starting with "/", "#", "?", a Jinja
url_for, or a non-HTTP pseudo-scheme such as mailto:) are skipped — there is
no cross-origin tabnabbing risk on those.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# Match <a ...> opening tags, tolerating multi-line attribute lists: the
# negated class [^>] already spans newlines, so a tag whose attributes wrap
# across lines is captured up to its first ">". IGNORECASE also matches
# <A ...>. Known limitation: a ">" inside a quoted attribute value (e.g.
# title="a > b") truncates the match early; our anchors don't contain one.
ANCHOR_RE = re.compile(r"<a\b([^>]*?)>", re.IGNORECASE)
ATTR_RE = re.compile(
r"""([A-Za-z_:][-A-Za-z0-9_:.]*) # attribute name
\s*=\s*
(?: "([^"]*)" | '([^']*)' | (\S+) ) # quoted or bare value
""",
re.VERBOSE,
)
# Jinja url_for() always resolves to a same-origin path, e.g.
# {{ url_for('x') }} or {{- url_for(...) }}.
URL_FOR_RE = re.compile(r"\{\{-?\s*url_for\b", re.IGNORECASE)
# Non-HTTP pseudo-schemes that are not new-tab navigation / tabnabbing vectors.
SAFE_SCHEMES = ("mailto:", "tel:", "sms:", "javascript:", "data:")
def requires_rel(href: str) -> bool:
"""Return True unless the href is provably same-origin / non-navigational.
Deliberately "flag unless proven internal": a new-tab link should carry
rel="noopener noreferrer" unless we can PROVE it stays same-origin. This
covers dynamic links — JS ${...} and Jinja {{ ... }} expressions that are
not url_for() — which an "only flag provably-external" check would miss
(and which is exactly where past regressions lived).
Provably internal (returns False): empty; a same-origin absolute path
"/..." (but not protocol-relative "//host"); a "#fragment" or "?query"; a
Jinja {{ url_for(...) }}; and a non-HTTP pseudo-scheme (mailto:, tel:,
sms:, javascript:, data:). Everything else — http(s)://, //host, a bare
host like "example.com", or an unresolved ${...} / {{ ... }} expression —
returns True so the rel is required.
"""
h = href.strip()
if not h:
return False
# Same-origin absolute path (but NOT protocol-relative //host).
if h.startswith("/") and not h.startswith("//"):
return False
# Fragment or query against the current document.
if h.startswith(("#", "?")):
return False
# Jinja url_for() always resolves to a same-origin path.
if URL_FOR_RE.match(h):
return False
# Non-HTTP pseudo-schemes are not new-tab navigation.
if h.lower().startswith(SAFE_SCHEMES):
return False
# Anything else (http(s)://, //host, bare host, or an unresolved
# ${...} / {{ ... }} expression) cannot be proven same-origin — flag it.
return True
def parse_attrs(attr_blob: str) -> dict[str, str]:
"""Parse anchor attributes into a name -> value dict (lowercased keys)."""
out: dict[str, str] = {}
for m in ATTR_RE.finditer(attr_blob):
name = m.group(1).lower()
value = m.group(2) or m.group(3) or m.group(4) or ""
out[name] = value
return out
def check_file(filepath: Path) -> list[tuple[int, str]]:
"""Return list of (line_number, snippet) violations for filepath."""
try:
text = filepath.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
violations: list[tuple[int, str]] = []
for match in ANCHOR_RE.finditer(text):
attr_blob = match.group(1)
attrs = parse_attrs(attr_blob)
target = attrs.get("target", "").lower()
if target != "_blank":
continue
href = attrs.get("href", "")
if not requires_rel(href):
continue
rel = attrs.get("rel", "").lower()
rel_tokens = set(rel.split())
if "noopener" in rel_tokens and "noreferrer" in rel_tokens:
continue
# Find the line number of the match start
line_num = text.count("\n", 0, match.start()) + 1
snippet = match.group(0).strip().replace("\n", " ")
if len(snippet) > 140:
snippet = snippet[:137] + "..."
violations.append((line_num, snippet))
return violations
def main() -> int:
files = [Path(f) for f in sys.argv[1:]]
skip_parts = {"vendor", "node_modules", ".venv", "venv", "__pycache__"}
all_violations: list[tuple[Path, list[tuple[int, str]]]] = []
for filepath in files:
if any(part in skip_parts for part in filepath.parts):
continue
vs = check_file(filepath)
if vs:
all_violations.append((filepath, vs))
if not all_violations:
return 0
print('\nExternal <a target="_blank"> missing rel="noopener noreferrer":\n')
print(
"Without noopener, the opened page can access window.opener "
"(tabnabbing).\nWithout noreferrer, the Referer header leaks the "
"LDR URL to the destination.\n"
)
for filepath, vs in all_violations:
print(f" {filepath}")
for line_num, snippet in vs:
print(f" line {line_num}: {snippet}")
print()
print(
'Fix: add rel="noopener noreferrer" to each flagged anchor. '
"Provably same-origin\n"
"links (href starts with /, #, ?, a Jinja url_for, or a mailto:/tel: "
"scheme)\nare skipped automatically. For a dynamic href that is "
'genuinely internal,\nprefer a leading "/" so the check can prove it.'
)
return 1
if __name__ == "__main__":
sys.exit(main())
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect time.sleep() calls in test files missing @pytest.mark.slow.
Tests that call time.sleep() with a delay > 0.1 seconds should be marked with
@pytest.mark.slow so CI can skip them with ``-m "not slow"``.
Uses Python AST to find real ``time.sleep(>0.1)`` calls (ignores mocked/patched
ones) and checks whether the enclosing test function or class carries the
``@pytest.mark.slow`` decorator.
Opt-out: add ``# allow: unmarked-sleep`` on the time.sleep() line.
"""
import ast
import re
import sys
SUPPRESS_RE = re.compile(r"#\s*allow:\s*unmarked-sleep(?:\s|$)")
# Minimum sleep duration (seconds) to flag. Tiny sleeps used for thread
# yielding (0.001-0.05 s) are not worth marking slow.
THRESHOLD = 0.1
class SleepMarkerChecker(ast.NodeVisitor):
"""AST visitor that flags ``time.sleep(>THRESHOLD)`` without ``@pytest.mark.slow``."""
def __init__(self, filepath: str, lines: list[str]):
self.filepath = filepath
self.lines = lines
self.issues: list[tuple[int, float, str]] = []
self._class_stack: list[ast.ClassDef] = []
self._func_stack: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
# Track import aliases for ``import time as X``
self._time_aliases: set[str] = {"time"}
# -- import tracking ---------------------------------------------------
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
if alias.name == "time":
self._time_aliases.add(alias.asname or alias.name)
self.generic_visit(node)
# -- scope tracking ----------------------------------------------------
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self._class_stack.append(node)
self.generic_visit(node)
self._class_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._func_stack.append(node)
self.generic_visit(node)
self._func_stack.pop()
visit_AsyncFunctionDef = visit_FunctionDef
# -- detection ---------------------------------------------------------
def visit_Call(self, node: ast.Call) -> None:
func = node.func
# Match ``time.sleep()``, ``t.sleep()`` (any alias of ``import time``)
if (
isinstance(func, ast.Attribute)
and func.attr == "sleep"
and isinstance(func.value, ast.Name)
and func.value.id in self._time_aliases
):
self._check_sleep(node)
self.generic_visit(node)
def _check_sleep(self, node: ast.Call) -> None:
if not node.args:
return
val = self._constant_value(node.args[0])
if val is None or val <= THRESHOLD:
return
# Check for suppress comment on the call's lines (handles multi-line calls)
start = node.lineno - 1
end = getattr(node, "end_lineno", node.lineno) - 1
for idx in range(start, min(end + 1, len(self.lines))):
if SUPPRESS_RE.search(self.lines[idx]):
return
# Check if enclosing function or class has @pytest.mark.slow
if self._func_stack and self._has_slow_marker(self._func_stack[-1]):
return
if self._class_stack and self._has_slow_marker(self._class_stack[-1]):
return
func_name = (
self._func_stack[-1].name if self._func_stack else "<module>"
)
cls_name = self._class_stack[-1].name if self._class_stack else None
qual = f"{cls_name}.{func_name}" if cls_name else func_name
self.issues.append((node.lineno, val, qual))
# -- helpers -----------------------------------------------------------
@staticmethod
def _constant_value(node: ast.expr) -> float | None:
"""Return numeric value of an AST node, or None if not a constant."""
if isinstance(node, ast.Constant) and isinstance(
node.value, (int, float)
):
return float(node.value)
# Handle negative numbers: ast.UnaryOp(op=USub, operand=Constant)
if (
isinstance(node, ast.UnaryOp)
and isinstance(node.op, ast.USub)
and isinstance(node.operand, ast.Constant)
and isinstance(node.operand.value, (int, float))
):
return -float(node.operand.value)
return None
@staticmethod
def _has_slow_marker(
node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
) -> bool:
"""True when node is decorated with ``@pytest.mark.slow``."""
for dec in node.decorator_list:
if _is_pytest_mark_slow(dec):
return True
return False
def _is_pytest_mark_slow(node: ast.expr) -> bool:
"""Check whether a decorator is exactly ``@pytest.mark.slow``."""
# Unwrap call: @pytest.mark.slow(reason="...")
if isinstance(node, ast.Call):
return _is_pytest_mark_slow(node.func)
# @pytest.mark.slow → Attribute(value=Attribute(value=Name(id='pytest'), attr='mark'), attr='slow')
if (
isinstance(node, ast.Attribute)
and node.attr == "slow"
and isinstance(node.value, ast.Attribute)
and node.value.attr == "mark"
and isinstance(node.value.value, ast.Name)
and node.value.value.id == "pytest"
):
return True
return False
def check_file(filepath: str) -> list[tuple[int, float, str]]:
"""Return list of (lineno, sleep_value, qualified_name) for violations."""
try:
with open(filepath, encoding="utf-8") as f:
source = f.read()
except Exception as exc:
print(f"WARNING: Cannot read {filepath}: {exc}", file=sys.stderr)
return []
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError as exc:
print(f"WARNING: Syntax error in {filepath}: {exc}", file=sys.stderr)
return []
lines = source.splitlines()
checker = SleepMarkerChecker(filepath, lines)
checker.visit(tree)
return checker.issues
def main() -> int:
exit_code = 0
violations = 0
for filepath in sys.argv[1:]:
issues = check_file(filepath)
for lineno, val, qual in issues:
violations += 1
print(
f"{filepath}:{lineno}: time.sleep({val}) in {qual} "
f"missing @pytest.mark.slow"
)
exit_code = 1
if exit_code:
print()
print(
"Hint: either add @pytest.mark.slow to the test function/class, "
"or replace time.sleep() with freezegun time travel."
)
print(
" Suppress with `# allow: unmarked-sleep` if the sleep is intentional."
)
print(f" {violations} violation(s) found.")
return exit_code
if __name__ == "__main__":
sys.exit(main())
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Pre-commit hook to check for unsafe URL scheme validation in JavaScript files.
This hook ensures that JavaScript code properly validates URLs to prevent
XSS attacks through javascript:, data:, and vbscript: schemes.
"""
import sys
import re
from pathlib import Path
def check_url_validation(file_path):
"""
Check if JavaScript file has proper URL validation.
Returns list of issues found.
"""
issues = []
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
# Note: More advanced pattern checking could be added here
# Currently focusing on basic URL validation presence
# Check if the file imports or includes URL validation
has_url_validator = (
"URLValidator" in content
or "isUnsafeScheme" in content
or "isSafeUrl" in content
or "url-validator.js" in content # Check for script include
)
# Now focusing on external URLs only instead of all URL handling
# For now, only warn about files that handle external URLs
# Skip files that only handle internal navigation
handles_external_urls = (
"fetch(" in content
or "XMLHttpRequest" in content
or re.search(r"window\.open\s*\([^)]*http", content)
or re.search(r"href\s*=\s*['\"]https?://", content)
)
# If the file handles external URLs but doesn't have validation, that's concerning.
# (Previously there was a "has_basic_protection" short-circuit that checked for
# "javascript:" plus any of startsWith/includes/indexOf anywhere in the file —
# trivially satisfied by unrelated code or comments, so it masked real gaps.)
if handles_external_urls and not has_url_validator:
issues.append(
f"{file_path}: File handles external URLs but lacks URL validation checks"
)
# Check for specific problematic patterns
for line_num, line in enumerate(lines, 1):
# Skip comments
if line.strip().startswith("//") or line.strip().startswith("*"):
continue
# Check for .href or .src assignments without proper validation
if re.search(r"\.(href|src)\s*=\s*[a-zA-Z_$]", line):
# Only skip truly safe patterns
safe_patterns = [
# Internal navigation using known safe constants
r"window\.location\.href\s*=\s*URLS\.",
r"\.href\s*=\s*URLBuilder\.",
# Explicit relative/fragment URLs (safe by definition)
r"\.href\s*=\s*['\"][/#]",
# Safe browser APIs for generating URLs
r"\.href\s*=\s*URL\.createObjectURL",
r"\.href\s*=\s*canvas\.toDataURL",
# Already using the URLValidator
r"URLValidator\.(safeAssign|isSafeUrl)",
]
if any(re.search(pattern, line) for pattern in safe_patterns):
continue
# Check if it's preceded by validation
context_start = max(0, line_num - 5)
context = "\n".join(lines[context_start:line_num])
if not any(
check in context
for check in [
"URLValidator",
"isUnsafeScheme",
"isSafeUrl",
"javascript:",
"data:",
"vbscript:",
]
):
issues.append(
f"{file_path}:{line_num}: URL assignment without validation: {line.strip()}"
)
return issues
def main():
"""Main function to check all provided files."""
if len(sys.argv) < 2:
print("No files to check")
return 0
all_issues = []
# Path segments that mark a file as non-production (tests, vendored code, build output).
# Segment-matching avoids the bug where a bare substring like "test" silently skipped
# real production files such as attestation_service.js or latest_products.js.
SKIP_SEGMENTS = {
"tests",
"test",
"spec",
"specs",
"__tests__",
"vendor",
"node_modules",
"dist",
"build",
}
for file_path in sys.argv[1:]:
# Only check JavaScript files
if not file_path.endswith(".js"):
continue
p = Path(file_path)
if (
SKIP_SEGMENTS.intersection(p.parts)
or p.name.startswith("test_")
or p.name.endswith((".test.js", ".spec.js", ".min.js"))
):
continue
try:
issues = check_url_validation(file_path)
all_issues.extend(issues)
except Exception as e:
print(f"Error checking {file_path}: {e}")
continue
if all_issues:
print("❌ URL Security Issues Found:")
print("-" * 60)
for issue in all_issues:
print(f"{issue}")
print("-" * 60)
print("\n📋 HOW TO FIX:")
print(
"\n1️⃣ Add this script tag to your HTML template (or include in your JS bundle):"
)
print(' <script src="/static/js/security/url-validator.js"></script>')
print(
"\n2️⃣ For dynamic URL assignments, use URLValidator.safeAssign():"
)
print(" // Instead of: element.href = url;")
print(" // Use: URLValidator.safeAssign(element, 'href', url);")
print("\n3️⃣ For URL validation before use:")
print(" if (URLValidator.isSafeUrl(url)) {")
print(" // URL is safe to use")
print(" }")
print("\n📁 URL Validator location:")
print(
" src/local_deep_research/web/static/js/security/url-validator.js"
)
print(
"\n🔒 This prevents XSS attacks through javascript:, data:, and vbscript: URLs"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Pre-commit hook to prevent removing parentheses from utcnow() in SQLAlchemy
Column defaults.
utcnow (from sqlalchemy_utc) is a FunctionElement *class*, not a plain
Python function. ``default=utcnow()`` creates a SQL expression object that
SQLAlchemy renders inline per-INSERT (e.g. CURRENT_TIMESTAMP). Passing the
bare class ``default=utcnow`` causes SQLAlchemy to call it as a Python
callable and then try to bind the resulting FunctionElement as a parameter
value, raising TypeError at insert time.
Correct: default=utcnow() onupdate=utcnow() server_default=utcnow()
Wrong: default=utcnow[no parens] onupdate=utcnow[no parens]
"""
import re
import sys
# Match default=utcnow or onupdate=utcnow NOT followed by (
# This catches: default=utcnow, default=utcnow) default=utcnow\n
_BAD_PATTERN = re.compile(r"\b(default|onupdate)\s*=\s*utcnow\s*(?=[,\)\s\n])")
def main() -> int:
exit_code = 0
for filepath in sys.argv[1:]:
try:
# bearer:disable python_lang_path_using_user_input
# Pre-commit hook: paths come from the pre-commit framework
# (staged files in this repo), not from external user input.
with open(filepath, encoding="utf-8") as f:
lines = f.readlines()
except Exception:
continue
for i, line in enumerate(lines, 1):
# Skip comment and docstring lines — a note like
# "# default=utcnow is wrong" should not fire the check.
if line.lstrip().startswith(("#", '"""', "'''")):
continue
if _BAD_PATTERN.search(line):
print(
f"{filepath}:{i}: utcnow without parentheses — "
f"use default=utcnow() or onupdate=utcnow(). "
f"utcnow is a FunctionElement class; the parens "
f"create a SQL expression object, not a frozen value."
)
exit_code = 1
if exit_code:
print()
print(
"Hint: utcnow() is correct — it creates a SQL expression "
"(CURRENT_TIMESTAMP) evaluated per-INSERT by the database."
)
return exit_code
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Check that package.json version matches __version__.py."""
import json
import re
import sys
from pathlib import Path
def main():
root = Path(__file__).parent.parent
# Read __version__.py
version_file = root / "src" / "local_deep_research" / "__version__.py"
if not version_file.exists():
print(f"ERROR: Version file not found: {version_file}")
return 1
try:
version_content = version_file.read_text(encoding="utf-8")
except OSError as e:
print(f"ERROR: Could not read {version_file}: {e}")
return 1
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', version_content)
if not match:
print("ERROR: Could not parse __version__.py")
return 1
py_version = match.group(1)
# Read package.json
package_file = root / "package.json"
if not package_file.exists():
print(f"ERROR: package.json not found: {package_file}")
return 1
try:
package_content = package_file.read_text(encoding="utf-8")
package_data = json.loads(package_content)
except OSError as e:
print(f"ERROR: Could not read {package_file}: {e}")
return 1
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {package_file}: {e}")
return 1
js_version = package_data.get("version", "")
if not js_version:
print("ERROR: No 'version' field found in package.json")
return 1
if py_version != js_version:
print("ERROR: Version mismatch!")
print(f" __version__.py: {py_version}")
print(f" package.json: {js_version}")
print()
print("Fix by updating one of the files to match.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Pre-commit hook to detect tautological / no-op test functions.
These patterns pass regardless of whether the system-under-test (SUT) works,
so they provide false confidence and inflate coverage without catching
regressions. A 2026 test-suite audit removed ~3,700 such tests
(see docs/processes/test-review/README.md); this hook prevents reintroduction.
Patterns flagged (each is *never* a legitimate assertion):
1. ASSERT_TRUE — a test whose only assertion(s) are ``assert True``
(or ``assert <constant-truthy-literal>``). Verifies nothing.
2. IMPORT_EXISTENCE — body is exactly ``from M import X`` + ``assert X is
not None``. The import already raises ImportError if X is missing, so
the assert pins nothing the import didn't already.
3. TAUTOLOGY_OR — ``assert <anything> or True`` (always True).
4. NULLCHECK_TAUTOLOGY — ``assert x is None or x is not None`` (or the
reverse ordering) on the same operand. Accepts every possible value.
Opt-out: add ``# allow: weak-test`` on the ``def`` line or the assert line.
Use sparingly and only with a comment explaining why the pattern is
intentional (e.g. a deliberately-minimal smoke test).
"""
import ast
import re
import sys
SUPPRESS_RE = re.compile(r"#\s*allow:\s*weak-test(?:\s|$)")
def _strip_docstring(body: list[ast.stmt]) -> list[ast.stmt]:
if (
body
and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)
):
return body[1:]
return body
def _is_truthy_constant(node: ast.expr) -> bool:
"""True for ``True`` / non-empty literal constants that are always truthy."""
return isinstance(node, ast.Constant) and bool(node.value)
def _is_none(node: ast.expr) -> bool:
return isinstance(node, ast.Constant) and node.value is None
def _operand_src(node: ast.expr) -> str:
try:
return ast.unparse(node)
except Exception: # pragma: no cover - unparse is stable on 3.9+
return repr(node)
def _is_nullcheck_tautology(test: ast.expr) -> bool:
"""Detect ``x is None or x is not None`` (either ordering, same operand)."""
if not (isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or)):
return False
if len(test.values) != 2:
return False
left, right = test.values
if not (isinstance(left, ast.Compare) and isinstance(right, ast.Compare)):
return False
if not (len(left.ops) == 1 and len(right.ops) == 1):
return False
left_is = isinstance(left.ops[0], ast.Is)
left_isnot = isinstance(left.ops[0], ast.IsNot)
right_is = isinstance(right.ops[0], ast.Is)
right_isnot = isinstance(right.ops[0], ast.IsNot)
# Both comparisons must be against None
if not (_is_none(left.comparators[0]) and _is_none(right.comparators[0])):
return False
# Same operand on both sides
if _operand_src(left.left) != _operand_src(right.left):
return False
# One "is None" and one "is not None" => covers all values
return (left_is and right_isnot) or (left_isnot and right_is)
def _has_or_true(test: ast.expr) -> bool:
"""Detect ``<anything> or True`` (or ``True or <anything>``)."""
if not (isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or)):
return False
return any(_is_truthy_constant(v) for v in test.values)
class WeakTestChecker(ast.NodeVisitor):
def __init__(self, filepath: str, lines: list[str]):
self.filepath = filepath
self.lines = lines
self.issues: list[tuple[int, str]] = []
def _suppressed(self, lineno: int) -> bool:
idx = lineno - 1
return 0 <= idx < len(self.lines) and bool(
SUPPRESS_RE.search(self.lines[idx])
)
def _has_skip_marker(self, node: ast.FunctionDef) -> bool:
for dec in node.decorator_list:
src = _operand_src(dec)
if "skip" in src or "xfail" in src:
return True
return False
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if node.name.startswith("test_") and not self._has_skip_marker(node):
self._check_test(node)
self.generic_visit(node)
def _check_test(self, node: ast.FunctionDef) -> None:
# Function-level opt-out (on the def line).
if self._suppressed(node.lineno):
return
body = _strip_docstring(node.body)
asserts = [s for s in body if isinstance(s, ast.Assert)]
# Pattern 3 & 4: per-assert tautologies (anywhere in the body).
for a in asserts:
if self._suppressed(a.lineno):
continue
if _has_or_true(a.test):
self.issues.append(
(a.lineno, "assertion is always true (`... or True`)")
)
elif _is_nullcheck_tautology(a.test):
self.issues.append(
(
a.lineno,
"assertion `x is None or x is not None` accepts any value",
)
)
if not asserts:
# Pattern 2: import-existence tautology (no Assert means it can't
# match here; handled below only when an assert exists).
return
# Pattern 1: every assertion is a truthy literal (e.g. `assert True`).
if all(_is_truthy_constant(a.test) for a in asserts):
if not self._suppressed(node.lineno):
self.issues.append(
(
node.lineno,
f"test '{node.name}' has no real assertion "
"(only `assert <truthy-literal>`)",
)
)
# Pattern 2: body is exactly import(s) + `assert X is not None`.
non_import = [
s for s in body if not isinstance(s, (ast.Import, ast.ImportFrom))
]
if (
len(non_import) == 1
and isinstance(non_import[0], ast.Assert)
and len(body) > 1 # at least one import present
):
test = non_import[0].test
if (
isinstance(test, ast.Compare)
and len(test.ops) == 1
and isinstance(test.ops[0], ast.IsNot)
and _is_none(test.comparators[0])
):
if not self._suppressed(node.lineno):
self.issues.append(
(
node.lineno,
f"test '{node.name}' is an import-existence "
"tautology (the import already fails if the "
"symbol is missing)",
)
)
def check_file(filepath: str) -> list[tuple[int, str]]:
try:
with open(filepath, encoding="utf-8") as fh:
source = fh.read()
except (OSError, UnicodeDecodeError):
return []
try:
tree = ast.parse(source)
except SyntaxError:
# Leave syntax errors to other tools (ruff / py_compile).
return []
checker = WeakTestChecker(filepath, source.splitlines())
checker.visit(tree)
return sorted(checker.issues)
def main(argv: list[str]) -> int:
files = [
f
for f in argv
if f.endswith(".py")
and ("/test_" in f or f.startswith("test_") or "/tests/" in f)
]
found = False
for filepath in files:
for lineno, message in check_file(filepath):
found = True
print(f"{filepath}:{lineno}: {message}")
if found:
print(
"\nWeak/tautological test patterns detected (see above).\n"
"These pass regardless of whether the code works. Either assert a "
"real outcome, or — if the no-op is intentional (e.g. a minimal "
"smoke test) — add `# allow: weak-test` with a justifying comment.\n"
"Background: docs/processes/test-review/README.md"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Pre-commit hook: verify test paths referenced in GitHub workflows exist.
GitHub Actions steps frequently invoke pytest on specific files or
directories, e.g.::
pdm run python -m pytest tests/security/test_ssrf_validator.py -v
When such a file is later renamed or deleted (as happened with
tests/security/test_input_validation.py, removed in #4243), the workflow
keeps pointing at the now-missing path. pytest then exits with code 5
("no tests collected") and the CI gate fails for *every* PR that triggers
it -- a failure unrelated to the triggering change and easy to miss until
release time (#4411).
This hook scans changed workflow YAML files for ``tests/...`` paths that
are passed as arguments to ``pytest`` (files or directories) and fails if
any do not exist on disk, so the drift is caught at commit time instead
of in CI.
Scope is deliberately limited to pytest arguments. Other ``tests/...``
references -- e.g. ``upload-artifact`` ``path:`` entries pointing at
runtime-generated output dirs like ``tests/screenshots/`` -- are not
validated, because those legitimately do not exist until a CI run creates
them.
False positives are further avoided by:
- ignoring the part of a line after an inline ``#`` comment,
- following backslash line-continuations so a multi-file pytest
invocation is analysed as one command, and
- skipping any path that is itself protected by a shell existence guard
(``[ -f <path> ]`` / ``[ -e <path> ]`` / ``test -f <path>``) anywhere
in the same file -- that is the established "legacy tests if they
exist" fallback pattern, which is intentionally tolerant of absence.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
# A test path token: starts at "tests/", runs over word chars, dots,
# dashes and slashes. Stops at whitespace, quotes, ":" (pytest node ids),
# "*" (globs) etc. We only act on tokens that clearly denote a concrete
# file (".py") or directory (trailing "/") -- globs and bare prefixes are
# left alone because they cannot be validated by simple existence checks.
PATH_TOKEN = re.compile(r"tests/[\w.\-/]+")
# Paths shielded by a shell existence check, e.g. `if [ -f tests/x.py ]`.
GUARD_PATTERN = re.compile(r"(?:\[\s*-[fe]|test\s+-[fe])\s+(tests/[\w.\-/]+)")
def strip_inline_comment(line: str) -> str:
"""Drop everything from the first '#' onwards (YAML and shell comment)."""
hash_index = line.find("#")
return line if hash_index == -1 else line[:hash_index]
def iter_logical_lines(lines: list[str]):
"""Yield (start_line_number, text) merging backslash continuations.
Inline comments are stripped per physical line first, so a trailing
"\\" inside a comment never joins lines.
"""
buffer = ""
start = None
for line_num, raw_line in enumerate(lines, 1):
line = strip_inline_comment(raw_line).rstrip()
if start is None:
start = line_num
if line.endswith("\\"):
buffer += line[:-1] + " "
else:
yield start, buffer + line
buffer = ""
start = None
if buffer:
yield start, buffer
def collect_guarded_paths(lines: list[str]) -> set[str]:
guarded: set[str] = set()
for line in lines:
for match in GUARD_PATTERN.finditer(line):
guarded.add(match.group(1))
return guarded
def path_exists(token: str) -> bool:
target = REPO_ROOT / token
if token.endswith("/"):
return target.is_dir()
return target.is_file()
def check_workflow(path: Path) -> list[tuple[int, str]]:
"""Return [(line_number, missing_path), ...] for a single workflow file."""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (UnicodeDecodeError, OSError):
return []
guarded = collect_guarded_paths(lines)
violations: list[tuple[int, str]] = []
for start, logical in iter_logical_lines(lines):
if "pytest" not in logical:
continue
# Only inspect the portion that is (part of) a pytest invocation.
args = logical[logical.index("pytest") :]
for match in PATH_TOKEN.finditer(args):
token = match.group(0)
# Only validate concrete files / directories.
if not (token.endswith(".py") or token.endswith("/")):
continue
if token in guarded:
continue
if not path_exists(token):
violations.append((start, token))
return violations
def main() -> int:
args = [Path(a) for a in sys.argv[1:]]
if args:
files = [
p
for p in args
if p.suffix in (".yml", ".yaml") and "workflows" in p.parts
]
else:
files = sorted(WORKFLOW_DIR.glob("*.y*ml"))
all_violations: list[tuple[Path, int, str]] = []
for wf in files:
for line_num, token in check_workflow(wf):
all_violations.append((wf, line_num, token))
if all_violations:
print("❌ WORKFLOW REFERENCES A NON-EXISTENT TEST PATH")
print("=" * 60)
print("A workflow invokes pytest on a path that does not exist.")
print("pytest exits with code 5 (no tests collected) on a missing")
print("target, which fails the CI gate for every PR that runs it.")
print("=" * 60)
for wf, line_num, token in all_violations:
try:
rel = wf.relative_to(REPO_ROOT)
except ValueError:
rel = wf
print(f"\n📄 {rel}")
print(f" Line {line_num}: {token}")
print("\n" + "=" * 60)
print("FIX:")
print("- Update the path to the file/directory's new location, or")
print("- Remove the step if the tests were intentionally deleted, or")
print("- Guard optional paths with `if [ -f <path> ]; then ...`")
print("=" * 60)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+615
View File
@@ -0,0 +1,615 @@
#!/usr/bin/env python3
"""
Custom pre-commit hook for Local Deep Research project.
Checks for:
1. If loguru is used instead of standard logging
2. If logger.exception is used instead of logger.error for error handling
3. That no raw SQL is used, only ORM methods
4. That ORM models (classes inheriting from Base) are defined in models/ folders
5. That logger.exception doesn't include redundant {e} in the message
"""
import ast
import sys
import re
import os
from pathlib import Path
from typing import List, Tuple
# Set environment variable for pre-commit hooks to allow unencrypted databases
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
# Dirs where logger.exception() routes through the diagnose-gated
# security.secure_logging wrapper (#4183): the message is production-visible
# at ERROR there, so the "automatically includes exception details" advice
# is wrong — keep in sync with check-sensitive-logging.py.
SECURE_LOGGING_DIRS = (
"src/local_deep_research/llm/providers/",
"src/local_deep_research/embeddings/providers/",
"src/local_deep_research/web_search_engines/",
)
class CustomCodeChecker(ast.NodeVisitor):
EXCEPTION_VAR_NAMES = {"e", "ex", "exc", "exception", "err", "error"}
def __init__(self, filename: str):
self.filename = filename
self.errors = []
self.has_loguru_import = False
self.has_standard_logging_import = False
self.in_except_handler = False
self.has_base_import = False
self.has_declarative_base_import = False
def visit_Import(self, node):
for alias in node.names:
if alias.name == "logging":
self.has_standard_logging_import = True
# Allow standard logging in specific files that need it:
# - log_utils.py: bridges loguru to standard logging
# - app_factory.py: configures Flask logging
# - conftest.py: bridges loguru to pytest caplog fixture
if not (
"log_utils.py" in self.filename
or "app_factory.py" in self.filename
or "conftest.py" in self.filename
):
self.errors.append(
(
node.lineno,
"Use loguru instead of standard logging library",
)
)
elif alias.name == "loguru":
self.has_loguru_import = True
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.module == "logging":
self.has_standard_logging_import = True
# Allow standard logging in specific files that need it:
# - log_utils.py: bridges loguru to standard logging
# - app_factory.py: configures Flask logging
# - conftest.py: bridges loguru to pytest caplog fixture
if not (
"log_utils.py" in self.filename
or "app_factory.py" in self.filename
or "conftest.py" in self.filename
):
self.errors.append(
(
node.lineno,
"Use loguru instead of standard logging library",
)
)
elif node.module == "loguru":
self.has_loguru_import = True
elif node.module and "sqlalchemy" in node.module:
# Check for SQLAlchemy ORM imports
for name in node.names:
if name.name == "declarative_base":
self.has_declarative_base_import = True
# Also check for database.models.base imports
elif node.module and (
"models.base" in node.module or "models" in node.module
):
for name in node.names:
if name.name == "Base":
self.has_base_import = True
self.generic_visit(node)
def visit_Try(self, node):
# Visit try body normally (not in exception handler)
for child in node.body:
self.visit(child)
# Visit exception handlers with the flag set
for handler in node.handlers:
self.visit(handler)
# Visit else and finally clauses normally
for child in node.orelse:
self.visit(child)
for child in node.finalbody:
self.visit(child)
def visit_ExceptHandler(self, node):
# Track when we're inside an exception handler
old_in_except = self.in_except_handler
self.in_except_handler = True
# Only visit the body of the exception handler
for child in node.body:
self.visit(child)
self.in_except_handler = old_in_except
def _is_exception_var(self, node):
"""Check if an AST node is a reference to a common exception variable name."""
return (
isinstance(node, ast.Name) and node.id in self.EXCEPTION_VAR_NAMES
)
def _in_secure_logging_dir(self):
"""True in dirs where logger.exception() is the secure_logging wrapper."""
normalized = self.filename.replace("\\", "/")
return any(d in normalized for d in SECURE_LOGGING_DIRS)
def _redundant_exception_msg(self, detail):
"""Rule-5 message; path-aware since #4183 step 2.
In SECURE_LOGGING_DIRS the wrapper gates the traceback behind
diagnose mode, so "automatically includes exception details" would
be wrong there — the message itself is production-visible at ERROR.
"""
if self._in_secure_logging_dir():
return (
"logger.exception() message is production-visible at ERROR "
"here (security.secure_logging wrapper; only the traceback "
"is diagnose-gated) — log a scrubbed safe_msg "
"(scrub_error(); engines: self._scrub_error()) and " + detail
)
return (
"logger.exception() automatically includes exception details, "
+ detail
)
def _format_string_references_exception(self, node):
"""Check if a format string (f-string) contains references to exception variables.
Catches patterns like {e}, {e!s}, {e!r}, {str(e)}, {repr(e)}.
"""
if not isinstance(node, ast.JoinedStr):
return False
for value in node.values:
if not isinstance(value, ast.FormattedValue):
continue
# Direct reference: {e}, {e!s}, {e!r}
if self._is_exception_var(value.value):
return True
# Wrapped in str()/repr(): {str(e)}, {repr(e)}
if (
isinstance(value.value, ast.Call)
and isinstance(value.value.func, ast.Name)
and value.value.func.id in ("str", "repr")
and value.value.args
and self._is_exception_var(value.value.args[0])
):
return True
return False
def _string_concat_references_exception(self, node):
"""Check if a string concatenation (BinOp with Add) references exception variables.
Catches patterns like "Error: " + str(e), "Error: " + repr(e).
"""
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.Add):
return False
# Check both sides of the + operator
for operand in (node.left, node.right):
if self._is_exception_var(operand):
return True
if (
isinstance(operand, ast.Call)
and isinstance(operand.func, ast.Name)
and operand.func.id in ("str", "repr")
and operand.args
and self._is_exception_var(operand.args[0])
):
return True
# Recurse for chained concatenation: "a" + "b" + str(e)
if isinstance(operand, ast.BinOp) and isinstance(
operand.op, ast.Add
):
if self._string_concat_references_exception(operand):
return True
return False
def visit_Call(self, node):
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "logger"
):
# Check for logger.error usage in exception handlers
if node.func.attr == "error" and self.in_except_handler:
# Skip if the error message indicates it's not actually an exception context
skip_patterns = [
"Cannot queue",
"no username provided",
"Path validation error",
"not available. Please install",
]
if node.args:
if isinstance(node.args[0], ast.Constant):
error_msg = str(node.args[0].value)
if any(
pattern in error_msg for pattern in skip_patterns
):
self.generic_visit(node)
return
elif isinstance(node.args[0], ast.JoinedStr):
for value in node.args[0].values:
if isinstance(value, ast.Constant) and any(
pattern in str(value.value)
for pattern in skip_patterns
):
self.generic_visit(node)
return
self.errors.append(
(
node.lineno,
"Use logger.exception() instead of logger.error() in exception handlers",
)
)
# Check for logger.exception with redundant exception variable
# logger.exception() automatically includes exception info, so passing
# the exception variable is redundant in all these forms:
# logger.exception(f"Error: {e}") -- f-string interpolation
# logger.exception("Error: %s", e) -- %-style formatting arg
# logger.exception("Error: " + str(e)) -- string concatenation
elif node.func.attr == "exception":
if node.args:
# Check f-string containing {e}, {exc}, etc.
if self._format_string_references_exception(node.args[0]):
self.errors.append(
(
node.lineno,
self._redundant_exception_msg(
"remove redundant exception variable from message"
),
)
)
# Check %-style: logger.exception("..%s..", e)
elif len(node.args) >= 2 and any(
self._is_exception_var(arg) for arg in node.args[1:]
):
self.errors.append(
(
node.lineno,
self._redundant_exception_msg(
"remove redundant exception variable from arguments"
),
)
)
# Check str(e) or repr(e) as argument
elif len(node.args) >= 2 and any(
isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Name)
and arg.func.id in ("str", "repr")
and arg.args
and self._is_exception_var(arg.args[0])
for arg in node.args[1:]
):
self.errors.append(
(
node.lineno,
self._redundant_exception_msg(
"remove redundant str(e)/repr(e) from arguments"
),
)
)
# Check string concatenation: logger.exception("Error: " + str(e))
elif self._string_concat_references_exception(node.args[0]):
self.errors.append(
(
node.lineno,
self._redundant_exception_msg(
"remove redundant exception variable from concatenation"
),
)
)
self.generic_visit(node)
def visit_ClassDef(self, node):
# Check if this class inherits from Base (SQLAlchemy model)
for base in node.bases:
base_name = ""
if isinstance(base, ast.Name):
base_name = base.id
elif isinstance(base, ast.Attribute):
base_name = base.attr
if base_name == "Base":
# This is an ORM model - check if it's in the models folder
if (
"/models/" not in self.filename
and not self.filename.endswith("/models.py")
):
# Allow exceptions for test files and migrations
if not (
"test" in self.filename.lower()
or "migration" in self.filename.lower()
or "migrate" in self.filename.lower()
or "alembic" in self.filename.lower()
):
self.errors.append(
(
node.lineno,
f"ORM model '{node.name}' should be defined in a models/ folder, not in {self.filename}",
)
)
self.generic_visit(node)
# Database utility files where direct SQL is required for bootstrap / low-level access.
# Single source of truth — used by both execute-call and SQL-string checks.
DB_UTIL_FILES = {
"sqlcipher_utils.py",
"socket_service.py",
"thread_local_session.py",
"encrypted_db.py",
"initialize.py",
"auth_db.py",
"backup_service.py",
}
def _is_raw_sql_exempt(filename: str) -> bool:
"""Return True if this file is exempt from raw-SQL checks."""
fn = filename.replace("\\", "/")
lower = fn.lower()
if "migration" in lower or "migrate" in lower or "alembic" in lower:
return True
base = Path(fn).name
# Test files: /tests/ directory or test_* prefix (strict, avoids "attest"/"contest" matches)
if "/tests/" in fn or base.startswith("test_") or base.endswith("_test.py"):
return True
if base in DB_UTIL_FILES:
return True
# journal_quality/db.py is the sole writer of the bundled read-only
# reference DB; bulk-insert paths there legitimately use raw SQL.
# Matched by path (basename "db.py" is too generic to allowlist).
if "journal_quality/db.py" in fn:
return True
return False
def check_raw_sql(content: str, filename: str) -> List[Tuple[int, str]]:
"""Check for raw SQL usage patterns."""
errors = []
lines = content.split("\n")
# Skip checking this file itself (contains regex patterns that look like SQL)
if Path(filename).name == "custom-checks.py":
return errors
if _is_raw_sql_exempt(filename):
return errors
# More specific patterns for database execute calls to avoid false positives
db_execute_patterns = [
r"cursor\.execute\s*\(", # cursor.execute()
r"cursor\.executemany\s*\(", # cursor.executemany()
r"conn\.execute\s*\(", # connection.execute()
r"connection\.execute\s*\(", # connection.execute()
r"session\.execute\s*\(\s*[\"']", # session.execute() with raw SQL string
r"session\.execute\s*\(\s*[fr]{1,2}[\"']", # session.execute(f"...") / fr"..." / rf"..." — prefixed SQL literal
]
# SQL statement patterns (only check if they appear to be raw SQL strings).
# The [fr]{0,2} prefix (with IGNORECASE) covers f"", F"", r"", fr"", rf"",
# and their case variants — the highest-risk form being the f-string (injection).
sql_statement_patterns = [
r"[fr]{0,2}[\"']\s*SELECT\s+.*FROM\s+",
r"[fr]{0,2}[\"']\s*INSERT\s+INTO\s+",
r"[fr]{0,2}[\"']\s*UPDATE\s+.*SET\s+",
r"[fr]{0,2}[\"']\s*DELETE\s+FROM\s+",
r"[fr]{0,2}[\"']\s*CREATE\s+TABLE\s+",
r"[fr]{0,2}[\"']\s*DROP\s+TABLE\s+",
r"[fr]{0,2}[\"']\s*ALTER\s+TABLE\s+",
]
# Allowed patterns (ORM usage only). Intentionally does NOT include:
# - f-strings (previously whitelisted the entire line — masked f-string SQL injection)
# - "# ... SQL" comments (trivially bypassed the check with a trailing comment)
allowed_patterns = [
r"session\.query\(",
r"\.filter\(",
r"\.filter_by\(",
r"\.join\(",
r"\.order_by\(",
r"\.group_by\(",
r"\.add\(",
r"\.merge\(",
r"Query\(",
r"relationship\(",
r"Column\(",
r"Table\(",
r"text\(", # SQLAlchemy text() function — the sanctioned way to do raw SQL
]
for line_num, line in enumerate(lines, 1):
line_stripped = line.strip()
# Skip comments, docstrings, and empty lines
if (
line_stripped.startswith("#")
or line_stripped.startswith('"""')
or line_stripped.startswith("'''")
or not line_stripped
):
continue
# Check if line has allowed patterns first
has_allowed_pattern = any(
re.search(pattern, line, re.IGNORECASE)
for pattern in allowed_patterns
)
if has_allowed_pattern:
continue
for pattern in db_execute_patterns:
if re.search(pattern, line, re.IGNORECASE):
errors.append(
(
line_num,
f"Raw SQL execute detected: '{line_stripped[:50]}...'. Use ORM methods instead.",
)
)
break
for pattern in sql_statement_patterns:
if re.search(pattern, line, re.IGNORECASE):
errors.append(
(
line_num,
f"Raw SQL statement detected: '{line_stripped[:50]}...'. Use ORM methods instead.",
)
)
break
return errors
def check_datetime_usage(content: str, filename: str) -> List[Tuple[int, str]]:
"""Check for non-UTC datetime usage."""
errors = []
lines = content.split("\n")
# Patterns to detect problematic datetime usage
datetime_patterns = [
# datetime.now() without timezone
(
r"datetime\.now\s*\(\s*\)",
"Use datetime.now(UTC) or utc_now() instead of datetime.now()",
),
# datetime.utcnow() - deprecated
(
r"datetime\.utcnow\s*\(\s*\)",
"datetime.utcnow() is deprecated. Use datetime.now(UTC) or utc_now() instead",
),
]
# Files where we allow datetime.now() for specific reasons
allowed_files = [
"test_", # Test files
"mock_", # Mock files
"/tests/", # Test directories
]
# Check if this file is allowed to use datetime.now()
is_allowed = any(pattern in filename.lower() for pattern in allowed_files)
if not is_allowed:
for line_num, line in enumerate(lines, 1):
line_stripped = line.strip()
# Skip comments and docstrings
if (
line_stripped.startswith("#")
or line_stripped.startswith('"""')
or line_stripped.startswith("'''")
or not line_stripped
):
continue
# Check for problematic patterns
for pattern, message in datetime_patterns:
if re.search(pattern, line):
# Check if it's already using UTC
if (
"datetime.now(UTC)" not in line
and "timezone.utc" not in line
):
errors.append((line_num, message))
return errors
def check_file(filename: str) -> bool:
"""Check a single Python file for violations."""
if not filename.endswith(".py"):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except UnicodeDecodeError:
# Skip binary files
return True
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
# Parse AST for logging checks (includes logger.exception redundant-arg check)
try:
tree = ast.parse(content, filename=filename)
checker = CustomCodeChecker(filename)
checker.visit(tree)
# Check for raw SQL
sql_errors = check_raw_sql(content, filename)
checker.errors.extend(sql_errors)
# Check for datetime usage
datetime_errors = check_datetime_usage(content, filename)
checker.errors.extend(datetime_errors)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors (they'll be caught by other tools)
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all staged Python files."""
if len(sys.argv) < 2:
print("Usage: custom-checks.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
print("Running custom code checks...")
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n❌ Custom checks failed. Please fix the issues above.")
print("\nGuidelines:")
print("1. Use 'from loguru import logger' instead of standard logging")
print(" - Exception: in llm/providers/, embeddings/providers/, and")
print(
" web_search_engines/, use "
"'from ...security.secure_logging import logger' instead"
)
print(
"2. Use 'logger.exception()' instead of 'logger.error()' in exception handlers"
)
print(
"3. Use ORM methods instead of raw SQL execute() calls and SQL strings"
)
print(" - Allowed: session.query(), .filter(), .add(), etc.")
print(" - Raw SQL is permitted in migration files and schema tests")
print(
"4. Define ORM models (classes inheriting from Base) in models/ folders"
)
print(
" - Models should be in files like models/user.py or database/models/"
)
print(" - Exception: Test files and migration files")
sys.exit(1)
else:
print("✅ All custom checks passed!")
sys.exit(0)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Pre-commit hook adapted from GitHub workflow file-whitelist-check.yml
# Only checks the files being committed, not all files
# Load allowed file patterns from shared whitelist (single source of truth)
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
WHITELIST_FILE="$REPO_ROOT/.file-whitelist.txt"
if [ ! -f "$WHITELIST_FILE" ]; then
echo "❌ Missing .file-whitelist.txt — cannot run whitelist check."
exit 1
fi
ALLOWED_PATTERNS=()
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
ALLOWED_PATTERNS+=("$line")
done < "$WHITELIST_FILE"
WHITELIST_VIOLATIONS=()
LARGE_FILES=()
echo "🔍 Running file whitelist security checks..."
# Process each file passed as argument
for file in "$@"; do
# Skip if file doesn't exist (deleted files)
if [ ! -f "$file" ]; then
continue
fi
# 1. Whitelist check
ALLOWED=false
for pattern in "${ALLOWED_PATTERNS[@]}"; do
if echo "$file" | grep -qE "$pattern"; then
ALLOWED=true
break
fi
done
if [ "$ALLOWED" = "false" ]; then
WHITELIST_VIOLATIONS+=("$file")
fi
# 2. Large file check (>1MB)
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0)
if [ "$FILE_SIZE" -gt 1048576 ]; then
LARGE_FILES+=("$file ($(echo "$FILE_SIZE" | awk '{printf "%.1fMB", $1/1024/1024}'))")
fi
done
# Report violations
TOTAL_VIOLATIONS=0
if [ ${#WHITELIST_VIOLATIONS[@]} -gt 0 ]; then
echo ""
echo "❌ WHITELIST VIOLATIONS - File types not allowed in repository:"
echo " Binary files (images, audio, etc.) bloat the repo and should NOT be committed."
echo " Only explicitly listed binary files are allowed — store others externally."
for violation in "${WHITELIST_VIOLATIONS[@]}"; do
echo " 🚫 $violation"
done
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#WHITELIST_VIOLATIONS[@]}))
fi
if [ ${#LARGE_FILES[@]} -gt 0 ]; then
echo ""
echo "❌ LARGE FILES (>1MB) - Files too big for repository:"
for violation in "${LARGE_FILES[@]}"; do
echo " 📏 $violation"
done
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#LARGE_FILES[@]}))
fi
if [ $TOTAL_VIOLATIONS -eq 0 ]; then
echo "✅ All file whitelist checks passed!"
exit 0
else
echo ""
echo "💡 To fix these issues:"
echo " - For text/config files: add pattern to .file-whitelist.txt (requires maintainer approval)"
echo " - For binary files: do NOT add to the repo — they permanently bloat git history"
echo " - For large files: use external storage"
echo ""
exit 1
fi
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
Auto-fixer for exception variable leaks and exc_info in production logs.
Handles two patterns:
1. Exception variables in log messages (f"...{e}", "...%s", e)
→ Removes the exception variable from the message
2. exc_info=True on warning/error/critical logs
→ Removes the exc_info keyword argument
Note: this hook is part of the reason we do NOT enforce ``raise X from e``
universally (see ADR-0003). Preserving exception chains via ``from e``
would re-expose the details this hook strips from logs.
Run standalone:
python .pre-commit-hooks/fix-exception-logging.py src/
Or via the pre-commit hook:
python .pre-commit-hooks/check-sensitive-logging.py --fix <files>
"""
import ast
import re
import sys
from pathlib import Path
from typing import List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# AST-based violation finder
# ---------------------------------------------------------------------------
class _ViolationFinder(ast.NodeVisitor):
"""Find exception-var and exc_info violations with precise locations."""
def __init__(self) -> None:
self.exc_var_violations: List[Tuple[int, int, str, Set[str]]] = []
# (start_line, end_line, level, except_var_names)
self.exc_info_violations: List[Tuple[int, int]] = []
# (keyword_line, keyword_end_line)
self._except_var_stack: List[Optional[str]] = []
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
self._except_var_stack.append(node.name)
self.generic_visit(node)
self._except_var_stack.pop()
def visit_Call(self, node: ast.Call) -> None:
if self._is_logger_call(node):
level = self._get_level(node)
if level and level not in {"exception", "debug"}:
self._check_exc_var(node, level)
if level in {"warning", "error", "critical"}:
self._check_exc_info(node)
self.generic_visit(node)
@staticmethod
def _is_logger_call(node: ast.Call) -> bool:
if isinstance(node.func, ast.Attribute):
if node.func.attr in {
"debug",
"info",
"warning",
"error",
"critical",
"exception",
}:
if isinstance(node.func.value, ast.Name):
return node.func.value.id == "logger"
if isinstance(node.func.value, ast.Attribute):
return node.func.value.attr == "logger"
return False
@staticmethod
def _get_level(node: ast.Call) -> Optional[str]:
if isinstance(node.func, ast.Attribute):
return node.func.attr
return None
def _check_exc_var(self, node: ast.Call, level: str) -> None:
except_vars = {v for v in self._except_var_stack if v is not None}
if not except_vars:
return
for arg in node.args:
if self._references(arg, except_vars):
self.exc_var_violations.append(
(
node.lineno,
node.end_lineno or node.lineno,
level,
except_vars,
)
)
return
def _check_exc_info(self, node: ast.Call) -> None:
for kw in node.keywords:
if (
kw.arg == "exc_info"
and isinstance(kw.value, ast.Constant)
and kw.value.value is True
):
self.exc_info_violations.append(
(kw.lineno, kw.end_lineno or kw.lineno)
)
def _references(self, expr: ast.AST, names: Set[str]) -> bool:
if isinstance(expr, ast.Name):
return expr.id in names
if isinstance(expr, ast.JoinedStr):
return any(
isinstance(v, ast.FormattedValue)
and self._references(v.value, names)
for v in expr.values
)
if isinstance(expr, ast.Call):
return any(self._references(a, names) for a in expr.args)
if isinstance(expr, ast.BinOp):
return self._references(expr.left, names) or self._references(
expr.right, names
)
if isinstance(expr, ast.Tuple):
return any(self._references(el, names) for el in expr.elts)
return False
# ---------------------------------------------------------------------------
# Text-based fixers
# ---------------------------------------------------------------------------
# Patterns for exception variable references in f-strings
# Matches: {e}, {e!s}, {e!r}, {exc}, {err}, {error}, {ex}
_FSTR_EXC_RE = re.compile(
r"""
[,;:\s\-–—]* # leading separator/whitespace before {e}
\{ # opening brace
(?:e|ex|exc|err|error) # common exception variable names
(?:![sra])? # optional conversion (!s, !r, !a)
(?::[^}]*)? # optional format spec
\} # closing brace
[,;:\s\-–—.]* # trailing separator/punctuation after {e}
""",
re.VERBOSE,
)
# Matches trailing , e) or , exc) etc. as positional arg in logger call
_PCTARG_EXC_RE = re.compile(r",\s*(?:e|ex|exc|err|error)\s*(?=\))")
# Matches %s (or %r, %d) placeholders that correspond to the exception arg
_PCT_PLACEHOLDER_RE = re.compile(r"[,;:\s\-–—]*%[srd][,;:\s\-–—.]*")
def _fix_exc_var_in_line(line: str, except_vars: Set[str]) -> str:
"""Remove exception variable references from a single line."""
original = line
# Build dynamic regex for the specific variable names in this scope
var_names = "|".join(re.escape(v) for v in except_vars)
# Fix f-string interpolation: {e}, {exc}, etc.
fstr_re = re.compile(
r"[,;:\s\-–—]*"
r"\{"
rf"(?:{var_names})"
r"(?:![sra])?"
r"(?::[^}]*)?"
r"\}"
r"[,;:\s\-–—.]*",
)
line = fstr_re.sub("", line)
# If we removed the only content of an f-string, convert to regular string
# f"" → ""
line = re.sub(r'\bf("")', r"\1", line)
line = re.sub(r"\bf('')", r"\1", line)
# Fix %-style positional arg: , e) → )
pctarg_re = re.compile(rf",\s*(?:{var_names})\s*(?=\))")
line = pctarg_re.sub("", line)
# If we removed a positional arg, also clean up the %s in the format string
if line != original and "%s" in line:
# Only remove trailing %s (the one that matched the exception var)
# This is conservative — only handles the simple single-%s case
line = re.sub(r"[,;:\s\-–—]*%s[,;:\s\-–—.]*(?=\")", "", line, count=1)
return line
def _fix_exc_info_in_line(line: str) -> str:
"""Remove exc_info=True from a line."""
# Handle: , exc_info=True) or , exc_info=True, or (exc_info=True)
line = re.sub(r",\s*exc_info=True", "", line)
line = re.sub(r"exc_info=True,\s*", "", line)
line = re.sub(r"exc_info=True", "", line)
return line
def _remove_unused_as_clause(lines: List[str], except_vars: Set[str]) -> None:
"""Remove 'as e' from except clauses when e is no longer used."""
for i, line in enumerate(lines):
for var in except_vars:
# Match: except SomeError as e: or except Exception as e:
pattern = re.compile(
rf"(\s*except\s+\S+(?:\s*,\s*\S+)*)\s+as\s+{re.escape(var)}\s*:"
)
m = pattern.match(line)
if m:
# Check if the variable is still used anywhere in nearby lines
# (simple heuristic: check next 20 lines in the except block)
still_used = False
for j in range(i + 1, min(i + 20, len(lines))):
if re.search(rf"\b{re.escape(var)}\b", lines[j]):
still_used = True
break
# Stop at next except/def/class/return at same or lesser indent
if re.match(r"\S", lines[j]):
break
if not still_used:
lines[i] = pattern.sub(r"\1:", line)
# ---------------------------------------------------------------------------
# File-level fix
# ---------------------------------------------------------------------------
def fix_file(filepath: str) -> Tuple[bool, int]:
"""Fix a file in-place. Returns (changed, fix_count)."""
path = Path(filepath)
try:
source = path.read_text(encoding="utf-8")
except Exception:
return False, 0
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError:
return False, 0
finder = _ViolationFinder()
finder.visit(tree)
if not finder.exc_var_violations and not finder.exc_info_violations:
return False, 0
lines = source.splitlines(keepends=True)
fix_count = 0
# Collect all except variable names used across violations in this file
all_except_vars: Set[str] = set()
# Fix exception variable violations
for start_line, end_line, _level, except_vars in finder.exc_var_violations:
all_except_vars |= except_vars
for i in range(start_line - 1, min(end_line, len(lines))):
fixed = _fix_exc_var_in_line(lines[i], except_vars)
if fixed != lines[i]:
lines[i] = fixed
fix_count += 1
# Fix exc_info violations
for start_line, end_line in finder.exc_info_violations:
for i in range(start_line - 1, min(end_line, len(lines))):
fixed = _fix_exc_info_in_line(lines[i])
if fixed != lines[i]:
lines[i] = fixed
fix_count += 1
# Clean up unused 'as e' clauses
if all_except_vars:
_remove_unused_as_clause(lines, all_except_vars)
if fix_count > 0:
path.write_text("".join(lines), encoding="utf-8")
return True, fix_count
return False, 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
paths: List[str] = []
for arg in sys.argv[1:]:
p = Path(arg)
if p.is_dir():
paths.extend(str(f) for f in p.rglob("*.py"))
elif p.suffix == ".py":
paths.append(str(p))
if not paths:
print("Usage: fix-exception-logging.py <file_or_dir> ...")
return 1
total_files = 0
total_fixes = 0
for filepath in sorted(paths):
changed, fixes = fix_file(filepath)
if changed:
total_files += 1
total_fixes += fixes
print(f" Fixed {filepath} ({fixes} changes)")
if total_files:
print(f"\nFixed {total_fixes} issues in {total_files} files")
else:
print("No issues to fix")
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Gentle reminder to update docs when source files change.
Always exits 0 (non-blocking). Prints a suggestion when source files
are staged without any documentation updates.
"""
import sys
from pathlib import Path
# Allow importing sibling module
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit
def main():
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: docs already included
if analysis.has_docs:
return 0
# Silent exit: trivial change (less than 10 added lines)
if analysis.total_source_added < 10:
return 0
# Print gentle reminder
print()
print(" \033[36mDocumentation Reminder\033[0m")
print(" " + "-" * 40)
print(
f" You're changing {len(analysis.source_files)} source file(s) "
f"with {analysis.total_source_added} new lines"
)
print(" but no documentation (.md) files are staged.")
print()
print(" Changed source files:")
for f in analysis.source_files:
print(f" - {f.path} (+{f.added})")
print()
print(" Consider updating README.md or docs/ if this changes")
print(" public behavior, configuration, or APIs.")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Advisory reminder for subsystem fast tests + opt-in live performance tests.
When staged source files touch a subsystem listed in PATH_RULES, print a
two-tier reminder: (1) the narrow fast mocked tests that catch
logic/contract regressions in seconds, and (2) the opt-in live
performance tests under ``tests/performance/<subsystem>/`` — plus a
nudge to add one if the subsystem doesn't have a live-tests dir yet.
Always exits 0 (advisory). Mirrors recommend-tests.py style.
"""
import sys
from pathlib import Path
# Allow importing sibling module
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit
# Keep this table at the top of the file — update it alongside any
# reshuffling of tests/performance/<subsystem>/ directories or when a
# new subsystem grows a fast-test suite worth recommending.
#
# Rules are evaluated most-specific-first: a staged file is attributed
# to the FIRST matching rule only, so overlapping prefixes
# (e.g. relevance_filter.py is inside web_search_engines/) don't
# fire two reminders.
PATH_RULES = [
{
"name": "relevance_filter",
"prefixes": (
"src/local_deep_research/web_search_engines/relevance_filter.py",
),
"fast_tests": [
"tests/web_search_engines/test_relevance_filter.py",
],
"perf_dir": "tests/performance/relevance_filter/",
"perf_exists": True,
},
{
"name": "search_engine_adapters",
"prefixes": ("src/local_deep_research/web_search_engines/engines/",),
"fast_tests": [
"tests/web_search_engines/test_search_engine_base.py",
"tests/web_search_engines/engines/test_full_search.py",
],
"perf_dir": "tests/performance/search_engines/",
"perf_exists": True,
},
{
"name": "web_search_engines",
"prefixes": ("src/local_deep_research/web_search_engines/",),
"fast_tests": [
"tests/web_search_engines/test_search_engine_base.py",
"tests/web_search_engines/test_search_engine_factory.py",
],
"perf_dir": None,
"perf_exists": False,
},
{
"name": "advanced_search_system",
"prefixes": ("src/local_deep_research/advanced_search_system/",),
"fast_tests": [
"tests/advanced_search_system/constraint_checking/test_threshold_checker.py",
"tests/advanced_search_system/constraint_checking/test_dual_confidence_checker.py",
"tests/advanced_search_system/constraint_checking/test_base_constraint_checker.py",
],
"perf_dir": "tests/performance/strategies/",
"perf_exists": True,
},
{
"name": "content_fetcher",
"prefixes": (
"src/local_deep_research/content_fetcher/",
"src/local_deep_research/research_library/downloaders/",
),
"fast_tests": [
"tests/content_fetcher/test_html_content_extraction.py",
"tests/research_library/downloaders/test_html_downloader.py",
"tests/research_library/downloaders/test_extraction_pipeline.py",
"tests/research_library/downloaders/test_metadata_extractor.py",
],
"perf_dir": "tests/performance/content_fetcher/",
"perf_exists": True,
},
]
def _print_rule(rule, matched):
print()
print(f" \033[33mPerformance Test Reminder — {rule['name']}\033[0m")
print(" " + "-" * 40)
print(f" You touched {len(matched)} file(s) under {rule['name']}:")
for p in matched:
print(f" - {p}")
print()
print(" Fast tests (mocked, ~5-30s) to run now:")
print(f" pdm run pytest {' '.join(rule['fast_tests'])}")
print()
if rule["perf_exists"]:
print(" Heavier real-service tests (opt-in, live):")
print(
f" LDR_TESTING_WITH_MOCKS=false pdm run pytest "
f"{rule['perf_dir']} -m integration"
)
print(
" If you added behavior only live tests catch, consider "
f"adding a test under {rule['perf_dir']}."
)
elif rule["perf_dir"]:
print(
f" No live tests exist yet at {rule['perf_dir']}. If your "
"change needs live-service coverage, consider adding one."
)
else:
print(
" No live-tests dir for this subsystem yet — consider "
"adding one if live coverage matters."
)
print()
def main():
analysis = analyze_commit()
if not analysis.source_files:
return 0
staged = [f.path for f in analysis.source_files]
# First-match-wins assignment so overlapping prefixes
# (relevance_filter.py is inside web_search_engines/) don't
# double-fire. PATH_RULES is ordered most-specific-first.
per_rule: dict[str, list[str]] = {}
for path in staged:
for rule in PATH_RULES:
if path.startswith(rule["prefixes"]):
per_rule.setdefault(rule["name"], []).append(path)
break
for rule in PATH_RULES:
matched = per_rule.get(rule["name"], [])
if matched:
_print_rule(rule, matched)
return 0
if __name__ == "__main__":
sys.exit(main())
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Nudge contributors to update PR description and changelog fragments.
Always exits 0 (non-blocking). Fires when:
- We are on a PR feature branch (detected via ``gh pr status``)
- Substantial source changes are staged (>= MIN_SOURCE_ADDED lines)
- The PR description appears stale or is still the default template
Silently exits when:
- ``gh`` CLI is not installed or not authenticated
- Not on a feature branch (e.g., on main)
- No substantial source changes staged
- PR description was updated recently
"""
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit # noqa: E402
# Minimum added source lines before the nudge fires
MIN_SOURCE_ADDED = 20
# Higher threshold for fragment-only nudge (big changes may need a
# changelog fragment even if the PR description is already fresh).
BIG_CHANGE_LINES = 60
# Grace period: if PR was updated within this many minutes, assume fresh.
PR_FRESHNESS_MINUTES = 60
# Default PR template body prefix (triggers staleness nudge regardless of
# timestamps).
_DEFAULT_BODY_PREFIXES = (
"## Description\n\nFixes #",
"## Description\r\n\r\nFixes #",
)
def _run_gh(args):
"""Run a ``gh`` command and return stdout, or None on any failure."""
try:
result = subprocess.run(
["gh"] + args,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return None
return result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _get_pr_context():
"""Return PR context dict or None if not on a PR feature branch.
Uses ``gh pr status --json number,title,body,updatedAt,headRefName``.
The ``currentBranch`` key is a single object (or null) — not an array.
"""
output = _run_gh(
[
"pr",
"status",
"--json",
"number,title,body,updatedAt,headRefName",
]
)
if not output:
return None
try:
data = json.loads(output)
except json.JSONDecodeError:
return None
current = data.get("currentBranch")
if not current or not isinstance(current, dict):
return None
head = current.get("headRefName", "")
# On main/master there may be a stale sync PR — skip.
if head in ("main", "master", ""):
return None
return {
"number": current.get("number"),
"title": current.get("title", ""),
"body": current.get("body", ""),
"updated_at": current.get("updatedAt", ""),
}
def _is_pr_description_stale(pr_context):
"""Return True if the PR description is likely stale.
Stale conditions (any triggers):
1. Body matches the default template (``## Description\\n\\nFixes #``)
2. Body is empty
3. PR updatedAt is older than the branch tip commit by > PR_FRESHNESS_MINUTES
"""
body = pr_context["body"].strip()
# Condition 1 & 2: empty or default template
if not body or body.startswith(_DEFAULT_BODY_PREFIXES):
return True
# Condition 3: timestamp-based staleness
updated_str = pr_context.get("updated_at", "")
if not updated_str:
return True # Can't determine freshness — nudge
try:
pr_updated = datetime.fromisoformat(updated_str.replace("Z", "+00:00"))
except (ValueError, TypeError):
return True
try:
result = subprocess.run(
["git", "log", "-1", "--format=%aI"],
capture_output=True,
text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return False # Can't determine — don't nudge
last_commit = datetime.fromisoformat(
result.stdout.strip().replace("Z", "+00:00")
)
except (ValueError, TypeError):
return False
# Normalise both to UTC for comparison
if last_commit.tzinfo is None:
last_commit = last_commit.replace(tzinfo=timezone.utc)
if pr_updated.tzinfo is None:
pr_updated = pr_updated.replace(tzinfo=timezone.utc)
staleness = last_commit - pr_updated
return staleness > timedelta(minutes=PR_FRESHNESS_MINUTES)
def _find_existing_fragments(pr_num):
"""Return list of Paths to existing changelog.d/ fragments for this PR.
Fragments follow towncrier naming: ``<id>.<category>[.<n>].md``.
"""
frag_dir = Path(__file__).resolve().parent.parent / "changelog.d"
if not frag_dir.is_dir():
return []
prefix = f"{pr_num}."
return sorted(
f
for f in frag_dir.glob("*.md")
if f.name.startswith(prefix) and f.name != "README.md"
)
def _fragments_staged():
"""Return True if any fragment under changelog.d/ is staged."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--", "changelog.d/"],
capture_output=True,
text=True,
)
files = [line for line in result.stdout.strip().splitlines() if line]
return any(f.endswith(".md") and Path(f).name != "README.md" for f in files)
def main():
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: trivial change
if analysis.total_source_added < MIN_SOURCE_ADDED:
return 0
# Silent exit: not on a PR feature branch (or gh unavailable)
pr_context = _get_pr_context()
if pr_context is None:
return 0
pr_num = pr_context["number"]
pr_title = pr_context["title"]
body = pr_context["body"].strip()
is_default = not body or body.startswith(_DEFAULT_BODY_PREFIXES)
desc_stale = _is_pr_description_stale(pr_context)
notes_stale = not _fragments_staged() and (
desc_stale or analysis.total_source_added >= BIG_CHANGE_LINES
)
# Nothing to nudge about
if not desc_stale and not notes_stale:
return 0
# Build nudge output
print()
print(" \033[36mPR Description Reminder\033[0m")
print(" " + "-" * 40)
print(
f" You're adding {analysis.total_source_added} lines across "
f"{len(analysis.source_files)} source file(s)"
)
print(f" on PR #{pr_num}: {pr_title}")
print()
# PR description nudge
if desc_stale:
if is_default:
print(
" \033[33mPR description is still the default template.\033[0m"
)
print(" Update it with:")
print(f" gh pr edit {pr_num}")
else:
print(" \033[33mPR description may be stale\033[0m")
print(" (not updated since the branch moved forward).")
print(" Refresh it with:")
print(f" gh pr edit {pr_num}")
# Fragment nudge — fires for stale descriptions OR big changes
if notes_stale:
existing = _find_existing_fragments(pr_num)
if existing:
print()
print(
" \033[33mExisting changelog.d/ fragments for this PR"
" (not staged):\033[0m"
)
for frag in existing:
print(f" - changelog.d/{frag.name}")
print(" Consider updating them if the approach has changed.")
else:
print()
print(" \033[33mNo changelog.d/ fragment for this PR.\033[0m")
print(f" Consider adding changelog.d/{pr_num}.<category>.md")
print(
" (categories: breaking, security, feature, bugfix,"
" removal, misc)."
)
print(" See changelog.d/README.md for conventions.")
if analysis.total_source_added >= BIG_CHANGE_LINES:
print(
f" This is a large change ({analysis.total_source_added}"
" lines) — strongly consider adding a fragment."
)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Remind contributors about news fragments.
Always exits 0 (non-blocking). Two messages:
- When any file under changelog.d/ is staged: confirm that the fragment
will be rolled into the next release's notes by towncrier and validate
the filename matches the expected pattern.
- When source changes are substantial but no fragment is staged: nudge
the contributor to add one.
Replaces the old shared docs/release_notes/<version>.md model — see
changelog.d/README.md for the rationale and conventions.
"""
import subprocess
import sys
import tomllib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _changelog_fragments import classify_fragment, load_categories
from _commit_analysis import analyze_commit
# Minimum added source lines before the nudge fires
MIN_SOURCE_ADDED = 20
REPO_ROOT = Path(__file__).resolve().parent.parent
PYPROJECT = REPO_ROOT / "pyproject.toml"
def _load_categories():
"""Load the canonical category list, falling back to a sensible
default if pyproject.toml or its towncrier section is unreadable —
this hook is non-blocking, so a degraded mode is preferable to a
hard failure here (the blocking check-changelog-fragments hook is
the one that fails loud on a broken config)."""
try:
return load_categories(PYPROJECT)
except (
OSError,
tomllib.TOMLDecodeError,
KeyError,
TypeError,
ValueError,
):
return ("breaking", "security", "feature", "bugfix", "removal", "misc")
CATEGORIES = _load_categories()
# Color helpers: only emit ANSI when stdout is a TTY. CI logs and Windows
# terminals without VT processing render the raw escape sequences as
# visible garbage.
_USE_COLOR = sys.stdout.isatty()
_CYAN = "\033[36m" if _USE_COLOR else ""
_YELLOW = "\033[33m" if _USE_COLOR else ""
_RESET = "\033[0m" if _USE_COLOR else ""
def _fragments_staged():
"""Return the list of staged files under changelog.d/, excluding
README.md and other non-fragment files."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--", "changelog.d/"],
capture_output=True,
text=True,
)
files = [line for line in result.stdout.strip().splitlines() if line]
return [
f for f in files if f.endswith(".md") and Path(f).name != "README.md"
]
def _print_staged_notice(staged):
"""Inform the committer that a news fragment was staged."""
print()
print(f" {_CYAN}News Fragment Staged{_RESET}")
print(" " + "-" * 40)
for f in staged:
print(f" - {f}")
print()
print(" Files under changelog.d/ are rendered into")
print(" docs/release_notes/<version>.md at release prep time by")
print(" `pdm run towncrier build --version <X.Y.Z> --yes`, then")
print(" surfaced in the GitHub release body by")
print(" .github/workflows/release.yml.")
# Validate filenames — non-blocking, but a typo'd category silently
# falls through towncrier's "no fragments matched" branch and the
# contributor's note vanishes from the release.
issues = []
for f in staged:
kind, value = classify_fragment(Path(f).name, CATEGORIES)
if kind != "ok":
issues.append((f, kind, value))
if issues:
print()
print(f" {_YELLOW}⚠ Fragment filename problems:{_RESET}")
for f, kind, value in issues:
if kind == "bad-name":
print(
f" {f} — does not match `<id>.<category>.md` or "
f"`+<slug>.<category>.md`"
)
else:
print(
f" {f} — unknown category `{value}`. Use one of: "
f"{', '.join(CATEGORIES)}"
)
print()
print(" See changelog.d/README.md for the convention.")
print()
print(" Format tips:")
print(" - One sentence is usually enough; longer prose is fine for")
print(" breaking changes that need a 'what to do' line.")
print(" - Markdown is supported. The PR/issue link is auto-appended")
print(" based on the fragment id (no need to add `(#NNNN)`).")
print(" - Skip dependency bumps, internal CI tweaks, and refactors")
print(" with no user-visible behavior — the auto-PR-list catches")
print(" those without a fragment.")
print()
def _print_missing_notice(analysis):
"""Nudge the committer to add a news fragment for a substantial change."""
print()
print(f" {_CYAN}News Fragment Reminder{_RESET}")
print(" " + "-" * 40)
print(
f" You're adding {analysis.total_source_added} lines across "
f"{len(analysis.source_files)} source file(s)"
)
print(" but no changelog.d/ fragment is staged.")
print()
print(" Changed source files:")
for f in analysis.source_files:
print(f" - {f.path} (+{f.added})")
print()
print(" If this change is user-facing, drop a fragment under")
print(" changelog.d/ named `<PR-number>.<category>.md` (categories:")
print(f" {', '.join(CATEGORIES)}). See changelog.d/README.md.")
print()
def main():
staged = _fragments_staged()
# Always inform when a fragment is staged — contributors should know
# the file gets rendered into the release, and any naming mistakes
# need to surface before the fragment silently goes ignored.
if staged:
_print_staged_notice(staged)
return 0
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: trivial change (less than MIN_SOURCE_ADDED added lines)
if analysis.total_source_added < MIN_SOURCE_ADDED:
return 0
_print_missing_notice(analysis)
return 0
if __name__ == "__main__":
sys.exit(main())
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Direct recommendation to include tests when source files change.
Always exits 0 (non-blocking). Prints an assertive warning when source
files are staged without any test files.
"""
import sys
from pathlib import Path
# Allow importing sibling module
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit, suggest_test_path
def main():
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: tests already included
if analysis.has_tests:
return 0
# Print assertive warning
print()
print(" \033[33mTest Coverage Recommendation\033[0m")
print(" " + "-" * 40)
print(
f" You're committing {len(analysis.source_files)} source file(s) "
"with no test files."
)
print()
print(" Source files and suggested tests:")
for f in analysis.source_files:
suggested = suggest_test_path(f.path)
print(f" {f.path} (+{f.added} lines)")
print(f" -> {suggested}")
print()
print(" Adding tests helps catch regressions and documents behavior.")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Nudge toward vitest tests when frontend JS source changes lack them.
Always exits 0 (non-blocking). Prints when staged JS source files under
src/.../static/js/ have no companion vitest tests staged. Sibling of
recommend-tests.py but vitest-specific so a Python-test-only commit
doesn't suppress the JS nudge.
"""
import sys
from pathlib import Path
# Allow importing sibling module
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit, suggest_test_path
def main():
analysis = analyze_commit()
js_sources = [f for f in analysis.source_files if f.path.endswith(".js")]
if not js_sources:
return 0
vitest_tests = [
f for f in analysis.test_files if f.path.endswith(".test.js")
]
if vitest_tests:
return 0
print()
print(" \033[33mVitest Coverage Nudge\033[0m")
print(" " + "-" * 40)
print(
f" You're committing {len(js_sources)} frontend JS source file(s) "
"with no vitest tests."
)
print()
print(" Files and suggested test paths:")
for f in js_sources:
print(f" {f.path} (+{f.added} lines)")
print(f" -> {suggest_test_path(f.path)}")
print()
print(
" Vitest already runs in CI (docker-tests.yml). Run locally: npm test"
)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Require a notes file when a file under ``advanced_search_system/`` is deleted.
Any commit that deletes a ``.py`` file under
``src/local_deep_research/advanced_search_system/`` — or renames one out of
that tree, which removes it from the tracked module — must also add or
modify a ``.md`` file under ``docs/strategies/deleted/`` in the same commit.
Rationale and template live in ``docs/strategies/deleted/README.md``.
Exempt files whose deletion does not by itself remove a component: package
``__init__.py`` aggregators. The exemption list is intentionally narrow; if
a new infra file is added (e.g. a base class in a new location), update
``EXEMPT_FILENAMES`` below. ``base_*.py`` files are NOT exempt — deleting
a base class is a significant refactor that does warrant a notes file.
"""
import subprocess
import sys
from pathlib import PurePosixPath
SCOPE_DIR = "src/local_deep_research/advanced_search_system/"
DOCS_DIR = "docs/strategies/deleted/"
EXEMPT_FILENAMES = {"__init__.py"}
# Suggested notes-file name format. PR number isn't known at commit time
# so we accept a placeholder the author fills in before pushing.
SUGGESTED_FILENAME = "pr-<number>-<short-slug>.md"
TEMPLATE = """\
# PR #<n> — <title>
Components deleted in PR #<n> (see that PR for the full pre-deletion
code — this file only summarises what was novel).
## Component: `<ClassName>`
- File deleted: `<path>` (<N> LOC at deletion).
- Reachability: <e.g. "not in factory; only referenced by its own test">.
- Closest reachable successor: `<ClassName>` (`<path>`, factory key `"<X>"`).
### Useful ideas from the pre-deletion version
- **<short name>** — <1-2 sentences on what it did, why it was
distinctive, whether it was validated>.
- **<short name>** — <...>.
### Why deletion was safe
<2-3 sentences mapping distinctive features to the successor, or
flagging at-risk items and why losing them is acceptable.>
### Recovery path
<1-2 sentences. Prefer "add a flag on the existing class" over
"restore the deleted file".>
"""
def staged_changes():
"""Yield ``(code, old_path, new_path)`` for each staged change.
For add/modify/delete, ``old_path == new_path``. For rename (R) and
copy (C), they differ; uniform unpacking lets callers use one code
path. See ``git diff --name-status`` for the format.
"""
result = subprocess.run(
["git", "diff", "--cached", "--name-status"],
capture_output=True,
text=True,
check=True,
)
for line in result.stdout.splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) < 2:
continue
code = parts[0][
0
] # A / M / D / R / C (R and C carry a similarity score suffix)
if code in ("R", "C") and len(parts) >= 3:
yield code, parts[1], parts[2]
else:
yield code, parts[1], parts[1]
def _is_exempt(path: str) -> bool:
# .lower() defends against case-insensitive filesystems (macOS, Windows).
return PurePosixPath(path).name.lower() in EXEMPT_FILENAMES
def main() -> int:
deleted_files = []
doc_changes = []
for code, old_path, new_path in staged_changes():
# A rename out of SCOPE_DIR removes the component from the tracked
# tree even though the file survives elsewhere — treat as deletion.
# Renames within SCOPE_DIR are legitimate refactors; don't fire.
is_deletion_from_scope = (
code == "D" and old_path.startswith(SCOPE_DIR)
) or (
code == "R"
and old_path.startswith(SCOPE_DIR)
and not new_path.startswith(SCOPE_DIR)
)
if (
is_deletion_from_scope
and old_path.endswith(".py")
and not _is_exempt(old_path)
):
deleted_files.append(old_path)
if (
code in ("A", "M")
and new_path.startswith(DOCS_DIR)
and new_path.endswith(".md")
and PurePosixPath(new_path).name.lower() != "readme.md"
):
doc_changes.append(new_path)
if deleted_files and not doc_changes:
err = sys.stderr
print(
"\n \033[31madvanced_search_system deletion without documentation\033[0m",
file=err,
)
print(" " + "=" * 50, file=err)
print(
"\n You are deleting (or renaming out of scope) these files:",
file=err,
)
for path in deleted_files:
print(f" - {path}", file=err)
print(
f"\n To unblock this commit, add a notes file at\n"
f" {DOCS_DIR}{SUGGESTED_FILENAME}\n"
f" (or extend an existing one under {DOCS_DIR}) with the following shape:\n",
file=err,
)
for line in TEMPLATE.splitlines():
print(f" {line}" if line else "", file=err)
print(
'\n Each bullet in "Useful ideas" should answer, in 1-2',
file=err,
)
print(
" sentences:",
file=err,
)
print(
" - what the component did that was different from the successor,",
file=err,
)
print(
" - why that difference was interesting (heuristic, tuning, prompt",
file=err,
)
print(
" trick, interface gap),",
file=err,
)
print(
" - and whether it was validated or exploratory.",
file=err,
)
print(
"\n Do NOT paste verbatim prompts, docstrings, or code blocks —",
file=err,
)
print(
" git already stores them via the deletion PR/commit. Link, don't",
file=err,
)
print(
" duplicate.",
file=err,
)
print(
f"\n Full convention + rationale: {DOCS_DIR}README.md\n",
file=err,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Block commits with substantial new code but no tests.
Exits 1 (blocks commit) when zero test files are staged AND at least one of:
- A new source file has >50 added lines (real new functionality)
- >300 total new lines across source files (substantial change)
Otherwise exits 0 silently.
"""
import sys
from pathlib import Path
# Allow importing sibling module
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit, suggest_test_path
# Thresholds
NEW_FILE_LINE_THRESHOLD = 50
TOTAL_LINES_THRESHOLD = 300
def main():
analysis = analyze_commit()
# Pass: no source files
if not analysis.source_files:
return 0
# Pass: tests are included
if analysis.has_tests:
return 0
# Check blocking conditions
large_new_files = [
f
for f in analysis.new_source_files
if f.added > NEW_FILE_LINE_THRESHOLD
]
substantial_total = analysis.total_source_added > TOTAL_LINES_THRESHOLD
if not large_new_files and not substantial_total:
return 0
# Block: print reason and suggestions
print()
print(" \033[31mTests Required\033[0m")
print(" " + "=" * 40)
if large_new_files:
print()
print(" New source files with significant code:")
for f in large_new_files:
suggested = suggest_test_path(f.path)
print(f" {f.path} (+{f.added} lines)")
print(f" -> {suggested}")
if substantial_total:
print()
print(
f" Total new source lines: {analysis.total_source_added} "
f"(threshold: {TOTAL_LINES_THRESHOLD})"
)
print()
print(" Please add tests before committing.")
print()
return 1
if __name__ == "__main__":
sys.exit(main())