graph-mind-memory 0.1.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.
- associative_memory.py +507 -0
- automatic_capture.py +288 -0
- automatic_capture_cli.py +72 -0
- brain_log.py +501 -0
- coding_memory.py +438 -0
- concept_plan_benchmark_v071.py +248 -0
- conversation_memory.py +476 -0
- development_memory.py +450 -0
- development_paths.py +40 -0
- embedding_warmup.py +171 -0
- entity_timeline.py +130 -0
- executable_memory.py +312 -0
- executable_plan_v2_diagnostic.py +272 -0
- graph_mind_mcp_server.py +864 -0
- graph_mind_memory-0.1.0.dist-info/METADATA +252 -0
- graph_mind_memory-0.1.0.dist-info/RECORD +43 -0
- graph_mind_memory-0.1.0.dist-info/WHEEL +5 -0
- graph_mind_memory-0.1.0.dist-info/entry_points.txt +4 -0
- graph_mind_memory-0.1.0.dist-info/licenses/LICENSE +661 -0
- graph_mind_memory-0.1.0.dist-info/top_level.txt +37 -0
- grounded_concept_bridge_v072.py +511 -0
- ingestion_gate.py +113 -0
- ingestion_synonyms.py +165 -0
- install.py +198 -0
- llm_concept_planner.py +170 -0
- local_brain.py +354 -0
- longmemeval_adapter.py +120 -0
- longmemeval_ingestion_calibration.py +366 -0
- longmemeval_ingestion_preflight.py +188 -0
- longmemeval_ingestion_v7.py +356 -0
- memory_algebra.py +155 -0
- memory_contract.py +154 -0
- memory_normalization.py +13 -0
- memory_vocabulary.py +34 -0
- semantic_grounding_v073.py +501 -0
- semantic_llm_extractor.py +215 -0
- semantic_memory.py +155 -0
- semantic_recall.py +100 -0
- state_relation_contract.py +158 -0
- temporal_resolver.py +272 -0
- temporal_state.py +210 -0
- universal_personal_memory.py +234 -0
- vector_cache.py +194 -0
coding_memory.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Indexed coding activity memory with source-bound diffs and symbols."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from development_paths import use_wal
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import ast
|
|
9
|
+
import difflib
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
|
|
16
|
+
from temporal_state import timestamp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CAPTURE_VERSION = "coding-memory-v0.1"
|
|
20
|
+
DEFAULT_SUFFIXES = frozenset({
|
|
21
|
+
".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".kt", ".swift",
|
|
22
|
+
".go", ".rs", ".cs", ".c", ".cc", ".cpp", ".h", ".hpp", ".sql",
|
|
23
|
+
".toml", ".yaml", ".yml",
|
|
24
|
+
})
|
|
25
|
+
DEFAULT_EXCLUDED_PARTS = frozenset({
|
|
26
|
+
".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__",
|
|
27
|
+
"external", "runs",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _json(value):
|
|
32
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
33
|
+
separators=(",", ":"), allow_nan=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _digest(value):
|
|
37
|
+
return hashlib.sha256(value).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text(value, error):
|
|
41
|
+
if not isinstance(value, str) or not value.strip():
|
|
42
|
+
raise ValueError(error)
|
|
43
|
+
return value.strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _string_list(value, error):
|
|
47
|
+
if value is None:
|
|
48
|
+
return []
|
|
49
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip()
|
|
50
|
+
for item in value):
|
|
51
|
+
raise ValueError(error)
|
|
52
|
+
return list(dict.fromkeys(item.strip() for item in value))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _decode_source(value):
|
|
56
|
+
try:
|
|
57
|
+
return value.decode("utf-8-sig", "strict")
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
return value.decode("utf-8", "replace")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _python_symbols(text):
|
|
63
|
+
try:
|
|
64
|
+
tree = ast.parse(text)
|
|
65
|
+
except (SyntaxError, ValueError):
|
|
66
|
+
return []
|
|
67
|
+
result = []
|
|
68
|
+
|
|
69
|
+
def visit(body, prefix=""):
|
|
70
|
+
for node in body:
|
|
71
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
72
|
+
name = f"{prefix}.{node.name}" if prefix else node.name
|
|
73
|
+
kind = "CLASS" if isinstance(node, ast.ClassDef) else "FUNCTION"
|
|
74
|
+
result.append({"name": name, "kind": kind,
|
|
75
|
+
"start_line": node.lineno,
|
|
76
|
+
"end_line": getattr(node, "end_lineno", node.lineno)})
|
|
77
|
+
visit(getattr(node, "body", []), name)
|
|
78
|
+
visit(tree.body)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _changed_lines(before, after):
|
|
83
|
+
old_lines, new_lines = before.splitlines(), after.splitlines()
|
|
84
|
+
old_changed, new_changed = set(), set()
|
|
85
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(
|
|
86
|
+
None, old_lines, new_lines, autojunk=False).get_opcodes():
|
|
87
|
+
if tag == "equal":
|
|
88
|
+
continue
|
|
89
|
+
old_changed.update(range(i1 + 1, i2 + 1))
|
|
90
|
+
new_changed.update(range(j1 + 1, j2 + 1))
|
|
91
|
+
return old_changed, new_changed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _affected_symbols(path, before, after):
|
|
95
|
+
if Path(path).suffix.lower() != ".py":
|
|
96
|
+
return []
|
|
97
|
+
old_changed, new_changed = _changed_lines(before, after)
|
|
98
|
+
found = {}
|
|
99
|
+
for side, text, changed in (("BEFORE", before, old_changed),
|
|
100
|
+
("AFTER", after, new_changed)):
|
|
101
|
+
for symbol in _python_symbols(text):
|
|
102
|
+
if any(symbol["start_line"] <= line <= symbol["end_line"] for line in changed):
|
|
103
|
+
found[(side, symbol["name"])] = {**symbol, "side": side}
|
|
104
|
+
if not found and (old_changed or new_changed):
|
|
105
|
+
return [{"name": "<module>", "kind": "MODULE", "start_line": 1,
|
|
106
|
+
"end_line": max(len(after.splitlines()), 1), "side": "AFTER"}]
|
|
107
|
+
return list(found.values())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CodingMemoryStore:
|
|
111
|
+
"""Indexed structured code-change events; query never reads the workspace."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, path, *, clock="SOURCE_LOCAL"):
|
|
114
|
+
self.path = Path(path)
|
|
115
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
self.clock = clock
|
|
117
|
+
# Claude, Codex and the capture service each open this file from their own process.
|
|
118
|
+
# WAL lets them read while one writes; the timeout makes a writer wait its turn
|
|
119
|
+
# instead of failing with "database is locked".
|
|
120
|
+
self.db = sqlite3.connect(str(self.path), timeout=30)
|
|
121
|
+
use_wal(self.db)
|
|
122
|
+
self.db.row_factory = sqlite3.Row
|
|
123
|
+
self.db.execute("PRAGMA foreign_keys=ON")
|
|
124
|
+
self.db.executescript("""
|
|
125
|
+
CREATE TABLE IF NOT EXISTS coding_changes(
|
|
126
|
+
revision INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
127
|
+
change_id TEXT NOT NULL UNIQUE,
|
|
128
|
+
project_id TEXT NOT NULL,
|
|
129
|
+
session_id TEXT NOT NULL,
|
|
130
|
+
workspace TEXT NOT NULL,
|
|
131
|
+
happened_at TEXT NOT NULL,
|
|
132
|
+
known_at TEXT NOT NULL,
|
|
133
|
+
change_kind TEXT NOT NULL,
|
|
134
|
+
file_path TEXT NOT NULL,
|
|
135
|
+
language TEXT NOT NULL,
|
|
136
|
+
before_sha256 TEXT,
|
|
137
|
+
after_sha256 TEXT,
|
|
138
|
+
reason TEXT NOT NULL,
|
|
139
|
+
related_tests_json TEXT NOT NULL,
|
|
140
|
+
diff_ref TEXT NOT NULL,
|
|
141
|
+
before_ref TEXT,
|
|
142
|
+
after_ref TEXT,
|
|
143
|
+
provenance_json TEXT NOT NULL,
|
|
144
|
+
fingerprint TEXT NOT NULL);
|
|
145
|
+
CREATE TABLE IF NOT EXISTS coding_change_symbols(
|
|
146
|
+
change_id TEXT NOT NULL,
|
|
147
|
+
symbol_name TEXT NOT NULL,
|
|
148
|
+
symbol_kind TEXT NOT NULL,
|
|
149
|
+
side TEXT NOT NULL,
|
|
150
|
+
start_line INTEGER NOT NULL,
|
|
151
|
+
end_line INTEGER NOT NULL,
|
|
152
|
+
PRIMARY KEY(change_id,symbol_name,side),
|
|
153
|
+
FOREIGN KEY(change_id) REFERENCES coding_changes(change_id));
|
|
154
|
+
CREATE INDEX IF NOT EXISTS coding_project_time
|
|
155
|
+
ON coding_changes(project_id,happened_at DESC);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS coding_file_time
|
|
157
|
+
ON coding_changes(file_path,happened_at DESC);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS coding_symbol_name
|
|
159
|
+
ON coding_change_symbols(symbol_name,change_id);
|
|
160
|
+
""")
|
|
161
|
+
self.closed = False
|
|
162
|
+
|
|
163
|
+
def __enter__(self):
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *args):
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
def close(self):
|
|
170
|
+
if not self.closed:
|
|
171
|
+
self.closed = True
|
|
172
|
+
self.db.close()
|
|
173
|
+
|
|
174
|
+
def _time(self, value):
|
|
175
|
+
return timestamp(value, self.clock).isoformat(timespec="microseconds")
|
|
176
|
+
|
|
177
|
+
def _query_time(self, value, *, end=False):
|
|
178
|
+
if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
|
179
|
+
value += "T23:59:59.999999" if end else "T00:00:00"
|
|
180
|
+
if isinstance(value, str) and self.clock == "SOURCE_LOCAL":
|
|
181
|
+
try:
|
|
182
|
+
parsed = datetime.fromisoformat(value)
|
|
183
|
+
except ValueError:
|
|
184
|
+
pass
|
|
185
|
+
else:
|
|
186
|
+
if parsed.utcoffset() is not None:
|
|
187
|
+
value = parsed.replace(tzinfo=None).isoformat(timespec="microseconds")
|
|
188
|
+
return self._time(value)
|
|
189
|
+
|
|
190
|
+
def record(self, change):
|
|
191
|
+
required = ("change_id", "project_id", "session_id", "workspace",
|
|
192
|
+
"change_kind", "file_path", "language", "reason", "diff_ref")
|
|
193
|
+
normalized = {key: _text(change.get(key), key.upper() + "_REQUIRED")
|
|
194
|
+
for key in required}
|
|
195
|
+
normalized["happened_at"] = self._time(change.get("happened_at"))
|
|
196
|
+
normalized["known_at"] = self._time(change.get("known_at"))
|
|
197
|
+
if normalized["happened_at"] > normalized["known_at"]:
|
|
198
|
+
raise ValueError("CODE_CHANGE_AFTER_RECORDING")
|
|
199
|
+
normalized["before_sha256"] = change.get("before_sha256")
|
|
200
|
+
normalized["after_sha256"] = change.get("after_sha256")
|
|
201
|
+
normalized["before_ref"] = change.get("before_ref")
|
|
202
|
+
normalized["after_ref"] = change.get("after_ref")
|
|
203
|
+
normalized["related_tests"] = _string_list(
|
|
204
|
+
change.get("related_tests"), "RELATED_TESTS_LIST_REQUIRED")
|
|
205
|
+
symbols = change.get("symbols", [])
|
|
206
|
+
if not isinstance(symbols, list):
|
|
207
|
+
raise ValueError("CODE_SYMBOLS_LIST_REQUIRED")
|
|
208
|
+
provenance = change.get("provenance")
|
|
209
|
+
if not isinstance(provenance, dict):
|
|
210
|
+
raise ValueError("CODE_CHANGE_PROVENANCE_REQUIRED")
|
|
211
|
+
normalized["provenance"] = {
|
|
212
|
+
"source_type": _text(provenance.get("source_type"), "SOURCE_TYPE_REQUIRED"),
|
|
213
|
+
"source_ref": _text(provenance.get("source_ref"), "SOURCE_REF_REQUIRED"),
|
|
214
|
+
}
|
|
215
|
+
fingerprint = _digest(_json({**normalized, "symbols": symbols}).encode("utf-8"))
|
|
216
|
+
existing = self.db.execute(
|
|
217
|
+
"SELECT revision,fingerprint FROM coding_changes WHERE change_id=?",
|
|
218
|
+
(normalized["change_id"],)).fetchone()
|
|
219
|
+
if existing:
|
|
220
|
+
if existing["fingerprint"] != fingerprint:
|
|
221
|
+
raise ValueError("CODE_CHANGE_ID_COLLISION")
|
|
222
|
+
return {"change_id": normalized["change_id"], "revision": existing["revision"],
|
|
223
|
+
"disposition": "ALREADY_RECORDED"}
|
|
224
|
+
with self.db:
|
|
225
|
+
cursor = self.db.execute("""INSERT INTO coding_changes(
|
|
226
|
+
change_id,project_id,session_id,workspace,happened_at,known_at,
|
|
227
|
+
change_kind,file_path,language,before_sha256,after_sha256,reason,
|
|
228
|
+
related_tests_json,diff_ref,before_ref,after_ref,provenance_json,fingerprint)
|
|
229
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
|
230
|
+
normalized["change_id"], normalized["project_id"],
|
|
231
|
+
normalized["session_id"], normalized["workspace"],
|
|
232
|
+
normalized["happened_at"], normalized["known_at"],
|
|
233
|
+
normalized["change_kind"], normalized["file_path"],
|
|
234
|
+
normalized["language"], normalized["before_sha256"],
|
|
235
|
+
normalized["after_sha256"], normalized["reason"],
|
|
236
|
+
_json(normalized["related_tests"]), normalized["diff_ref"],
|
|
237
|
+
normalized["before_ref"], normalized["after_ref"],
|
|
238
|
+
_json(normalized["provenance"]), fingerprint))
|
|
239
|
+
for symbol in symbols:
|
|
240
|
+
self.db.execute("""INSERT INTO coding_change_symbols VALUES(?,?,?,?,?,?)""", (
|
|
241
|
+
normalized["change_id"], _text(symbol.get("name"), "SYMBOL_NAME_REQUIRED"),
|
|
242
|
+
_text(symbol.get("kind"), "SYMBOL_KIND_REQUIRED"),
|
|
243
|
+
_text(symbol.get("side"), "SYMBOL_SIDE_REQUIRED"),
|
|
244
|
+
int(symbol.get("start_line")), int(symbol.get("end_line"))))
|
|
245
|
+
return {"change_id": normalized["change_id"], "revision": cursor.lastrowid,
|
|
246
|
+
"disposition": "RECORDED"}
|
|
247
|
+
|
|
248
|
+
def query(self, *, project_id=None, happened_from=None, happened_to=None,
|
|
249
|
+
file_path=None, symbol=None, change_kind=None, limit=20,
|
|
250
|
+
include_diff_excerpt=True, max_diff_chars=6000):
|
|
251
|
+
if type(limit) is not int or not 1 <= limit <= 100:
|
|
252
|
+
raise ValueError("INVALID_CODE_QUERY_LIMIT")
|
|
253
|
+
if type(max_diff_chars) is not int or not 200 <= max_diff_chars <= 20000:
|
|
254
|
+
raise ValueError("INVALID_DIFF_EXCERPT_LIMIT")
|
|
255
|
+
where, parameters = [], []
|
|
256
|
+
if project_id:
|
|
257
|
+
where.append("c.project_id=?")
|
|
258
|
+
parameters.append(_text(project_id, "PROJECT_ID_REQUIRED"))
|
|
259
|
+
if happened_from:
|
|
260
|
+
where.append("c.happened_at>=?")
|
|
261
|
+
parameters.append(self._query_time(happened_from))
|
|
262
|
+
if happened_to:
|
|
263
|
+
where.append("c.happened_at<=?")
|
|
264
|
+
parameters.append(self._query_time(happened_to, end=True))
|
|
265
|
+
if file_path:
|
|
266
|
+
selected_path = _text(file_path, "FILE_PATH_REQUIRED").replace("\\", "/")
|
|
267
|
+
where.append("(c.file_path=? OR c.file_path LIKE ?)")
|
|
268
|
+
parameters.extend([selected_path, "%/" + selected_path])
|
|
269
|
+
if change_kind:
|
|
270
|
+
where.append("c.change_kind=?")
|
|
271
|
+
parameters.append(_text(change_kind, "CHANGE_KIND_REQUIRED").upper())
|
|
272
|
+
if symbol:
|
|
273
|
+
where.append("EXISTS(SELECT 1 FROM coding_change_symbols s "
|
|
274
|
+
"WHERE s.change_id=c.change_id AND s.symbol_name LIKE ?)")
|
|
275
|
+
parameters.append("%" + _text(symbol, "SYMBOL_REQUIRED") + "%")
|
|
276
|
+
clause = " WHERE " + " AND ".join(where) if where else ""
|
|
277
|
+
parameters.append(limit)
|
|
278
|
+
rows = self.db.execute("SELECT c.* FROM coding_changes c" + clause
|
|
279
|
+
+ " ORDER BY c.happened_at DESC,c.revision DESC LIMIT ?",
|
|
280
|
+
parameters).fetchall()
|
|
281
|
+
changes = []
|
|
282
|
+
for row in rows:
|
|
283
|
+
symbols = [dict(item) for item in self.db.execute("""SELECT symbol_name,symbol_kind,
|
|
284
|
+
side,start_line,end_line FROM coding_change_symbols WHERE change_id=?
|
|
285
|
+
ORDER BY symbol_name,side""", (row["change_id"],)).fetchall()]
|
|
286
|
+
diff_excerpt, diff_truncated = None, False
|
|
287
|
+
if include_diff_excerpt:
|
|
288
|
+
try:
|
|
289
|
+
full_diff = Path(row["diff_ref"]).read_text(encoding="utf-8")
|
|
290
|
+
diff_excerpt = full_diff[:max_diff_chars]
|
|
291
|
+
diff_truncated = len(full_diff) > max_diff_chars
|
|
292
|
+
except (OSError, UnicodeError):
|
|
293
|
+
diff_excerpt = None
|
|
294
|
+
changes.append({"change_id": row["change_id"], "revision": row["revision"],
|
|
295
|
+
"project_id": row["project_id"], "session_id": row["session_id"],
|
|
296
|
+
"happened_at": row["happened_at"], "change_kind": row["change_kind"],
|
|
297
|
+
"file_path": row["file_path"], "language": row["language"],
|
|
298
|
+
"reason": row["reason"],
|
|
299
|
+
"related_tests": json.loads(row["related_tests_json"]),
|
|
300
|
+
"symbols": symbols, "diff_ref": row["diff_ref"],
|
|
301
|
+
"diff_excerpt": diff_excerpt, "diff_truncated": diff_truncated,
|
|
302
|
+
"before_ref": row["before_ref"], "after_ref": row["after_ref"],
|
|
303
|
+
"provenance": json.loads(row["provenance_json"])})
|
|
304
|
+
return {"status": "KNOWN" if changes else "UNKNOWN",
|
|
305
|
+
"reason": "CODE_CHANGES_FOUND" if changes else "NO_MATCHING_CODE_CHANGE",
|
|
306
|
+
"scope_completeness": "UNATTESTED", "changes": changes}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class CodingMemoryCapture:
|
|
310
|
+
"""Create structured code-change events from content-addressed local snapshots."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, workspace, store, *, project_id, session_id, state_dir,
|
|
313
|
+
suffixes=DEFAULT_SUFFIXES, excluded_parts=DEFAULT_EXCLUDED_PARTS,
|
|
314
|
+
now=None):
|
|
315
|
+
self.workspace = Path(workspace).resolve()
|
|
316
|
+
if not self.workspace.is_dir():
|
|
317
|
+
raise ValueError("CODING_WORKSPACE_NOT_FOUND")
|
|
318
|
+
if not isinstance(store, CodingMemoryStore):
|
|
319
|
+
raise ValueError("CODING_MEMORY_STORE_REQUIRED")
|
|
320
|
+
self.store = store
|
|
321
|
+
self.project_id = _text(project_id, "PROJECT_ID_REQUIRED")
|
|
322
|
+
self.session_id = _text(session_id, "SESSION_ID_REQUIRED")
|
|
323
|
+
self.state_dir = Path(state_dir).resolve()
|
|
324
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
self.blob_dir = self.state_dir / "blobs"
|
|
326
|
+
self.diff_dir = self.state_dir / "diffs"
|
|
327
|
+
self.manifest_path = self.state_dir / "coding-manifest.json"
|
|
328
|
+
self.suffixes = frozenset(item.lower() for item in suffixes)
|
|
329
|
+
self.excluded_parts = frozenset(excluded_parts)
|
|
330
|
+
self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
|
|
331
|
+
|
|
332
|
+
def _included(self, path):
|
|
333
|
+
try:
|
|
334
|
+
relative = path.resolve().relative_to(self.workspace)
|
|
335
|
+
except (ValueError, OSError):
|
|
336
|
+
return False
|
|
337
|
+
return (path.is_file() and not path.is_symlink()
|
|
338
|
+
and path.suffix.lower() in self.suffixes
|
|
339
|
+
and not any(part in self.excluded_parts for part in relative.parts)
|
|
340
|
+
and self.state_dir not in path.resolve().parents)
|
|
341
|
+
|
|
342
|
+
def _blob(self, content):
|
|
343
|
+
digest = _digest(content)
|
|
344
|
+
target = self.blob_dir / digest[:2] / (digest + ".blob")
|
|
345
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
if not target.exists():
|
|
347
|
+
target.write_bytes(content)
|
|
348
|
+
return digest, str(target)
|
|
349
|
+
|
|
350
|
+
def snapshot(self):
|
|
351
|
+
files = {}
|
|
352
|
+
for path in self.workspace.rglob("*"):
|
|
353
|
+
if not self._included(path):
|
|
354
|
+
continue
|
|
355
|
+
content = path.read_bytes()
|
|
356
|
+
digest, blob_ref = self._blob(content)
|
|
357
|
+
relative = path.resolve().relative_to(self.workspace).as_posix()
|
|
358
|
+
files[relative] = {"sha256": digest, "size": len(content),
|
|
359
|
+
"blob_ref": blob_ref}
|
|
360
|
+
return files
|
|
361
|
+
|
|
362
|
+
def _manifest(self, files):
|
|
363
|
+
return {"capture_version": CAPTURE_VERSION, "project_id": self.project_id,
|
|
364
|
+
"workspace": str(self.workspace), "suffixes": sorted(self.suffixes),
|
|
365
|
+
"excluded_parts": sorted(self.excluded_parts), "files": files}
|
|
366
|
+
|
|
367
|
+
def _write_manifest(self, files):
|
|
368
|
+
temporary = self.manifest_path.with_suffix(".tmp")
|
|
369
|
+
temporary.write_text(json.dumps(self._manifest(files), ensure_ascii=False, indent=2)
|
|
370
|
+
+ "\n", encoding="utf-8")
|
|
371
|
+
os.replace(temporary, self.manifest_path)
|
|
372
|
+
|
|
373
|
+
def initialize_baseline(self):
|
|
374
|
+
files = self.snapshot()
|
|
375
|
+
self._write_manifest(files)
|
|
376
|
+
return {"status": "BASELINE_CREATED", "file_count": len(files),
|
|
377
|
+
"manifest": str(self.manifest_path)}
|
|
378
|
+
|
|
379
|
+
def _load_manifest(self):
|
|
380
|
+
if not self.manifest_path.is_file():
|
|
381
|
+
raise ValueError("CODING_BASELINE_REQUIRED")
|
|
382
|
+
value = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
|
383
|
+
if (value.get("capture_version") != CAPTURE_VERSION
|
|
384
|
+
or value.get("project_id") != self.project_id
|
|
385
|
+
or Path(value.get("workspace", "")).resolve() != self.workspace
|
|
386
|
+
or value.get("suffixes") != sorted(self.suffixes)
|
|
387
|
+
or value.get("excluded_parts") != sorted(self.excluded_parts)
|
|
388
|
+
or not isinstance(value.get("files"), dict)):
|
|
389
|
+
raise ValueError("CODING_MANIFEST_SCOPE_OR_VERSION_MISMATCH")
|
|
390
|
+
return value
|
|
391
|
+
|
|
392
|
+
def scan(self, *, reason, related_tests=None):
|
|
393
|
+
reason = _text(reason, "CODE_CHANGE_REASON_REQUIRED")
|
|
394
|
+
related_tests = _string_list(related_tests, "RELATED_TESTS_LIST_REQUIRED")
|
|
395
|
+
previous = self._load_manifest()["files"]
|
|
396
|
+
current = self.snapshot()
|
|
397
|
+
now = self.now()
|
|
398
|
+
recorded, changes = [], []
|
|
399
|
+
self.diff_dir.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
for path in sorted(set(previous) | set(current)):
|
|
401
|
+
before, after = previous.get(path), current.get(path)
|
|
402
|
+
if before and after and before["sha256"] == after["sha256"]:
|
|
403
|
+
continue
|
|
404
|
+
kind = "ADDED" if before is None else "DELETED" if after is None else "MODIFIED"
|
|
405
|
+
before_bytes = Path(before["blob_ref"]).read_bytes() if before else b""
|
|
406
|
+
after_bytes = Path(after["blob_ref"]).read_bytes() if after else b""
|
|
407
|
+
before_text, after_text = _decode_source(before_bytes), _decode_source(after_bytes)
|
|
408
|
+
symbols = _affected_symbols(path, before_text, after_text)
|
|
409
|
+
binding = {"project_id": self.project_id, "session_id": self.session_id,
|
|
410
|
+
"file_path": path, "before": before and before["sha256"],
|
|
411
|
+
"after": after and after["sha256"], "reason": reason}
|
|
412
|
+
change_id = "code-change-" + _digest(_json(binding).encode("utf-8"))
|
|
413
|
+
diff = "".join(difflib.unified_diff(
|
|
414
|
+
before_text.splitlines(True), after_text.splitlines(True),
|
|
415
|
+
fromfile="a/" + path, tofile="b/" + path, n=3))
|
|
416
|
+
diff_path = self.diff_dir / (change_id + ".patch")
|
|
417
|
+
diff_path.write_text(diff, encoding="utf-8")
|
|
418
|
+
change = {"change_id": change_id, "project_id": self.project_id,
|
|
419
|
+
"session_id": self.session_id, "workspace": str(self.workspace),
|
|
420
|
+
"happened_at": now, "known_at": now, "change_kind": kind,
|
|
421
|
+
"file_path": path, "language": Path(path).suffix.lower().lstrip(".") or "text",
|
|
422
|
+
"before_sha256": before and before["sha256"],
|
|
423
|
+
"after_sha256": after and after["sha256"], "reason": reason,
|
|
424
|
+
"related_tests": related_tests, "symbols": symbols,
|
|
425
|
+
"diff_ref": str(diff_path), "before_ref": before and before["blob_ref"],
|
|
426
|
+
"after_ref": after and after["blob_ref"],
|
|
427
|
+
"provenance": {"source_type": "workspace-snapshot-diff",
|
|
428
|
+
"source_ref": path}}
|
|
429
|
+
result = self.store.record(change)
|
|
430
|
+
recorded.append(result)
|
|
431
|
+
changes.append({"change_id": change_id, "change_kind": kind,
|
|
432
|
+
"file_path": path,
|
|
433
|
+
"symbols": list(dict.fromkeys(item["name"] for item in symbols)),
|
|
434
|
+
"diff_ref": str(diff_path)})
|
|
435
|
+
self._write_manifest(current)
|
|
436
|
+
return {"status": "CHANGES_RECORDED" if changes else "NO_CHANGES",
|
|
437
|
+
"change_count": len(changes), "changes": changes,
|
|
438
|
+
"record_results": recorded}
|