Files
wehub-resource-sync 7a0da7932b
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

616 lines
23 KiB
Python
Executable File

#!/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()