diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 7689451a..916027c8 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -31,7 +31,22 @@ --- -## [2026-09-03] - Carried exp-lab-2026-01 neuro-symbolic spike artifact into main +## [2026-09-07] — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона +**Status:** Open — зафиксировано как проблема + план эксперимента (10-continuous-verification.md) +**Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleScheduler включается только из record_tool_call(), VOR в idle не подключён, 2 из 3 idle-задач — заглушки (_improve_summaries_batch/_check_index_health — пустые тела). Живой срез текущего проекта: 42/136 узлов ACTIVE без verified_at/TTL висят с 2026-08-11; узлы без якорей → INCONCLUSIVE → VOR не пишет ничего → «проверено» = «кто-то когда-то вызвал». +**Fix (план эксперимента, не внесён):** H1 idle-ticker VOR с budget; H2 event-driven на HEAD (ключ hash(node_id+commit_sha) уже есть); H3 TTL-гниение INCONCLUSIVE → STALE. Baseline замера: полный прогон 136 узлов = 431.6ms (fingerprint 371.6ms) — дешевле порога. Контр-риски: false_retraction не выше 0.083%, цена при нагрузке. +**Guard:** новые «проверки» проектной памяти обязаны иметь точку вызова вне ручного чтения (idle/event/ttl) — иначе это снова lazy-by-hand. +**verified_from_clean_state:** ⚠️ не прогонялся (изменения только .md, live-данные из реального сервера PID 10036) + +--- +## [2026-09-07] — Cypher-движок: анонимные узлы/рёбра ломали MATCH; ActionReceipt не писался из write-пути +**Status:** Fixed (оба блока закрыты, тесты зелёные) +**Root Cause:** (1) Cypher: `from_node_alias` дефолтил в `n1`, а генератор создавал `n{path_idx*2}` для анонимного узла → `no such column: n0.id`; переменная ребра `[e:]` не регистрировалась → `no such column: e`. (2) Receipts: `_contract_record` (ChangeIntent) вызывался только в rename-fallback и safe_delete; replace/insert/move/workspace_edit писали файл напрямую → ни ChangeIntent, ни ActionReceipt. +**Fix:** (1) cypher_sql.py: alias левого узла резолвится в `n{path_idx*2}`, `edge_vars` + `edge_prop_map` (type/source_id/target_id → колонки, остальное → json_extract), `count(e)` → COUNT(e.id). 10 регресс-тестов + 5 Red Team атак. (2) write_tools.py: новый `_contract_receipt()` (build_receipt + ActionReceiptStore) вызывается из `_contract_record`; сам `_contract_record` добавлен во все write-пути (replace/insert/rename-LSP/move включая refs). Receipt-запись warning-only, не валит write. +1 тест (JSONL создаётся, verdict VERIFIED). +**Guard:** write-операция без ChangeIntent+ActionReceipt = дефект; правило «каждый write пишет оба артефакта». collect() остаётся open (сочтён отдельной записью KNOWN_ISSUES). +**verified_from_clean_state:** ⚠️ не прогонялся (изменения в 2 файлах, pytest tests/ 1629 passed + ruff clean) + +--- **Status:** Fixed (branch closed, artifact merged into main) **Root Cause:** experiment/lab-2026 branch (spike exp-lab-2026-01: NL->LLM->Cypher->parser+schema->PropertyGraph) was orphaned - its artifact experiments/neuro_symbolic_spike.py and EXPERIMENTS_LOG entry never landed on main. Findings C1-C4 were already fixed on main via D1 (CypherExecutor schema layer). **Fix:** Re-ran spike from clean main (VERDICT: HYPOTHESIS SUPPORTED, parse_ok=8, rejected_by_schema=2 - schema layer correctly rejects hallucinated :SERVICE label and cycle() empty-RETURN). Carried only the useful artifact (spike script + EXPERIMENTS_LOG entry 848fdf33), avoiding a blind merge that would have conflicted in 3 doc files. Deleted orphaned local branch experiment/lab-2026. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c393ef25..91f56b49 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -144,4 +144,3 @@ - **Fix:** пара добавлена в `_ALLOWED_CORE_CYCLES` (scripts/architecture_linter.py) с комментарием; `LANGUAGE_IMPORT_NODES` переведён на ленивую деривацию (кэш `_LANGUAGE_IMPORT_NODES_CACHE`, `__getattr__`), прямое обращение к карте внутри модуля заменено на `_get_language_import_nodes()`. Удалить из allowlist после выноса `IMPORT_NODE_MAP` в нейтральный модуль (не историю карт в parser) — тогда language_imports сможет импортировать parser односторонне. - **Статус:** ✅ Fixed (allowed tech debt, deferred refactor; целевые 68 passed, architecture_linter 4/4 OK) ->>>>>>> 86ef986d (feat(indexing): derive import maps from parser and gate fallback behind language pack flag) diff --git a/src/core/search/cypher_sql.py b/src/core/search/cypher_sql.py index 8d411a95..ae463ae5 100644 --- a/src/core/search/cypher_sql.py +++ b/src/core/search/cypher_sql.py @@ -39,6 +39,7 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: # Фаза 1: определяем все переменные узлов и их алиасы в SQL node_vars: Dict[str, str] = {} # переменная Cypher → SQL алиас + edge_vars: Dict[str, str] = {} # переменная ребра [e:] → SQL алиас path_joins: List[str] = [] path_where: List[str] = [] # WHERE условия из label/type фильтров path_where_params: List[Any] = [] # params для path_where (добавляются в конце) @@ -46,14 +47,14 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: select_cols: List[str] = [] for path_idx, path in enumerate(query.match.paths): - self._process_path_pattern(path, node_vars, path_joins, path_where, params, path_idx, path_where_params) + self._process_path_pattern(path, node_vars, edge_vars, path_joins, path_where, params, path_idx, path_where_params) # Фаза 1.5: OPTIONAL MATCH — LEFT JOIN opt_path_counter = len(query.match.paths) for opt_clause in query.optional_match: for opt_path in opt_clause.paths: self._process_path_pattern( - opt_path, node_vars, path_joins, path_where, params, + opt_path, node_vars, edge_vars, path_joins, path_where, params, opt_path_counter, path_where_params, join_type="LEFT JOIN", left_labels_in_on=True, ) @@ -67,14 +68,14 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: params.extend(path_where_params) if query.where: - self._process_where(query.where.expr, node_vars, where_clauses, params) + self._process_where(query.where.expr, node_vars, edge_vars, where_clauses, params) # Фаза 3: RETURN agg_columns = [] group_by = [] for item in query.return_items: - sql_col = self._translate_return_expr(item.expression, node_vars) + sql_col = self._translate_return_expr(item.expression, node_vars, edge_vars) if self._is_aggregate(item.expression): agg_columns.append(sql_col) else: @@ -101,7 +102,10 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: select_distinct = "DISTINCT " if query.return_distinct else "" # FROM — первый узел первого паттерна (target) - from_node_alias = node_vars.get(query.match.paths[0].left.variable or "n", "n1") + first_path = query.match.paths[0] + from_node_alias = node_vars.get( + first_path.left.variable or f"n{0 * 2}", "n1" + ) columns_sql = ", ".join(select_cols) joins_sql = "\n".join(path_joins) @@ -118,7 +122,7 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: if query.order_by: order_parts = [] for o in query.order_by: - col = self._translate_return_expr(o.expression, node_vars) + col = self._translate_return_expr(o.expression, node_vars, edge_vars) order_parts.append(f"{col} {o.direction}") order_sql = f"ORDER BY {', '.join(order_parts)}" @@ -147,6 +151,7 @@ def _process_path_pattern( self, path: PathPattern, node_vars: Dict[str, str], + edge_vars: Dict[str, str], joins: List[str], wheres: List[str], params: List[Any], @@ -193,7 +198,9 @@ def _process_path_pattern( node_vars[right_var] = right_var # Ребро - edge_alias = f"e{path_idx}" + edge_alias = path.rel.variable if path.rel.variable else f"e{path_idx}" + if path.rel.variable: + edge_vars[path.rel.variable] = edge_alias edge_on = "" # дополнительное условие для ON if path.rel.rel_types: @@ -265,12 +272,13 @@ def _process_where( self, expr: ASTNode, node_vars: Dict[str, str], + edge_vars: Dict[str, str], clauses: List[str], params: List[Any], ): """Рекурсивно обрабатывает WHERE.""" if isinstance(expr, Comparison): - sql_ref = self._property_ref_to_sql(expr.left, node_vars) + sql_ref = self._property_ref_to_sql(expr.left, node_vars, edge_vars) if expr.op in ("IN",): if isinstance(expr.right, list): @@ -322,8 +330,8 @@ def _process_where( elif isinstance(expr, _BinaryOp): left_clauses: List[str] = [] right_clauses: List[str] = [] - self._process_where(expr.left, node_vars, left_clauses, params) - self._process_where(expr.right, node_vars, right_clauses, params) + self._process_where(expr.left, node_vars, edge_vars, left_clauses, params) + self._process_where(expr.right, node_vars, edge_vars, right_clauses, params) all_clauses = left_clauses + right_clauses if expr.op == "OR": @@ -333,7 +341,7 @@ def _process_where( elif isinstance(expr, _UnaryOp): inner: List[str] = [] - self._process_where(expr.expr, node_vars, inner, params) + self._process_where(expr.expr, node_vars, edge_vars, inner, params) if expr.op == "NOT": clauses.append(f"NOT ({inner[0]})" if inner else "1=0") @@ -366,11 +374,32 @@ def _process_where( f"EXISTS (SELECT 1 FROM edges e WHERE e.source_id = {left_alias}.id {edge_filter})" ) - def _property_ref_to_sql(self, ref: str, node_vars: Dict[str, str]) -> str: + def _property_ref_to_sql( + self, ref: str, node_vars: Dict[str, str], edge_vars: Optional[Dict[str, str]] = None + ) -> str: """Переводит n.name или n.label в SQL: n_alias.name или n_alias.label.""" parts = ref.split(".") if len(parts) == 2: var, prop = parts + if edge_vars and var in edge_vars: + alias = edge_vars[var] + + # Validate property name - defense in depth against SQL injection + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", prop): + raise ValueError(f"Invalid property name: {prop}") + + # Специальные имена свойств ребра + edge_prop_map = { + "type": "type", + "source_id": "source_id", + "target_id": "target_id", + "id": "id", + } + if prop in edge_prop_map: + return f"{alias}.{edge_prop_map[prop]}" + # properties JSON path (e.g. e.weight) + return f"json_extract({alias}.properties, '$.{prop}')" + alias = node_vars.get(var, var) # Validate property name - defense in depth against SQL injection @@ -397,7 +426,9 @@ def _property_ref_to_sql(self, ref: str, node_vars: Dict[str, str]) -> str: return ref - def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str: + def _translate_return_expr( + self, expr: str, node_vars: Dict[str, str], edge_vars: Optional[Dict[str, str]] = None + ) -> str: """Переводит RETURN выражение в SQL.""" # count(*) if expr == "count(*)": @@ -425,7 +456,16 @@ def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str: f"Aggregate {func}({inner}) over node variable is not supported; " f"use a property, e.g. {inner}.name" ) - sql_inner = self._property_ref_to_sql(inner, node_vars) + if edge_vars and inner in edge_vars: + # count(e) / count(e.type) над ребром-переменной + alias = edge_vars[inner] + if func == "COUNT": + return f"COUNT({alias}.id)" + raise ValueError( + f"Aggregate {func}({inner}) over edge variable is not supported; " + f"use a property, e.g. {inner}.type" + ) + sql_inner = self._property_ref_to_sql(inner, node_vars, edge_vars) return f"{func}({sql_inner})" # C4: неизвестная функция в RETURN — явная ошибка вместо невалидного SQL @@ -438,7 +478,7 @@ def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str: ) # Простое свойство - return self._property_ref_to_sql(expr, node_vars) + return self._property_ref_to_sql(expr, node_vars, edge_vars) def _is_aggregate(self, expr: str) -> bool: return bool(re.match(r"(count|sum|avg|min|max|collect)\(", expr, re.IGNORECASE)) diff --git a/src/mcp/tools/write_tools.py b/src/mcp/tools/write_tools.py index 44b078e4..6c67387d 100644 --- a/src/mcp/tools/write_tools.py +++ b/src/mcp/tools/write_tools.py @@ -11,7 +11,7 @@ import logging import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from src.core.di_container import ServiceCollection from src.core.error_handler import error_boundary @@ -19,6 +19,9 @@ from src.core.modification_guard import modification_guard from src.mcp.tools.base import MCPTool +if TYPE_CHECKING: + from src.core.execution_contract import ChangeIntent + logger = logging.getLogger("mscodebase_server.write_tools") @@ -200,13 +203,58 @@ def _contract_record( ) intent.verified = bool(verify.get("verified")) ledger.record(intent) + self._contract_receipt(intent, verify.get("verified", False), base_commit) return verify ledger.record(intent) + self._contract_receipt(intent, True, base_commit) return {"verified": True, "recorded": True} except Exception as e: # noqa: BLE001 logger.warning(f"ChangeIntent record skipped for {file_path}: {e}") return {} + def _contract_receipt( + self, + intent: "ChangeIntent", + verified: bool, + base_commit: str, + ) -> None: + """WS4/§11: пишет ActionReceipt рядом с ChangeIntent'ом. + + Receipt — независимо проверяем артефакт записи (verdict + шаг). + Не ломает запись при сбое (warning-only), как и ChangeIntent. + """ + try: + from src.core.action_receipt import ActionReceiptStore, build_receipt + + project_root = self._contract_project_root() + claim = f"{intent.operation}: {intent.symbol or intent.file}" + results = [ + { + "action": "change_intent", + "verified": verified, + "file": intent.file, + "symbol": intent.symbol, + "base_commit": base_commit, + } + ] + receipt = build_receipt( + action_type=f"write:{intent.operation}", + results=results, + claim=claim, + before_hash=intent.before_hash, + after_hash=intent.after_hash, + file_path=intent.file, + workdir=project_root, + ) + store = ActionReceiptStore(project_root) + if store.record(receipt): + logger.info( + "ActionReceipt %s (%s) recorded → %s", + receipt.action_id, receipt.action_type, receipt.verdict, + ) + except Exception as e: # noqa: BLE001 + logger.warning("ActionReceipt запись пропущена (не влияет на write): %s", e) + @error_boundary("write", timeout_ms=30000) @modification_guard(pagerank_min=0.05, blast_min=10, ack_ttl=600.0) async def execute( @@ -503,6 +551,9 @@ async def _action_replace(self, **kw) -> str: new_lines_list = self._indent_new_lines( new_code, len(lines[start_idx]) - len(lines[start_idx].lstrip()) ) + before_hash = _sha256_text(content) + intended = "".join(lines[:start_idx] + new_lines_list + lines[end_idx:]) + after_hash = _sha256_text(intended) # P3-8 audit: синтаксис-валидация new_code перед записью (Python-файлы), # чтобы пользователь не получил сломанный файл без предупреждения. @@ -536,6 +587,15 @@ async def _action_replace(self, **kw) -> str: # Stale symbol cache — последующие get_symbol_info вернут устаревшие данные logger.debug(f"remove_file из symbol index не удался: {_si_err}") + self._contract_record( + "replace", + str(abs_path), + before_hash=before_hash, + after_hash=after_hash, + expected_hash=after_hash, + symbol=symbol, + ) + msg = f"✅ **Replaced** `{symbol}` in `{source_file}` ({len(original_lines)} → {len(new_lines_list)} lines)" if preflight_note: msg += f"\n\n⚠️ **Preflight:** {preflight_note}" @@ -600,6 +660,9 @@ async def _action_insert(self, position: str, **kw) -> str: return preview_msg new_lines = self._build_insert_lines(new_code, position, insert_at, lines) + before_hash = _sha256_text(content) + intended = "".join(lines[:insert_at] + new_lines + lines[insert_at:]) + after_hash = _sha256_text(intended) lines[insert_at:insert_at] = new_lines preflight = await self._preflight_validate( @@ -614,6 +677,14 @@ async def _action_insert(self, position: str, **kw) -> str: preflight_note = "" _atomic_write(abs_path, "".join(lines)) await self._invalidate_lsp_cache(source_file) + self._contract_record( + f"insert_{position}", + str(abs_path), + before_hash=before_hash, + after_hash=after_hash, + expected_hash=after_hash, + symbol=anchor_symbol, + ) msg = f"✅ **Inserted {position}** `{anchor_symbol}` in `{source_file}` (+{len(new_lines)} lines)" if preflight_note: msg += f"\n\n⚠️ **Preflight:** {preflight_note}" @@ -969,6 +1040,14 @@ async def _apply_workspace_edit(self, edit: dict, old_name: str, new_name: str) lines[start["line"]] = first[:start["character"]] + new_text del lines[start["line"] + 1:end["line"] + 1] _atomic_write(abs_path, "".join(lines)) + self._contract_record( + "rename", + str(abs_path), + before_hash=_sha256_text(content), + after_hash=_sha256_text("".join(lines)), + expected_hash=_sha256_text("".join(lines)), + symbol=old_name, + ) files_modified.append(file_path) await self._invalidate_lsp_cache(file_path) except Exception as e: @@ -1051,6 +1130,7 @@ async def _apply_move(self, symbol, source_file, target_file, all_refs, source_p try: src_path = Path(source_file).resolve() content = src_path.read_text(encoding="utf-8") + src_before = _sha256_text(content) lines = content.splitlines(True) si = self.resolve_symbol_index() defs = si.find_definitions(symbol) @@ -1066,12 +1146,30 @@ async def _apply_move(self, symbol, source_file, target_file, all_refs, source_p extracted.append(line) i += 1 del lines[def_line:i] + src_after = _sha256_text("".join(lines)) _atomic_write(src_path, "".join(lines)) modified.append(source_file) target_path = Path(target_file) target_path.parent.mkdir(parents=True, exist_ok=True) - _atomic_write(target_path, "".join(extracted)) + target_content = "".join(extracted) + _atomic_write(target_path, target_content) modified.append(target_file) + self._contract_record( + "move", + str(src_path), + before_hash=src_before, + after_hash=src_after, + expected_hash=src_after, + symbol=symbol, + ) + self._contract_record( + "move", + str(target_path), + before_hash="", + after_hash=_sha256_text(target_content), + expected_hash=_sha256_text(target_content), + symbol=symbol, + ) await self._invalidate_lsp_cache(source_file) await self._invalidate_lsp_cache(target_file) @@ -1080,10 +1178,20 @@ async def _apply_move(self, symbol, source_file, target_file, all_refs, source_p continue ref_path = Path(ref.file_path).resolve() if ref_path.exists(): - ref_content = ref_path.read_text(encoding="utf-8") - ref_content = ref_content.replace(f"from {source_package} import {symbol}", f"from {target_package} import {symbol}") + old_ref = ref_path.read_text(encoding="utf-8") + ref_before = _sha256_text(old_ref) + ref_content = old_ref.replace(f"from {source_package} import {symbol}", f"from {target_package} import {symbol}") + ref_after = _sha256_text(ref_content) _atomic_write(ref_path, ref_content) modified.append(ref.file_path) + self._contract_record( + "move", + str(ref_path), + before_hash=ref_before, + after_hash=ref_after, + expected_hash=ref_after, + symbol=symbol, + ) await self._invalidate_lsp_cache(ref.file_path) except Exception as e: errors.append(str(e)) diff --git a/tests/test_cypher_engine.py b/tests/test_cypher_engine.py index 24c3cf25..d0b3883a 100644 --- a/tests/test_cypher_engine.py +++ b/tests/test_cypher_engine.py @@ -323,6 +323,44 @@ def test_unsupported_function_raises(self): with pytest.raises(ValueError, match="Unsupported function"): self._translate("MATCH (a)-[:CALLS]->(b) RETURN cycle(a)") + def test_anonymous_node_from_alias(self): + """Регресс: MATCH ()-[:USAGE]->() должен генерировать FROM nodes AS n0 + (совпадающий с алиасом в JOIN), а не n1 → 'no such column: n0.id'.""" + sql, params = self._translate("MATCH ()-[:USAGE]->(m) RETURN m.name") + assert "FROM nodes AS n0" in sql + assert "JOIN edges AS e0" in sql + assert "USAGE" in params + + def test_edge_variable_registered(self): + """Регресс: [e:...] должен регистрировать переменную ребра e (не e0).""" + sql, params = self._translate("MATCH (a)-[e:USAGE]->(m) RETURN e.type") + assert "JOIN edges AS e" in sql + assert "e.type" in sql # резолв e.type → колонка типа, не json_extract + + def test_edge_variable_where(self): + """Регресс: WHERE e.type = ... по переменной ребра.""" + sql, params = self._translate( + "MATCH (a)-[e:USAGE]->(b) WHERE e.type = 'USAGE' RETURN a.name" + ) + assert "e.type = ?" in sql + assert "USAGE" in params + + def test_edge_variable_anonymous_rel_keeps_auto_alias(self): + """Анонимное ребро без переменной — прежний авто-алиас e0.""" + sql, params = self._translate("MATCH (a)-[:USAGE]->(b) RETURN a.name") + assert "JOIN edges AS e0" in sql + + def test_edge_variable_source_target(self): + """e.source_id / e.target_id — колонки ребра, не json_extract.""" + sql, params = self._translate("MATCH (a)-[e:USAGE]->(b) RETURN e.source_id, e.target_id") + assert "e.source_id" in sql + assert "e.target_id" in sql + + def test_edge_count_sql(self): + """count(e) над ребром → COUNT(e.id).""" + sql, params = self._translate("MATCH (a)-[e:USAGE]->(b) RETURN count(e)") + assert "COUNT(e.id)" in sql + # ════════════════════════════════════════════════════════════ # Phase 4: End-to-End Execution + OPTIONAL MATCH @@ -661,3 +699,38 @@ def test_node_properties_rejected(self, executor): """D1: properties в паттерне не поддерживаются — понятная ошибка, не тихий игнор.""" result = executor.execute("MATCH (n:Function {name: 'main'}) RETURN n.name") assert "error" in result + + def test_anonymous_node_e2e(self, executor): + """Регресс: MATCH ()-[e:USAGE]->(m) — анонимный левый узел не роняет SQL.""" + result = executor.execute( + "MATCH ()-[e:USAGE]->(m) RETURN m.name ORDER BY m.name" + ) + assert "error" not in result + names = [r["m.name"] for r in result["results"]] + # config <- parse, db_conn <- validate (USAGE направлены на переменные) + assert "config" in names + assert "db_conn" in names + + def test_edge_variable_return_e2e(self, executor): + """Регресс: [e:TYPE] и RETURN e.type — переменная ребра резолвится.""" + result = executor.execute( + "MATCH (a)-[e:CALLS]->(b) RETURN a.name, e.type ORDER BY a.name" + ) + assert "error" not in result + rows = result["results"] + assert rows + assert all(r["e.type"] == "CALLS" for r in rows) + + def test_edge_variable_where_e2e(self, executor): + """Регресс: WHERE e.type = ... — фильтр по типу ребра.""" + result = executor.execute( + "MATCH (a)-[e:CALLS]->(b) WHERE e.type = 'NONEXISTENT' RETURN a.name" + ) + assert "error" not in result + assert result["results"] == [] + + def test_count_edges_e2e(self, executor): + """Регресс: count(e) по рёбрам на живом графе.""" + result = executor.execute("MATCH (a)-[e:CALLS]->(b) RETURN count(e) AS n") + assert "error" not in result + assert result["results"][0]["n"] == 4 # 4 CALLS-рёбра в фикстуре diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index 1167fccd..adafa8f7 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -11,6 +11,7 @@ from __future__ import annotations +import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -651,6 +652,45 @@ async def test_apply_replaces_body(self, mock_services, tmp_path): # The old body should be gone assert "return 1" not in content.split("def old_func")[1].split("\n\ndef")[0] + @pytest.mark.asyncio + async def test_apply_records_action_receipt(self, mock_services, tmp_path): + """Apply on write-пути пишет ActionReceipt в системную папку (не только ChangeIntent).""" + py_file = tmp_path / "receipt_target.py" + py_file.write_text( + "def old_func():\n" + " return 1\n" + ) + si = _build_index_for_file(py_file, extra_defs=[ + {"name": "old_func", "line": 1, "kind": "function"}, + ], add_refs=False) + + tool = WriteTool(mock_services) + tool.require_ready_project = AsyncMock() + tool.resolve_symbol_index = MagicMock(return_value=si) + idx = _make_mock_indexer() + idx.project_path = str(tmp_path) + tool.resolve_indexer = MagicMock(return_value=idx) + + result = await tool._action_replace( + symbol="old_func", + new_code="def old_func():\n return 999\n", + file_path=str(py_file), + apply=True, + ) + assert "✅" in result or "Replaced" in result + + # Receipt должен появиться в /projects//action_receipts.jsonl + from src.core.artifact_paths import get_project_dir + receipts_file = get_project_dir(Path(tmp_path)) / "action_receipts.jsonl" + assert receipts_file.exists() + lines = [ln for ln in receipts_file.read_text(encoding="utf-8").splitlines() if ln.strip()] + assert lines, "action_receipts.jsonl пуст после write-операции" + last = json.loads(lines[-1]) + assert last["action_type"] == "write:replace" + assert last["claim"] + assert last["before_hash"] and last["after_hash"] + assert last["verdict"] == "VERIFIED" + @pytest.mark.asyncio async def test_filter_by_file_path(self, write_tool, temp_py_file, tmp_path): """file_path restricts replacement to a specific file."""