diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index ab3ee965..7689451a 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1819,3 +1819,18 @@ verified_from_clean_state: ⚠️ не проверено — verify_clean_state **Status:** ✅ Fixed / **Root Cause:** повтор инцидента 2026-08-14 (P-001, «чёрные окна CMD»). Фикс 2026-08-14 добавил CREATE_NO_WINDOW для git/netstat/wmic/taskkill в runtime, но ПОЗВОЛИЛ дыру: `resource_monitor.py:303` (powershell Get-CimInstance — RAM-монитор детей) и `:503` (nvidia-smi), `llama_runner.py:1338/1366/1394` (powershell Get-NetTCPConnection, Get-CimInstance CommandLine, taskkill в kill_process_on_port). Под pythonw (no console) каждый такой вызов из фонового сервиса при открытии/запуске проекта создавал видимое окно cmd → «опять появляется cmd и виснет». **Fix:** CREATE_NO_WINDOW во все 5 сайтов. Guard: `tests/test_subprocess_windows.py` — placeholder (assert True) заменён на реальный статический тест: grep по src/**/*.py за консоль-спавнами (powershell/wsl/wmic/netstat/taskkill/nvidia-smi) без флага → fail; + тест «daemon-потоки без capture_output (pipe-deadlock §5.16)». Оба passed. **verified_from_clean_state:** ✅ да — test_subprocess_windows 2 passed (реальный прогон с диска, не мок): guard находит дыры, ruff clean (все 4 файла), syntax OK. + + +## [2026-09-08] — B3: grammar-карты parser.py (imports/calls/assigns/conditions) внесены + живые фиксы + +**Status:** ✅ Fixed / **Root Cause и итог:** внесены из study 05 карты CALL_NODES/IMPORT_NODE_MAP/ASSIGNMENT_NODE_MAP/CONDITIONAL_NODE_MAP (пер-язычные) в `src/core/indexing/parser.py`. Живые tree-sitter пробы вскрыли недопонимания raw-грамматик, исправлено: CALL_IDENTIFIER_TYPES + simple_identifier (Swift/Kotlin/Dart callee); имя функции + simple_identifier/type_identifier + Dart function_signature и подъём имени из сиблинга function_body (Dart: signature/body — сиблинги, не parent-child); TARGET_NODES + method (Ruby); assignment left + variable_name (PHP $a) и directly_assignable_expression (Swift), right + name-узлы (PHP/Rust); _extract_import_target + regex #include <...>; IMPORT_KEYWORDS + дедуп импортов по (target,line) (Kotlin keyword-дубль, Dart library_import+import_specification); Ruby-imports через call с фильтром require/include. +**Tests:** 94 passed (assignments+calls+imports) > полный прогон 1646 passed. Новые live-тесты: TestMultiLangCalls (7) + TestMultiLangImports (9) в test_symbol_index_call_graph.py, PHP-assign в test_assignments.py. Red Team 5 атак отражены. temp probe-скрипты удалены. +**Guard:** grammar-факты — только через живые tree-sitter пробы (raw grammar_kinds_raw.txt ненадёжен: Go short_var_declaration не перечислен, но существует). +**verified_from_clean_state:** ⚠️ не проверено — чистый clone не гонялся (нет сети в сессии); локально полный pytest 1646 passed / 5 skipped / 91 deselected (Windows). +## [2026-09-08 12:35] — B4: import-экстракция через language_imports (деривация карт + флаг-гейт) + +**Status:** ✅ Fixed / **Root Cause:** два источника node-типов импортов (parser.IMPORT_NODE_MAP и литерал LANGUAGE_IMPORT_NODES) расходились (kt/dart/php); ungated fallback-2 в мосте. +**Fix:** LANGUAGE_IMPORT_NODES — производная IMPORT_NODE_MAP (вариант B); fallback-2 только в else-ветке _walk_file (ext без карты) за двойным гейтом MSCODEBASE_LANGUAGE_PACK (else-ветка + is_enabled в iter_import_candidate_nodes); extract_imports_from_file читает флаг через language_pack.is_enabled; дедуп общий. (коммит B4, main) +**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 зелёный. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 2b353a47..c393ef25 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -114,3 +114,34 @@ - **Fix:** CREATE_NO_WINDOW добавлен во все 5 сайтов (3 файла: resource_monitor.py ×2, llama_runner.py ×3). Guard: `tests/test_subprocess_windows.py` — из placeholder'ов превращён в реальный статический тест (grep по всем src/**/*.py за консоль-спавнами powershell/wsl/wmic/netstat/taskkill/nvidia-smi без флага → fail) + тест daemon-потоки без capture_output. Прогон: 2 passed. - **Статус:** ✅ Fixed + +## 2026-09-07 — Cypher-движок ломается на анонимных узлах/рёбрах (fixed) + Receipts не писались из write-пути (fixed) + collect() некорректно заявлен (open) + +- **Источник:** live-проба против реальной БД `bfe9644b/graph.db` (PropertyGraph, 6435 Variable / 22031 CALLS / 6152 ASSIGNED_FROM рёбер) +- **Описание (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 + + +## 2026-09-07 — Lazy-only верификация: память не проверяется без вызова агента; нет TTL/фона (open, эксперимент нужен) + +- **Источник:** live-срез project_memory.json текущего проекта (136 узлов) + grep точек вызова VOR/idle-планировщика +- **Симптомы (все Verified):** + - VOR вызывается ровно из 1 места — `intel_get_project_memory` (layer.py:1097). Таймеров/старт-хуков/idle-подписок нет. + - У 42 узлов ACTIVE нет ни одного поля TTL/last_checked/next_check — висят без статуса с 2026-08-11 (1 месяц). + - Узлы без якорей (`file:/import:/env:/pkg:`) → INCONCLUSIVE → VOR **не пишет ничего** (ни статуса, ни verified_at) — их нельзя ни подтвердить, ни отозвать автоматически. Пример: ADRs с commit_hash в data, но без шпилей. + - 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 — нужен эксперимент (гипотеза → замер → выбор) + + +## 2026-09-08 — B4: статический цикл parser ⇄ language_imports (осознанный техдолг, lazy, allowed) + +- **Источник:** `architecture_linter` (Invariant 3) после деривации `LANGUAGE_IMPORT_NODES` из `CodeParser.IMPORT_NODE_MAP` (B4). +- **Описание:** `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) + +>>>>>>> 86ef986d (feat(indexing): derive import maps from parser and gate fallback behind language pack flag) diff --git a/docs/en/CHANGELOG.md b/docs/en/CHANGELOG.md index 2fb7af45..dfcaedbb 100644 --- a/docs/en/CHANGELOG.md +++ b/docs/en/CHANGELOG.md @@ -19,12 +19,15 @@ All notable changes to this project will be documented in this file. - **Jupyter support**: `.ipynb` в INDEX/PARSE_EXTENSIONS; `CodeParser._parse_notebook` — code cells → чанки через tree-sitter (stdlib json; nbformat не требуется). - **find_duplicates**: детектор copy-paste — AST-нормализованные отпечатки + multiset-Jaccard + minhash-LSH, мультиязычно через грамматики CodeParser (src/core/duplication.py). - **get_context(targets=[...])**: task-shaped агрегатор — контекст по нескольким символам одним вызовом (обёртка над get_symbol_info/impact_analysis). +- **Per-language grammar maps** (B3): `CALL_NODES`/`IMPORT_NODE_MAP`/`ASSIGNMENT_NODE_MAP`/`CONDITIONAL_NODE_MAP` in `CodeParser` verified against live tree-sitter grammars of 11 languages (callee `simple_identifier` for Swift/Kotlin/Dart, Ruby `method` target, PHP/Swift assigns, C/C++ `#include`, Dart `library_import` dedup, Ruby `require/include`; live tests TestMultiLangCalls/TestMultiLangImports). +- **Derived import maps + gated fallback** (B4): `LANGUAGE_IMPORT_NODES` now derived from `CodeParser.IMPORT_NODE_MAP` (single source of truth; kt/dart/php drift gone); fallback mode 2 (extension without a map) runs only in the else-branch of `_walk_file` and is gated by the `MSCODEBASE_LANGUAGE_PACK` flag; import dedup moved to the shared path; derivation is lazy (module `__getattr__`, PEP 562 — parser is not imported at import-time). ### Changed - Регистрация MCP-тулов 55 → 57 (28 core + 13 intel + 13 inline + 4 dev); README/AGENTS.md/docs en+ru+zh синхронизированы; контракт-тест количества тулов обновлён до 57. ### Tests - +19 новых тестов (test_graph_path, test_duplication, test_jupyter, test_edge_transparency); полный pytest: 956 passed / 4 skipped / 94 deselected. +- B3: 94 targeted passed (assignments+calls+imports), full run 1646 passed; B4: 68 targeted passed (language_imports + call_graph), full run 1651 passed / 5 skipped / 91 deselected (Windows). --- ## [3.3.13] — 2026-08-05 — get_last_progress → core (ARCH-03 закрыт полностью), bump_version fix, test sys.path pollution fix diff --git a/docs/ru/CHANGELOG.md b/docs/ru/CHANGELOG.md index 73a1c939..b176ba52 100644 --- a/docs/ru/CHANGELOG.md +++ b/docs/ru/CHANGELOG.md @@ -21,12 +21,15 @@ - **Поддержка Jupyter**: `.ipynb` в INDEX/PARSE_EXTENSIONS; `CodeParser._parse_notebook` — code cells → чанки через tree-sitter (stdlib json; nbformat не требуется). - **find_duplicates**: детектор copy-paste — AST-нормализованные отпечатки + multiset-Jaccard + minhash-LSH, мультиязычно (src/core/duplication.py). - **get_context(targets=[...])**: task-shaped агрегатор — контекст по нескольким символам одним вызовом. +- **Per-language grammar maps** (B3): `CALL_NODES`/`IMPORT_NODE_MAP`/`ASSIGNMENT_NODE_MAP`/`CONDITIONAL_NODE_MAP` в `CodeParser` выверены против живых tree-sitter грамматик 11 языков (callee `simple_identifier` Swift/Kotlin/Dart, Ruby `method` target, PHP/Swift assigns, C/C++ `#include`, Dart `library_import` dedup, Ruby `require/include`; live-тесты TestMultiLangCalls/TestMultiLangImports). +- **Derived import maps + gated fallback** (B4): `LANGUAGE_IMPORT_NODES` теперь производная от `CodeParser.IMPORT_NODE_MAP` (единый источник истины; ушёл дрейф kt/dart/php); fallback-режим 2 (расширение без карты) выполняется только в else-ветке `_walk_file` и за флагом `MSCODEBASE_LANGUAGE_PACK`; дедуп импортов вынесен в общий путь; деривация карты ленивая (module `__getattr__`, PEP 562 — на import-time parser не импортируется). ### Изменено - Регистрация MCP-тулов 55 → 57 (28 core + 13 intel + 13 inline + 4 dev); README/AGENTS.md/docs en+ru+zh синхронизированы; контракт-тест количества тулов обновлён до 57. ### Тесты - +19 новых тестов (test_graph_path, test_duplication, test_jupyter, test_edge_transparency); полный pytest: 956 passed / 4 skipped / 94 deselected. +- B3: 94 targeted passed (assignments+calls+imports), полный прогон 1646 passed; B4: 68 targeted passed (language_imports + call_graph), полный прогон 1651 passed / 5 skipped / 91 deselected (Windows). --- ## [3.3.13] — 2026-08-05 — get_last_progress → core (ARCH-03 закрыт полностью), bump_version fix, test sys.path pollution fix diff --git a/docs/zh/CHANGELOG.md b/docs/zh/CHANGELOG.md index 29ea0e7f..c415714d 100644 --- a/docs/zh/CHANGELOG.md +++ b/docs/zh/CHANGELOG.md @@ -21,12 +21,15 @@ - **Jupyter 支持**: `.ipynb` 加入 INDEX/PARSE_EXTENSIONS;`CodeParser._parse_notebook` — code cells → chunks(stdlib json)。 - **find_duplicates**: copy-paste 检测 — AST 归一化指纹 + multiset-Jaccard + minhash-LSH(src/core/duplication.py)。 - **get_context(targets=[...])**: task-shaped 聚合 — 一次调用获取多个符号的上下文。 +- **逐语言语法映射(B3)**:`CALL_NODES`/`IMPORT_NODE_MAP`/`ASSIGNMENT_NODE_MAP`/`CONDITIONAL_NODE_MAP` 在 `CodeParser` 中对照 11 种语言的实时 tree-sitter 语法校验(Swift/Kotlin/Dart 的 callee `simple_identifier`、Ruby `method` 目标、PHP/Swift 赋值、C/C++ `#include`、Dart `library_import` 去重、Ruby `require/include`;live 测试 TestMultiLangCalls/TestMultiLangImports)。 +- **派生导入映射 + flag 门控的 fallback(B4)**:`LANGUAGE_IMPORT_NODES` 现由 `CodeParser.IMPORT_NODE_MAP` 派生(单一事实来源;kt/dart/php 漂移消除);fallback 模式 2(无映射的扩展名)仅在 `_walk_file` 的 else 分支且 `MSCODEBASE_LANGUAGE_PACK` flag 开启时执行;导入去重移至共享路径;映射派生为惰性(module `__getattr__`,PEP 562 — import-time 不导入 parser)。 ### 变更 - MCP 工具注册 55 → 57(28 core + 13 intel + 13 inline + 4 dev);README/AGENTS.md/docs en+ru+zh 同步;工具数量契约测试更新为 57。 ### 测试 - +19 个新测试(test_graph_path, test_duplication, test_jupyter, test_edge_transparency);完整 pytest:956 passed / 4 skipped / 94 deselected。 +- B3:94 个定向测试通过(assignments+calls+imports),完整 1646 passed;B4:68 个定向测试通过(language_imports + call_graph),完整 1651 passed / 5 skipped / 91 deselected(Windows)。 --- ## [3.3.13] — 2026-08-05 — get_last_progress → core(ARCH-03 完全关闭)、bump_version 修复、test sys.path 污染修复 diff --git a/scripts/architecture_linter.py b/scripts/architecture_linter.py index 6bcc0081..8d9594b3 100644 --- a/scripts/architecture_linter.py +++ b/scripts/architecture_linter.py @@ -223,8 +223,15 @@ def _check_stale_references() -> list[str]: # task_queue.py:414 `from src.core.error_handler import _LAST_CALL_AT`) — # цикл разрывается в рантайме, при загрузке модулей не выполняется. # Удалить из исключений после рефакторинга (см. KNOWN_ISSUES 2026-08-24). +# +# parser ⇄ language_imports (B4): parser импортирует language_imports только +# локально в _extract_fallback_imports; language_imports импортирует parser +# ТОЛЬКО лениво (module __getattr__ → _derive_language_import_nodes) при первом +# обращении к LANGUAGE_IMPORT_NODES. На import-time ничего не выполняется. +# Удалить после выноса IMPORT_NODE_MAP в нейтральный модуль (см. KNOWN_ISSUES B4). _ALLOWED_CORE_CYCLES: set[frozenset[str]] = { frozenset({"src.core.error_handler", "src.core.task_queue"}), + frozenset({"src.core.indexing.parser", "src.core.language_imports"}), } diff --git a/src/core/indexing/parser.py b/src/core/indexing/parser.py index cfdf5764..97ac3838 100644 --- a/src/core/indexing/parser.py +++ b/src/core/indexing/parser.py @@ -124,21 +124,34 @@ class CodeParser: "impl_item", # Rust impl "method_declaration", # Java, C# "function_declaration", # Go, Swift, Kotlin, C, C++ + "method", # Ruby def } - # Узлы вызовов функций — для построения графа вызовов + # Узлы вызовов функций — для построения графа вызовов. + # Имена сверены с реальными грамматиками (05-grammar-node-kinds.md, + # Verified 2026-08-18 + живые прогоны tree-sitter): + # Java → method_invocation, PHP → function/member/scoped_call_expression, + # TS/JS/C++/Dart → new_expression, Kotlin/Dart → constructor_invocation. CALL_NODES = { - "call_expression", # Python, JS, Go, Rust - "call", # Альтернативные грамматики - "function_invocation", # Java - "invocation_expression", # Java (method invocation) - "macro_invocation", # Rust macros! + "call_expression", # Python, JS, Go, Rust, Scala, Swift, Kotlin, C, C++ + "call", # Python, Ruby + "method_invocation", # Java + "explicit_constructor_invocation", # Java + "invocation_expression", # C# + "macro_invocation", # Rust, Swift + "function_call_expression", # PHP + "member_call_expression", # PHP + "nullsafe_member_call_expression", # PHP + "scoped_call_expression", # PHP + "new_expression", # TS/JS, C++, Dart + "constructor_invocation", # Kotlin, Dart } # Типы узлов, которые мы считаем "идентификаторами" при поиске вызовов CALL_IDENTIFIER_TYPES = { "identifier", "type_identifier", + "simple_identifier", # Swift, Kotlin, Dart "field_expression", # JS/TS: obj.method() "attribute", # Python: self.method() / obj.method() "scoped_identifier", # module::func() @@ -831,50 +844,78 @@ def _build_chunk_metadata( # ── Unified AST Walker (один parse → два результата) ── - # Узлы импортов для разных языков — для построения IMPORTS-рёбер + # Узлы импортов для разных языков — для построения IMPORTS-рёбер. + # Имена сверены с реальными грамматиками (05-grammar-node-kinds.md, + # Verified 2026-08-18 + живые прогоны tree-sitter): + # Kotlin → `import` (нет import_declaration); C/C++ → preproc_include + # (нет include_statement); Dart → library_import/library_export; + # Ruby: require/include — это `call` (обрабатывается отдельно, т.к. + # узел call_имя_сомнения в грамматике не имеет собственного типа). + IMPORT_KEYWORDS = { + "import", "from", "use", "require", "include", + "as", "pub", "crate", "self", "super", + } IMPORT_NODE_MAP = { ".py": {"import_statement", "import_from_statement"}, ".rs": {"use_declaration"}, - ".ts": {"import_statement", "import_declaration"}, - ".tsx": {"import_statement", "import_declaration"}, + ".ts": {"import_statement"}, + ".tsx": {"import_statement"}, ".go": {"import_declaration"}, - ".js": {"import_statement", "import_declaration"}, + ".js": {"import_statement"}, ".java": {"import_declaration"}, ".cs": {"using_directive"}, - ".rb": {"require", "include"}, - ".php": {"include_expression", "require_expression"}, - ".kt": {"import_declaration"}, + ".rb": {"call"}, # require/require_relative/include — обычные call (фильтр по имени в _extract_imports_recursive) + ".php": { + "include_expression", "include_once_expression", + "require_expression", "require_once_expression", + "namespace_use_declaration", + }, + ".kt": {"import"}, ".swift": {"import_declaration"}, - ".c": {"include_statement", "import_declaration"}, - ".cpp": {"include_statement", "import_declaration"}, - ".cxx": {"include_statement", "import_declaration"}, - ".hpp": {"include_statement", "import_declaration"}, - ".scala": {"import"}, - ".dart": {"import_declaration", "export_declaration"}, - ".sh": {"source_statement"}, - ".bash": {"source_statement"}, + ".c": {"preproc_include"}, + ".cpp": {"preproc_include"}, + ".cxx": {"preproc_include"}, + ".hpp": {"preproc_include"}, + ".scala": {"import_declaration", "export_declaration"}, + ".dart": { + "library_import", "library_export", "import_specification", + }, + # Bash отказ по дизайну: source — это команда (command с name=source), + # ловить всех command как импорты — шум, спец-типа на него нет (05 п.3.1). } - # Типы assignment-узлов для разных языков (мультиязычность) + # Типы assignment-узлов для разных языков (мультиязычность). + # Имена сверены с реальными грамматиками + живые прогоны (2026-08-18): + # Go "x := src" → short_var_declaration; Ruby "x += y" → operator_assignment; + # Java consts → variable_declarator; Rust let → let_declaration. ASSIGNMENT_NODE_MAP = { ".py": {"assignment", "augmented_assignment"}, - ".rs": {"let_declaration", "assignment_expression"}, - ".ts": {"variable_declarator", "assignment_expression"}, - ".tsx": {"variable_declarator", "assignment_expression"}, + ".rs": {"let_declaration", "assignment_expression", + "compound_assignment_expr"}, + ".ts": {"variable_declarator", "assignment_expression", + "augmented_assignment_expression"}, + ".tsx": {"variable_declarator", "assignment_expression", + "augmented_assignment_expression"}, ".go": {"short_var_declaration", "assignment_statement", "var_spec", "send_statement"}, - ".js": {"variable_declarator", "assignment_expression"}, - ".java": {"variable_declarator", "assignment_expression"}, + ".js": {"variable_declarator", "assignment_expression", + "augmented_assignment_expression"}, + ".java": {"variable_declarator", "assignment_expression", + "local_variable_declaration"}, ".cs": {"variable_declarator", "assignment_expression"}, - ".rb": {"assignment", "op_assignment"}, - ".php": {"assignment_expression", "variable_declarator"}, - ".kt": {"property_declaration"}, - ".swift": {"property_declaration"}, - ".c": {"init_declarator"}, - ".cpp": {"init_declarator"}, - ".cxx": {"init_declarator"}, - ".hpp": {"init_declarator"}, - ".scala": {"val_definition", "var_definition"}, + ".rb": {"assignment", "operator_assignment"}, + ".php": {"assignment_expression", "augmented_assignment_expression", + "reference_assignment_expression", + "static_variable_declaration"}, + ".kt": {"property_declaration", "assignment", + "variable_declaration"}, + ".swift": {"property_declaration", "assignment"}, + ".c": {"init_declarator", "assignment_expression"}, + ".cpp": {"init_declarator", "assignment_expression"}, + ".cxx": {"init_declarator", "assignment_expression"}, + ".hpp": {"init_declarator", "assignment_expression"}, + ".scala": {"val_definition", "var_definition", + "assignment_expression"}, ".dart": {"initialized_variable_definition", "local_variable_declaration"}, # Bash, SQL, YAML, TOML, HTML, CSS, HCL — без ASSIGNED_FROM @@ -895,7 +936,8 @@ def _build_chunk_metadata( "metadata", # Dart @deprecated } - # Узлы, которые создают "условный контекст" для ASSIGNED_FROM + # Узлы, которые создают "условный контекст" для ASSIGNED_FROM. + # Python-набор — дефолт; для других языков — пер-языковые карты ниже. CONDITIONAL_NODES = { "if_statement", "else_clause", "for_statement", "while_statement", @@ -903,6 +945,123 @@ def _build_chunk_metadata( "except_clause", "match_statement", "case_clause", } + # Пер-языковые conditional-узлы (05 п.3.3): Rust/Scala используют *_expression + # (if_expression, match_expression...), Kotlin — if_expression/try_expression/ + # when_entry, Ruby — без _statement-суффикса (if, while, case, unless). + # Для остальных языков словари дополняют Python-набор спецификой + # (catch_clause, switch_*, ternary_expression). + CONDITIONAL_NODE_MAP = { + ".py": { + "if_statement", "else_clause", + "for_statement", "while_statement", + "with_statement", "try_statement", + "except_clause", "match_statement", "case_clause", + }, + ".rs": { + "if_expression", "for_expression", "while_expression", + "match_expression", "loop_expression", + "try_expression", "try_block", + "else_clause", "match_arm", "match_block", + }, + ".ts": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "try_statement", "catch_clause", + "finally_clause", "switch_statement", "switch_body", + "switch_case", "switch_default", "ternary_expression", + "with_statement", + }, + ".tsx": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "try_statement", "catch_clause", + "finally_clause", "switch_statement", "switch_body", + "switch_case", "switch_default", "ternary_expression", + "with_statement", + }, + ".go": { + "if_statement", "else_clause", "for_statement", + "expression_switch_statement", "type_switch_statement", + "communication_case", "expression_case", "default_case", + }, + ".js": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "try_statement", "catch_clause", + "finally_clause", "switch_statement", "switch_body", + "switch_case", "switch_default", "ternary_expression", + "with_statement", + }, + ".java": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "enhanced_for_statement", "try_statement", + "try_with_resources_statement", "catch_clause", "switch_expression", + "switch_block", "switch_block_statement_group", "switch_rule", + "ternary_expression", + }, + ".cs": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "try_statement", "catch_clause", "catch_declaration", + "catch_filter_clause", "finally_clause", "switch_statement", + "switch_section", "switch_body", "switch_expression", + "conditional_expression", "conditional_access_expression", + }, + ".rb": { + "if", "unless", "while", "case", "else", + "if_modifier", "while_modifier", "conditional", + "case_match", "match_pattern", + }, + ".php": { + "if_statement", "else_clause", "else_if_clause", "for_statement", + "while_statement", "do_statement", "try_statement", "catch_clause", + "finally_clause", "switch_statement", "switch_block", + "conditional_expression", "match_expression", "match_block", + }, + ".kt": { + "if_expression", "try_expression", "when_entry", + "for_statement", "while_statement", "do_while_statement", + "catch_block", + }, + ".swift": { + "if_statement", "else_clause", "for_statement", "while_statement", + "repeat_while_statement", "try_statement", "try_expression", + "catch_block", "do_statement", "switch_statement", + "ternary_expression", + }, + ".c": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "switch_statement", "case_statement", + "conditional_expression", + }, + ".cpp": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "for_range_loop", "try_statement", "catch_clause", + "switch_statement", "case_statement", "conditional_expression", + "noexcept", + }, + ".cxx": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "for_range_loop", "try_statement", "catch_clause", + "switch_statement", "case_statement", "conditional_expression", + "noexcept", + }, + ".hpp": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "for_range_loop", "try_statement", "catch_clause", + "switch_statement", "case_statement", "conditional_expression", + "noexcept", + }, + ".scala": { + "if_expression", "for_expression", "while_expression", + "match_expression", "try_expression", "catch_clause", + "case_block", "case_clause", + }, + ".dart": { + "if_statement", "else_clause", "for_statement", "do_statement", + "while_statement", "try_statement", "catch_clause", + "catch_parameters", "switch_statement", "switch_block", + "switch_expression", "conditional_expression", + "if_null_expression", "if_element", + }, + } + def _get_tree(self, file_path: Path): """Возвращает (code, tree) с кэшем на 1 файл (thread-local). @@ -1115,10 +1274,14 @@ def _walk_file(self, file_path: Path): ) assignments = [] assignment_types = self.ASSIGNMENT_NODE_MAP.get(ext, set()) + conditional_types = self.CONDITIONAL_NODE_MAP.get( + ext, set(self.CONDITIONAL_NODES) + ) self._extract_assignments_recursive( tree.root_node, code, file_path, assignments, current_function="", assigned=None, condition_path=None, assignment_types=assignment_types, + conditional_types=conditional_types, ) imports = [] import_types = self.IMPORT_NODE_MAP.get(ext, set()) @@ -1127,6 +1290,27 @@ def _walk_file(self, file_path: Path): tree.root_node, code, file_path, imports, import_types=import_types, ) + else: + # Основной grammar-путь недоступен (ext вне IMPORT_NODE_MAP): + # best-effort fallback из language_imports (режим 2). Гейт флага + # внутри iter_import_candidate_nodes — при выключенном + # MSCODEBASE_LANGUAGE_PACK это no-op. + self._extract_fallback_imports( + tree.root_node, code, file_path, imports + ) + # Дедуп: Dart library_import оборачивает import_specification + # (оба узла дают один target на одной строке); Kotlin import + # содержит keyword-ребёнок. Оставляем первое вхождение. + # Общий для точного и fallback-пути (вложенные import-узлы). + seen = set() + uniq = [] + for imp in imports: + key = (imp["target_module"], imp["line"]) + if key in seen: + continue + seen.add(key) + uniq.append(imp) + imports = uniq return calls, assignments, imports def extract_calls(self, file_path: Path) -> List[Dict]: @@ -1177,10 +1361,17 @@ def _extract_calls_recursive( # классом ("Class.method") — иначе CALLS-рёбра из методов не # находят узел определения в PropertyGraph (узлы хранятся с # qualified name) и молча дропаются add_edge(). - if node.type in self.TARGET_NODES: - name_node = self._find_child_by_type( - node, "identifier" - ) or self._find_child_by_type(node, "name") + # Dart: функция = function_signature (без function_definition); + # имя — первый identifier/type_identifier (m), не сигнатурный узел. + if node.type in self.TARGET_NODES or node.type in ( + "function_signature", "method_signature", + ): + name_node = ( + self._find_child_by_type(node, "identifier") + or self._find_child_by_type(node, "name") + or self._find_child_by_type(node, "simple_identifier") + or self._find_child_by_type(node, "type_identifier") + ) if name_node: fname = code[ name_node.start_byte : name_node.end_byte @@ -1188,6 +1379,24 @@ def _extract_calls_recursive( current_function = ( f"{current_class}.{fname}" if current_class else fname ) + # Dart function_body — сиблинг function_signature (не ребёнок): + # имя функции живёт в соседнем узле того же parent. + elif node.type == "function_body" and node.parent is not None: + for sib in node.parent.children: + if sib.type not in ("function_signature", "method_signature"): + continue + name_node = ( + self._find_child_by_type(sib, "identifier") + or self._find_child_by_type(sib, "type_identifier") + ) + if name_node: + fname = code[ + name_node.start_byte : name_node.end_byte + ].decode("utf-8", errors="ignore") + current_function = ( + f"{current_class}.{fname}" if current_class else fname + ) + break # Если это узел вызова — извлекаем имя вызываемой функции if node.type in self.CALL_NODES: @@ -1217,6 +1426,29 @@ def _extract_callee_name(self, call_node, code: bytes) -> str: - Цепочки: a.b.c() → "c" - Scoped: module::func() → "func" """ + # Java method_invocation: поле name = имя метода (obj.run() → run) + if call_node.type == "method_invocation": + name_field = call_node.child_by_field_name("name") + if name_field is not None: + return code[name_field.start_byte : name_field.end_byte].decode( + "utf-8", errors="ignore" + ) + # PHP: *_call_expression хранит имя функции в последнем name-ребёнке + # (scoped_call_expression: mod::bar → bar, member: obj->run → run) + if call_node.type in ( + "function_call_expression", + "member_call_expression", + "nullsafe_member_call_expression", + "scoped_call_expression", + ): + last_name = None + for child in call_node.children: + if child.type == "name": + last_name = code[child.start_byte : child.end_byte].decode( + "utf-8", errors="ignore" + ) + if last_name: + return last_name # Ищем идентификатор среди прямых детей for child in call_node.children: if child.type in self.CALL_IDENTIFIER_TYPES: @@ -1775,15 +2007,30 @@ def _extract_imports_recursive( import_types: Множество типов узлов импорта для этого языка """ if node.type in import_types: - # Извлекаем текст импорта (всё строковое содержимое узла) - import_text = code[node.start_byte : node.end_byte].decode( - "utf-8", errors="ignore" - ) + # Ruby: require/require_relative/include — это обычные call-узлы. + # Фильтруем по первому identifier-ребёнку (маловероятная коллизия + # с Python call — Ruby-импорты не имеют собственного типа узла). + if node.type == "call": + callee = self._node_name(node, code) + if callee not in ("require", "require_relative", "include"): + import_text = "" + else: + import_text = code[node.start_byte : node.end_byte].decode( + "utf-8", errors="ignore" + ) + else: + import_text = code[node.start_byte : node.end_byte].decode( + "utf-8", errors="ignore" + ) if import_text.strip(): # Для разных языков импорт выглядит по-разному, # но нас интересует имя модуля/пакета. # Извлекаем первое имя после ключевого слова import/use. module_name = self._extract_import_target(node, code) + # Kotlin/прочие: keyword-узел import (текст = "import") без + # целевого модуля — не импорт, пропускаем. + if module_name and module_name.lower() in self.IMPORT_KEYWORDS: + module_name = "" if module_name: imports.append( { @@ -1848,8 +2095,45 @@ def _extract_import_target(import_node, code: bytes) -> str: ): return w_clean.split(".")[0].split("::")[0] + # Fallback 2: C/C++ #include / #include "config.h" + inc = re.search(r"#include\s*[<\"']([^>\"]+)", text) + if inc: + return inc.group(1).split(".")[0] + return text.split()[0] if text else "" + def _extract_fallback_imports( + self, node, code: bytes, file_path: Path, imports: List[Dict] + ): + """Best-effort импорты для ext без точной карты (language_imports, режим 2). + + Основной grammar-путь (IMPORT_NODE_MAP) всегда приоритетен: метод + вызывается ТОЛЬКО когда для ext нет карты (_walk_file, else-ветка). + Двойной гейт: активация флагом MSCODEBASE_LANGUAGE_PACK внутри + iter_import_candidate_nodes (language_pack.is_enabled) + локальный + импорт (модульный создал бы цикл: language_imports деривирует карту + из CodeParser.IMPORT_NODE_MAP на верхнем уровне). + """ + from src.core.language_imports import iter_import_candidate_nodes + + for imp_node in iter_import_candidate_nodes(node): + module_name = self._extract_import_target(imp_node, code) + # Ключевые слова не становятся модулями (как в точном пути). + if module_name and module_name.lower() in self.IMPORT_KEYWORDS: + module_name = "" + if not module_name: + continue + imports.append( + { + "source_file": str(file_path), + "target_module": module_name, + "line": imp_node.start_point[0] + 1, + "text": code[imp_node.start_byte : imp_node.end_byte].decode( + "utf-8", errors="ignore" + ).strip(), + } + ) + def _extract_assignments_recursive( self, node, @@ -1860,6 +2144,7 @@ def _extract_assignments_recursive( assigned: Optional[Set[str]] = None, condition_path: Optional[List[str]] = None, assignment_types: Optional[Set[str]] = None, + conditional_types: Optional[Set[str]] = None, scope_id: Optional[str] = None, ): """Рекурсивно обходит AST, отслеживая присваивания внутри функций. @@ -1874,11 +2159,15 @@ def _extract_assignments_recursive( для отслеживания контекста присваивания. assignment_types: set[str] — типы assignment-узлов для языка. None → Python ("assignment", "augmented_assignment"). + conditional_types: set[str] — типы conditional-узлов для языка. + None → self.CONDITIONAL_NODES (Python-набор). scope_id: str — идентификатор scope (файл::функция::строка). None для глобального scope. """ if assignment_types is None: assignment_types = {"assignment", "augmented_assignment"} + if conditional_types is None: + conditional_types = set(self.CONDITIONAL_NODES) if assigned is None: assigned = set() if condition_path is None: @@ -1886,7 +2175,7 @@ def _extract_assignments_recursive( # ── Управление стеком условных блоков ── pushed_conditional = False - if node.type in self.CONDITIONAL_NODES: + if node.type in conditional_types: condition_path.append(node.type) pushed_conditional = True @@ -1937,6 +2226,16 @@ def _extract_assignments_recursive( left_name = code[left.start_byte : left.end_byte].decode( "utf-8", errors="ignore" ) + elif left.type == "variable_name": + # PHP: $a = ... → variable_name { $, name } + name_node = ( + left.child_by_field_name("name") + or self._find_child_by_type(left, "name") + ) + if name_node: + left_name = code[name_node.start_byte : name_node.end_byte].decode( + "utf-8", errors="ignore" + ) elif left.type == "expression_list": # Берём первый identifier из списка (x, y := ...) for child in left.children: @@ -1945,10 +2244,19 @@ def _extract_assignments_recursive( "utf-8", errors="ignore" ) break + elif left.type == "directly_assignable_expression": + # Swift: a = 2 → directly_assignable_expression + # содержит simple_identifier (a) или navigation (x.y) + for sub in left.children: + if sub.type == "simple_identifier": + left_name = code[sub.start_byte : sub.end_byte].decode( + "utf-8", errors="ignore" + ) + break else: # Fallback: ищем первый identifier среди детей (Go var_spec, Kotlin) for child in node.children: - if child.type == "identifier": + if child.type in ("identifier", "simple_identifier"): left_name = code[child.start_byte : child.end_byte].decode( "utf-8", errors="ignore" ) @@ -1956,7 +2264,7 @@ def _extract_assignments_recursive( # Kotlin: property_declaration → variable_declaration → identifier if child.type == "variable_declaration": for sub in child.children: - if sub.type == "identifier": + if sub.type in ("identifier", "simple_identifier"): left_name = code[sub.start_byte : sub.end_byte].decode( "utf-8", errors="ignore" ) @@ -1990,6 +2298,7 @@ def _extract_assignments_recursive( assigned, condition_path, assignment_types, + conditional_types, scope_id, ) @@ -2057,6 +2366,13 @@ def _get_names_from_node(self, node, code: bytes) -> List[str]: "utf-8", errors="ignore" ) names.append(name) + elif node.type == "name": + # PHP/Rust: $var → variable_name { name }, module.name → name + name = code[node.start_byte : node.end_byte].decode( + "utf-8", errors="ignore" + ) + if name and not name.startswith(("$", "#")): + names.append(name) # Не заходим в вложенные определения — их идентификаторы # относятся к внутреннему scope if node.type not in ("function_definition", "class_definition"): diff --git a/src/core/language_imports.py b/src/core/language_imports.py index a16b7315..f7228b69 100644 --- a/src/core/language_imports.py +++ b/src/core/language_imports.py @@ -4,55 +4,101 @@ commit 142761d), удалён рефакторингом к 04.08.2026 — claim «20 языков» в CHANGELOG разошёлся с кодом (закрыто 24.08.2026). +ЕДИНЫЙ ИСТОЧНИК ИСТИНЫ по node-типам импортов — CodeParser.IMPORT_NODE_MAP +(parser.py, B3-карты, сверены с живыми грамматиками 2026-08-18). +LANGUAGE_IMPORT_NODES ниже — ПРОИЗВОДНАЯ (ext→lang через _EXT_TO_LANG), +литерала карты здесь больше нет; расхождение ловит TestMapConsistency +в tests/test_language_imports.py. + Экстрактор ЧИСТЫЙ и duck-typed: работает с любым деревом, у которого есть node.type / node.children / node.text — поэтому тестируется синтетикой БЕЗ tree-sitter (герметичность). Реальные грамматики (tree-sitter-language-pack, +56 языков) скачиваются по сети при первом использовании — это вне юнит-тестов. Два режима: - 1. Точный: node-типы из LANGUAGE_IMPORT_NODES (исходные 20 языков). + 1. Точный: node-типы из LANGUAGE_IMPORT_NODES (производной от IMPORT_NODE_MAP). 2. Fallback: для языков без карты — любой node, чей тип содержит 'import'/'use'/'include' (best-effort, никогда не падает — Negative control). + Активен ТОЛЬКО при MSCODEBASE_LANGUAGE_PACK=true; реальный путь parser.py + (_extract_fallback_imports) вызывает его только когда точная карта для + расширения отсутствует — при доступном основном grammar-пути fallback + не запускается никогда. -Гейт включения — как у language_pack.py: MSCODEBASE_LANGUAGE_PACK=true. +Гейт включения — как у language_pack.py: MSCODEBASE_LANGUAGE_PACK=true +(единая точка чтения флага — language_pack.is_enabled()). """ from __future__ import annotations +import logging from typing import Dict, List, Sequence +from src.core.language_pack import is_enabled as _pack_enabled + __all__ = [ "extract_imports", "extract_imports_from_file", - "LANGUAGE_IMPORT_NODES", + "iter_import_candidate_nodes", + "LANGUAGE_IMPORT_NODES", # noqa: F822 — ленивый lazy-export через module __getattr__ (PEP 562) "known_languages", ] -# Язык -> node-типы tree-sitter, представляющие импорты (исходные 20). -# Ключи нормализованы (нижний регистр, без расширений). -LANGUAGE_IMPORT_NODES: Dict[str, tuple[str, ...]] = { - "python": ("import_statement", "import_from_statement"), - "rust": ("use_declaration",), - "javascript": ("import_statement", "export_statement"), - "typescript": ("import_statement", "export_statement"), - "tsx": ("import_statement", "export_statement"), - "go": ("import_declaration",), - "java": ("import_declaration",), - "csharp": ("using_directive",), - "ruby": ("call",), # require / require_relative — фильтруем по имени вызова - "php": ("namespace_use_declaration",), - "kotlin": ("import_header",), - "swift": ("import_declaration",), - "c": ("preproc_include",), - "cpp": ("preproc_include",), - "scala": ("import_declaration",), - "dart": ("import_directive", "export_directive"), - "bash": ("declaration_command",), # source / import — грубый best-effort - "objectivec": ("preproc_include",), - "cpp_objectivec": ("preproc_include",), - "vue": ("import_statement", "export_statement"), +logger = logging.getLogger(__name__) + +# Каноническое соответствие расширение → имя языка. Обслуживает и деривацию +# карты из parser.IMPORT_NODE_MAP, и _lang_for_ext — второй копии быть не должно. +_EXT_TO_LANG: Dict[str, str] = { + ".py": "python", ".rs": "rust", ".js": "javascript", ".jsx": "javascript", + ".ts": "typescript", ".tsx": "tsx", ".vue": "vue", ".go": "go", + ".java": "java", ".cs": "csharp", ".rb": "ruby", ".php": "php", + ".kt": "kotlin", ".swift": "swift", ".c": "c", ".h": "c", + ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", + ".scala": "scala", ".dart": "dart", ".sh": "bash", ".bash": "bash", + ".m": "objectivec", } + +def _derive_language_import_nodes() -> Dict[str, tuple[str, ...]]: + """Строит LANGUAGE_IMPORT_NODES из CodeParser.IMPORT_NODE_MAP (B3). + + ВЫЗЫВАЕТСЯ ТОЛЬКО лениво (module __getattr__) при первом обращении, + НИКОГДА при загрузке модуля — иначе статический цикл + parser ⇄ language_imports (architecture_linter, _ALLOWED_CORE_CYCLES). + Ext без маппинга в _EXT_TO_LANG пропускается с warning; полноту покрытия + закрепляет консистентный тест. + """ + from src.core.indexing.parser import CodeParser + + merged: Dict[str, set] = {} + for ext, types in CodeParser.IMPORT_NODE_MAP.items(): + lang = _EXT_TO_LANG.get(ext) + if lang is None: + logger.warning("language_imports: ext %s без маппинга _EXT_TO_LANG", ext) + continue + merged.setdefault(lang, set()).update(types) + return {lang: tuple(sorted(types)) for lang, types in sorted(merged.items())} + + +# Язык -> node-типы tree-sitter, представляющие импорты (производная, см. выше). +# ЛЕНИВАЯ: первый доступ (module __getattr__, PEP 562) деривирует карту из +# CodeParser.IMPORT_NODE_MAP. При загрузке модуля parser НЕ импортируется — +# иначе статический цикл parser ⇄ language_imports (architecture_linter). +# Число синхронизировано с _ALLOWED_CORE_CYCLES (scripts/architecture_linter.py). +_LANGUAGE_IMPORT_NODES_CACHE: Dict[str, tuple[str, ...]] | None = None + + +def _get_language_import_nodes() -> Dict[str, tuple[str, ...]]: + global _LANGUAGE_IMPORT_NODES_CACHE + if _LANGUAGE_IMPORT_NODES_CACHE is None: + _LANGUAGE_IMPORT_NODES_CACHE = _derive_language_import_nodes() + return _LANGUAGE_IMPORT_NODES_CACHE + + +def __getattr__(name: str): + if name == "LANGUAGE_IMPORT_NODES": + return _get_language_import_nodes() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # Импорт НЕ должен содержать эти слова (ключевые слова/служебные имена). _IMPORT_KEYWORDS = frozenset( { @@ -65,6 +111,11 @@ _FALLBACK_SUBSTR = ("import", "use", "include") +def _fallback_type_match(ntype: str) -> bool: + """Подстрочный критерий fallback-режима 2 (одно определение на модуль).""" + return any(s in ntype for s in _FALLBACK_SUBSTR) + + def _iter_nodes(node) -> Sequence: """Обход узлов. Реальный tree-sitter даёт объект TREE (дети в .root_node), fake-узлы — сами родители (.children). Унифицируем через root_node. @@ -78,7 +129,7 @@ def _iter_nodes(node) -> Sequence: def _is_import_node(node, lang: str) -> bool: ntype = str(getattr(node, "type", "") or "") - targets = LANGUAGE_IMPORT_NODES.get(lang) + targets = _get_language_import_nodes().get(lang) if targets: if ntype not in targets: return False @@ -90,7 +141,26 @@ def _is_import_node(node, lang: str) -> bool: ) return True # Fallback для неизвестных языков: best-effort по имени node-типа. - return any(s in ntype for s in _FALLBACK_SUBSTR) + # Гейт: ТОЛЬКО при MSCODEBASE_LANGUAGE_PACK (докстринг модуля) — без флага + # незнакомый язык даёт пустой результат (негативный тест закреплён). + if not _pack_enabled(): + return False + return _fallback_type_match(ntype) + + +def iter_import_candidate_nodes(tree): + """Узлы-кандидаты fallback-режима 2 для реального пути parser.py. + + CodeParser._extract_fallback_imports вызывает это ТОЛЬКО для ext без + точной карты (IMPORT_NODE_MAP) — основной grammar-путь всегда приоритетен. + Пустой генератор при выключенном MSCODEBASE_LANGUAGE_PACK + (language_pack.is_enabled). + """ + if not _pack_enabled(): + return + for node in _iter_nodes(tree): + if _fallback_type_match(str(getattr(node, "type", "") or "")): + yield node # Листья, из которых собирается имя модуля. Обычные 'identifier' НЕ входят: @@ -184,15 +254,13 @@ def extract_imports_from_file( language_pack.try_enable). Для юнит-тестов передаём фейк-провайдера — сам мост остаётся герметичным. """ - import os from pathlib import Path if lang is None: lang = _lang_for_ext(Path(file_path).suffix) if parser_provider is None: - if os.getenv("MSCODEBASE_LANGUAGE_PACK", "").strip().lower() not in ( - "1", "true", "yes", "on", - ): + # Единый гейт флага (language_pack.is_enabled) — дубля чтения env нет. + if not _pack_enabled(): return [] parser_provider = _default_parser_provider try: @@ -206,19 +274,12 @@ def extract_imports_from_file( def known_languages() -> List[str]: - return sorted(LANGUAGE_IMPORT_NODES) + return sorted(_get_language_import_nodes()) def _lang_for_ext(ext: str) -> str: - _EXT_LANG = { - ".py": "python", ".rs": "rust", ".js": "javascript", ".jsx": "javascript", - ".ts": "typescript", ".tsx": "tsx", ".vue": "vue", ".go": "go", - ".java": "java", ".cs": "csharp", ".rb": "ruby", ".php": "php", - ".kt": "kotlin", ".swift": "swift", ".c": "c", ".h": "c", - ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp", ".scala": "scala", - ".dart": "dart", ".sh": "bash", ".bash": "bash", ".m": "objectivec", - } - return _EXT_LANG.get((ext or "").lower(), "") + # _EXT_TO_LANG — единственный словарь ext→lang в модуле (деривация + мост). + return _EXT_TO_LANG.get((ext or "").lower(), "") def _default_parser_provider(file_path: str, lang: str): diff --git a/tests/test_assignments.py b/tests/test_assignments.py index 708aa2e3..5bc021ce 100644 --- a/tests/test_assignments.py +++ b/tests/test_assignments.py @@ -651,4 +651,19 @@ def test_dart_assign(self, parser): edges = _edges_from(assign) assert ("x", "y") in edges + def test_php_assign(self, parser): + """PHP: $x = src; $y = $x → x→y (variable_name left/right)""" + if not self._has_grammar(".php", parser): + pytest.skip("tree_sitter_php not available") + code = b''' Path: + fd, path = tempfile.mkstemp(suffix=suffix) + import os as _os + + _os.close(fd) + f = Path(path) + f.write_text(code, encoding="utf-8") + return f + + +class TestMultiLangCalls: + """Извлечение вызовов через реальные грамматики (B3):""" + + @staticmethod + def _has(live_parser, ext: str) -> bool: + return ext in live_parser.parsers + + def test_php_calls(self, live_parser): + if not self._has(live_parser, ".php"): + pytest.skip("tree_sitter_php not available") + code = """run($b); + mod::bar($c); +}""" + f = _tmp_file(".php", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "foo" and c["caller"] == "m" for c in calls) + assert any(c["callee"] == "run" for c in calls) + assert any(c["callee"] == "bar" for c in calls) + + def test_java_method_invocation_callee(self, live_parser): + if not self._has(live_parser, ".java"): + pytest.skip("tree_sitter_java not available") + code = """import java.util.List; +class F { + void m() { obj.run(); } +}""" + f = _tmp_file(".java", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "run" for c in calls) + + def test_dart_constructor_callee(self, live_parser): + if not self._has(live_parser, ".dart"): + pytest.skip("tree_sitter_dart not available") + code = """import 'package:foo/bar.dart'; +void m() { run(1); var a = new Foo(1); }""" + f = _tmp_file(".dart", code) + calls = live_parser.extract_calls(f) + f.unlink() + # Dart: обычные вызовы — identifier+selector (не call_expression), + # конструктор — new_expression. Ловим хотя бы конструктор. + assert any(c["callee"] == "Foo" for c in calls) + + def test_swift_call_expression(self, live_parser): + if not self._has(live_parser, ".swift"): + pytest.skip("tree_sitter_swift not available") + code = """ +import Foundation +func m() { run(1); }""" + f = _tmp_file(".swift", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "run" for c in calls) + + def test_kotlin_call_expression(self, live_parser): + if not self._has(live_parser, ".kt"): + pytest.skip("tree_sitter_kotlin not available") + code = """ +import kotlinx.coroutines +fun m() { run(1); }""" + f = _tmp_file(".kt", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "run" for c in calls) + + def test_rust_macro_and_fn_call(self, live_parser): + if not self._has(live_parser, ".rs"): + pytest.skip("tree_sitter_rust not available") + code = """ +use std::io; +fn m() { println!("x"); run(1); }""" + f = _tmp_file(".rs", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "run" for c in calls) + + def test_ruby_call(self, live_parser): + if not self._has(live_parser, ".rb"): + pytest.skip("tree_sitter_ruby not available") + code = """ +require 'json' +def m + foo() +end""" + f = _tmp_file(".rb", code) + calls = live_parser.extract_calls(f) + f.unlink() + assert any(c["callee"] == "foo" for c in calls) + + +class TestMultiLangImports: + """Извлечение импортов через реальные грамматики (B3):""" + + @staticmethod + def _has(live_parser, ext: str) -> bool: + return ext in live_parser.parsers + + def test_java_import(self, live_parser): + if not self._has(live_parser, ".java"): + pytest.skip("tree_sitter_java not available") + f = _tmp_file(".java", "import java.util.List;\nclass F {}") + imports = live_parser.extract_imports(f) + f.unlink() + assert any(i["target_module"] == "java" for i in imports) + + def test_c_preproc_include(self, live_parser): + if not self._has(live_parser, ".c"): + pytest.skip("tree_sitter_c not available") + f = _tmp_file(".c", "#include \n#include \"mycfg.h\"\nint x;") + imports = live_parser.extract_imports(f) + f.unlink() + targets = {i["target_module"] for i in imports} + assert "stdio" in targets + assert "mycfg" in targets + + def test_cpp_preproc_include(self, live_parser): + if not self._has(live_parser, ".cpp"): + pytest.skip("tree_sitter_cpp not available") + f = _tmp_file(".cpp", "#include \n#include \"cfg.h\"\nint x;") + imports = live_parser.extract_imports(f) + f.unlink() + targets = {i["target_module"] for i in imports} + assert "vector" in targets + assert "cfg" in targets + + def test_php_namespace_use(self, live_parser): + if not self._has(live_parser, ".php"): + pytest.skip("tree_sitter_php not available") + code = """