From ec74a42f976d773fd461bbd14dc9ee0f325d4007 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 00:52:31 +0530 Subject: [PATCH 1/6] feat(extract): recognize .zsh files through the shell extractor (#2825) .zsh was missing from CODE_EXTENSIONS and the shell dispatch table, so suffixed zsh scripts were silently unclassified even though the shebang path already mapped zsh to extract_bash. Add .zsh to the extension tables and the bash source-edge filter, plus a fixture and dispatch test. Co-authored-by: CommandCodeBot --- graphify/detect.py | 2 +- graphify/extract.py | 3 ++- tests/fixtures/sample.zsh | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/sample.zsh diff --git a/graphify/detect.py b/graphify/detect.py index 4cb123104c..3e08c9ef82 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -42,7 +42,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -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'} +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', '.zsh', '.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'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index a113d4a7ac..3d26d2e13a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4948,6 +4948,7 @@ def add_existing_edge(edge: dict) -> None: ".lpk": extract_lazarus_package, ".sh": extract_bash, ".bash": extract_bash, + ".zsh": extract_bash, ".json": extract_json, ".tf": extract_terraform, ".tfvars": extract_terraform, @@ -6196,7 +6197,7 @@ def _looks_like_bash(result: object) -> bool: sh_pairs = [ (r, p) for r, p in zip(per_file, paths) - if p.suffix in (".sh", ".bash") or _looks_like_bash(r) + if p.suffix in (".sh", ".bash", ".zsh") or _looks_like_bash(r) ] if sh_pairs: sh_results = [r for r, _ in sh_pairs] diff --git a/tests/fixtures/sample.zsh b/tests/fixtures/sample.zsh new file mode 100644 index 0000000000..ce6ef984c2 --- /dev/null +++ b/tests/fixtures/sample.zsh @@ -0,0 +1,14 @@ +#!/usr/bin/env zsh +set -euo pipefail + +greet() { + print "Hello, $1" +} + +deploy() { + local env_name="${1:-production}" + greet "$env_name" + print "Deploying to $env_name" +} + +deploy staging From 15fd50a0fac11e241cca809eb2ad0dc9d85d46d8 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 00:57:55 +0530 Subject: [PATCH 2/6] feat(extract): add SAS (.sas) structural extraction (#2681) SAS files were not classified at all, so they were invisible to detect() and contributed nothing. Add a dedicated tree-sitter SAS extractor that emits file, data/proc step, and %macro nodes plus same-file macro call edges, registered in LANGUAGE_EXTRACTORS and re-exported from the facade. .sas is now in CODE_EXTENSIONS and wired through _DISPATCH with the sas optional extra (mirroring the commonlisp/ocaml niche-language pattern). Co-authored-by: CommandCodeBot --- graphify/detect.py | 2 +- graphify/extract.py | 3 + graphify/extractors/__init__.py | 2 + graphify/extractors/sas.py | 105 ++++++++++++++++++++++++++++++++ pyproject.toml | 6 +- tests/fixtures/sample.sas | 14 +++++ tests/test_detect.py | 8 +++ tests/test_extract.py | 93 +++++++++++++++++++++++++++- uv.lock | 26 +++++++- 9 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 graphify/extractors/sas.py create mode 100644 tests/fixtures/sample.sas diff --git a/graphify/detect.py b/graphify/detect.py index 3e08c9ef82..e8f391e584 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -42,7 +42,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -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', '.zsh', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd'} +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', '.zsh', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.sas'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 3d26d2e13a..c947de7921 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -52,6 +52,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 +from graphify.extractors.sas import extract_sas # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform # noqa: F401 @@ -4968,6 +4969,7 @@ def add_existing_edge(edge: dict) -> None: ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".sas": extract_sas, } @@ -4988,6 +4990,7 @@ def add_existing_edge(edge: dict) -> None: ".cl": "commonlisp", ".lsp": "commonlisp", ".asd": "commonlisp", + ".sas": "sas", } # Substrings an extractor's error carries to classify why a dependency-backed diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index 68ff3340c3..082c0e9185 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -28,6 +28,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust +from graphify.extractors.sas import extract_sas from graphify.extractors.sln import extract_sln from graphify.extractors.sql import extract_sql from graphify.extractors.terraform import extract_terraform @@ -58,6 +59,7 @@ "powershell_manifest": extract_powershell_manifest, "razor": extract_razor, "rust": extract_rust, + "sas": extract_sas, "sln": extract_sln, "sql": extract_sql, "terraform": extract_terraform, diff --git a/graphify/extractors/sas.py b/graphify/extractors/sas.py new file mode 100644 index 0000000000..1243d15db3 --- /dev/null +++ b/graphify/extractors/sas.py @@ -0,0 +1,105 @@ +"""SAS extractor (tree-sitter). + +Extracts data steps, proc steps, and %macro definitions from a .sas file, +plus calls edges from %macro call sites to macros defined in the same file. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _file_stem, _make_id + + +def extract_sas(path: Path) -> dict: + """Extract data/proc steps and macro definitions from a .sas file.""" + try: + import tree_sitter_sas as tssas + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree_sitter_sas not installed"} + + try: + language = Language(tssas.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + macro_defs: dict[str, str] = {} + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}"}) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", weight: float = 1.0, + context: str | None = None) -> None: + edge = {"source": src, "target": tgt, "relation": relation, + "confidence": confidence, "source_file": str_path, + "source_location": f"L{line}", "weight": weight} + if context: + edge["context"] = context + edges.append(edge) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def _macro_name_text(node: Any) -> str | None: + for child in node.children: + if child.type == "macro_name": + return child.text.decode("utf-8", errors="replace").strip() + return None + + # First pass: collect macro definitions so call sites can resolve. + for node in root.children: + if node.type == "macro_definition": + name = _macro_name_text(node) + if name: + macro_defs[name] = _make_id(stem, name) + + def _step_label(node: Any) -> str | None: + for child in node.children: + if child.type in ("data_step_header", "proc_step_header"): + text = child.text.decode("utf-8", errors="replace").strip() + # strip the trailing `;` so the label reads `data work.customers` + return text.rstrip(";").strip() if text else None + return None + + for node in root.children: + if node.type == "macro_definition": + name = _macro_name_text(node) + if not name: + continue + nid = macro_defs[name] + add_node(nid, f"%{name}", node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="macro") + elif node.type == "data_step": + label = _step_label(node) or "data" + nid = _make_id(stem, "data") + add_node(nid, label, node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="data_step") + elif node.type == "proc_step": + label = _step_label(node) or "proc" + nid = _make_id(stem, "proc") + add_node(nid, label, node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="proc_step") + + # Macro call sites: emit calls edges to macros defined in this file. + for node in root.children: + if node.type == "macro_call_statement": + name = _macro_name_text(node) + if name and name in macro_defs: + add_edge(file_nid, macro_defs[name], "calls", + node.start_point.row + 1, context="call") + + return {"nodes": nodes, "edges": edges, "raw_calls": []} diff --git a/pyproject.toml b/pyproject.toml index 882ef533cb..274ccddc07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,10 @@ ocaml = ["tree-sitter-ocaml"] # tree-sitter-commonlisp ships prebuilt abi3 wheels for every platform; optional # because Common Lisp is a niche corpus language. commonlisp = ["tree-sitter-commonlisp"] -all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp"] +# tree-sitter-sas ships prebuilt wheels for every platform; optional because SAS +# is a niche corpus language. +sas = ["tree-sitter-sas"] +all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "tree-sitter-sas"] [project.scripts] graphify = "graphify.__main__:main" @@ -116,6 +119,7 @@ dev = [ "tree-sitter-hcl>=1.2.0", "tree-sitter-ocaml>=0.25.0", "tree-sitter-commonlisp>=0.4.1", + "tree-sitter-sas>=0.4.2", ] [tool.uv] diff --git a/tests/fixtures/sample.sas b/tests/fixtures/sample.sas new file mode 100644 index 0000000000..9c8b5eca1b --- /dev/null +++ b/tests/fixtures/sample.sas @@ -0,0 +1,14 @@ +%macro greet(name); + %put Hello &name; +%mend greet; + +data work.customers; + set raw.import; + length name $ 50; +run; + +proc sort data=work.customers; + by name; +run; + +%greet(World); diff --git a/tests/test_detect.py b/tests/test_detect.py index 5be697ce72..9bdcbbc469 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -42,6 +42,14 @@ def test_classify_powershell_manifest(): # #1331: .psd1 manifests must be classified as CODE so the manifest extractor runs. assert classify_file(Path("MyModule.psd1")) == FileType.CODE +def test_classify_zsh(): + # #2825: .zsh was missing from CODE_EXTENSIONS so zsh scripts were unclassified. + assert classify_file(Path("script.zsh")) == FileType.CODE + +def test_classify_sas(): + # #2681: .sas files should be classified as CODE so the SAS extractor runs. + assert classify_file(Path("model.sas")) == FileType.CODE + def test_classify_markdown(): assert classify_file(Path("README.md")) == FileType.DOCUMENT diff --git a/tests/test_extract.py b/tests/test_extract.py index 416d358280..8006d56237 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -7,7 +7,7 @@ import pytest from graphify.build import build_from_json -from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, _DISPATCH +from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, extract_sas, _DISPATCH FIXTURES = Path(__file__).parent / "fixtures" @@ -2234,9 +2234,100 @@ def wrapped_sequential(uncached_work, per_file, *args, **kwargs): def test_dispatch_includes_sh_and_json(): assert ".sh" in _DISPATCH assert ".bash" in _DISPATCH + assert ".zsh" in _DISPATCH assert ".json" in _DISPATCH +def test_dispatch_routes_zsh_to_bash(): + from graphify.extract import _get_extractor + assert _get_extractor(Path("script.zsh")) is extract_bash + + +def test_extract_bash_handles_zsh_extension(): + # #2825: a suffixed .zsh file must route through the shell extractor and + # produce the same node shape as .sh — not be silently dropped. + result = extract_bash(FIXTURES / "sample.zsh") + assert "error" not in result + labels = {n["label"] for n in result["nodes"]} + assert "greet()" in labels + assert "deploy()" in labels + + +# --------------------------------------------------------------------------- +# SAS extractor tests (#2681) +# --------------------------------------------------------------------------- + +def test_dispatch_includes_sas(): + assert ".sas" in _DISPATCH + + +def test_dispatch_routes_sas_to_sas_extractor(): + from graphify.extract import _get_extractor + assert _get_extractor(Path("model.sas")) is extract_sas + + +def test_extract_sas_emits_step_and_macro_nodes(): + result = extract_sas(FIXTURES / "sample.sas") + assert "error" not in result + labels = {n["label"] for n in result["nodes"]} + assert "data work.customers" in labels + assert any(lbl.startswith("proc sort") for lbl in labels) + assert "%greet" in labels + +def test_extract_sas_nodes_have_source_location(): + result = extract_sas(FIXTURES / "sample.sas") + for n in result["nodes"]: + assert n["file_type"] == "code" + assert n["source_file"].endswith("sample.sas") + assert n["source_location"].startswith("L") + + +def test_extract_sas_emits_defines_edges(): + result = extract_sas(FIXTURES / "sample.sas") + defines = [(e["source"], e["target"], e.get("context")) for e in result["edges"] + if e["relation"] == "defines"] + # file node defines each step/macro + assert len(defines) == 3 + assert any(ctx == "data_step" for _, _, ctx in defines) + assert any(ctx == "proc_step" for _, _, ctx in defines) + assert any(ctx == "macro" for _, _, ctx in defines) + + +def test_extract_sas_macro_call_resolves_to_same_file_definition(): + result = extract_sas(FIXTURES / "sample.sas") + calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + source, target = calls[0] + assert source == result["nodes"][0]["id"] # the file node + assert "greet" in target + + +def test_extract_sas_missing_dependency_returns_error_marker(monkeypatch): + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "tree_sitter_sas": + raise ImportError("tree_sitter_sas not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + result = extract_sas(Path("model.sas")) + assert result["nodes"] == [] + assert "error" in result + + +def test_extract_sas_via_extract_no_1689_warning(tmp_path, capsys): + # A .sas file routed through extract() must not fire the #1689 + # no-AST-extractor warning and must produce a non-empty node set. + f = tmp_path / "model.sas" + f.write_text("data work.t;\nrun;\n") + result = extract([f]) + assert result["nodes"] + out = capsys.readouterr().err + assert "no AST extractor" not in out + + def test_extract_bash_finds_functions(): result = extract_bash(FIXTURES / "sample.sh") assert "error" not in result diff --git a/uv.lock b/uv.lock index 8e8313f133..b911c7c90c 100644 --- a/uv.lock +++ b/uv.lock @@ -1150,6 +1150,7 @@ all = [ { name = "tree-sitter-hcl" }, { name = "tree-sitter-ocaml" }, { name = "tree-sitter-pascal" }, + { name = "tree-sitter-sas" }, { name = "tree-sitter-sql" }, { name = "watchdog" }, { name = "yt-dlp" }, @@ -1217,6 +1218,9 @@ pdf = [ postgres = [ { name = "psycopg", extra = ["binary"] }, ] +sas = [ + { name = "tree-sitter-sas" }, +] sql = [ { name = "tree-sitter-sql" }, ] @@ -1253,6 +1257,7 @@ dev = [ { name = "tree-sitter-commonlisp" }, { name = "tree-sitter-hcl" }, { name = "tree-sitter-ocaml" }, + { name = "tree-sitter-sas" }, { name = "wheel" }, ] @@ -1333,6 +1338,8 @@ requires-dist = [ { name = "tree-sitter-python", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-ruby", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-rust", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-sas", marker = "extra == 'all'" }, + { name = "tree-sitter-sas", marker = "extra == 'sas'" }, { name = "tree-sitter-scala", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-sql", marker = "extra == 'all'" }, { name = "tree-sitter-sql", marker = "extra == 'sql'" }, @@ -1345,7 +1352,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "sas", "all"] [package.metadata.requires-dev] dev = [ @@ -1365,6 +1372,7 @@ dev = [ { name = "tree-sitter-commonlisp", specifier = ">=0.4.1" }, { name = "tree-sitter-hcl", specifier = ">=1.2.0" }, { name = "tree-sitter-ocaml", specifier = ">=0.25.0" }, + { name = "tree-sitter-sas", specifier = ">=0.4.2" }, { name = "wheel", specifier = ">=0.47.0" }, ] @@ -4878,6 +4886,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, ] +[[package]] +name = "tree-sitter-sas" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7b/5644acdbef190098d022ca2feb747e0b9de8bd0aed974e0bfba1f11fd6ff/tree_sitter_sas-0.4.2.tar.gz", hash = "sha256:0bf60b6434015b36a0105bbb8b78b2f46568566b5849a74bee3000d39f3926cb", size = 86250, upload-time = "2026-05-26T02:35:18.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/d8/92640ff898e0de9ed85b11ec0b8e1dc2913de9f61082a3cb95c0899a5613/tree_sitter_sas-0.4.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4bf2d35f18920a1eb2d7088b2a49462e75a496edb4b8b56696bc7e0399f52cd5", size = 53795, upload-time = "2026-05-26T02:35:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/fa/6f91ae2c330c9266d11fe19a616a582ecfa6cceb57088d3d5cbe1f3b7c26/tree_sitter_sas-0.4.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:684197a93b0d3df37740b3186a150107abeffa5577173d6a0e04b9ee726549a0", size = 56709, upload-time = "2026-05-26T02:35:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/3b/12/f3508c424a57970717569962d7563db1fe8070f73670fc7fb016b7917dcc/tree_sitter_sas-0.4.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:46931772298910d76cf1f3bc64bd49eaca6e6d954646ebfb6f6c02470975a308", size = 69010, upload-time = "2026-05-26T02:35:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/b9a577b563fb6f0cd5b6f280e83290190be8080a4ed580c20224325b1d31/tree_sitter_sas-0.4.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:074fd05168c7d8ec9e173d4d98ba9698a94b9a7d0a78b5896cb7d110a0d43158", size = 69320, upload-time = "2026-05-26T02:35:13.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/c6bfe22d61537015a90d3b2c13749ea8db0524ce1b94e4cb728c3a344179/tree_sitter_sas-0.4.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d6a4e7518a1465ed4f2e783ae0901f6e7b660c892379f9447466f94ab8011e2", size = 68687, upload-time = "2026-05-26T02:35:14.848Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/2295752b3ef6863ef0e1039748f0b8922527ba287017a4f55dcf1a7c30f8/tree_sitter_sas-0.4.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1be73663c7385e3e894f8b10ecdae636c1d77ea5e921b2d2aee32999c416914d", size = 68387, upload-time = "2026-05-26T02:35:15.823Z" }, + { url = "https://files.pythonhosted.org/packages/db/e0/fa793931bc57c6dccf10cd2ffdc2a96366d157a2f8cc612e7f0829370f3f/tree_sitter_sas-0.4.2-cp310-abi3-win_amd64.whl", hash = "sha256:ca96574dfae47f5a94e2273e38ece25863fd48a4ae5a3d2c03ac2056935a2ce5", size = 57347, upload-time = "2026-05-26T02:35:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/3a/93/1a638a8df76af05abb73ef6e2b22d35e898d3cb15ccbe446b8d125288887/tree_sitter_sas-0.4.2-cp310-abi3-win_arm64.whl", hash = "sha256:d3d4676498418e4d622c244ad36b898d9aaf23ce4cf656ec266673af933642da", size = 55433, upload-time = "2026-05-26T02:35:17.567Z" }, +] + [[package]] name = "tree-sitter-scala" version = "0.26.0" From df4b3c1bc88c5ee2cfcde448b51297a1405502e1 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 00:58:31 +0530 Subject: [PATCH 3/6] docs(readme): list .zsh and .sas in supported file types Co-authored-by: CommandCodeBot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 272c5f6f81..ba2cd0432b 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (38 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .zsh .json .sas .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`, `.sas` requires `uv tool install graphifyy[sas]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar, `.zsh` reuses the Bash grammar) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) | From e5e875f192914f33dd1fd252c8f0dd65d3393804 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 01:10:15 +0530 Subject: [PATCH 4/6] refactor(extract): reuse _read_text, tag .zsh shell family, disambiguate SAS steps Apply simplification-review findings: use the shared _read_text helper for tree-sitter node text (matching sibling extractors), map .zsh to the shell language family so family-gated passes see it, and disambiguate repeated data/proc step node IDs by line so multi-step SAS files keep every step. Co-authored-by: CommandCodeBot --- graphify/extract.py | 2 +- graphify/extractors/sas.py | 11 ++++++----- tests/test_extract.py | 13 +++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index c947de7921..70d13be6bf 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2129,7 +2129,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".ex": "elixir", ".exs": "elixir", ".jl": "julia", ".dart": "dart", - ".sh": "shell", ".bash": "shell", + ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", } diff --git a/graphify/extractors/sas.py b/graphify/extractors/sas.py index 1243d15db3..795e3cb017 100644 --- a/graphify/extractors/sas.py +++ b/graphify/extractors/sas.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -from graphify.extractors.base import _file_stem, _make_id +from graphify.extractors.base import _file_stem, _make_id, _read_text def extract_sas(path: Path) -> dict: @@ -57,7 +57,7 @@ def add_edge(src: str, tgt: str, relation: str, line: int, def _macro_name_text(node: Any) -> str | None: for child in node.children: if child.type == "macro_name": - return child.text.decode("utf-8", errors="replace").strip() + return _read_text(child, source).strip() return None # First pass: collect macro definitions so call sites can resolve. @@ -70,7 +70,7 @@ def _macro_name_text(node: Any) -> str | None: def _step_label(node: Any) -> str | None: for child in node.children: if child.type in ("data_step_header", "proc_step_header"): - text = child.text.decode("utf-8", errors="replace").strip() + text = _read_text(child, source).strip() # strip the trailing `;` so the label reads `data work.customers` return text.rstrip(";").strip() if text else None return None @@ -85,12 +85,13 @@ def _step_label(node: Any) -> str | None: add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="macro") elif node.type == "data_step": label = _step_label(node) or "data" - nid = _make_id(stem, "data") + # disambiguate by line so multiple data steps in one file stay distinct + nid = _make_id(stem, "data", str(node.start_point.row + 1)) add_node(nid, label, node.start_point.row + 1) add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="data_step") elif node.type == "proc_step": label = _step_label(node) or "proc" - nid = _make_id(stem, "proc") + nid = _make_id(stem, "proc", str(node.start_point.row + 1)) add_node(nid, label, node.start_point.row + 1) add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="proc_step") diff --git a/tests/test_extract.py b/tests/test_extract.py index 8006d56237..c333a37691 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2293,6 +2293,19 @@ def test_extract_sas_emits_defines_edges(): assert any(ctx == "macro" for _, _, ctx in defines) +def test_extract_sas_multiple_data_steps_stay_distinct(tmp_path): + # Two data steps in one file must not collapse into a single node (#2681). + f = tmp_path / "multi.sas" + f.write_text("data work.a;\nrun;\n\ndata work.b;\nrun;\n") + result = extract_sas(f) + labels = {n["label"] for n in result["nodes"]} + assert "data work.a" in labels + assert "data work.b" in labels + data_ids = [n["id"] for n in result["nodes"] if n["label"].startswith("data")] + assert len(data_ids) == 2 + assert len(set(data_ids)) == 2 + + def test_extract_sas_macro_call_resolves_to_same_file_definition(): result = extract_sas(FIXTURES / "sample.sas") calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] From 9de8cd40262a8cb9faabdfb31fed3934f41b4c46 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 01:33:50 +0530 Subject: [PATCH 5/6] fix(extract): harden SAS macro resolution and zsh source edges Address code-review findings: resolve macro calls nested inside data/proc steps, case-fold macro names per SAS semantics, dedup defines edges, disambiguate same-line steps by byte offset, distinguish an installed-but- broken SAS grammar from a missing one (#2602 pattern), and drop the dead raw_calls key. Extend bash source/invocation guards from .sh-only to the shell-family suffixes so bare zsh sourcing resolves, and register the new families in build.py's edge-family mirror. Co-authored-by: CommandCodeBot --- graphify/build.py | 1 + graphify/extractors/bash.py | 10 ++++- graphify/extractors/sas.py | 81 ++++++++++++++++++++++++------------- tests/test_extract.py | 51 +++++++++++++++++++++++ 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 470f2c6ba6..b52a3a2685 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -79,6 +79,7 @@ def _is_ast_tier(item: dict) -> bool: ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".sas": "sas", } diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 3c387be1ae..6abdf48a73 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -9,6 +9,12 @@ from graphify.extractors.base import _file_stem, _make_id, _read_text +# Shell-script suffixes the bash extractor resolves as source/invocation +# targets. `.zsh` is included so zsh files route through the same source-edge +# pass (#2825); `.ksh` is accepted for symmetry with the shebang dispatcher. +_SHELL_SUFFIXES = (".sh", ".bash", ".zsh", ".ksh") + + # Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The # canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes # such a variable resolve to the script's own directory, so the literal suffix that @@ -456,10 +462,10 @@ def walk(node, parent_nid: str) -> None: add_edge(file_nid, tgt_nid, "imports", line, context="import") elif cmd and cmd not in defined_functions: - raw = cmd if cmd.endswith(".sh") else None + raw = cmd if cmd.endswith(_SHELL_SUFFIXES) else None if cmd in _BASH_SCRIPT_RUNNERS and args: raw = literal(args[0]) - if raw and raw.endswith(".sh"): + if raw and raw.endswith(_SHELL_SUFFIXES): resolved = (path.parent / raw).resolve() if resolved.is_file(): target_path = resolved diff --git a/graphify/extractors/sas.py b/graphify/extractors/sas.py index 795e3cb017..dcf0b86ea6 100644 --- a/graphify/extractors/sas.py +++ b/graphify/extractors/sas.py @@ -16,8 +16,16 @@ def extract_sas(path: Path) -> dict: try: import tree_sitter_sas as tssas from tree_sitter import Language, Parser - except ImportError: - return {"nodes": [], "edges": [], "error": "tree_sitter_sas not installed"} + except ImportError as e: + import importlib.util + # Distinguish a genuinely-absent grammar from an installed-but-broken + # one (e.g. a C extension built for a different Python ABI, #2602) so + # the #1745 warning does not send the user to a no-op install. + if importlib.util.find_spec("tree_sitter_sas") is None: + return {"nodes": [], "edges": [], + "error": "tree_sitter_sas not installed"} + return {"nodes": [], "edges": [], + "error": f"tree_sitter_sas is installed but failed to load: {e}"} try: language = Language(tssas.language()) @@ -33,6 +41,7 @@ def extract_sas(path: Path) -> dict: nodes: list[dict] = [] edges: list[dict] = [] seen_ids: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() macro_defs: dict[str, str] = {} def add_node(nid: str, label: str, line: int) -> None: @@ -42,11 +51,14 @@ def add_node(nid: str, label: str, line: int) -> None: "source_file": str_path, "source_location": f"L{line}"}) def add_edge(src: str, tgt: str, relation: str, line: int, - confidence: str = "EXTRACTED", weight: float = 1.0, context: str | None = None) -> None: + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) edge = {"source": src, "target": tgt, "relation": relation, - "confidence": confidence, "source_file": str_path, - "source_location": f"L{line}", "weight": weight} + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0} if context: edge["context"] = context edges.append(edge) @@ -54,53 +66,66 @@ def add_edge(src: str, tgt: str, relation: str, line: int, file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) - def _macro_name_text(node: Any) -> str | None: + def _child_text(node: Any, child_types: tuple[str, ...]) -> str | None: for child in node.children: - if child.type == "macro_name": + if child.type in child_types: return _read_text(child, source).strip() return None - # First pass: collect macro definitions so call sites can resolve. + def _macro_name_text(node: Any) -> str | None: + return _child_text(node, ("macro_name",)) + + # First pass: collect macro definitions (case-insensitively, per SAS) so + # call sites resolve regardless of where the definition appears. for node in root.children: if node.type == "macro_definition": name = _macro_name_text(node) if name: - macro_defs[name] = _make_id(stem, name) + macro_defs[name.casefold()] = _make_id(stem, name) def _step_label(node: Any) -> str | None: - for child in node.children: - if child.type in ("data_step_header", "proc_step_header"): - text = _read_text(child, source).strip() - # strip the trailing `;` so the label reads `data work.customers` - return text.rstrip(";").strip() if text else None - return None + text = _child_text(node, ("data_step_header", "proc_step_header")) + # strip the trailing `;` so the label reads `data work.customers` + return text.rstrip(";").strip() if text else None + + def _emit_macro_calls(node: Any) -> None: + """Emit calls edges for macro call statements anywhere in the subtree.""" + stack = [node] + while stack: + current = stack.pop() + if current.type == "macro_call_statement": + name = _macro_name_text(current) + if name: + nid = macro_defs.get(name.casefold()) + if nid: + add_edge(file_nid, nid, "calls", + current.start_point.row + 1, context="call") + stack.extend(current.children) for node in root.children: if node.type == "macro_definition": name = _macro_name_text(node) if not name: continue - nid = macro_defs[name] + nid = macro_defs[name.casefold()] add_node(nid, f"%{name}", node.start_point.row + 1) add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="macro") + _emit_macro_calls(node) elif node.type == "data_step": label = _step_label(node) or "data" - # disambiguate by line so multiple data steps in one file stay distinct - nid = _make_id(stem, "data", str(node.start_point.row + 1)) + # disambiguate by byte offset so multiple steps (even on one line) + # stay distinct + nid = _make_id(stem, "data", str(node.start_byte)) add_node(nid, label, node.start_point.row + 1) add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="data_step") + _emit_macro_calls(node) elif node.type == "proc_step": label = _step_label(node) or "proc" - nid = _make_id(stem, "proc", str(node.start_point.row + 1)) + nid = _make_id(stem, "proc", str(node.start_byte)) add_node(nid, label, node.start_point.row + 1) add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="proc_step") + _emit_macro_calls(node) + else: + _emit_macro_calls(node) - # Macro call sites: emit calls edges to macros defined in this file. - for node in root.children: - if node.type == "macro_call_statement": - name = _macro_name_text(node) - if name and name in macro_defs: - add_edge(file_nid, macro_defs[name], "calls", - node.start_point.row + 1, context="call") - - return {"nodes": nodes, "edges": edges, "raw_calls": []} + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_extract.py b/tests/test_extract.py index c333a37691..2bab4e9bc6 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2315,6 +2315,57 @@ def test_extract_sas_macro_call_resolves_to_same_file_definition(): assert "greet" in target +def test_extract_sas_macro_call_inside_step_resolves(tmp_path): + # #2681: a macro invoked inside a data step (the common inline-function + # idiom) must still emit a calls edge to its same-file definition. + f = tmp_path / "inline.sas" + f.write_text( + "%macro gen_cols();\n" + " %put generating;\n" + "%mend gen_cols;\n" + "data work.x;\n" + " %gen_cols();\n" + "run;\n" + ) + result = extract_sas(f) + calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + assert "gen_cols" in calls[0][1] + + +def test_extract_sas_macro_case_insensitive(tmp_path): + # SAS macro names are case-insensitive: %greet() must resolve %macro Greet. + f = tmp_path / "case.sas" + f.write_text( + "%macro Greet(name);\n" + " %put Hello &name;\n" + "%mend Greet;\n" + "%greet(World);\n" + ) + result = extract_sas(f) + calls = [e for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + assert "greet" in calls[0]["target"] + + +def test_extract_sas_duplicate_macro_defs_dedup_edges(tmp_path): + # Legal SAS redefinition of %macro util must not emit duplicate defines edges. + f = tmp_path / "dup.sas" + f.write_text("%macro util;\n%mend util;\n%macro util;\n%mend util;\n") + result = extract_sas(f) + defines = [e for e in result["edges"] if e["relation"] == "defines"] + assert len(defines) == 1 + + +def test_extract_sas_same_line_steps_stay_distinct(tmp_path): + # Two data steps packed on one line must not collapse into one node. + f = tmp_path / "oneline.sas" + f.write_text("data work.a; run; data work.b; run;\n") + result = extract_sas(f) + data_nodes = [n for n in result["nodes"] if n["label"].startswith("data")] + assert len(data_nodes) == 2 + + def test_extract_sas_missing_dependency_returns_error_marker(monkeypatch): import builtins real_import = builtins.__import__ From 8a47e83cc590752a546a65df5773d4c5c1bab31f Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Tue, 18 Aug 2026 07:53:50 +0530 Subject: [PATCH 6/6] chore: trigger re-review Co-authored-by: CommandCodeBot