From 9b66fbed2a5927baed265a1d77336290a8772006 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 8 Sep 2026 07:51:47 +0300 Subject: [PATCH 1/3] feat(indexing): per-language grammar maps for imports, calls, assigns, conditions Wire CALL_NODES/IMPORT_NODE_MAP/ASSIGNMENT_NODE_MAP/CONDITIONAL_NODE_MAP from universal-engine study into CodeParser walk, verified against live tree-sitter grammars for 11 languages. - calls: simple_identifier (Swift/Kotlin/Dart), method_invocation name field (Java), PHP *_call_expression last-name rule, Ruby method target - imports: C/C++ preproc_include regex, Kotlin keyword-import filter, Dart dedup, Ruby call require/include filter - assigns: variable_name left (PHP), directly_assignable_expression + simple_identifier (Swift), name-node RHS - conditional: per-language CONDITIONAL_NODE_MAP replaces shared set - live tests: TestMultiLangCalls (7) + TestMultiLangImports (9) + php assign --- AGENT_DIARY.md | 8 + src/core/indexing/parser.py | 371 ++++++++++++++++++++++---- tests/test_assignments.py | 15 ++ tests/test_symbol_index_call_graph.py | 210 +++++++++++++++ 4 files changed, 556 insertions(+), 48 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index ab3ee965..22789a9d 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1819,3 +1819,11 @@ 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). diff --git a/src/core/indexing/parser.py b/src/core/indexing/parser.py index cfdf5764..43f181fd 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,18 @@ def _walk_file(self, file_path: Path): tree.root_node, code, file_path, imports, import_types=import_types, ) + # Дедуп: Dart library_import оборачивает import_specification + # (оба узла дают один target на одной строке); Kotlin import + # содержит keyword-ребёнок. Оставляем первое вхождение. + 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 +1352,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 +1370,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 +1417,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 +1998,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,6 +2086,11 @@ 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_assignments_recursive( @@ -1860,6 +2103,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 +2118,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 +2134,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 +2185,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 +2203,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 +2223,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 +2257,7 @@ def _extract_assignments_recursive( assigned, condition_path, assignment_types, + conditional_types, scope_id, ) @@ -2057,6 +2325,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/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 = """ Date: Tue, 8 Sep 2026 10:06:05 +0300 Subject: [PATCH 2/3] feat(indexing): derive import maps from parser and gate fallback behind language pack flag Route import extraction through a single source of truth: LANGUAGE_IMPORT_NODES is now a derived view of CodeParser.IMPORT_NODE_MAP instead of a separate literal that had drifted for kt/dart/php. - parser._walk_file: run fallback (mode 2) only in the else-branch when ext has no map, behind a double gate (else-branch + language_pack.is_enabled inside iter_import_candidate_nodes); main grammar path stays priority - extract_imports_from_file reads the flag via language_pack.is_enabled (removes env re-read) - dedup moved out to a shared path used by both precise and fallback modes - LANGUAGE_IMPORT_NODES: lazy derivation via module __getattr__ (PEP 562) so parser is never imported at module load; static parser<->language_imports cycle registered as deliberate tech debt in _ALLOWED_CORE_CYCLES (KNOWN_ISSUES 2026-09-08) - tests: TestMapConsistency (card drift), TestFallbackImports (4 integration), fallback-gate negative controls; full suite green --- AGENT_DIARY.md | 7 ++ KNOWN_ISSUES.md | 31 ++++++ scripts/architecture_linter.py | 7 ++ src/core/indexing/parser.py | 65 +++++++++--- src/core/language_imports.py | 145 ++++++++++++++++++-------- tests/test_language_imports.py | 80 +++++++++++++- tests/test_symbol_index_call_graph.py | 121 +++++++++++++++++++++ 7 files changed, 399 insertions(+), 57 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 22789a9d..175036de 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1827,3 +1827,10 @@ verified_from_clean_state: ⚠️ не проверено — verify_clean_state **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/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 43f181fd..97ac3838 100644 --- a/src/core/indexing/parser.py +++ b/src/core/indexing/parser.py @@ -1290,18 +1290,27 @@ def _walk_file(self, file_path: Path): tree.root_node, code, file_path, imports, import_types=import_types, ) - # Дедуп: Dart library_import оборачивает import_specification - # (оба узла дают один target на одной строке); Kotlin import - # содержит keyword-ребёнок. Оставляем первое вхождение. - 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 + 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]: @@ -2093,6 +2102,38 @@ def _extract_import_target(import_node, code: bytes) -> str: 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, 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_language_imports.py b/tests/test_language_imports.py index efc57808..f2febb05 100644 --- a/tests/test_language_imports.py +++ b/tests/test_language_imports.py @@ -98,8 +98,25 @@ def test_case_normalized_lang(self): # "TypeScript" → нормализация в tsx/рабочий ключ assert extract_imports(_py_tree(), "PYTHON") # не падает, lang нормализуется - def test_unknown_language_fallback_no_crash(self): - """Negative control: экзотический язык без карты — тишина, не падение.""" + def test_unknown_language_fallback_gated_by_flag(self, monkeypatch): + """Fallback-режим 2 МОЛЧИТ при выключенном MSCODEBASE_LANGUAGE_PACK. + + Негативный тест требования «fallback только за флагом»: без флага + экзотический язык без карты даёт пустой результат, не падение. + """ + monkeypatch.delenv("MSCODEBASE_LANGUAGE_PACK", raising=False) + exo = N( + "weird_doc", + children=[ + N("import_like_thing", children=[N("name", text="x")]), + N("ordinary_statement", children=[N("name", text="y")]), + ], + ) + assert extract_imports(exo, "mooncript") == [] + + def test_unknown_language_fallback_active_with_flag(self, monkeypatch): + """Позитивный контроль: тот же язык с флагом — best-effort находит x.""" + monkeypatch.setenv("MSCODEBASE_LANGUAGE_PACK", "true") exo = N( "weird_doc", children=[ @@ -109,7 +126,28 @@ def test_unknown_language_fallback_no_crash(self): ) mods = extract_imports(exo, "mooncript") assert isinstance(mods, list) - assert all(m == "x" for m in mods) or "x" in mods # import_like_thing пойман + assert "x" in mods # import_like_thing пойман fallback-ом + + def test_mapped_lang_never_falls_back_even_with_flag(self, monkeypatch): + """Негативный тест: язык из карты использует ТОЧНЫЙ режим даже с флагом. + + 'useless_thing' содержит 'use' — по fallback-подстроке подошёл бы, + но rust есть в карте: посторонние типы не матчатся. + """ + monkeypatch.setenv("MSCODEBASE_LANGUAGE_PACK", "true") + tree = N( + "source_file", + children=[ + N( + "use_declaration", + children=[N("scoped_identifier", text="std::io")], + ), + N("useless_thing", children=[N("name", text="junk")]), + ], + ) + mods = extract_imports(tree, "rust") + assert mods == ["std::io"] + assert "junk" not in mods def test_no_junk_from_keywords(self): """Negative control: ключевые слова не становятся модулями.""" @@ -184,3 +222,39 @@ def test_known_languages_cover_original_20(): "csharp", "ruby", "php", "kotlin", "swift", "c", "cpp", "scala", "dart", ): assert original in langs, f"слой потерял язык {original}" + + +class TestMapConsistency: + """Единый источник истины (B4): LANGUAGE_IMPORT_NODES — производная + CodeParser.IMPORT_NODE_MAP. Падает при любом расхождении двух карт: + ручная правка производной, новый ext без маппинга, смена деривации. + """ + + def test_derived_from_parser_map(self): + from src.core.indexing.parser import CodeParser + from src.core.language_imports import _EXT_TO_LANG, LANGUAGE_IMPORT_NODES + + merged: dict = {} + for ext, types in CodeParser.IMPORT_NODE_MAP.items(): + assert ext in _EXT_TO_LANG, ( + f"ext {ext} из IMPORT_NODE_MAP без маппинга _EXT_TO_LANG" + ) + merged.setdefault(_EXT_TO_LANG[ext], set()).update(types) + assert {k: set(v) for k, v in LANGUAGE_IMPORT_NODES.items()} == merged + + def test_no_literal_legacy_entries(self): + """Исторические неверные имена старой карты не должны вернуться + (докстринг language_imports: карта однажды была фактически неверной).""" + from src.core.language_imports import LANGUAGE_IMPORT_NODES + + assert LANGUAGE_IMPORT_NODES["kotlin"] == ("import",) + assert "import_header" not in LANGUAGE_IMPORT_NODES["kotlin"] + assert "library_import" in LANGUAGE_IMPORT_NODES["dart"] + assert "import_directive" not in LANGUAGE_IMPORT_NODES["dart"] + assert "require_expression" in LANGUAGE_IMPORT_NODES["php"] + assert "export_statement" not in LANGUAGE_IMPORT_NODES["typescript"] + + def test_known_languages_matches_map(self): + from src.core.language_imports import LANGUAGE_IMPORT_NODES + + assert known_languages() == sorted(LANGUAGE_IMPORT_NODES) diff --git a/tests/test_symbol_index_call_graph.py b/tests/test_symbol_index_call_graph.py index 7881c8ed..5cbf4920 100644 --- a/tests/test_symbol_index_call_graph.py +++ b/tests/test_symbol_index_call_graph.py @@ -769,3 +769,124 @@ def test_stats_includes_references(symbol_index): assert stats["total_definitions"] == 2 assert stats["total_references"] == 1 assert stats["total_symbols"] == 2 + + +# ── B4: Fallback-режим 2 (language_imports) в реальном пути _walk_file ──── + + +class _FakeNode: + """Минимальный duck-typed tree-sitter узел с байтовыми офсетами.""" + + def __init__(self, ntype, children=(), start_byte=0, end_byte=0, + start_point=(0, 0)): + self.type = ntype + self.children = list(children) + self.start_byte = start_byte + self.end_byte = end_byte + self.start_point = start_point + + +class _FakeTree: + def __init__(self, root): + self.root_node = root + + +class _FakeParser: + def __init__(self, root): + self._root = root + + def parse(self, code): + return _FakeTree(self._root) + + +class TestFallbackImports: + """Интеграция fallback-режима 2 language_imports в CodeParser._walk_file. + + Основной grammar-путь (IMPORT_NODE_MAP) всегда приоритетен; fallback + активен только для ext без карты и только при MSCODEBASE_LANGUAGE_PACK. + """ + + @staticmethod + def _install_fake_parser(monkeypatch, parser, ext, root): + """Регистрирует ext и подменяет шов _get_parser (без tree-sitter).""" + parser.parsers[ext] = object() # guard ext in self.parsers + monkeypatch.setattr( + CodeParser, "_get_parser", lambda self, e: _FakeParser(root) + ) + + @staticmethod + def _spy_fallback(monkeypatch): + import src.core.language_imports as li + + calls = [] + original = li.iter_import_candidate_nodes + + def spy(tree): + calls.append(tree) + yield from original(tree) + + monkeypatch.setattr(li, "iter_import_candidate_nodes", spy) + return calls + + def test_fallback_fires_for_unmapped_ext_when_flag_on( + self, tmp_path, monkeypatch + ): + monkeypatch.setenv("MSCODEBASE_LANGUAGE_PACK", "true") + src = b"import mod\n" + root = _FakeNode("program", children=[ + _FakeNode("import_statement", start_byte=0, end_byte=len(src) - 1), + ]) + p = CodeParser() + self._install_fake_parser(monkeypatch, p, ".xyz", root) + f = tmp_path / "a.xyz" + f.write_bytes(src) + + imports = p.extract_imports(f) + assert [i["target_module"] for i in imports] == ["mod"] + assert imports[0]["line"] == 1 + assert imports[0]["text"] == "import mod" + + def test_fallback_silent_when_flag_off(self, tmp_path, monkeypatch): + monkeypatch.delenv("MSCODEBASE_LANGUAGE_PACK", raising=False) + src = b"import mod\n" + root = _FakeNode("program", children=[ + _FakeNode("import_statement", start_byte=0, end_byte=len(src) - 1), + ]) + p = CodeParser() + self._install_fake_parser(monkeypatch, p, ".xyz", root) + f = tmp_path / "a.xyz" + f.write_bytes(src) + + assert p.extract_imports(f) == [] + + def test_mapped_ext_never_uses_fallback(self, tmp_path, monkeypatch): + """Негативный тест: для ext из карты fallback не вызывается никогда + (основной grammar-путь приоритетен), даже с включённым флагом.""" + monkeypatch.setenv("MSCODEBASE_LANGUAGE_PACK", "true") + calls = self._spy_fallback(monkeypatch) + src = b"import os\n" + root = _FakeNode("module", children=[ + _FakeNode("import_statement", start_byte=0, end_byte=len(src) - 1), + ]) + p = CodeParser() + self._install_fake_parser(monkeypatch, p, ".py", root) + f = tmp_path / "a.py" + f.write_bytes(src) + + imports = p.extract_imports(f) + assert [i["target_module"] for i in imports] == ["os"] # точный путь + assert calls == [] # fallback не активирован + + def test_fallback_never_fires_when_ext_unparseable( + self, tmp_path, monkeypatch + ): + """Негативный тест: ext не регистрирован → дерево None → никакого + обхода и никакого fallback (нет грамматики — нет и импортов).""" + monkeypatch.setenv("MSCODEBASE_LANGUAGE_PACK", "true") + calls = self._spy_fallback(monkeypatch) + p = CodeParser() # .xyz не в self.parsers + f = tmp_path / "a.xyz" + f.write_bytes(b"import mod\n") + + assert p.extract_imports(f) == [] + assert calls == [] From 65a5f766dba013c792ff25fa61d163eb7629fe2d Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 8 Sep 2026 10:32:12 +0300 Subject: [PATCH 3/3] docs: sync diary status marker and CHANGELOG for B3+B4 grammar maps Fix broken '? Fixed' status marker in the B3 diary entry (cp1251 roundtrip dropped the checkmark) and add B3/B4 entries to the current CHANGELOG section across ru/en/zh. --- AGENT_DIARY.md | 2 +- docs/en/CHANGELOG.md | 3 +++ docs/ru/CHANGELOG.md | 3 +++ docs/zh/CHANGELOG.md | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 175036de..7689451a 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1823,7 +1823,7 @@ verified_from_clean_state: ⚠️ не проверено — verify_clean_state ## [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. +**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). 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 污染修复