Files
LearningCircuit 6d27abd38e feat(notes): add AI-enhanced Notes system with semantic search (#3277)
Adds a first-class **Notes** feature to the research library — a continuation of #1464, rebuilt on current `main`. A note is stored as a `documents` row (`source_type='note'`) and reuses the existing library / RAG / embedding stack, so notes get collections, chunking, and semantic search for free.
2026-07-18 14:54:14 +02:00

238 lines
6.8 KiB
Python

"""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) and Playwright specs
# (tests/ui_tests/playwright/**/*.spec.js) — both are this repo's
# JS test layers; a commit shipping UI code with .spec.js coverage
# must not be flagged as untested.
if "tests" in parts_set and (
basename.endswith(".test.js") or basename.endswith(".spec.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