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

@@ -268,6 +268,7 @@ def search(
files_only: bool = typer.Option(False, "--files-only", "-f", help="Return only file paths without content snippets."),
mode: str = typer.Option("auto", "--mode", "-m", help="Search mode: auto, exact, fuzzy, hybrid, vector, pure-vector."),
weights: Optional[str] = typer.Option(None, "--weights", help="Custom RRF weights as 'exact,fuzzy,vector' (e.g., '0.5,0.3,0.2')."),
enrich: bool = typer.Option(False, "--enrich", help="Enrich results with code graph relationships (calls, imports)."),
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
) -> None:
@@ -411,19 +412,42 @@ def search(
console.print(fp)
else:
result = engine.search(query, search_path, options)
results_list = [
{
"path": r.path,
"score": r.score,
"excerpt": r.excerpt,
"source": getattr(r, "search_source", None),
"symbol": getattr(r, "symbol", None),
}
for r in result.results
]
# Enrich results with relationship data if requested
enriched = False
if enrich:
try:
from codexlens.search.enrichment import RelationshipEnricher
# Find index path for the search path
project_record = registry.find_by_source_path(str(search_path))
if project_record:
index_path = Path(project_record["index_root"]) / "_index.db"
if index_path.exists():
with RelationshipEnricher(index_path) as enricher:
results_list = enricher.enrich(results_list, limit=limit)
enriched = True
except Exception as e:
# Enrichment failure should not break search
if verbose:
console.print(f"[yellow]Warning: Enrichment failed: {e}[/yellow]")
payload = {
"query": query,
"mode": actual_mode,
"count": len(result.results),
"results": [
{
"path": r.path,
"score": r.score,
"excerpt": r.excerpt,
"source": getattr(r, "search_source", None),
}
for r in result.results
],
"count": len(results_list),
"enriched": enriched,
"results": results_list,
"stats": {
"dirs_searched": result.stats.dirs_searched,
"files_matched": result.stats.files_matched,
@@ -434,7 +458,8 @@ def search(
print_json(success=True, result=payload)
else:
render_search_results(result.results, verbose=verbose)
console.print(f"[dim]Mode: {actual_mode} | Searched {result.stats.dirs_searched} directories in {result.stats.time_ms:.1f}ms[/dim]")
enrich_status = " | [green]Enriched[/green]" if enriched else ""
console.print(f"[dim]Mode: {actual_mode} | Searched {result.stats.dirs_searched} directories in {result.stats.time_ms:.1f}ms{enrich_status}[/dim]")
except SearchError as exc:
if json_mode:

View File

@@ -0,0 +1,77 @@
# Symbol Extraction and Indexing
This module provides symbol extraction and relationship tracking for code graph enrichment.
## Overview
The `SymbolExtractor` class extracts code symbols (functions, classes) and their relationships (calls, imports) from source files using regex-based pattern matching.
## Supported Languages
- Python (.py)
- TypeScript (.ts, .tsx)
- JavaScript (.js, .jsx)
## Database Schema
### Symbols Table
Stores code symbols with their location information:
- `id`: Primary key
- `qualified_name`: Fully qualified name (e.g., "module.ClassName")
- `name`: Symbol name
- `kind`: Symbol type (function, class)
- `file_path`: Path to source file
- `start_line`: Starting line number
- `end_line`: Ending line number
### Symbol Relationships Table
Stores relationships between symbols:
- `id`: Primary key
- `source_symbol_id`: Foreign key to symbols table
- `target_symbol_fqn`: Fully qualified name of target symbol
- `relationship_type`: Type of relationship (calls, imports)
- `file_path`: Path to source file
- `line`: Line number where relationship occurs
## Usage Example
```python
from pathlib import Path
from codexlens.indexing.symbol_extractor import SymbolExtractor
# Initialize extractor
db_path = Path("./code_index.db")
extractor = SymbolExtractor(db_path)
extractor.connect()
# Extract from file
file_path = Path("src/my_module.py")
with open(file_path) as f:
content = f.read()
symbols, relationships = extractor.extract_from_file(file_path, content)
# Save to database
name_to_id = extractor.save_symbols(symbols)
extractor.save_relationships(relationships, name_to_id)
# Clean up
extractor.close()
```
## Pattern Matching
The extractor uses regex patterns to identify:
- **Functions**: Function definitions (including async, export keywords)
- **Classes**: Class definitions (including export keyword)
- **Imports**: Import/require statements
- **Calls**: Function/method invocations
## Future Enhancements
- Tree-sitter integration for more accurate parsing
- Support for additional languages
- Method and variable extraction
- Enhanced scope tracking
- Relationship type expansion (inherits, implements, etc.)

View File

@@ -0,0 +1,4 @@
"""Code indexing and symbol extraction."""
from codexlens.indexing.symbol_extractor import SymbolExtractor
__all__ = ["SymbolExtractor"]

View File

@@ -0,0 +1,234 @@
"""Symbol and relationship extraction from source code."""
import re
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
class SymbolExtractor:
"""Extract symbols and relationships from source code using regex patterns."""
# Pattern definitions for different languages
PATTERNS = {
'python': {
'function': r'^(?:async\s+)?def\s+(\w+)\s*\(',
'class': r'^class\s+(\w+)\s*[:\(]',
'import': r'^(?:from\s+([\w.]+)\s+)?import\s+([\w.,\s]+)',
'call': r'(?<![.\w])(\w+)\s*\(',
},
'typescript': {
'function': r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[<\(]',
'class': r'(?:export\s+)?class\s+(\w+)',
'import': r"import\s+.*\s+from\s+['\"]([^'\"]+)['\"]",
'call': r'(?<![.\w])(\w+)\s*[<\(]',
},
'javascript': {
'function': r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(',
'class': r'(?:export\s+)?class\s+(\w+)',
'import': r"(?:import|require)\s*\(?['\"]([^'\"]+)['\"]",
'call': r'(?<![.\w])(\w+)\s*\(',
}
}
LANGUAGE_MAP = {
'.py': 'python',
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
}
def __init__(self, db_path: Path):
self.db_path = db_path
self.db_conn: Optional[sqlite3.Connection] = None
def connect(self) -> None:
"""Connect to database and ensure schema exists."""
self.db_conn = sqlite3.connect(str(self.db_path))
self._ensure_tables()
def _ensure_tables(self) -> None:
"""Create symbols and relationships tables if they don't exist."""
if not self.db_conn:
return
cursor = self.db_conn.cursor()
# Create symbols table with qualified_name
cursor.execute('''
CREATE TABLE IF NOT EXISTS 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,
UNIQUE(file_path, name, start_line)
)
''')
# Create relationships table with target_symbol_fqn
cursor.execute('''
CREATE TABLE IF NOT EXISTS 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) ON DELETE CASCADE
)
''')
# Create performance indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_rel_source ON symbol_relationships(source_symbol_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_rel_target ON symbol_relationships(target_symbol_fqn)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_rel_type ON symbol_relationships(relationship_type)')
self.db_conn.commit()
def extract_from_file(self, file_path: Path, content: str) -> Tuple[List[Dict], List[Dict]]:
"""Extract symbols and relationships from file content.
Args:
file_path: Path to the source file
content: File content as string
Returns:
Tuple of (symbols, relationships) where:
- symbols: List of symbol dicts with qualified_name, name, kind, file_path, start_line, end_line
- relationships: List of relationship dicts with source_scope, target, type, file_path, line
"""
ext = file_path.suffix.lower()
lang = self.LANGUAGE_MAP.get(ext)
if not lang or lang not in self.PATTERNS:
return [], []
patterns = self.PATTERNS[lang]
symbols = []
relationships = []
lines = content.split('\n')
current_scope = None
for line_num, line in enumerate(lines, 1):
# Extract function/class definitions
for kind in ['function', 'class']:
if kind in patterns:
match = re.search(patterns[kind], line)
if match:
name = match.group(1)
qualified_name = f"{file_path.stem}.{name}"
symbols.append({
'qualified_name': qualified_name,
'name': name,
'kind': kind,
'file_path': str(file_path),
'start_line': line_num,
'end_line': line_num, # Simplified - would need proper parsing for actual end
})
current_scope = name
# Extract imports
if 'import' in patterns:
match = re.search(patterns['import'], line)
if match:
import_target = match.group(1) or match.group(2) if match.lastindex >= 2 else match.group(1)
if import_target and current_scope:
relationships.append({
'source_scope': current_scope,
'target': import_target.strip(),
'type': 'imports',
'file_path': str(file_path),
'line': line_num,
})
# Extract function calls (simplified)
if 'call' in patterns and current_scope:
for match in re.finditer(patterns['call'], line):
call_name = match.group(1)
# Skip common keywords and the current function
if call_name not in ['if', 'for', 'while', 'return', 'print', 'len', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', current_scope]:
relationships.append({
'source_scope': current_scope,
'target': call_name,
'type': 'calls',
'file_path': str(file_path),
'line': line_num,
})
return symbols, relationships
def save_symbols(self, symbols: List[Dict]) -> Dict[str, int]:
"""Save symbols to database and return name->id mapping.
Args:
symbols: List of symbol dicts with qualified_name, name, kind, file_path, start_line, end_line
Returns:
Dictionary mapping symbol name to database id
"""
if not self.db_conn or not symbols:
return {}
cursor = self.db_conn.cursor()
name_to_id = {}
for sym in symbols:
try:
cursor.execute('''
INSERT OR IGNORE INTO symbols
(qualified_name, name, kind, file_path, start_line, end_line)
VALUES (?, ?, ?, ?, ?, ?)
''', (sym['qualified_name'], sym['name'], sym['kind'],
sym['file_path'], sym['start_line'], sym['end_line']))
# Get the id
cursor.execute('''
SELECT id FROM symbols
WHERE file_path = ? AND name = ? AND start_line = ?
''', (sym['file_path'], sym['name'], sym['start_line']))
row = cursor.fetchone()
if row:
name_to_id[sym['name']] = row[0]
except sqlite3.Error:
continue
self.db_conn.commit()
return name_to_id
def save_relationships(self, relationships: List[Dict], name_to_id: Dict[str, int]) -> None:
"""Save relationships to database.
Args:
relationships: List of relationship dicts with source_scope, target, type, file_path, line
name_to_id: Dictionary mapping symbol names to database ids
"""
if not self.db_conn or not relationships:
return
cursor = self.db_conn.cursor()
for rel in relationships:
source_id = name_to_id.get(rel['source_scope'])
if source_id:
try:
cursor.execute('''
INSERT INTO symbol_relationships
(source_symbol_id, target_symbol_fqn, relationship_type, file_path, line)
VALUES (?, ?, ?, ?, ?)
''', (source_id, rel['target'], rel['type'], rel['file_path'], rel['line']))
except sqlite3.Error:
continue
self.db_conn.commit()
def close(self) -> None:
"""Close database connection."""
if self.db_conn:
self.db_conn.close()
self.db_conn = None

View File

@@ -0,0 +1,150 @@
# codex-lens/src/codexlens/search/enrichment.py
"""Relationship enrichment for search results."""
import sqlite3
from pathlib import Path
from typing import List, Dict, Any, Optional
class RelationshipEnricher:
"""Enriches search results with code graph relationships."""
def __init__(self, index_path: Path):
"""Initialize with path to index database.
Args:
index_path: Path to _index.db SQLite database
"""
self.index_path = index_path
self.db_conn: Optional[sqlite3.Connection] = None
self._connect()
def _connect(self) -> None:
"""Establish read-only database connection."""
if self.index_path.exists():
self.db_conn = sqlite3.connect(
f"file:{self.index_path}?mode=ro",
uri=True,
check_same_thread=False
)
self.db_conn.row_factory = sqlite3.Row
def enrich(self, results: List[Dict[str, Any]], limit: int = 10) -> List[Dict[str, Any]]:
"""Add relationship data to search results.
Args:
results: List of search result dictionaries
limit: Maximum number of results to enrich
Returns:
Results with relationships field added
"""
if not self.db_conn:
return results
for result in results[:limit]:
file_path = result.get('file') or result.get('path')
symbol_name = result.get('symbol')
result['relationships'] = self._find_relationships(file_path, symbol_name)
return results
def _find_relationships(self, file_path: Optional[str], symbol_name: Optional[str]) -> List[Dict[str, Any]]:
"""Query relationships for a symbol.
Args:
file_path: Path to file containing the symbol
symbol_name: Name of the symbol
Returns:
List of relationship dictionaries with type, direction, target/source, file, line
"""
if not self.db_conn or not symbol_name:
return []
relationships = []
cursor = self.db_conn.cursor()
try:
# Find symbol ID(s) by name and optionally file
if file_path:
cursor.execute(
'SELECT id FROM symbols WHERE name = ? AND file_path = ?',
(symbol_name, file_path)
)
else:
cursor.execute('SELECT id FROM symbols WHERE name = ?', (symbol_name,))
symbol_ids = [row[0] for row in cursor.fetchall()]
if not symbol_ids:
return []
# Query outgoing relationships (symbol is source)
placeholders = ','.join('?' * len(symbol_ids))
cursor.execute(f'''
SELECT sr.relationship_type, sr.target_symbol_fqn, sr.file_path, sr.line
FROM symbol_relationships sr
WHERE sr.source_symbol_id IN ({placeholders})
''', symbol_ids)
for row in cursor.fetchall():
relationships.append({
'type': row[0],
'direction': 'outgoing',
'target': row[1],
'file': row[2],
'line': row[3],
})
# Query incoming relationships (symbol is target)
# Match against symbol name or qualified name patterns
cursor.execute('''
SELECT sr.relationship_type, s.name AS source_name, sr.file_path, sr.line
FROM symbol_relationships sr
JOIN symbols s ON sr.source_symbol_id = s.id
WHERE sr.target_symbol_fqn = ? OR sr.target_symbol_fqn LIKE ?
''', (symbol_name, f'%.{symbol_name}'))
for row in cursor.fetchall():
rel_type = row[0]
# Convert to incoming type
incoming_type = self._to_incoming_type(rel_type)
relationships.append({
'type': incoming_type,
'direction': 'incoming',
'source': row[1],
'file': row[2],
'line': row[3],
})
except sqlite3.Error:
return []
return relationships
def _to_incoming_type(self, outgoing_type: str) -> str:
"""Convert outgoing relationship type to incoming type.
Args:
outgoing_type: The outgoing relationship type (e.g., 'calls', 'imports')
Returns:
Corresponding incoming type (e.g., 'called_by', 'imported_by')
"""
type_map = {
'calls': 'called_by',
'imports': 'imported_by',
'extends': 'extended_by',
}
return type_map.get(outgoing_type, f'{outgoing_type}_by')
def close(self) -> None:
"""Close database connection."""
if self.db_conn:
self.db_conn.close()
self.db_conn = None
def __enter__(self) -> 'RelationshipEnricher':
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()