continuum-toolkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +12 -0
- cli/main.py +415 -0
- confidence/__init__.py +13 -0
- confidence/calculator.py +308 -0
- confidence/models.py +40 -0
- context/__init__.py +17 -0
- context/models.py +108 -0
- context/pruner.py +228 -0
- context/selector.py +193 -0
- continuum_toolkit-1.0.0.dist-info/METADATA +511 -0
- continuum_toolkit-1.0.0.dist-info/RECORD +72 -0
- continuum_toolkit-1.0.0.dist-info/WHEEL +5 -0
- continuum_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- continuum_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- continuum_toolkit-1.0.0.dist-info/top_level.txt +13 -0
- contradictions/__init__.py +19 -0
- contradictions/detector.py +442 -0
- contradictions/models.py +75 -0
- core/__init__.py +97 -0
- core/enums.py +130 -0
- core/evidence.py +117 -0
- core/interfaces.py +209 -0
- core/schema.py +391 -0
- core/serializer.py +84 -0
- core/state_models.py +530 -0
- daemon/__init__.py +12 -0
- daemon/service.py +170 -0
- extractors/__init__.py +51 -0
- extractors/base.py +117 -0
- extractors/config/parsers.py +288 -0
- extractors/config/secret_sanitizer.py +91 -0
- extractors/config_extractor.py +172 -0
- extractors/conversation/analyzers.py +193 -0
- extractors/conversation/models.py +148 -0
- extractors/conversation_extractor.py +166 -0
- extractors/git_extractor.py +305 -0
- extractors/parsers/base.py +91 -0
- extractors/parsers/comment_parser.py +51 -0
- extractors/parsers/js_ts_parser.py +171 -0
- extractors/parsers/python_parser.py +180 -0
- extractors/verification/runners.py +278 -0
- extractors/verification_extractor.py +263 -0
- extractors/workspace_extractor.py +221 -0
- graph/__init__.py +24 -0
- graph/diff.py +109 -0
- graph/manager.py +473 -0
- graph/models.py +62 -0
- graph/propagator.py +194 -0
- graph/query.py +86 -0
- graph/snapshot.py +85 -0
- handoff/__init__.py +28 -0
- handoff/adapters/__init__.py +45 -0
- handoff/adapters/base.py +90 -0
- handoff/adapters/claude_adapter.py +176 -0
- handoff/adapters/codex_gpt_adapter.py +151 -0
- handoff/adapters/gemini_adapter.py +151 -0
- handoff/adapters/local_model_adapter.py +130 -0
- handoff/models.py +88 -0
- handoff/packager.py +288 -0
- pipeline/__init__.py +9 -0
- pipeline/orchestrator.py +260 -0
- resolution/__init__.py +15 -0
- resolution/resolver.py +311 -0
- storage/__init__.py +18 -0
- storage/hooks.py +125 -0
- storage/manager.py +127 -0
- storage/models.py +39 -0
- storage/recovery.py +98 -0
- watcher/__init__.py +17 -0
- watcher/detector.py +136 -0
- watcher/models.py +62 -0
- watcher/updater.py +163 -0
storage/recovery.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Continuum - State Corruption Detection & Safe Auto-Recovery
|
|
3
|
+
===================================================================
|
|
4
|
+
Milestone 7 - Phase 17: Git Hooks & Persistent State Management.
|
|
5
|
+
Validates active state JSON integrity, detects schema or byte-level corruption,
|
|
6
|
+
and safely restores from the most recent valid history snapshot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from core.schema import validate_canonical_state_dict
|
|
13
|
+
from core.serializer import CanonicalStateSerializer, StateSerializationError, StateValidationError
|
|
14
|
+
from core.state_models import CanonicalProjectState
|
|
15
|
+
from storage.manager import ContinuumStorageManager
|
|
16
|
+
from storage.models import RecoveryResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StateRecoveryManager:
|
|
20
|
+
"""
|
|
21
|
+
Guarantees state recoverability:
|
|
22
|
+
1. Audits active state for corruption.
|
|
23
|
+
2. Recovers cleanly from the latest uncorrupted history snapshot if damaged.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, storage_manager: ContinuumStorageManager):
|
|
27
|
+
self._storage = storage_manager
|
|
28
|
+
|
|
29
|
+
def verify_active_state_integrity(self) -> Tuple[bool, Optional[str]]:
|
|
30
|
+
"""
|
|
31
|
+
Verifies if active state file is valid and compliant with schema.
|
|
32
|
+
Returns (is_valid, error_reason).
|
|
33
|
+
"""
|
|
34
|
+
if not self._storage.state_exists():
|
|
35
|
+
return False, "Active state file does not exist."
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
state = self._storage.load_state(validate=True)
|
|
39
|
+
return True, None
|
|
40
|
+
except (StateSerializationError, StateValidationError, Exception) as e:
|
|
41
|
+
return False, str(e)
|
|
42
|
+
|
|
43
|
+
def recover_corrupted_state(self) -> RecoveryResult:
|
|
44
|
+
"""
|
|
45
|
+
Attempts to recover from state corruption by finding the most recent
|
|
46
|
+
valid snapshot in .continuum/history/ and restoring it to state.json.
|
|
47
|
+
"""
|
|
48
|
+
is_valid, error_reason = self.verify_active_state_integrity()
|
|
49
|
+
if is_valid:
|
|
50
|
+
return RecoveryResult(
|
|
51
|
+
success=True,
|
|
52
|
+
recovered_from=str(self._storage.active_state_file),
|
|
53
|
+
state_restored=False,
|
|
54
|
+
details={"status": "Active state is healthy; no recovery needed."}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Active state is corrupted: inspect history snapshots
|
|
58
|
+
snapshots = self._storage.list_history_snapshots()
|
|
59
|
+
if not snapshots:
|
|
60
|
+
# Check backup directory
|
|
61
|
+
backup_file = self._storage.backup_dir / "state.json.bak"
|
|
62
|
+
if backup_file.exists():
|
|
63
|
+
try:
|
|
64
|
+
state = CanonicalStateSerializer.load_from_file(backup_file, validate=True)
|
|
65
|
+
self._storage.save_state(state, create_history_snapshot=False)
|
|
66
|
+
return RecoveryResult(
|
|
67
|
+
success=True,
|
|
68
|
+
recovered_from=str(backup_file),
|
|
69
|
+
state_restored=True,
|
|
70
|
+
details={"restored_from": "backup"}
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
return RecoveryResult(
|
|
76
|
+
success=False,
|
|
77
|
+
error_message=f"Active state corrupted ({error_reason}), and no valid history snapshots exist."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Search snapshots for the newest valid one
|
|
81
|
+
for snapshot_path in snapshots:
|
|
82
|
+
try:
|
|
83
|
+
state = CanonicalStateSerializer.load_from_file(snapshot_path, validate=True)
|
|
84
|
+
# Found valid snapshot! Restore as active state
|
|
85
|
+
self._storage.save_state(state, create_history_snapshot=False)
|
|
86
|
+
return RecoveryResult(
|
|
87
|
+
success=True,
|
|
88
|
+
recovered_from=str(snapshot_path),
|
|
89
|
+
state_restored=True,
|
|
90
|
+
details={"snapshot_name": snapshot_path.name, "corrupted_reason": error_reason}
|
|
91
|
+
)
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
return RecoveryResult(
|
|
96
|
+
success=False,
|
|
97
|
+
error_message=f"All {len(snapshots)} history snapshots were tested and none were valid."
|
|
98
|
+
)
|
watcher/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Continuum - Watcher Package Exports
|
|
3
|
+
===========================================
|
|
4
|
+
Milestone 7 - Phase 16: Filesystem Observation & Incremental State Updates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from watcher.models import ChangeType, FileChangeEvent, IncrementalUpdateResult
|
|
8
|
+
from watcher.detector import WorkspaceChangeDetector
|
|
9
|
+
from watcher.updater import IncrementalStateUpdater
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ChangeType",
|
|
13
|
+
"FileChangeEvent",
|
|
14
|
+
"IncrementalUpdateResult",
|
|
15
|
+
"WorkspaceChangeDetector",
|
|
16
|
+
"IncrementalStateUpdater",
|
|
17
|
+
]
|
watcher/detector.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Continuum - Workspace Change Detector
|
|
3
|
+
==============================================
|
|
4
|
+
Milestone 7 - Phase 16: Filesystem Observation & Incremental State Updates.
|
|
5
|
+
Computes filesystem delta sets (creations, modifications, deletions) against
|
|
6
|
+
a cached baseline snapshot with built-in ignore filtering.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
14
|
+
|
|
15
|
+
from extractors.base import DEFAULT_IGNORE_DIRS
|
|
16
|
+
from watcher.models import ChangeType, FileChangeEvent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkspaceChangeDetector:
|
|
20
|
+
"""
|
|
21
|
+
Tracks workspace file timestamps and SHA-256 hashes to detect modifications,
|
|
22
|
+
creations, and deletions with high efficiency.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
workspace_root: str,
|
|
28
|
+
ignore_dirs: Optional[Set[str]] = None,
|
|
29
|
+
max_file_size_mb: float = 10.0
|
|
30
|
+
):
|
|
31
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
32
|
+
self.ignore_dirs = ignore_dirs or DEFAULT_IGNORE_DIRS
|
|
33
|
+
self.max_file_size_bytes = int(max_file_size_mb * 1024 * 1024)
|
|
34
|
+
self._baseline_snapshot: Dict[str, Tuple[float, int, str]] = {} # rel_path -> (mtime, size, hash)
|
|
35
|
+
|
|
36
|
+
def compute_current_snapshot(self) -> Dict[str, Tuple[float, int, str]]:
|
|
37
|
+
"""
|
|
38
|
+
Walks the workspace and constructs a map of rel_path -> (mtime, size, sha256).
|
|
39
|
+
"""
|
|
40
|
+
snapshot: Dict[str, Tuple[float, int, str]] = {}
|
|
41
|
+
if not self.workspace_root.exists():
|
|
42
|
+
return snapshot
|
|
43
|
+
|
|
44
|
+
for root, dirs, files in os.walk(self.workspace_root):
|
|
45
|
+
# Prune ignored directories
|
|
46
|
+
dirs[:] = [d for d in dirs if d not in self.ignore_dirs and not d.startswith(".")]
|
|
47
|
+
|
|
48
|
+
for file in files:
|
|
49
|
+
full_path = Path(root) / file
|
|
50
|
+
try:
|
|
51
|
+
stat = full_path.stat()
|
|
52
|
+
if stat.st_size > self.max_file_size_bytes:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
rel_path = str(full_path.relative_to(self.workspace_root)).replace("\\", "/")
|
|
56
|
+
mtime = stat.st_mtime
|
|
57
|
+
size = stat.st_size
|
|
58
|
+
|
|
59
|
+
# Hash only if size or mtime differs from cached snapshot or on first pass
|
|
60
|
+
cached = self._baseline_snapshot.get(rel_path)
|
|
61
|
+
if cached and cached[0] == mtime and cached[1] == size:
|
|
62
|
+
f_hash = cached[2]
|
|
63
|
+
else:
|
|
64
|
+
f_hash = self._hash_file(full_path)
|
|
65
|
+
|
|
66
|
+
snapshot[rel_path] = (mtime, size, f_hash)
|
|
67
|
+
except (OSError, PermissionError):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
return snapshot
|
|
71
|
+
|
|
72
|
+
def initialize_baseline(self) -> int:
|
|
73
|
+
"""
|
|
74
|
+
Takes initial baseline snapshot of the workspace.
|
|
75
|
+
Returns number of tracked files.
|
|
76
|
+
"""
|
|
77
|
+
self._baseline_snapshot = self.compute_current_snapshot()
|
|
78
|
+
return len(self._baseline_snapshot)
|
|
79
|
+
|
|
80
|
+
def detect_changes(self) -> List[FileChangeEvent]:
|
|
81
|
+
"""
|
|
82
|
+
Compares current filesystem state against baseline snapshot and returns
|
|
83
|
+
the list of FileChangeEvents, updating the baseline in-place.
|
|
84
|
+
"""
|
|
85
|
+
current_snapshot = self.compute_current_snapshot()
|
|
86
|
+
changes: List[FileChangeEvent] = []
|
|
87
|
+
|
|
88
|
+
# 1. Check for creations and modifications
|
|
89
|
+
for rel_path, (mtime, size, f_hash) in current_snapshot.items():
|
|
90
|
+
if rel_path not in self._baseline_snapshot:
|
|
91
|
+
changes.append(
|
|
92
|
+
FileChangeEvent(
|
|
93
|
+
file_path=rel_path,
|
|
94
|
+
change_type=ChangeType.CREATED,
|
|
95
|
+
file_hash=f_hash,
|
|
96
|
+
file_size_bytes=size
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
base_mtime, base_size, base_hash = self._baseline_snapshot[rel_path]
|
|
101
|
+
if f_hash != base_hash:
|
|
102
|
+
changes.append(
|
|
103
|
+
FileChangeEvent(
|
|
104
|
+
file_path=rel_path,
|
|
105
|
+
change_type=ChangeType.MODIFIED,
|
|
106
|
+
file_hash=f_hash,
|
|
107
|
+
file_size_bytes=size
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# 2. Check for deletions
|
|
112
|
+
for rel_path, (base_mtime, base_size, base_hash) in self._baseline_snapshot.items():
|
|
113
|
+
if rel_path not in current_snapshot:
|
|
114
|
+
changes.append(
|
|
115
|
+
FileChangeEvent(
|
|
116
|
+
file_path=rel_path,
|
|
117
|
+
change_type=ChangeType.DELETED,
|
|
118
|
+
file_hash=base_hash,
|
|
119
|
+
file_size_bytes=base_size
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Update baseline to current snapshot
|
|
124
|
+
self._baseline_snapshot = current_snapshot
|
|
125
|
+
return changes
|
|
126
|
+
|
|
127
|
+
def _hash_file(self, path: Path) -> str:
|
|
128
|
+
"""Calculates SHA-256 hash of file content."""
|
|
129
|
+
hasher = hashlib.sha256()
|
|
130
|
+
try:
|
|
131
|
+
with open(path, "rb") as f:
|
|
132
|
+
while chunk := f.read(65536):
|
|
133
|
+
hasher.update(chunk)
|
|
134
|
+
return hasher.hexdigest()
|
|
135
|
+
except (OSError, PermissionError):
|
|
136
|
+
return ""
|
watcher/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Continuum - Filesystem Observation & Incremental State Models
|
|
3
|
+
======================================================================
|
|
4
|
+
Milestone 7 - Phase 16: Filesystem Observation & Incremental State Updates.
|
|
5
|
+
Defines change event structures and incremental update result containers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChangeType(str, Enum):
|
|
15
|
+
"""Types of filesystem change events."""
|
|
16
|
+
CREATED = "CREATED"
|
|
17
|
+
MODIFIED = "MODIFIED"
|
|
18
|
+
DELETED = "DELETED"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FileChangeEvent:
|
|
23
|
+
"""Represents a discrete filesystem modification event."""
|
|
24
|
+
file_path: str
|
|
25
|
+
change_type: ChangeType
|
|
26
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
27
|
+
file_hash: Optional[str] = None
|
|
28
|
+
file_size_bytes: int = 0
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
31
|
+
d = asdict(self)
|
|
32
|
+
d["change_type"] = self.change_type.value
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class IncrementalUpdateResult:
|
|
38
|
+
"""
|
|
39
|
+
Summary of an incremental state update cycle.
|
|
40
|
+
"""
|
|
41
|
+
workspace_root: str
|
|
42
|
+
changes_processed: List[FileChangeEvent] = field(default_factory=list)
|
|
43
|
+
re_extracted_symbols_count: int = 0
|
|
44
|
+
removed_symbols_count: int = 0
|
|
45
|
+
invalidated_node_ids: List[str] = field(default_factory=list)
|
|
46
|
+
propagated_stale_node_ids: List[str] = field(default_factory=list)
|
|
47
|
+
full_scan_fallback_triggered: bool = False
|
|
48
|
+
duration_ms: float = 0.0
|
|
49
|
+
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"workspace_root": self.workspace_root,
|
|
54
|
+
"changes_processed": [c.to_dict() for c in self.changes_processed],
|
|
55
|
+
"re_extracted_symbols_count": self.re_extracted_symbols_count,
|
|
56
|
+
"removed_symbols_count": self.removed_symbols_count,
|
|
57
|
+
"invalidated_node_ids": self.invalidated_node_ids,
|
|
58
|
+
"propagated_stale_node_ids": self.propagated_stale_node_ids,
|
|
59
|
+
"full_scan_fallback_triggered": self.full_scan_fallback_triggered,
|
|
60
|
+
"duration_ms": self.duration_ms,
|
|
61
|
+
"updated_at": self.updated_at
|
|
62
|
+
}
|
watcher/updater.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Continuum - Incremental State Updater
|
|
3
|
+
==============================================
|
|
4
|
+
Milestone 7 - Phase 16: Filesystem Observation & Incremental State Updates.
|
|
5
|
+
Applies detected workspace changes to CanonicalProjectState and the Canonical State Graph,
|
|
6
|
+
re-extracting only affected evidence and propagating invalidations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from core.enums import NodeType, RelationType, Status
|
|
15
|
+
from core.state_models import (
|
|
16
|
+
AstSymbol,
|
|
17
|
+
CanonicalProjectState,
|
|
18
|
+
GraphNode,
|
|
19
|
+
ProjectState,
|
|
20
|
+
)
|
|
21
|
+
from extractors.parsers.python_parser import PythonParser
|
|
22
|
+
from extractors.parsers.js_ts_parser import JavaScriptTypeScriptParser
|
|
23
|
+
from extractors.parsers.comment_parser import CommentMarkerParser
|
|
24
|
+
from graph.manager import StateGraphManager
|
|
25
|
+
from watcher.models import ChangeType, FileChangeEvent, IncrementalUpdateResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IncrementalStateUpdater:
|
|
29
|
+
"""
|
|
30
|
+
Applies fine-grained file changes to a CanonicalProjectState, updating
|
|
31
|
+
symbols, files, and propagating invalidations through the StateGraph.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
MAX_CHANGES_BEFORE_FALLBACK_FULL_SCAN = 50
|
|
35
|
+
|
|
36
|
+
def __init__(self, workspace_root: str):
|
|
37
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
38
|
+
self._python_parser = PythonParser()
|
|
39
|
+
self._js_ts_parser = JavaScriptTypeScriptParser()
|
|
40
|
+
|
|
41
|
+
def apply_changes(
|
|
42
|
+
self,
|
|
43
|
+
canonical_state: CanonicalProjectState,
|
|
44
|
+
changes: List[FileChangeEvent],
|
|
45
|
+
graph_manager: Optional[StateGraphManager] = None
|
|
46
|
+
) -> IncrementalUpdateResult:
|
|
47
|
+
"""
|
|
48
|
+
Updates canonical_state in-place based on the list of file change events.
|
|
49
|
+
"""
|
|
50
|
+
start_time = time.time()
|
|
51
|
+
p_state = canonical_state.project_state
|
|
52
|
+
|
|
53
|
+
# Fallback to full scan if too many simultaneous modifications occur
|
|
54
|
+
if len(changes) > self.MAX_CHANGES_BEFORE_FALLBACK_FULL_SCAN:
|
|
55
|
+
duration = (time.time() - start_time) * 1000.0
|
|
56
|
+
return IncrementalUpdateResult(
|
|
57
|
+
workspace_root=str(self.workspace_root),
|
|
58
|
+
changes_processed=changes,
|
|
59
|
+
full_scan_fallback_triggered=True,
|
|
60
|
+
duration_ms=duration
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
re_extracted_symbols = 0
|
|
64
|
+
removed_symbols = 0
|
|
65
|
+
invalidated_nodes: Set[str] = set()
|
|
66
|
+
propagated_stale_nodes: Set[str] = set()
|
|
67
|
+
|
|
68
|
+
for change in changes:
|
|
69
|
+
rel_path = change.file_path.replace("\\", "/")
|
|
70
|
+
full_path = self.workspace_root / rel_path
|
|
71
|
+
|
|
72
|
+
if change.change_type == ChangeType.DELETED:
|
|
73
|
+
# 1. Remove file from files list
|
|
74
|
+
if rel_path in p_state.files:
|
|
75
|
+
p_state.files.remove(rel_path)
|
|
76
|
+
|
|
77
|
+
# 2. Remove all symbols belonging to this file
|
|
78
|
+
old_sym_count = len(p_state.symbols)
|
|
79
|
+
p_state.symbols = [s for s in p_state.symbols if s.file_path != rel_path]
|
|
80
|
+
removed_symbols += (old_sym_count - len(p_state.symbols))
|
|
81
|
+
|
|
82
|
+
# 3. Find associated graph node and mark STALE
|
|
83
|
+
node_id = self._find_node_for_file(canonical_state, rel_path)
|
|
84
|
+
if node_id:
|
|
85
|
+
invalidated_nodes.add(node_id)
|
|
86
|
+
|
|
87
|
+
elif change.change_type in (ChangeType.CREATED, ChangeType.MODIFIED):
|
|
88
|
+
# 1. Ensure file is in files list
|
|
89
|
+
if rel_path not in p_state.files:
|
|
90
|
+
p_state.files.append(rel_path)
|
|
91
|
+
|
|
92
|
+
# 2. Remove previous symbols for this file
|
|
93
|
+
old_sym_count = len(p_state.symbols)
|
|
94
|
+
p_state.symbols = [s for s in p_state.symbols if s.file_path != rel_path]
|
|
95
|
+
removed_symbols += (old_sym_count - len(p_state.symbols))
|
|
96
|
+
|
|
97
|
+
# 3. Re-extract symbols from file
|
|
98
|
+
new_symbols = self._extract_file_symbols(full_path, rel_path)
|
|
99
|
+
p_state.symbols.extend(new_symbols)
|
|
100
|
+
re_extracted_symbols += len(new_symbols)
|
|
101
|
+
|
|
102
|
+
# 4. Find associated graph node
|
|
103
|
+
node_id = self._find_node_for_file(canonical_state, rel_path)
|
|
104
|
+
if node_id:
|
|
105
|
+
invalidated_nodes.add(node_id)
|
|
106
|
+
|
|
107
|
+
# Invalidate graph nodes and propagate STALE downstream if graph_manager provided
|
|
108
|
+
if graph_manager:
|
|
109
|
+
for n_id in invalidated_nodes:
|
|
110
|
+
if n_id in canonical_state.graph_nodes:
|
|
111
|
+
node = canonical_state.graph_nodes[n_id]
|
|
112
|
+
node.status = Status.STALE
|
|
113
|
+
affected = graph_manager.propagate_invalidation(n_id)
|
|
114
|
+
propagated_stale_nodes.update(affected)
|
|
115
|
+
|
|
116
|
+
canonical_state.updated_at = datetime.now(timezone.utc).isoformat()
|
|
117
|
+
p_state.last_scanned_at = datetime.now(timezone.utc).isoformat()
|
|
118
|
+
|
|
119
|
+
duration = (time.time() - start_time) * 1000.0
|
|
120
|
+
return IncrementalUpdateResult(
|
|
121
|
+
workspace_root=str(self.workspace_root),
|
|
122
|
+
changes_processed=changes,
|
|
123
|
+
re_extracted_symbols_count=re_extracted_symbols,
|
|
124
|
+
removed_symbols_count=removed_symbols,
|
|
125
|
+
invalidated_node_ids=sorted(list(invalidated_nodes)),
|
|
126
|
+
propagated_stale_node_ids=sorted(list(propagated_stale_nodes)),
|
|
127
|
+
full_scan_fallback_triggered=False,
|
|
128
|
+
duration_ms=duration
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _find_node_for_file(self, state: CanonicalProjectState, rel_path: str) -> Optional[str]:
|
|
132
|
+
"""Finds a graph node that directly references the given file."""
|
|
133
|
+
file_stem = Path(rel_path).stem.lower()
|
|
134
|
+
for node_id, node in state.graph_nodes.items():
|
|
135
|
+
if node_id.lower() == file_stem or node.name.lower() == file_stem:
|
|
136
|
+
return node_id
|
|
137
|
+
if node.metadata.get("file_path") == rel_path:
|
|
138
|
+
return node_id
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _extract_file_symbols(self, full_path: Path, rel_path: str) -> List[AstSymbol]:
|
|
142
|
+
"""Extracts AST symbols for a single file."""
|
|
143
|
+
if not full_path.exists():
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
ext = full_path.suffix.lower()
|
|
147
|
+
try:
|
|
148
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
149
|
+
except Exception:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
symbols: List[AstSymbol] = []
|
|
153
|
+
if ext == ".py":
|
|
154
|
+
res = self._python_parser.parse_source(str(full_path), content)
|
|
155
|
+
symbols.extend(res.symbols)
|
|
156
|
+
elif ext in (".js", ".ts", ".jsx", ".tsx"):
|
|
157
|
+
res = self._js_ts_parser.parse_source(str(full_path), content)
|
|
158
|
+
symbols.extend(res.symbols)
|
|
159
|
+
|
|
160
|
+
# Standardize relative paths
|
|
161
|
+
for s in symbols:
|
|
162
|
+
s.file_path = rel_path
|
|
163
|
+
return symbols
|