Static analysis tool that parses a multi-file Bash project with
tree-sitter-bash,
resolves if/elif/case control flow across function-call
boundaries, and renders the result as a Graphviz decision tree.
Unlike a plain AST dump, the tool follows function calls used as
conditions (e.g. if check_database; then ...) and — wherever the
called function's own return value can be determined statically —
inlines that function's internal logic directly into the branch that
called it, instead of leaving a separate, opaque "see elsewhere" edge.
check_database() {
if [ "$status" = "up" ]; then
echo "DB OK"; return 0
else
echo "DB DOWN"; return 1
fi
}
if check_database; then
echo "Deploy"
else
echo "Rollback"
fiis rendered as a single, flat decision — $status = up directly
drives Deploy / Rollback — rather than two disconnected nodes
joined by a generic "detail" edge.
- Pipeline overview
- Requirements
- Project files
- Usage
- Output format
- How the flattening works
- Known limitations
The tool is three independent stages, connected only through JSON on disk. Each stage can be run — and tested — on its own.
.sh source files
│
▼
┌───────────────────┐
│ project_analyzer.py│ tree-sitter parse → raw per-function / per-file JSON
└───────────────────┘
│ raw JSON {"files": {...}, "functions": {...}}
▼
┌───────────────────┐
│ simplify.py │ expands function calls, resolves return/exit
│ (library, no CLI) │ semantics, inlines nested conditions
└───────────────────┘
│ clean JSON {filename: [decision_tree, ...]}
▼
┌───────────────────┐
│ render_dot.py │ clean JSON → Graphviz DOT
└───────────────────┘
│
▼
dot -Tpng / -Tsvg → final diagram
simplify.py has no command-line entry point of its own; it is
imported directly by project_analyzer.py when the --clean flag is
passed. It never touches Bash or tree-sitter — it only transforms
already-parsed JSON — so it can be re-run or unit-tested on a saved
output.json without re-parsing anything.
- Python 3.9+
tree-sittertree-sitter-bash- Graphviz (
dotexecutable) to render the.dotoutput to an image — not required to generate the.dotfile itself
pip install tree-sitter tree-sitter-bashOnly the files that take part in actually running the pipeline are listed; everything else used during development (ad-hoc test scripts, one-off JSON samples) is not part of the tool itself.
| File | Role |
|---|---|
project_analyzer.py |
Stage 1. Parses the project with tree-sitter and produces the raw JSON (functions + per-file decision_trees). Has the command-line entry point for the whole pipeline. |
simplify.py |
Stage 2. Expands function calls, resolves return/exit semantics, and inlines nested conditions. Imported by project_analyzer.py --clean; not run directly. |
render_dot.py |
Stage 3. Converts the clean JSON into Graphviz .dot files. Has its own command-line entry point. |
python project_analyzer.py <path-to-project-or-.sh-files> --clean -o clean.json<path-to-project-or-.sh-files>— one or more arguments: a directory (searched recursively for*.sh) and/or individual.shfile paths.--clean— run the output throughsimplify.py(function-call expansion and flattening) instead of emitting the raw, unexpanded AST-level JSON.-o / --output— write to a file instead of stdout.
Omitting --clean produces the raw per-function / per-file JSON that
simplify.py itself consumes — useful for inspecting what
tree-sitter actually extracted before any flattening logic runs.
python render_dot.py clean.json -o tree_dots/One .dot file is written per top-level decision tree, named
<source-file-stem>_<index>.dot.
| Flag | Effect |
|---|---|
-o / --outdir |
Output directory (default: tree_dots/) |
--simple |
Omit condition_detail / value_detail edges (shown by default) |
--show-before |
Also show before context on if/case condition nodes (hidden by default; function-call nodes always show it, since there it's essential control flow rather than decorative lead-in) |
--depth N |
Collapse anything deeper than N levels below the root into a single ... node |
dot -Tpng tree_dots/myscript_1.dot -o myscript_1.png
dot -Tsvg tree_dots/myscript_1.dot -o myscript_1.svgcirco or neato can be used instead of dot for a circular or
spring layout.
Raw JSON (project_analyzer.py without --clean):
{
"files": {
"main.sh": {
"sources": ["lib/checks.sh", "..."],
"decision_trees": [ /* top-level if/case nodes, AST-faithful */ ]
}
},
"functions": {
"check_database": {
"defined_in": "lib/checks.sh",
"actions": [...],
"nested_ifs": [...],
"after_actions": [...]
}
}
}Clean JSON (--clean): {filename: [decision_tree, ...]}, where
each decision tree is built from a small set of node shapes:
| Shape | Meaning |
|---|---|
{"condition", "yes", "no", "condition_detail"?} |
An if (or a folded function condition). condition_detail is only present when the condition's own internal logic could not be resolved statically. |
{"case_value", "branches", "value_detail"?} |
A case statement; branches is a list of {"pattern", "then"}. |
{"function", "defined_in", "actions", "before"?, "then"?, "call_text"?} |
An expanded function call. actions is the callee's own body; then is what runs next at this specific call site (never merged into actions, since the body is shared across call sites); call_text is the literal call as written (e.g. log_error "disk full"), since the generic body alone can't show which argument was passed. |
| string / list of strings | A plain leaf — one or more sequential statements with no further branching. |
- Expansion (
_expand_callinsimplify.py): every function call is replaced by that function's own simplified body, recursively, with a cache so a given function is only analyzed once and avisitedset to guard against (mutual) recursion. - Truth resolution (
_leaf_truth): a branch's outcome is known when it ends in an explicitreturn N—return 0→ true, any other value → false. This also looks inside a trailing function call (e.g.log_error; return 1) rather than giving up as soon as the last statement isn't a barereturn. - Exit is not ambiguity (
_dead_ends_in_exit): a path ending inexitnever returns control to its caller at all, so it is excluded from the vote on the function's truth value rather than blocking resolution the way a genuinely unknown outcome would. - Inlining (
_flatten_node/_try_flatten): if every path through a condition'scondition_detailresolves (exits aside), the detail subtree replaces the condition outright, with the caller's ownyes/nospliced onto each resolved leaf. If even one path is genuinely unresolvable, the condition is left untouched with itscondition_detailattached, rather than guessing. This inlining is unbounded — it keeps following resolvable nested function conditions as deep as the project actually has them, with no artificial depth or size cap.
- No argument substitution. A callee's body is shown generically
(e.g.
echo "ERROR: $1"); the actual argument text at a given call site is preserved separately ascall_text, but it is not substituted into the body itself. - Only explicit numeric
return Nis resolved. A barereturn,return $?, orreturn $SOME_VARremains unresolved, so the surrounding condition keeps itscondition_detailrather than being inlined. - Output size grows with nested, resolvable conditions, with no cap.
Each inline duplicates its surroundings across the branches of what's
being inlined, so the cost compounds with the depth of nested,
resolvable function conditions — a project with several such levels
stacked on one path, or many independently-resolvable decisions
overall, can produce a very large clean JSON /
.dotfile. There is currently no depth or size limit guarding against this; if it becomes a problem in practice,render_dot.py's--depthflag can still be used to limit how much of an already-generated tree gets drawn. - No general data-flow analysis. Variable values are not tracked
across statements or function boundaries beyond what's needed for
return/exitresolution.