Skip to content

Commit 72f2428

Browse files
feat(languages): parse shell to AST (functions, source edges, complexity) (#768)
Promote shell from the config/data passthrough tier to a parsed language. tree-sitter-bash now yields function symbols, `source` / `.` import edges, command call edges, and function-level code-health complexity. - specs/shell.py: grammar + scm + builtin call filter; drop passthrough - queries/shell.scm: function_definition symbols, source/. imports, calls - resolvers/shell.py: literal-relative paths, the $SCRIPT_DIR / dirname idioms, and a unique path-suffix match for project-root anchors (`$BATS_ROOT/$LIBDIR/lib/x.sh`); genuinely dynamic paths stay external - complexity map: CCN / nesting with `&&` / `||` command lists counted - dead code: never-flag *.sh / *.bash / *.zsh (invoked by name, not imported) Smoke on bats-core (54 files): 100% parse rate, 151 function symbols, statically-resolvable source edges resolve, external binaries mint no call edges, and the gnarliest function (CCN 23) surfaces in health.
1 parent 890cce9 commit 72f2428

19 files changed

Lines changed: 688 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,8 @@ Install from the Marketplace (search **Repowise**) or Open VSX, then run
363363
| **Full** | Python · TypeScript · JavaScript · Java · Kotlin · Go · Rust · C++ · C# · Scala · Ruby | AST parsing, import resolution, named bindings, call resolution, heritage extraction, docstrings; multi-project workspace resolvers; framework-aware edges; per-language dynamic-hint extractors; **code-health markers** |
364364
| **Good** | C · Swift · PHP · Dart | AST parsing, import resolution, named bindings, call resolution, heritage (mixins / derive / extensions / traits), docstrings; dedicated workspace-aware resolvers; Laravel / TYPO3 / Flutter framework edges; dynamic-hint extractors; Dart adds code-health + perf markers |
365365
| **SQL / dbt** | `.sql` via sqlglot (postgres, mysql, tsql, clickhouse, ...) | Tables / views / functions / procedures as symbols with wiki pages; dbt projects get real `ref()` / `source()` lineage edges: model-level DAG, hotspots, co-change, ownership |
366-
| **Config / data** | OpenAPI · Protobuf · GraphQL · Dockerfile · Makefile · YAML · JSON · TOML · Terraform · Markdown · Shell | Included in the file tree; special handlers extract endpoints / targets where applicable |
366+
| **Shell** | `.sh` · `.bash` · `.zsh` | Functions as symbols, `source` / `.` import edges (`$SCRIPT_DIR` / `dirname` idioms), and function-level code-health complexity. No class metrics, heritage, or dead-code flagging |
367+
| **Config / data** | OpenAPI · Protobuf · GraphQL · Dockerfile · Makefile · YAML · JSON · TOML · Terraform · Markdown | Included in the file tree; special handlers extract endpoints / targets where applicable |
367368
| **Git-blame only** | Objective-C · Elixir · Erlang · Zig · Julia · Clojure · Haskell · OCaml · F# · … | Tracked in git history (blame, hotspots, co-change); no AST parsing yet |
368369

369370
Adding a language needs **one `.scm` query file and one config entry**, with no

docs/LANGUAGE_SUPPORT.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Language Support
22

3-
repowise parses **15 languages to a full AST**, resolves imports and call
3+
repowise parses **16 languages to a full AST**, resolves imports and call
44
graphs across them, and scores **11 at the Full tier** with code-health markers.
55
Everything else in your repo is still tracked through git history and appears in
66
the wiki. This page is the "what works for my language today" reference.
@@ -22,7 +22,8 @@ produce meaningful output.
2222
| **Full** | Python · TypeScript · JavaScript · Java · Kotlin · Go · Rust · C++ · C# · Scala · Ruby | AST parsing, import resolution, named bindings, call resolution, heritage, docstrings, framework-aware edges, dynamic-hint extractors, and **code-health markers** |
2323
| **Good** | C · Swift · PHP · Dart | Everything above except code-health markers (C, Swift, PHP; Dart *does* get health markers). Dedicated workspace resolvers and framework edges per language |
2424
| **SQL / dbt** | `.sql` via sqlglot | Tables / views / functions / procedures as symbols with wiki pages; dbt projects get real `ref()` / `source()` lineage |
25-
| **Config / data** | OpenAPI · Protobuf · GraphQL · Dockerfile · Makefile · YAML · JSON · TOML · Terraform · Markdown · Shell | In the file tree and wiki; special handlers extract endpoints / targets where applicable |
25+
| **Shell** | `.sh` `.bash` `.zsh` | Function definitions as symbols, `source` / `.` import edges (incl. `$SCRIPT_DIR` / `dirname` / `$BATS_ROOT` idioms), and function-level code-health complexity (CCN, nesting, cognitive). No class metrics, heritage, bindings, or dead-code flagging |
26+
| **Config / data** | OpenAPI · Protobuf · GraphQL · Dockerfile · Makefile · YAML · JSON · TOML · Terraform · Markdown | In the file tree and wiki; special handlers extract endpoints / targets where applicable |
2627
| **Lightweight** | Elixir · Clojure · Haskell · Lean 4 · Erlang · F# | Regex-tier file-level import graph (no symbols/calls). Honest file-to-file dependencies, no symbol-level claims |
2728
| **Partial** | Luau / Roblox | AST symbols + `require()` resolution (Rojo / `.luaurc` aware); no health markers yet |
2829
| **Structural** | Objective-C · R · Zig · Julia · Elm · OCaml · Crystal · Nim · D | Git history only (blame, hotspots, co-change). No AST parsing |
@@ -150,6 +151,20 @@ instance paths (including `:WaitForChild` idioms), absolute Roblox paths via
150151
Rojo's `default.project.json`, and `@alias` requires via `.luaurc`. No health
151152
markers yet.
152153

154+
**Shell** (`.sh` / `.bash` / `.zsh`), function definitions (both `foo()` and
155+
`function foo` forms) become symbols, `source` / `.` statements become import
156+
edges, and calls to functions defined in the same or a sourced file resolve to
157+
call edges (external binaries like `grep` mint no edge). Import resolution
158+
covers literal relative paths plus the common directory-anchor idioms
159+
(`$SCRIPT_DIR/x.sh`, `$(dirname "$0")/x.sh`, `${BASH_SOURCE%/*}/x.sh`) and
160+
project-root anchors (`$BATS_ROOT/$LIBDIR/lib/x.sh`) via a unique path-suffix
161+
match; genuinely dynamic paths (`source "$1"`) stay external. Shell also gets
162+
**function-level complexity** markers (CCN / nesting / cognitive, with `&&` /
163+
`||` command lists counted). tree-sitter-bash parses the bash/POSIX subset, so
164+
zsh mostly works and fish does not; any parse error degrades that file to
165+
passthrough. No class metrics, heritage, bindings, or dead-code flagging (shell
166+
scripts are invoked by name, so static reachability is meaningless).
167+
153168
**Structural** (Objective-C, R, Zig, Julia, Elm, OCaml, Crystal, Nim, D) -
154169
tracked in git history (blame, hotspots, co-change) but no AST parsing. Files
155170
appear in the wiki as traversal-level entries, and the knowledge graph runs in
@@ -178,6 +193,7 @@ is "Full" vs "Good".
178193
| Dart || n/a³ || later ||
179194
| Scala ||| ✅⁴ | later | ✅⁵ |
180195
| Ruby || ✅⁶ | ✅⁷ | later | ✅⁸ |
196+
| Shell | ✅⁹ | n/a | n/a | n/a | n/a |
181197

182198
¹ Go methods attach to a type via an external receiver rather than nesting in a
183199
class body, so class-level metrics aren't computable; Go gets the function- and
@@ -216,6 +232,11 @@ keeps in-memory `Registry.find(name)` lookups silent. Backticks / `system` /
216232
`Open3` are subprocess sinks; `s += "…"` in a loop is flagged while `s << x`
217233
(amortized append) never is.
218234

235+
⁹ Shell gets function-level complexity only (CCN / nesting / cognitive / NLOC).
236+
`&&` / `||` command lists count toward CCN (`cmd || exit 1` is +1), which is
237+
honest: shell branching is chained command lists. There are no classes,
238+
assertions, dataflow, or perf dialect for shell.
239+
219240
The **performance** signal (`io_in_loop`, `string_concat_in_loop`,
220241
`resource_construction_in_loop`, language-specific markers like Go
221242
`defer_in_loop` and C# sync-over-async) and the **dataflow** layer (powering
@@ -237,6 +258,7 @@ where a dialect isn't wired yet. Per-marker mechanics and precision hazards:
237258
| Elixir | Good | Lightweight tier shipped; AST upgrade planned (`tree-sitter-elixir` available) |
238259
| F# | Good | Lightweight tier shipped; AST upgrade planned (`tree-sitter-f-sharp` available) |
239260
| SQL / dbt | - | DDL symbols, dbt lineage, app-to-database contracts, health markers shipped. Next: column-level blast radius |
261+
| Shell | - | Function symbols, `source` import edges, function-level complexity shipped. Next: shebang-based detection of extensionless executables (a traverser capability) |
240262

241263
---
242264

packages/core/src/repowise/core/analysis/dead_code/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020

2121
# Patterns that should never be flagged as dead.
2222
_NEVER_FLAG_PATTERNS: tuple[str, ...] = (
23+
# Shell scripts are invoked by name from CI configs, Makefiles, and humans
24+
# — static reachability is meaningless, so they are never flagged as dead.
25+
# (Shell parses to a real AST now, so it left the passthrough-derived
26+
# ``_NON_CODE_LANGUAGES`` exemption below; these globs restore it.)
27+
"*.sh",
28+
"*.bash",
29+
"*.zsh",
2330
"*__init__.py",
2431
"*__main__.py",
2532
"*conftest.py",

packages/core/src/repowise/core/analysis/health/complexity/languages.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,34 @@ class LanguageNodeMap:
664664
)
665665

666666

667+
_SHELL = LanguageNodeMap(
668+
# Both `foo() {}` and `function foo {}` parse as function_definition; its
669+
# body is a sibling `compound_statement` the shared walker measures.
670+
function_kinds=frozenset({"function_definition"}),
671+
# Shell has no anonymous-function literal.
672+
lambda_kinds=frozenset(),
673+
# `elif` is its own clause (a branch); `else_clause` is not a decision.
674+
branch_kinds=frozenset({"if_statement", "elif_clause"}),
675+
# `until ...` parses as `while_statement` in tree-sitter-bash, so it is
676+
# covered here; `for ((;;))` is `c_style_for_statement`.
677+
loop_kinds=frozenset(
678+
{"for_statement", "c_style_for_statement", "while_statement"}
679+
),
680+
# No exceptions in shell (`trap` is not `try`).
681+
try_kinds=frozenset(),
682+
catch_kinds=frozenset(),
683+
switch_kinds=frozenset({"case_statement"}),
684+
case_kinds=frozenset({"case_item"}),
685+
boolean_operator_kinds=frozenset(),
686+
# `cmd && other` / `cmd || exit 1` parse as a `list` node carrying the
687+
# operator as a direct `&&` / `||` token — the walker sniffs that token.
688+
# This counts the guard idiom (`cmd || exit 1`) as +1 CCN, which is honest:
689+
# shell branching IS chained command lists.
690+
boolean_operator_text_kinds=frozenset({"list"}),
691+
# No classes, no assertions, no perf/dataflow dialect for shell.
692+
)
693+
694+
667695
LANGUAGE_MAPS: dict[str, LanguageNodeMap] = {
668696
"python": _PY,
669697
"typescript": _TS,
@@ -679,6 +707,7 @@ class LanguageNodeMap:
679707
"dart": _DART,
680708
"scala": _SCALA,
681709
"ruby": _RUBY,
710+
"shell": _SHELL,
682711
}
683712

684713

packages/core/src/repowise/core/ingestion/language_configs.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,16 @@ class LanguageConfig:
355355
parent_extraction="none",
356356
parent_class_types=frozenset(),
357357
),
358+
"shell": LanguageConfig(
359+
# Both `foo() {}` and `function foo {}` parse as function_definition.
360+
# Shell has no classes, so no parent tracking.
361+
symbol_node_types={
362+
"function_definition": "function",
363+
},
364+
import_node_types=["command"], # `source` / `.` — see queries/shell.scm
365+
export_node_types=[],
366+
visibility_fn=public_by_default,
367+
parent_extraction="none",
368+
parent_class_types=frozenset(),
369+
),
358370
}

packages/core/src/repowise/core/ingestion/languages/specs/shell.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,71 @@
22

33
from ..spec import LanguageSpec
44

5+
# Shell builtins and no-op keywords that show up as `command` invocations but
6+
# are never user-defined functions — suppressed as call targets so the graph
7+
# only records edges to real functions (defined here or in a sourced file).
8+
_SHELL_BUILTINS = frozenset(
9+
{
10+
".",
11+
":",
12+
"[",
13+
"[[",
14+
"alias",
15+
"bg",
16+
"break",
17+
"builtin",
18+
"cd",
19+
"command",
20+
"continue",
21+
"declare",
22+
"echo",
23+
"eval",
24+
"exec",
25+
"exit",
26+
"export",
27+
"false",
28+
"fg",
29+
"getopts",
30+
"hash",
31+
"let",
32+
"local",
33+
"logout",
34+
"printf",
35+
"pushd",
36+
"popd",
37+
"pwd",
38+
"read",
39+
"readonly",
40+
"return",
41+
"set",
42+
"shift",
43+
"shopt",
44+
"source",
45+
"test",
46+
"trap",
47+
"true",
48+
"type",
49+
"typeset",
50+
"ulimit",
51+
"umask",
52+
"unalias",
53+
"unset",
54+
"wait",
55+
}
56+
)
57+
558
SPEC = LanguageSpec(
659
tag="shell",
760
display_name="Shell",
861
extensions=frozenset({".sh", ".bash", ".zsh"}),
62+
# Shell is code *and* infra: files still promote to CI/infra presentation
63+
# by name/path (see registry.non_infra_code_extensions), but they now get a
64+
# real AST (functions, source edges, function-level complexity).
965
is_infra=True,
10-
is_passthrough=True,
11-
shebang_tokens=("bash", " sh"),
66+
import_support="partial",
67+
grammar_package="tree_sitter_bash",
68+
scm_file="shell.scm",
69+
shebang_tokens=("bash", " sh", "zsh"),
70+
builtin_calls=_SHELL_BUILTINS,
1271
color_hex="#89E051",
1372
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
; =============================================================================
2+
; repowise — Shell (bash/sh/zsh) symbol, import, and call queries
3+
; tree-sitter-bash (install separately if needed)
4+
;
5+
; Scope is deliberately small: function definitions as symbols, `source` / `.`
6+
; as import edges, and command invocations as call targets. External binaries
7+
; (grep, awk, …) are captured as call targets too, but resolve to nothing when
8+
; no matching function symbol exists — the three-tier call resolver drops them.
9+
;
10+
; tree-sitter-bash represents both function forms with `function_definition`
11+
; carrying a `name:` field:
12+
; foo() { ... }
13+
; function foo { ... }
14+
; =============================================================================
15+
16+
; ---------------------------------------------------------------------------
17+
; Symbols — function definitions (both `foo()` and `function foo` forms).
18+
; ---------------------------------------------------------------------------
19+
(function_definition
20+
name: (word) @symbol.name
21+
) @symbol.def
22+
23+
; ---------------------------------------------------------------------------
24+
; Imports — `source <file>` and `. <file>`.
25+
;
26+
; The argument is captured as raw text (a string, word, or concatenation); the
27+
; parser strips surrounding quotes before handing it to resolvers/shell.py,
28+
; which resolves the common `$SCRIPT_DIR/x.sh` / `$(dirname "$0")/x.sh` idioms
29+
; against the sourcing file's directory.
30+
; ---------------------------------------------------------------------------
31+
(command
32+
name: (command_name) @_src_cmd
33+
argument: (_) @import.module
34+
(#match? @_src_cmd "^(source|\\.)$")
35+
) @import.statement
36+
37+
; ---------------------------------------------------------------------------
38+
; Calls — every command invocation's name. Only resolves for calls to
39+
; functions defined in the same file or a sourced file; builtins are filtered
40+
; via `builtin_calls` on the spec, external binaries drop during resolution.
41+
; ---------------------------------------------------------------------------
42+
(command
43+
name: (command_name) @call.target
44+
) @call.site

packages/core/src/repowise/core/ingestion/resolvers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .ruby import resolve_ruby_import
2525
from .rust import resolve_rust_import
2626
from .scala import resolve_scala_import
27+
from .shell import resolve_shell_import
2728
from .sql import resolve_dbt_import
2829
from .swift import resolve_swift_import
2930
from .typescript import resolve_ts_js_import
@@ -46,6 +47,8 @@
4647
"swift": resolve_swift_import,
4748
"scala": resolve_scala_import,
4849
"php": resolve_php_import,
50+
# source ./lib.sh + the $SCRIPT_DIR/$(dirname "$0") idioms.
51+
"shell": resolve_shell_import,
4952
# Lightweight regex-tier resolvers (import_support="partial")
5053
"elixir": resolve_elixir_import,
5154
"dart": resolve_dart_import,

0 commit comments

Comments
 (0)