From 529cd16ce521c311d3be02ade184774c2573dd7a Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 03:45:30 +0530 Subject: [PATCH] feat(extract): support .bats Bash Automated Testing suites (#3361) The bug: Bats test files (.bats) were entirely ignored because the extension was missing from CODE_EXTENSIONS, and even when copied to .bash, the bash AST extractor lacked grammar support for Bats' specific test definitions. The fix: - Added .bats to CODE_EXTENSIONS, _DISPATCH, and language mappings. - Upgraded walk() in extractors/bash.py to properly extract bats test definition nodes. - Recognises native Bats forms: `@test "description" { ... }` - Recognises Bash-compliant forms: `function foo { #@test ... }` - Both emit linkable 'bash_test' nodes into the graph. --- graphify/detect.py | 2 +- graphify/extract.py | 5 +++-- graphify/extractors/bash.py | 29 ++++++++++++++++++++++++----- tests/test_extract_bats.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 tests/test_extract_bats.py diff --git a/graphify/detect.py b/graphify/detect.py index 1adad00bbc..c84c0c17c6 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -41,7 +41,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', '.robot', '.resource'} +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', '.bats', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource'} 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 32c59484db..fc71a5d4e0 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2555,7 +2555,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", ".bats": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", } @@ -5768,6 +5768,7 @@ def add_existing_edge(edge: dict) -> None: ".lpk": extract_lazarus_package, ".sh": extract_bash, ".bash": extract_bash, + ".bats": extract_bash, ".json": extract_json, ".tf": extract_terraform, ".tfvars": extract_terraform, @@ -7057,7 +7058,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", ".bats") or _looks_like_bash(r) ] if sh_pairs: sh_results = [r for r, _ in sh_pairs] diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 341a63785c..33a7e45326 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -283,17 +283,24 @@ def walk(node, parent_nid: str) -> None: if t == "function_definition": name = _bash_func_name(node) if name: - fn_nid = _make_id(stem, name) - line = node.start_point[0] + 1 - add_node(fn_nid, f"{name}()", line, kind="bash_function") - add_edge(parent_nid, fn_nid, "defines", line) - defined_functions.add(name) # find the compound_statement body body = None for child in node.children: if child.type == "compound_statement": body = child break + + kind = "bash_function" + if body is not None: + if "#@test" in _read_text(body, source): + kind = "bash_test" + + fn_nid = _make_id(stem, name) + line = node.start_point[0] + 1 + add_node(fn_nid, f"{name}()", line, kind=kind) + add_edge(parent_nid, fn_nid, "defines", line) + defined_functions.add(name) + function_bodies.append((fn_nid, body)) # Recurse into the body so nested function definitions are discovered # and added to function_bodies for the second-pass walk_calls. @@ -312,6 +319,18 @@ def walk(node, parent_nid: str) -> None: args = [c for c in node.children if c.type in ("word", "string", "concatenation") and c != cmd_name_node] + + if cmd == "@test" and args: + desc = _read_text(args[0], source).strip().strip("'\"") + if desc: + test_nid = _make_id(stem, desc) + line = node.start_point[0] + 1 + add_node(test_nid, desc, line, kind="bash_test") + add_edge(parent_nid, test_nid, "defines", line) + # The test body is parsed as subsequent arguments to the command + function_bodies.append((test_nid, node)) + # We don't return here so `walk()` will still recurse into the children + # in case there are nested function definitions inside the test block. if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions: # find the path argument (first word after command name) if args: diff --git a/tests/test_extract_bats.py b/tests/test_extract_bats.py new file mode 100644 index 0000000000..39ecefdfe3 --- /dev/null +++ b/tests/test_extract_bats.py @@ -0,0 +1,30 @@ +from pathlib import Path +from graphify.extractors.bash import extract_bash +import pytest + +def test_bats_native_and_comment_forms(tmp_path): + src = b''' +@test "addition using bc" { + result="$(echo 2+2 | bc)" + [ "$result" -eq 4 ] +} + +function invoking_foo_without_arguments_prints_usage { #@test + run foo + [ "$status" -eq 1 ] +} +''' + bats_file = tmp_path / 'dummy.bats' + bats_file.write_bytes(src) + + res = extract_bash(bats_file) + if res.get('error'): + pytest.skip(res['error']) + + nodes = {n['id']: n for n in res['nodes']} + + assert 'dummy.bats:addition using bc' in nodes, "Missing native test node" + assert nodes['dummy.bats:addition using bc']['kind'] == 'bash_test' + + assert 'dummy.bats:invoking_foo_without_arguments_prints_usage' in nodes, "Missing comment-form test node" + assert nodes['dummy.bats:invoking_foo_without_arguments_prints_usage']['kind'] == 'bash_test'