feat: Add CLAUDE.md freshness tracking and update reminders

- Add SQLite table and CRUD methods for tracking update history
- Create freshness calculation service based on git file changes
- Add API endpoints for freshness data, marking updates, and history
- Display freshness badges in file tree (green/yellow/red indicators)
- Show freshness gauge and details in metadata panel
- Auto-mark files as updated after CLI sync
- Add English and Chinese i18n translations

Freshness algorithm: 100 - min((changedFilesCount / 20) * 100, 100)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-20 16:14:46 +08:00
parent 4a3ff82200
commit b27d8a9570
18 changed files with 2260 additions and 18 deletions

View File

@@ -0,0 +1,122 @@
"""Tests for CLI search command with --enrich flag."""
import json
import pytest
from typer.testing import CliRunner
from codexlens.cli.commands import app
class TestCLISearchEnrich:
"""Test CLI search command with --enrich flag integration."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
def test_search_with_enrich_flag_help(self, runner):
"""Test --enrich flag is documented in help."""
result = runner.invoke(app, ["search", "--help"])
assert result.exit_code == 0
assert "--enrich" in result.output
assert "relationships" in result.output.lower() or "graph" in result.output.lower()
def test_search_with_enrich_flag_accepted(self, runner):
"""Test --enrich flag is accepted by the CLI."""
result = runner.invoke(app, ["search", "test", "--enrich"])
# Should not show 'unknown option' error
assert "No such option" not in result.output
assert "error: unrecognized" not in result.output.lower()
def test_search_without_enrich_flag(self, runner):
"""Test search without --enrich flag has no relationships."""
result = runner.invoke(app, ["search", "test", "--json"])
# Even without an index, JSON should be attempted
if result.exit_code == 0:
try:
data = json.loads(result.output)
# If we get results, they should not have enriched=true
if data.get("success") and "result" in data:
assert data["result"].get("enriched", False) is False
except json.JSONDecodeError:
pass # Not JSON output, that's fine for error cases
def test_search_enrich_json_output_structure(self, runner):
"""Test JSON output structure includes enriched flag."""
result = runner.invoke(app, ["search", "test", "--json", "--enrich"])
# If we get valid JSON output, check structure
if result.exit_code == 0:
try:
data = json.loads(result.output)
if data.get("success") and "result" in data:
# enriched field should exist
assert "enriched" in data["result"]
except json.JSONDecodeError:
pass # Not JSON output
def test_search_enrich_with_mode(self, runner):
"""Test --enrich works with different search modes."""
modes = ["exact", "fuzzy", "hybrid"]
for mode in modes:
result = runner.invoke(
app, ["search", "test", "--mode", mode, "--enrich"]
)
# Should not show validation errors
assert "Invalid" not in result.output
class TestEnrichFlagBehavior:
"""Test behavioral aspects of --enrich flag."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
def test_enrich_failure_does_not_break_search(self, runner):
"""Test that enrichment failure doesn't prevent search from returning results."""
# Even without proper index, search should not crash due to enrich
result = runner.invoke(app, ["search", "test", "--enrich", "--verbose"])
# Should not have unhandled exception
assert "Traceback" not in result.output
def test_enrich_flag_with_files_only(self, runner):
"""Test --enrich is accepted with --files-only mode."""
result = runner.invoke(app, ["search", "test", "--enrich", "--files-only"])
# Should not show option conflict error
assert "conflict" not in result.output.lower()
def test_enrich_flag_with_limit(self, runner):
"""Test --enrich works with --limit parameter."""
result = runner.invoke(app, ["search", "test", "--enrich", "--limit", "5"])
# Should not show validation error
assert "Invalid" not in result.output
class TestEnrichOutputFormat:
"""Test output format with --enrich flag."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
def test_enrich_verbose_shows_status(self, runner):
"""Test verbose mode shows enrichment status."""
result = runner.invoke(app, ["search", "test", "--enrich", "--verbose"])
# Verbose mode may show enrichment info or warnings
# Just ensure it doesn't crash
assert result.exit_code in [0, 1] # 0 = success, 1 = no index
def test_json_output_has_enriched_field(self, runner):
"""Test JSON output always has enriched field when --enrich used."""
result = runner.invoke(app, ["search", "test", "--json", "--enrich"])
if result.exit_code == 0:
try:
data = json.loads(result.output)
if data.get("success"):
result_data = data.get("result", {})
assert "enriched" in result_data
assert isinstance(result_data["enriched"], bool)
except json.JSONDecodeError:
pass

View File

@@ -0,0 +1,234 @@
"""Tests for search result enrichment with relationship data."""
import sqlite3
import tempfile
import time
from pathlib import Path
import pytest
from codexlens.search.enrichment import RelationshipEnricher
@pytest.fixture
def mock_db():
"""Create a mock database with symbols and relationships."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "_index.db"
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Create schema
cursor.execute('''
CREATE TABLE symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qualified_name TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL,
file_path TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL
)
''')
cursor.execute('''
CREATE TABLE symbol_relationships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_symbol_id INTEGER NOT NULL,
target_symbol_fqn TEXT NOT NULL,
relationship_type TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER,
FOREIGN KEY (source_symbol_id) REFERENCES symbols(id)
)
''')
# Insert test data
cursor.execute('''
INSERT INTO symbols (qualified_name, name, kind, file_path, start_line, end_line)
VALUES ('module.main', 'main', 'function', 'module.py', 1, 10)
''')
main_id = cursor.lastrowid
cursor.execute('''
INSERT INTO symbols (qualified_name, name, kind, file_path, start_line, end_line)
VALUES ('module.helper', 'helper', 'function', 'module.py', 12, 20)
''')
helper_id = cursor.lastrowid
cursor.execute('''
INSERT INTO symbols (qualified_name, name, kind, file_path, start_line, end_line)
VALUES ('utils.fetch', 'fetch', 'function', 'utils.py', 1, 5)
''')
fetch_id = cursor.lastrowid
# main calls helper
cursor.execute('''
INSERT INTO symbol_relationships (source_symbol_id, target_symbol_fqn, relationship_type, file_path, line)
VALUES (?, 'helper', 'calls', 'module.py', 5)
''', (main_id,))
# main calls fetch
cursor.execute('''
INSERT INTO symbol_relationships (source_symbol_id, target_symbol_fqn, relationship_type, file_path, line)
VALUES (?, 'utils.fetch', 'calls', 'module.py', 6)
''', (main_id,))
# helper imports os
cursor.execute('''
INSERT INTO symbol_relationships (source_symbol_id, target_symbol_fqn, relationship_type, file_path, line)
VALUES (?, 'os', 'imports', 'module.py', 13)
''', (helper_id,))
conn.commit()
conn.close()
yield db_path
class TestRelationshipEnricher:
"""Test suite for RelationshipEnricher."""
def test_enrich_with_relationships(self, mock_db):
"""Test enriching results with valid relationships."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "module.py", "score": 0.9, "excerpt": "def main():", "symbol": "main"},
{"path": "module.py", "score": 0.8, "excerpt": "def helper():", "symbol": "helper"},
]
enriched = enricher.enrich(results, limit=10)
# Check main's relationships
main_result = enriched[0]
assert "relationships" in main_result
main_rels = main_result["relationships"]
assert len(main_rels) >= 2
# Verify outgoing relationships
outgoing = [r for r in main_rels if r["direction"] == "outgoing"]
targets = [r["target"] for r in outgoing]
assert "helper" in targets or any("helper" in t for t in targets)
# Check helper's relationships
helper_result = enriched[1]
assert "relationships" in helper_result
helper_rels = helper_result["relationships"]
assert len(helper_rels) >= 1
# Verify incoming relationships (main calls helper)
incoming = [r for r in helper_rels if r["direction"] == "incoming"]
assert len(incoming) >= 1
assert incoming[0]["type"] == "called_by"
def test_enrich_missing_symbol(self, mock_db):
"""Test graceful handling of missing symbols."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "unknown.py", "score": 0.9, "excerpt": "code", "symbol": "nonexistent"},
]
enriched = enricher.enrich(results, limit=10)
# Should return empty relationships, not crash
assert "relationships" in enriched[0]
assert enriched[0]["relationships"] == []
def test_enrich_no_symbol_name(self, mock_db):
"""Test handling results without symbol names."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "module.py", "score": 0.9, "excerpt": "code", "symbol": None},
]
enriched = enricher.enrich(results, limit=10)
assert "relationships" in enriched[0]
assert enriched[0]["relationships"] == []
def test_enrich_performance(self, mock_db):
"""Test that enrichment is fast (<100ms for 10 results)."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "module.py", "score": 0.9, "excerpt": f"code{i}", "symbol": "main"}
for i in range(10)
]
start = time.perf_counter()
enricher.enrich(results, limit=10)
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < 100, f"Enrichment took {elapsed_ms:.1f}ms, expected < 100ms"
def test_enrich_limit(self, mock_db):
"""Test that limit parameter is respected."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "module.py", "score": 0.9, "symbol": "main"},
{"path": "module.py", "score": 0.8, "symbol": "helper"},
{"path": "utils.py", "score": 0.7, "symbol": "fetch"},
]
# Only enrich first 2
enriched = enricher.enrich(results, limit=2)
assert "relationships" in enriched[0]
assert "relationships" in enriched[1]
# Third result should NOT have relationships key
assert "relationships" not in enriched[2]
def test_connection_failure_graceful(self):
"""Test graceful handling when database doesn't exist."""
nonexistent = Path("/nonexistent/path/_index.db")
with RelationshipEnricher(nonexistent) as enricher:
results = [{"path": "test.py", "score": 0.9, "symbol": "test"}]
enriched = enricher.enrich(results)
# Should return original results without crashing
assert len(enriched) == 1
def test_incoming_type_conversion(self, mock_db):
"""Test that relationship types are correctly converted for incoming."""
with RelationshipEnricher(mock_db) as enricher:
results = [
{"path": "module.py", "score": 0.9, "symbol": "helper"},
]
enriched = enricher.enrich(results)
rels = enriched[0]["relationships"]
incoming = [r for r in rels if r["direction"] == "incoming"]
if incoming:
# calls should become called_by
assert incoming[0]["type"] == "called_by"
def test_context_manager(self, mock_db):
"""Test that context manager properly opens and closes connections."""
enricher = RelationshipEnricher(mock_db)
assert enricher.db_conn is not None
enricher.close()
assert enricher.db_conn is None
# Using context manager
with RelationshipEnricher(mock_db) as e:
assert e.db_conn is not None
assert e.db_conn is None
def test_relationship_data_structure(self, mock_db):
"""Test that relationship data has correct structure."""
with RelationshipEnricher(mock_db) as enricher:
results = [{"path": "module.py", "score": 0.9, "symbol": "main"}]
enriched = enricher.enrich(results)
rels = enriched[0]["relationships"]
for rel in rels:
# All relationships should have required fields
assert "type" in rel
assert "direction" in rel
assert "file" in rel
assert rel["direction"] in ["outgoing", "incoming"]
# Outgoing should have target, incoming should have source
if rel["direction"] == "outgoing":
assert "target" in rel
else:
assert "source" in rel

View File

@@ -0,0 +1,238 @@
"""Tests for symbol extraction and relationship tracking."""
import tempfile
from pathlib import Path
import pytest
from codexlens.indexing.symbol_extractor import SymbolExtractor
@pytest.fixture
def extractor():
"""Create a temporary symbol extractor for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
ext = SymbolExtractor(db_path)
ext.connect()
yield ext
ext.close()
class TestSymbolExtractor:
"""Test suite for SymbolExtractor."""
def test_database_schema_creation(self, extractor):
"""Test that database tables and indexes are created correctly."""
cursor = extractor.db_conn.cursor()
# Check symbols table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='symbols'"
)
assert cursor.fetchone() is not None
# Check symbol_relationships table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='symbol_relationships'"
)
assert cursor.fetchone() is not None
# Check indexes exist
cursor.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'"
)
idx_count = cursor.fetchone()[0]
assert idx_count == 5
def test_python_function_extraction(self, extractor):
"""Test extracting functions from Python code."""
code = """
def hello():
pass
async def world():
pass
"""
symbols, _ = extractor.extract_from_file(Path("test.py"), code)
assert len(symbols) == 2
assert symbols[0]["name"] == "hello"
assert symbols[0]["kind"] == "function"
assert symbols[1]["name"] == "world"
assert symbols[1]["kind"] == "function"
def test_python_class_extraction(self, extractor):
"""Test extracting classes from Python code."""
code = """
class MyClass:
pass
class AnotherClass(BaseClass):
pass
"""
symbols, _ = extractor.extract_from_file(Path("test.py"), code)
assert len(symbols) == 2
assert symbols[0]["name"] == "MyClass"
assert symbols[0]["kind"] == "class"
assert symbols[1]["name"] == "AnotherClass"
assert symbols[1]["kind"] == "class"
def test_typescript_extraction(self, extractor):
"""Test extracting symbols from TypeScript code."""
code = """
export function calculateSum(a: number, b: number): number {
return a + b;
}
export class Calculator {
multiply(x: number, y: number) {
return x * y;
}
}
"""
symbols, _ = extractor.extract_from_file(Path("test.ts"), code)
assert len(symbols) == 2
assert symbols[0]["name"] == "calculateSum"
assert symbols[0]["kind"] == "function"
assert symbols[1]["name"] == "Calculator"
assert symbols[1]["kind"] == "class"
def test_javascript_extraction(self, extractor):
"""Test extracting symbols from JavaScript code."""
code = """
function processData(data) {
return data;
}
class DataProcessor {
transform(input) {
return input;
}
}
"""
symbols, _ = extractor.extract_from_file(Path("test.js"), code)
assert len(symbols) == 2
assert symbols[0]["name"] == "processData"
assert symbols[1]["name"] == "DataProcessor"
def test_relationship_extraction(self, extractor):
"""Test extracting relationships between symbols."""
code = """
def helper():
pass
def main():
helper()
print("done")
"""
_, relationships = extractor.extract_from_file(Path("test.py"), code)
# Should find calls to helper and print
call_targets = [r["target"] for r in relationships if r["type"] == "calls"]
assert "helper" in call_targets
def test_save_and_query_symbols(self, extractor):
"""Test saving symbols to database and querying them."""
code = """
def test_func():
pass
class TestClass:
pass
"""
symbols, _ = extractor.extract_from_file(Path("test.py"), code)
name_to_id = extractor.save_symbols(symbols)
assert len(name_to_id) == 2
assert "test_func" in name_to_id
assert "TestClass" in name_to_id
# Query database
cursor = extractor.db_conn.cursor()
cursor.execute("SELECT COUNT(*) FROM symbols")
count = cursor.fetchone()[0]
assert count == 2
def test_save_relationships(self, extractor):
"""Test saving relationships to database."""
code = """
def caller():
callee()
def callee():
pass
"""
symbols, relationships = extractor.extract_from_file(Path("test.py"), code)
name_to_id = extractor.save_symbols(symbols)
extractor.save_relationships(relationships, name_to_id)
# Query database
cursor = extractor.db_conn.cursor()
cursor.execute("SELECT COUNT(*) FROM symbol_relationships")
count = cursor.fetchone()[0]
assert count > 0
def test_qualified_name_generation(self, extractor):
"""Test that qualified names are generated correctly."""
code = """
class MyClass:
pass
"""
symbols, _ = extractor.extract_from_file(Path("module.py"), code)
assert symbols[0]["qualified_name"] == "module.MyClass"
def test_unsupported_language(self, extractor):
"""Test that unsupported languages return empty results."""
code = "some random code"
symbols, relationships = extractor.extract_from_file(Path("test.txt"), code)
assert len(symbols) == 0
assert len(relationships) == 0
def test_empty_file(self, extractor):
"""Test handling empty files."""
symbols, relationships = extractor.extract_from_file(Path("test.py"), "")
assert len(symbols) == 0
assert len(relationships) == 0
def test_complete_workflow(self, extractor):
"""Test complete workflow: extract, save, and verify."""
code = """
class UserService:
def get_user(self, user_id):
return fetch_user(user_id)
def main():
service = UserService()
service.get_user(1)
"""
file_path = Path("service.py")
symbols, relationships = extractor.extract_from_file(file_path, code)
# Save to database
name_to_id = extractor.save_symbols(symbols)
extractor.save_relationships(relationships, name_to_id)
# Verify symbols
cursor = extractor.db_conn.cursor()
cursor.execute("SELECT name, kind FROM symbols ORDER BY start_line")
db_symbols = cursor.fetchall()
assert len(db_symbols) == 2
assert db_symbols[0][0] == "UserService"
assert db_symbols[1][0] == "main"
# Verify relationships
cursor.execute(
"""
SELECT s.name, r.target_symbol_fqn, r.relationship_type
FROM symbol_relationships r
JOIN symbols s ON r.source_symbol_id = s.id
"""
)
db_rels = cursor.fetchall()
assert len(db_rels) > 0