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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/core/change_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,25 @@ 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),
)
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:
Expand Down Expand Up @@ -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]}"]
Expand Down
41 changes: 35 additions & 6 deletions src/core/language_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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 (".", "/", "\\"):
Expand Down
19 changes: 19 additions & 0 deletions tests/test_language_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading