From 19eff61eb2686bb2b1bec4e7caed2c6b0984f49b Mon Sep 17 00:00:00 2001 From: Ainour108 Date: Tue, 25 Aug 2026 14:57:28 +0700 Subject: [PATCH] feat: extractors for UI files (CSS, HTML) and JS linkage --- graphify/detect.py | 4 + graphify/extract.py | 26 ++- graphify/extractors/resolution.py | 2 +- graphify/extractors/webui.py | 323 ++++++++++++++++++++++++++++++ tests/test_webui.py | 57 ++++++ 5 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 graphify/extractors/webui.py create mode 100644 tests/test_webui.py diff --git a/graphify/detect.py b/graphify/detect.py index d16b5800ce..fb783e5db0 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -45,6 +45,10 @@ class FileType(str, Enum): CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} + +# webui: стили и разметка разбираются детерминированно, поэтому идут по коду, +# а не через ИИ-проход для документов. +CODE_EXTENSIONS = CODE_EXTENSIONS | {'.css', '.scss', '.html', '.htm'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} OFFICE_EXTENSIONS = {'.docx', '.xlsx'} VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'} diff --git a/graphify/extract.py b/graphify/extract.py index 89082af878..827f7db090 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5248,6 +5248,28 @@ def add_existing_edge(edge: dict) -> None: ".trigger": extract_apex, } +# webui: интерфейсные разборщики (стили, разметка) и связь кода с ними. +from graphify.extractors.webui import extract_css, extract_html, selector_edges +_DISPATCH['.css'] = extract_css +_DISPATCH['.scss'] = extract_css +_DISPATCH['.html'] = extract_html +_DISPATCH['.htm'] = extract_html + +def _extract_js_with_ui(path): + """Обычный разбор JS плюс рёбра «функция трогает такой-то класс».""" + res = extract_js(path) + try: + extra = selector_edges(path, res.get('nodes') or []) + res.setdefault('nodes', []).extend(extra['nodes']) + res.setdefault('edges', []).extend(extra['edges']) + except Exception: + pass + return res + +for _ext in ('.js', '.jsx', '.mjs', '.cjs'): + if _DISPATCH.get(_ext) is extract_js: + _DISPATCH[_ext] = _extract_js_with_ui + # Extensions whose extractor depends on an optional-dependency extra # (pyproject [project.optional-dependencies]) and hard-fails without it, @@ -6206,7 +6228,9 @@ def _portable_out_of_root_sf(p: Path) -> str: # stay identical across every manifest that references the package, so # they are exempt from the file-stem prefix remap (#1377), like the # type=module anchors (#1327). - if n.get("type") == "package": + # webui: узлы интерфейса (type="ui") тоже общие для файлов — + # один класс не должен распадаться на три узла. + if n.get("type") in ("package", "ui"): continue try: entry = prefix_remap.get(Path(sf).resolve()) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 5369637aeb..0836939884 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -671,7 +671,7 @@ def _disambiguate_colliding_node_ids( """ by_id: dict[str, list[dict]] = {} for node in nodes: - if node.get("type") in ("module", "namespace"): + if node.get("type") in ("module", "namespace", "ui"): continue nid = node.get("id") if isinstance(nid, str) and nid: diff --git a/graphify/extractors/webui.py b/graphify/extractors/webui.py new file mode 100644 index 0000000000..bd492324f8 --- /dev/null +++ b/graphify/extractors/webui.py @@ -0,0 +1,323 @@ +"""Разбор интерфейса: стили (.css) и разметка (.html) + связь их с кодом. + +Зачем. Штатный graphify строит граф по коду: функции и вызовы. Всё, что касается +вёрстки, для него не существует, поэтому вопрос «что сломается, если тронуть этот +класс» граф ответить не мог. Здесь добавлены три вещи: + + * из CSS достаются классы, идентификаторы и токены-переменные; + * из HTML — те же классы и идентификаторы, встречающиеся в разметке; + * из JS — обращения к ним из кода (querySelector, classList, closest и т. п.). + +Узлы классов и идентификаторов у всех трёх разборщиков лежат в общем пространстве +имён, поэтому они склеиваются: один узел «.tp-canvas» связан и с правилом в стилях, +и с элементом в разметке, и с функцией, которая его трогает. + +Сознательное упрощение: узел заводится на КЛАСС, а не на каждое правило. Правил в +крупном проекте десятки тысяч, граф из них превращается в кашу, а рассуждает человек +всё равно классами. +""" +from __future__ import annotations + +import re +from html.parser import HTMLParser +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id # noqa: F401 + +# Ограничители: интерфейсные файлы бывают огромными (styles.css на 400 КБ — обычное дело), +# но узлов из них должно выходить разумное количество, иначе граф теряет читаемость. +_MAX_BYTES = 4 * 1024 * 1024 +_MAX_NODES_PER_FILE = 4000 + +_CLASS_RE = re.compile(r"\.(-?[_a-zA-Z][\w-]*)") +_ID_RE = re.compile(r"#(-?[_a-zA-Z][\w-]*)") +_VAR_DEF_RE = re.compile(r"(--[\w-]+)\s*:") +_VAR_USE_RE = re.compile(r"var\(\s*(--[\w-]+)") +_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S) + + +def _sel_node_id(kind: str, name: str) -> str: + """Общее пространство имён для стилей, разметки и кода.""" + return _make_id("ui", kind, name) + + +def _sel_label(kind: str, name: str) -> str: + return {"class": "." + name, "id": "#" + name, "var": name}[kind] + + +class _Collector: + """Складывает узлы и рёбра, следит за потолком и не плодит повторов.""" + + def __init__(self, path: Path): + self.path = path + self.str_path = str(path) + self.nodes: list[dict] = [] + self.edges: list[dict] = [] + self._seen_nodes: set[str] = set() + self._seen_edges: set[tuple] = set() + + def node(self, nid: str, label: str, line: int, file_type: str = "code", + shared: bool = False) -> str | None: + """shared=True — узел общий для стилей, разметки и кода. + + Такие узлы помечаются type="ui": graphify не добавляет им приставку по имени + файла, поэтому «.tp-canvas» из styles.css, module.html и textproc.js — это + один и тот же узел, а не три разных. Ровно ради этого всё и затевалось. + """ + if not nid: + return None + if nid not in self._seen_nodes: + if len(self._seen_nodes) >= _MAX_NODES_PER_FILE: + return None + self._seen_nodes.add(nid) + n = { + "id": nid, "label": label, "file_type": file_type, + "source_file": self.str_path, "source_location": f"L{line}", + } + if shared: + n["type"] = "ui" + self.nodes.append(n) + return nid + + def edge(self, src: str | None, tgt: str | None, relation: str, line: int, + context: str | None = None) -> None: + if not src or not tgt or src == tgt: + return + key = (src, tgt, relation) + if key in self._seen_edges: + return + self._seen_edges.add(key) + e = { + "source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "source_file": self.str_path, + "source_location": f"L{line}", "weight": 1.0, + } + if context: + e["context"] = context + self.edges.append(e) + + def result(self) -> dict: + return {"nodes": self.nodes, "edges": self.edges} + + +def _read(path: Path) -> str | None: + try: + with path.open("rb") as f: + raw = f.read(_MAX_BYTES + 1) + if len(raw) > _MAX_BYTES: + return None + return raw.decode("utf-8", errors="replace") + except Exception: + return None + + +# ---------------------------------------------------------------- стили + + +def _iter_css_rules(text: str): + """Выдаёт (селектор, тело, номер строки) для правил верхнего уровня и внутри @-блоков. + + Свой проход вместо готового разборщика: зависимостей не добавляем, а нужны только + селекторы и тела — с этим справляется счётчик скобок. + """ + depth = 0 + buf: list[str] = [] + line = 1 + sel_line = 1 + i = 0 + n = len(text) + while i < n: + ch = text[i] + if ch == "\n": + line += 1 + if ch == "{": + selector = "".join(buf).strip() + buf = [] + if selector.startswith("@"): + # @media / @supports: внутрь заходим, само правило узлом не считаем + depth += 1 + sel_line = line + i += 1 + continue + # тело правила + body_start = i + 1 + body_depth = 1 + j = body_start + body_line = line + while j < n and body_depth: + if text[j] == "{": + body_depth += 1 + elif text[j] == "}": + body_depth -= 1 + elif text[j] == "\n": + line += 1 + j += 1 + yield selector, text[body_start:j - 1], sel_line if sel_line > body_line else body_line + i = j + sel_line = line + continue + if ch == "}": + depth = max(0, depth - 1) + buf = [] + sel_line = line + i += 1 + continue + if ch == ";" and depth >= 0 and not buf: + i += 1 + continue + buf.append(ch) + if len(buf) > 4000: # защита от мусорного файла + buf = [] + i += 1 + + +def extract_css(path: Path) -> dict: + """Классы, идентификаторы и токены из файла стилей.""" + text = _read(path) + if text is None: + return {"nodes": [], "edges": [], "error": "css file unreadable or too large"} + text = _COMMENT_RE.sub(lambda m: "\n" * m.group(0).count("\n"), text) + + c = _Collector(path) + file_nid = c.node(_make_id(str(path)), path.name, 1) + + for selector, body, line in _iter_css_rules(text): + if not selector or len(selector) > 600: + continue + targets: list[str] = [] + for name in set(_CLASS_RE.findall(selector)): + nid = c.node(_sel_node_id("class", name), _sel_label("class", name), line, shared=True) + c.edge(file_nid, nid, "styles", line, context="selector") + if nid: + targets.append(nid) + for name in set(_ID_RE.findall(selector)): + nid = c.node(_sel_node_id("id", name), _sel_label("id", name), line, shared=True) + c.edge(file_nid, nid, "styles", line, context="selector") + if nid: + targets.append(nid) + + # Токены оформления: где заданы и кто ими пользуется. + for var_name in set(_VAR_DEF_RE.findall(body)): + vnid = c.node(_sel_node_id("var", var_name), _sel_label("var", var_name), line, shared=True) + c.edge(file_nid, vnid, "defines_token", line) + for var_name in set(_VAR_USE_RE.findall(body)): + vnid = c.node(_sel_node_id("var", var_name), _sel_label("var", var_name), line, shared=True) + for t in targets: + c.edge(t, vnid, "uses_token", line) + if not targets: + c.edge(file_nid, vnid, "uses_token", line) + + return c.result() + + +# ---------------------------------------------------------------- разметка + + +class _MarkupParser(HTMLParser): + def __init__(self, collector: _Collector, file_nid: str | None): + super().__init__(convert_charrefs=True) + self.c = collector + self.file_nid = file_nid + + def handle_starttag(self, tag, attrs): + line = self.getpos()[0] + d = dict(attrs) + el_id = (d.get("id") or "").strip() + if el_id: + nid = self.c.node(_sel_node_id("id", el_id), _sel_label("id", el_id), line, shared=True) + self.c.edge(self.file_nid, nid, "markup", line, context=tag) + classes = (d.get("class") or "").split() + for name in classes[:12]: + nid = self.c.node(_sel_node_id("class", name), _sel_label("class", name), line, shared=True) + self.c.edge(self.file_nid, nid, "markup", line, context=tag) + + +def extract_html(path: Path) -> dict: + """Идентификаторы и классы, встречающиеся в разметке.""" + text = _read(path) + if text is None: + return {"nodes": [], "edges": [], "error": "html file unreadable or too large"} + c = _Collector(path) + file_nid = c.node(_make_id(str(path)), path.name, 1) + try: + _MarkupParser(c, file_nid).feed(text) + except Exception as e: # разметка бывает сломанной — это не повод падать + return {"nodes": c.nodes, "edges": c.edges, "error": str(e)} + return c.result() + + +# ---------------------------------------------------------------- код → интерфейс + +# Обращения к элементам из кода. Ловим и штатные способы, и принятые в проекте +# сокращения $ / $$ (обёртки над querySelector). +_JS_CALL_RE = re.compile( + r"""(?PgetElementById|querySelectorAll|querySelector|closest|matches|\$\$|\$)\s*\(\s* + (?P['"`])(?P[^'"`\n]{1,200})(?P=q)""", + re.X, +) +_JS_CLASSLIST_RE = re.compile( + r"""classList\s*\.\s*(?:add|remove|toggle|contains|replace)\s*\(\s* + (?P['"`])(?P[\w -]{1,120})(?P=q)""", + re.X, +) + + +def _line_of(text: str, pos: int) -> int: + return text.count("\n", 0, pos) + 1 + + +def _enclosing_symbol(nodes: list[dict], line: int) -> str | None: + """Ближайшая функция выше по файлу — к ней и относим обращение. + + Точного разбора здесь нет намеренно: ради одной этой связи тащить AST заново + дорого, а ошибка «прицепилось к соседней функции» не искажает картину — обе + функции всё равно живут в одном файле и в одном сообществе. + """ + best = None + best_line = -1 + for n in nodes: + loc = str(n.get("source_location") or "") + if not loc.startswith("L"): + continue + try: + nline = int(loc[1:].split("-")[0]) + except ValueError: + continue + if nline <= line and nline > best_line: + best_line = nline + best = n.get("id") + return best + + +def selector_edges(path: Path, base_nodes: list[dict]) -> dict: + """Рёбра «функция трогает этот класс/идентификатор» для файла кода.""" + text = _read(path) + if text is None: + return {"nodes": [], "edges": []} + c = _Collector(path) + file_nid = _make_id(str(path)) + + def link(kind: str, name: str, line: int, how: str) -> None: + nid = c.node(_sel_node_id(kind, name), _sel_label(kind, name), line, shared=True) + src = _enclosing_symbol(base_nodes, line) or file_nid + c.edge(src, nid, "touches_ui", line, context=how) + + for m in _JS_CALL_RE.finditer(text): + arg = m.group("arg").strip() + line = _line_of(text, m.start()) + fn = m.group("fn") + if fn == "getElementById": + if _ID_RE.fullmatch("#" + arg): + link("id", arg, line, "getElementById") + continue + # селектор целиком: берём все классы и идентификаторы из него + for name in set(_CLASS_RE.findall(arg)): + link("class", name, line, fn) + for name in set(_ID_RE.findall(arg)): + link("id", name, line, fn) + + for m in _JS_CLASSLIST_RE.finditer(text): + for name in m.group("arg").split(): + link("class", name, _line_of(text, m.start()), "classList") + + return c.result() diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000000..9081815bcf --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,57 @@ +from pathlib import Path +from graphify.extract import extract + +def _write(path: Path, body: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + +def test_webui_css(tmp_path): + f = _write(tmp_path / "styles.css", ".a .b { color: var(--t) }") + res = extract([f]) + + labels = {n["label"] for n in res.get("nodes", [])} + assert ".a" in labels + assert ".b" in labels + assert "--t" in labels + + edges = {(e["source"], e["target"], e["relation"]) for e in res.get("edges", [])} + + a_id = next(n["id"] for n in res["nodes"] if n["label"] == ".a") + t_id = next(n["id"] for n in res["nodes"] if n["label"] == "--t") + + assert (a_id, t_id, "uses_token") in edges + +def test_webui_html(tmp_path): + f = _write(tmp_path / "index.html", '
') + res = extract([f]) + + labels = {n["label"] for n in res.get("nodes", [])} + assert "#x" in labels + assert ".a" in labels + assert ".b" in labels + +def test_webui_shared_nodes(tmp_path): + f_css = _write(tmp_path / "styles.css", ".my-class { }") + f_html = _write(tmp_path / "index.html", '
') + res = extract([f_css, f_html]) + + my_class_nodes = [n for n in res.get("nodes", []) if n["label"] == ".my-class"] + assert len(my_class_nodes) >= 2, "Should have been extracted from both files" + + # Check that they share the exact same ID so they merge in the final graph + ids = {n["id"] for n in my_class_nodes} + assert len(ids) == 1, "Should be deduplicated into one node ID" + +def test_webui_js_touches_ui(tmp_path): + f_js = _write(tmp_path / "app.js", "\nfunction init() {\n document.querySelector('.my-class');\n}") + res = extract([f_js]) + + labels = {n["label"] for n in res.get("nodes", [])} + assert ".my-class" in labels + + fn_node = next(n for n in res["nodes"] if n["label"] == "init()") + cls_node = next(n for n in res["nodes"] if n["label"] == ".my-class") + + edges = {(e["source"], e["target"], e["relation"]) for e in res.get("edges", [])} + assert (fn_node["id"], cls_node["id"], "touches_ui") in edges