Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,11 @@
**Tests:** целевые 103 passed; полный прогон 1651 passed / 5 skipped / 91 deselected (169.2s); ruff clean ×4 файла. Новые: TestMapConsistency (3), TestFallbackImports (4), гейт-тесты флага.
**Guard:** TestMapConsistency структурно ловит любое расхождение карт; ungated-путь закреплён негативными тестами (флаг off → пусто). Lazy-цикл parser⇄language_imports (статика) — осознанный техдолг в _ALLOWED_CORE_CYCLES (KNOWN_ISSUES 2026-09-08), деривация карты переведена на module __getattr__ (PEP 562), на import-time ничего не исполняется.
**verified_from_clean_state:** ⚠️ не проверено — чистый clone требует сети (нет в сессии); локально полный pytest зелёный.

## [2026-09-08 19:40] — collect() в Cypher: json_group_array + типизированный декод (fixed)

**Status:** ✅ Fixed. / **Root Cause:** KNOWN_ISSUES 2026-09-07 ⏳ — `_translate_return_expr` заявлял `collect` как Supported, но SQLite не имеет функции COLLECT («no such function»); ни одного теста на `RETURN collect(...)` не было.
**Fix:** `cypher_sql.py` — COLLECT(expr) → `json_group_array(<sql>) FILTER (WHERE <sql> IS NOT NULL)` (семантика Neo4j: null-игнор, пустой матч → []); `collect(*)` и вложенный obtain → явные ValueError; DISTINCT — SyntaxError из парсера (не наш уровень). Маркер `collect_cols` на трансляторе, в `cypher_executor.py` step5 декодится json.loads ТОЛЬКО помеченных колонок со str-значением (try/except → warning, не роняет весь результат).
**Tests:** 13 новых (SQL/E2E/errors incl. decode-collision `'["not_a_list"]'`); файл 93 passed; полный 1663 passed / 6 skipped / 91 deselected (168.6s). ruff clean, verify_diary 15/0. Эксперименты Г1/Г2 (sqlite 3.50.4, Python 3.14.3) — FILTER и empty→[] подтверждены сырым прогоном, см. .agent_task_state.md.
**Guard:** тест «collect() без алиаса → имя колонки = выражение», decode-collision guard (не-decode не-marked колонок), Red Team 5/5 (empty, null, unicode/quotes, DISTINCT, nested/*).
**verified_from_clean_state:** ⚠️ не проверено — чистый clone требует сети (нет в сессии); локально полный pytest 1663 passed green.
2 changes: 1 addition & 1 deletion KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
- **Описание (Cypher, fixed):** работают только запросы с типизированными узлами: `MATCH (n:Variable) RETURN count(n)` → 6435 (1.1ms). НО `MATCH ()-[e:ASSIGNED_FROM]->()` падал `sqlite3.OperationalError: no such column: e`, а `MATCH ()-[:ASSIGNED_FROM]->()` — `no such column: n0.id`. **Fix внесён:** cypher_sql.py — (1) `from_node_alias` резолвится в `n{path_idx*2}` для анонимного левого узла; (2) переменные ребра `[e:]` регистрируются в `edge_vars` и резолвятся в колонки (`e.type/source_id/target_id`), включён `count(e)`. 10 регресс-тестов (SQL + E2E) + 5 Red Team атак (направления `<-`, WHERE e.target_id, OPTIONAL MATCH, оба анонимных конца, collect) — все защищены, корректность результатов подтверждена (count=2 для 2 рёбер). **⚠️ collect() остаётся нерабочим**: `_translate_return_expr` заявляет `collect` как Supported (стр. 434-438 «Supported: count, sum, avg, min, max, collect»), но SQLite не имеет функции COLLECT (Red Team: `no such function: COLLECT`). Ни одного теста на `RETURN collect(...)` нет — заявка и реализация расходятся.
- **Описание (Receipts, fixed):** ActionReceipt компонент реализован (action_receipt.py, TD §11), но в проекте bfe9644b файла `action_receipts.jsonl` НЕТ — писались только в проектах 48baae8f/98d66cfa (19.08); `change_intents.jsonl` (96 записей) остаётся последней живой записью от 13.08. Receipt-путь для текущего проекта не срабатывал при повседневных MCP-вызовах (заполнялся только через lifecycle-tools reindex-путь).
- **Fix (Cypher):** внесён (см. выше, коммит 80a7acf8). **Fix (collect):** либо реализовать JSON-агрегацию `collect()` (json_group_array в SQLite), либо убрать из списка Supported и добавить негативный тест. **Fix (Receipts):** внесён — `_contract_record` в write_tools.py теперь вызывает новый `_contract_receipt()` (ActionReceipt рядом с ChangeIntent), а сам `_contract_record` добавлен во ВСЕ write-пути: replace, insert_before/after, rename (LSP workspace edit + fallback), safe_delete, move (source/target/refs). Receipt-запись warning-only, не ломает write. Тест `tests/test_write_tools.py::test_apply_records_action_receipt` (создание action_receipts.jsonl из реального write-вызова). Коммит см. git log.
- **Статус:** 🟢 Cypher-часть fixed; 🟢 receipts fixed; collect() open
- **Статус:** 🟢 Cypher-часть fixed; 🟢 receipts fixed; 🟢 collect() fixed (2026-09-08: json_group_array + FILTER null-игнор, decode только marked-колонок; 13 новых тестов, полный pytest 1663 passed)


## 2026-09-07 — Lazy-only верификация: память не проверяется без вызова агента; нет TTL/фона (open, эксперимент нужен)
Expand Down
3 changes: 3 additions & 0 deletions scripts/verify_diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
"from_pretrained",
"getdefaultlocale", "get_inputs", "get_objects", "getrusage",
"is_relative_to",
# SQLite aggregate — упоминается в дневнике как результат трансляции
# Cypher collect() → json_group_array, не символ проекта (2026-09-08).
"json_group_array",
"kill",
"optimize",
"reindexing", "rmtree", "run_in_executor",
Expand Down
19 changes: 17 additions & 2 deletions src/core/search/cypher_executor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Cypher query engine — компонент для подмножества openCypher."""
from __future__ import annotations

import json
import logging
from typing import Any, Dict, Optional

Expand Down Expand Up @@ -84,13 +85,27 @@ def execute(
cursor = conn.execute(sql, sql_params)
rows = cursor.fetchall()

# 5. Format results
# 5. Format results. collect()-колонки (json_group_array возвращает
# строку) декодируются из JSON только по маркеру транслятора —
# обычные строковые колонки, выглядящие как JSON, НЕ трогаются.
collect_cols = set(translator.collect_cols)
columns = [desc[0] for desc in cursor.description] if cursor.description else []
results = []
for row in rows:
result_row = {}
for i, col in enumerate(columns):
result_row[col] = row[i]
value = row[i]
if col in collect_cols and isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
# json_group_array всегда валиден, но guard:
# невалидная JSON-строка — не причина ронять запрос.
logger.warning(
f"Cypher collect column {col!r} is not valid JSON: "
f"{value[:80]!r}"
)
result_row[col] = value
results.append(result_row)

elapsed = (time.monotonic() - start) * 1000
Expand Down
35 changes: 35 additions & 0 deletions src/core/search/cypher_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class CypherToSQL:
def __init__(self, graph):
self._graph = graph
self._cte_counter = 0
# Имена колонок, которые собраны через collect() → будут JSON-докодированы
# в executor (json_group_array возвращает строку).
self.collect_cols: List[str] = []

def translate(self, query: Query) -> Tuple[str, List[Any]]:
"""Генерирует SQL из AST Cypher.
Expand Down Expand Up @@ -73,13 +76,23 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]:
# Фаза 3: RETURN
agg_columns = []
group_by = []
self.collect_cols = []

for item in query.return_items:
sql_col = self._translate_return_expr(item.expression, node_vars, edge_vars)
if self._is_aggregate(item.expression):
agg_columns.append(sql_col)
else:
group_by.append(sql_col)
if re.match(r"collect\(", item.expression, re.IGNORECASE):
# Маркер collect-колонки: executor JSON-декодирует только эти
# колонки (json_group_array возвращает строку). Имя колонки —
# алиас, иначе выражение (всегда есть "." внутри свойства),
# иначе SQL-имя.
self.collect_cols.append(
item.alias
or (item.expression if "." in item.expression else sql_col)
)
# Всегда используем AS для консистентности имён колонок
if item.alias:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item.alias):
Expand Down Expand Up @@ -443,6 +456,19 @@ def _translate_return_expr(
if agg_match:
func = agg_match.group(1).upper()
inner = agg_match.group(2)
if func == "COLLECT":
# collect(*) / вложенный вызов — явная ошибка вместо
# тихого невалидного SQL (json_group_array(*) / двойного call).
if inner == "*":
raise ValueError(
"collect(*) is not supported; use a property, "
"e.g. collect(n.name) or collect(n.label)"
)
if "(" in inner:
raise ValueError(
f"collect({inner}) is not supported; use a property, "
f"e.g. collect(n.name)"
)
if inner in node_vars:
# C2: агрегат над узлом-переменной. count(n) → COUNT(n.id)
# (считает не-NULL узлы — точная семантика Cypher; раньше
Expand All @@ -466,6 +492,15 @@ def _translate_return_expr(
f"use a property, e.g. {inner}.type"
)
sql_inner = self._property_ref_to_sql(inner, node_vars, edge_vars)
if func == "COLLECT":
# collect(expr) → json_group_array(expr). NULL-семантика Cypher:
# пустые значения не попадают в список (neo4j collect) →
# FILTER (WHERE IS NOT NULL). Возврат всегда JSON-строка,
# декодируется в executor по маркеру self.collect_cols.
return (
f"json_group_array({sql_inner}) "
f"FILTER (WHERE {sql_inner} IS NOT NULL)"
)
return f"{func}({sql_inner})"

# C4: неизвестная функция в RETURN — явная ошибка вместо невалидного SQL
Expand Down
102 changes: 102 additions & 0 deletions tests/test_cypher_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,33 @@ def test_edge_count_sql(self):
sql, params = self._translate("MATCH (a)-[e:USAGE]->(b) RETURN count(e)")
assert "COUNT(e.id)" in sql

def test_collect_property_sql(self):
"""collect(f.name) → json_group_array с NULL-фильтром, не алиасовый COLLECT."""
sql, params = self._translate("MATCH (f:Function) RETURN collect(f.name) AS names")
assert "json_group_array(f.name) FILTER (WHERE f.name IS NOT NULL)" in sql
assert "collect(" not in sql
assert params == ["Function"]

def test_collect_star_raises(self):
"""collect(*) — явная ошибка, а не тихий невалидный SQL."""
with pytest.raises(ValueError, match="collect\\(\\*\\) is not supported"):
self._translate("MATCH (f:Function) RETURN collect(*)")

def test_collect_nested_raises(self):
"""Вложенный collect(collect(...)) — явная ошибка."""
with pytest.raises(ValueError, match="is not supported; use a property"):
self._translate("MATCH (f:Function) RETURN collect(collect(f.name))")

def test_collect_distinct_raises(self):
"""collect(DISTINCT ...) — явная ошибка (парсер режет первой скобкой)."""
with pytest.raises((SyntaxError, ValueError)):
self._translate("MATCH (f:Function) RETURN collect(DISTINCT f.name)")

def test_collect_no_alias_sql(self):
"""collect(f.name) без алиаса — имя колонки = выражение (как у count)."""
sql, params = self._translate("MATCH (f:Function) RETURN collect(f.name)")
assert '"collect(f.name)"' in sql


# ════════════════════════════════════════════════════════════
# Phase 4: End-to-End Execution + OPTIONAL MATCH
Expand Down Expand Up @@ -468,6 +495,63 @@ def test_query_graph_function(self, executor, pg):
result = query_graph(pg, "MATCH (f:Function) RETURN count(*)")
assert result["results"][0]["count(*)"] == 5

def test_collect_list_e2e(self, executor):
"""collect() возвращает Python list (json_group_array декодируется)."""
result = executor.execute(
"MATCH (f:Function) RETURN collect(f.name) AS names"
)
assert "error" not in result
names = result["results"][0]["names"]
assert isinstance(names, list)
assert "main" in names
assert "parse" in names

def test_collect_empty_match_returns_empty_list(self, executor):
"""Пустой матч → [] (семантика Neo4j, не None)."""
result = executor.execute(
"MATCH (f:Function) WHERE f.name = 'NOPE' RETURN collect(f.name) AS x"
)
assert "error" not in result
assert result["results"][0]["x"] == []

def test_collect_grouped(self, executor):
"""collect() с группировкой по src-name."""
result = executor.execute(
"MATCH (a)-[:CALLS]->(b) RETURN a.name AS src, collect(b.name) AS targets"
)
assert "error" not in result
rows = result["results"]
by_src = {r["src"]: r["targets"] for r in rows}
assert by_src["main"] == ["parse", "validate"]
assert by_src["validate"] == ["log_error"]

def test_collect_edge_property_e2e(self, executor):
"""collect(e.type) — сбор значения свойства ребра."""
result = executor.execute(
"MATCH (a)-[e:CALLS]->(b) RETURN collect(e.type) AS types"
)
assert "error" not in result
types = result["results"][0]["types"]
assert isinstance(types, list)
assert types == ["CALLS"] * 4

def test_collect_non_collect_column_not_decoded(self, executor):
"""Guard-коллизия: обычная строка, похожая на JSON, НЕ декодируется."""
from src.core.graph import NodeLabel

executor._graph.add_node(
'["not_a_list"]', label=NodeLabel.FUNCTION,
qualified_name='["not_a_list"]', file_path='weird.py',
)
result = executor.execute(
"MATCH (f:Function) WHERE f.name = '[\"not_a_list\"]' "
"RETURN f.name AS raw, collect(f.name) AS col"
)
assert "error" not in result
row = result["results"][0]
assert row["raw"] == '["not_a_list"]' # НЕ декодируется
assert row["col"] == ['["not_a_list"]'] # декодируется как список


# ════════════════════════════════════════════════════════════
# Phase 5: Error Handling
Expand Down Expand Up @@ -535,6 +619,24 @@ def test_multiarg_function_returns_syntax_error(self, executor):
assert "error" in result
assert "Syntax error" in result["error"]

def test_collect_star_returns_error(self, executor):
"""collect(*) — понятная ошибка (не JSON, не невалидный SQL)."""
result = executor.execute("MATCH (a) RETURN collect(*)")
assert "error" in result
assert "collect(*) is not supported" in result["error"]

def test_collect_nested_returns_error(self, executor):
"""Вложенный collect(collect()) — понятная ошибка."""
result = executor.execute("MATCH (a) RETURN collect(collect(a.name))")
assert "error" in result
assert "is not supported; use a property" in result["error"]

def test_collect_distinct_returns_error(self, executor):
"""collect(DISTINCT ...) — понятная ошибка."""
result = executor.execute("MATCH (a) RETURN collect(DISTINCT a.name)")
assert "error" in result
assert "Syntax error" in result["error"]

def test_syntax_error_is_logged(self, executor, caplog):
"""C3: синтаксические ошибки пишутся в лог (раньше молча возвращались)."""
import logging
Expand Down
Loading