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
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:
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
Executable
+55
@@ -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())
|
||||
Executable
+278
@@ -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())
|
||||
Executable
+234
@@ -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
@@ -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())
|
||||
Executable
+79
@@ -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())
|
||||
Executable
+516
@@ -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())
|
||||
Executable
+154
@@ -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
@@ -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())
|
||||
Executable
+148
@@ -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())
|
||||
Executable
+328
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
Executable
+97
@@ -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
@@ -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))
|
||||
Executable
+127
@@ -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())
|
||||
Executable
+93
@@ -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())
|
||||
Executable
+155
@@ -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())
|
||||
Executable
+179
@@ -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())
|
||||
Executable
+217
@@ -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())
|
||||
Executable
+19
@@ -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."
|
||||
Executable
+62
@@ -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())
|
||||
Executable
+244
@@ -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) / .
|
||||
# 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
@@ -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())
|
||||
Executable
+110
@@ -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())
|
||||
Executable
+157
@@ -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()
|
||||
Executable
+1168
File diff suppressed because it is too large
Load Diff
+499
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
Executable
+142
@@ -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:]))
|
||||
Executable
+244
@@ -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()
|
||||
Executable
+107
@@ -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())
|
||||
Executable
+170
@@ -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())
|
||||
Executable
+200
@@ -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())
|
||||
Executable
+180
@@ -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())
|
||||
Executable
+62
@@ -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())
|
||||
Executable
+64
@@ -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())
|
||||
Executable
+227
@@ -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
@@ -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())
|
||||
Executable
+615
@@ -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()
|
||||
Executable
+86
@@ -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
|
||||
Executable
+317
@@ -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())
|
||||
Executable
+54
@@ -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
@@ -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
@@ -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())
|
||||
Executable
+171
@@ -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())
|
||||
Executable
+50
@@ -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())
|
||||
Executable
+54
@@ -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
@@ -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())
|
||||
Executable
+74
@@ -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())
|
||||
Reference in New Issue
Block a user