From 32011ffd0cdb1dabdbe53e5df1491055142dea8f Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 8 Sep 2026 15:40:32 +0300 Subject: [PATCH 1/2] fix(cypher): translate collect() to json_group_array with typed decode COLLECT(expr) now lowers to json_group_array(expr) FILTER (WHERE expr IS NOT NULL): Neo4j semantics (null-ignored, empty match -> []). collect(*) and nested collect raise explicit ValueError; DISTINCT is rejected by the parser layer. Translator marks collect columns (collect_cols); cypher_executor decodes json.loads only for marked string columns (collision-safe, warning-only on decode failure). 13 new SQL/E2E/error tests incl. decode-collision guard; full pytest 1663 passed / 6 skipped / 91 deselected (verified 5x incl. sanitized-env gate-zero replica); ruff clean; verify_diary 16/0; negative controls: stale_detector PROVEN. Red Team 5/5. Experiments G1/G2 raw-verified on sqlite 3.50.4. Refs KNOWN_ISSUES 2026-09-07 (collect open -> fixed). --- AGENT_DIARY.md | 8 +++ KNOWN_ISSUES.md | 2 +- scripts/verify_diary.py | 3 + src/core/search/cypher_executor.py | 19 +++++- src/core/search/cypher_sql.py | 35 ++++++++++ tests/test_cypher_engine.py | 102 +++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 3 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 45df08e1..8d9c785d 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -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() FILTER (WHERE 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. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 91f56b49..55d3208c 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -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, эксперимент нужен) diff --git a/scripts/verify_diary.py b/scripts/verify_diary.py index 85a5b8f0..df541be0 100644 --- a/scripts/verify_diary.py +++ b/scripts/verify_diary.py @@ -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", diff --git a/src/core/search/cypher_executor.py b/src/core/search/cypher_executor.py index f4ad345a..7a81a7f5 100644 --- a/src/core/search/cypher_executor.py +++ b/src/core/search/cypher_executor.py @@ -1,6 +1,7 @@ """Cypher query engine — компонент для подмножества openCypher.""" from __future__ import annotations +import json import logging from typing import Any, Dict, Optional @@ -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 diff --git a/src/core/search/cypher_sql.py b/src/core/search/cypher_sql.py index ae463ae5..92023d9d 100644 --- a/src/core/search/cypher_sql.py +++ b/src/core/search/cypher_sql.py @@ -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. @@ -73,6 +76,7 @@ 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) @@ -80,6 +84,15 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]: 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): @@ -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; раньше @@ -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 diff --git a/tests/test_cypher_engine.py b/tests/test_cypher_engine.py index d0b3883a..bd3232cf 100644 --- a/tests/test_cypher_engine.py +++ b/tests/test_cypher_engine.py @@ -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 @@ -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 @@ -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 From 7f227a6ddf6d5688d0a9a3ed66a90c5be5bbda7e Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 8 Sep 2026 18:01:12 +0300 Subject: [PATCH 2/2] chore(issues): dedup entries, add temperatures and deadlines Remove 3 duplicate issue blocks (full version kept), fix false entry counter (11 -> 14) after dedup, set yellow temperature + deadline + owner on open items (VOR lazy-only by 2026-09-15, B4 refactor by 2026-10-01). --- KNOWN_ISSUES.md | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 55d3208c..dbc01c9b 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -6,7 +6,7 @@ --- -**11 entries** — compressed per §4.8 R3 (conclusion-first) +**14 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08) ## 2026-09-02 20:51 — drift_gate заблокировал коммит: контроль остановил самого автора @@ -52,30 +52,6 @@ - **Fix:** Removed stray `\"\"\"` in same commit 986c9be7. - **Статус:** ✅ Fixed -## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (both fixed) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (commit 32f11662; 5 pre-commit hooks OK; full pytest 1587 passed, 2 pre-existing unrelated env_extractor failures) -**Root Cause 1 (ETA "~8s"):** `_enrich_job_response` had a dead h... -- **Статус:** автоматически синхронизировано - - -## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (architecture_linter) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (commit f210ed7c; CI all-jobs green on ubuntu+windows) -**Root Cause:** My ETA refactor added `tools_reg → layer` import for `_embed_progress_from_log`, closing an existing `layer →... -- **Статус:** автоматически синхронизировано - - -## 2026-09-04 11:15 — CI RED: ruff lint errors caught only after push (3 commits) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (commit 986c9be7) -**Root Cause:** Pre-commit hook did not run ruff. CI (`ruff check src/ tests/` in ci.yml) caught F401/W292 only after push, forcing fix-commits. Repeated 3 times ... -- **Статус:** автоматически синхронизировано - - ## 2026-09-05 — Process leak: hung git cat-file leaks git+git.exe+conhost chains (RAM 81%, ~200 procs) - **Источник:** AGENT_DIARY.md @@ -134,7 +110,8 @@ - IdleScheduler (`enable_idle_scheduler`, task_queue.py:345) включается только из `record_tool_call()` — после вызова инструмента; VOR туда не подключён; из 3 idle-задач 2 — заглушки (`_improve_summaries_batch`, `_check_index_health` — пустые тела, только debug-лог). - Со стороны агента: вызвал `intel_get_project_memory` → 110/110 узлов проверено (47 VERIFIED, 63 не-refuted) — работает, но только «по руке». - **Дизайн-решение для эксперимента (следующий шаг):** непрерывная проверка «без вызова» — (a) idle-тикер VOR в фоне по расписанию с cooldown; (b) react на git/файловые события (HEAD сменился → перепроверка затронутых узлов); (c) TTL/`verified_at` для INCONCLUSIVE → по возрастанию падать в REFUTED label «не подтверждён за N дней». Контр-риск: цена (CPU/disk) непрерывной проверки vs польза свежести — мерить, не угадывать (см. docs/research/universal-engine-study/10-continuous-verification.md). -- **Статус:** ⏳ Open — нужен эксперимент (гипотеза → замер → выбор) +- **Статус:** ⏳ Open 🟡 — нужен эксперимент (гипотеза → замер → выбор) +- **Дедлайн:** 2026-09-15 · **Owner:** ManSio ## 2026-09-08 — B4: статический цикл parser ⇄ language_imports (осознанный техдолг, lazy, allowed) @@ -143,4 +120,5 @@ - **Описание:** `src.core.language_imports` импортирует `src.core.indexing.parser` (для деривации карты), а `parser._extract_fallback_imports` импортирует `language_imports` (fallback-режим 2). Статически — цикл; в рантайме ни один импорт при загрузке модулей не выполняется: parser импортирует language_imports только локально в функции; language_imports импортирует parser только лениво (module `__getattr__` → `_derive_language_import_nodes`, PEP 562) при первом обращении к `LANGUAGE_IMPORT_NODES`. - **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) +- **Дедлайн рефактора:** 2026-10-01 · **Owner:** ManSio