From e199ef0b3977ed99cf47300270352b8130954323 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 25 Aug 2026 19:47:38 +0300 Subject: [PATCH] =?UTF-8?q?fix(verify):=20real-boundary=20fixes=20?= =?UTF-8?q?=E2=80=94=20CRLF=20patch,=20tree-sitter=20Tree/bytes,=20stderr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - change_preview._run: surface stderr on failure (was DEVNULL -> empty diagnostics; 'patch --check failed:' had no message). - change_preview: write patch with newline='\n' — Path.write_text CRLF-translated the patch on Windows, git apply --check failed (bash-repro with LF patch passed). - language_imports._iter_nodes: real tree-sitter returns a TREE (.root_node holds children) — fake-tree tests missed it, real parse silently returned []. - language_imports: decode bytes node.text (tree-sitter gives bytes -> 'b\'ast\''); skip import_list/aliased_import subtrees (names of imports are not modules). - Guard: test_real_treesitter_tree_shape (Tree-shape regression). Live accuracy vs ground truth: ast/re/pathlib/typing correct; __future__ miss + import-symbol noise documented as best-effort limits. --- src/core/change_preview.py | 13 +++++++---- src/core/language_imports.py | 41 +++++++++++++++++++++++++++++----- tests/test_language_imports.py | 19 ++++++++++++++++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/core/change_preview.py b/src/core/change_preview.py index 3e4a388..7af29f1 100644 --- a/src/core/change_preview.py +++ b/src/core/change_preview.py @@ -40,7 +40,7 @@ def _run(cmd: List[str], cwd: Path, timeout: int = DEFAULT_TIMEOUT) -> subproces cmd, cwd=str(cwd), stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, encoding="utf-8", errors="replace", creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), @@ -48,12 +48,17 @@ def _run(cmd: List[str], cwd: Path, timeout: int = DEFAULT_TIMEOUT) -> subproces except FileNotFoundError: return subprocess.CompletedProcess(cmd, 127, f"command not found: {cmd[0]}") try: - stdout, _ = proc.communicate(timeout=timeout) + stdout, stderr = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: proc.kill() proc.communicate() raise - return subprocess.CompletedProcess(cmd, proc.returncode, stdout or "") + # stderr не теряем (git apply и др. пишут ОШИБКУ в stderr): на провале + # добавляем её к сообщению — иначе диагностика пустая (2026-08-25). + out = stdout or "" + if proc.returncode != 0 and stderr and stderr.strip(): + out = out.rstrip() + "\n[stderr] " + (stderr or "").strip() + return subprocess.CompletedProcess(cmd, proc.returncode, out) def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess: @@ -141,7 +146,7 @@ def _apply_and_verify(self, changed: List[str]) -> List[str]: # Применяем через файл (надёжнее пайпов на Windows, §5.16) patch_file = wt / ".preview.patch" try: - patch_file.write_text(patch_text, encoding="utf-8") + patch_file.write_text(patch_text, encoding="utf-8", newline="\n") check = _run(["git", "apply", "--check", str(patch_file)], wt, timeout=60) if check.returncode != 0: return [f"patch --check failed: {(check.stdout or '').strip()[:300]}"] diff --git a/src/core/language_imports.py b/src/core/language_imports.py index cab2af9..a16b731 100644 --- a/src/core/language_imports.py +++ b/src/core/language_imports.py @@ -66,8 +66,13 @@ def _iter_nodes(node) -> Sequence: - yield node - for child in getattr(node, "children", ()) or (): + """Обход узлов. Реальный tree-sitter даёт объект TREE (дети в .root_node), + fake-узлы — сами родители (.children). Унифицируем через root_node. + """ + root = getattr(node, "root_node", None) + start = root if root is not None else node + yield start + for child in getattr(start, "children", ()) or (): yield from _iter_nodes(child) @@ -96,20 +101,44 @@ def _is_import_node(node, lang: str) -> bool: "import_prefix", "module", "name", "path", "namespace", ) +# Subtree, внутри которых лежат ИМЕНА из списка импорта, а не модуль: +# python import_list / aliased_import, js import_clause. Не спускаемся туда. +_SKIP_SUBTREES = frozenset({"import_list", "aliased_import", "import_clause"}) + + +def _leaf_text(child) -> str: + """Текст узла с декодированием байт (tree-sitter отдаёт bytes, не str). + + str(b'ast') → "b'ast'" — реальная ошибка фиделити, вскрыта живым + прогоном с tree-sitter-language-pack (fake-деревья давали str). + """ + raw = getattr(child, "text", "") or "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + return str(raw).strip().strip('\"\'') + def _module_names(node) -> List[str]: """Имена модулей из узла импорта — по одному на каждый собирающий лист. Склейка листьев НЕ выполняется: go-block import ("os"; "strings") даёт - два имени, python from-import (dotted 'collections' + identifiers вне - _LEAF_TYPES) — одно (модуль), а не 'collections.defaultdict'. + два имени, python from-import (dotted 'collections' вне import_list) — + одно (модуль), а не 'collections.defaultdict'. """ + + def walk(n): + yield n + for ch in getattr(n, "children", ()) or (): + if str(getattr(ch, "type", "") or "") in _SKIP_SUBTREES: + continue # имена из списка импорта — не модуль + yield from walk(ch) + names: List[str] = [] - for child in _iter_nodes(node): + for child in walk(node): ctype = str(getattr(child, "type", "") or "") if ctype not in _LEAF_TYPES: continue - text = str(getattr(child, "text", "") or "").strip().strip('\"\'') + text = _leaf_text(child) if not text: continue if len(text) == 1 and text in (".", "/", "\\"): diff --git a/tests/test_language_imports.py b/tests/test_language_imports.py index 55cd866..efc5780 100644 --- a/tests/test_language_imports.py +++ b/tests/test_language_imports.py @@ -127,6 +127,25 @@ def test_dedup_preserves_order(self): mods = extract_imports(tree, "python") assert mods == ["os"] + def test_real_treesitter_tree_shape(self): + """Регрессия 2026-08-25: реальный tree-sitter даёт TREE с .root_node, + а не node с .children — без унификации экстрактор молча возвращал []. + Живой прогон с tree-sitter-language-pack вскрыл, fake-дерево — нет. + """ + + class FakeTree: + def __init__(self, root): + self.root_node = root + + tree = FakeTree( + N( + "module", + children=[N("import_statement", children=[N("dotted_name", text="os")])], + ) + ) + mods = extract_imports(tree, "python") + assert mods == ["os"] + class TestFromFile: def test_hermetic_with_fake_provider(self, tmp_path):