script-chain-walk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: script-chain-walk
3
+ Version: 0.1.0
4
+ Summary: Trace cross-file script call chains: build a dependency graph of shell/Python scripts, find broken chains and call cycles
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/script-chain-walk
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Environment :: Console
11
+ Classifier: Topic :: Utilities
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # script-chain-walk
19
+
20
+ Trace **cross-file script call chains**. Point it at a directory of shell and
21
+ Python scripts and it builds the full call graph — `a.sh` calls `b.py` calls
22
+ `c.sh` — then flags **broken chains** (calls to files that don't exist) and
23
+ **call cycles**. Standard library only, zero dependencies.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install script-chain-walk
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Text tree of every call chain in ./scripts
35
+ walk ./scripts
36
+
37
+ # Machine-readable output
38
+ walk ./scripts --format json
39
+
40
+ # Only the chain starting from one entry point
41
+ walk ./scripts --entry deploy.sh
42
+
43
+ # CI / guardrail mode: exit 1 if any broken chain or cycle is found
44
+ walk ./scripts --check
45
+ ```
46
+
47
+ Example output:
48
+
49
+ ```
50
+ deploy.sh
51
+ |-- migrate.py (line 3, exec)
52
+ | `-- seed.sh (line 12, exec)
53
+ `-- notify.sh (line 5, exec)
54
+
55
+ ========================================
56
+ scripts: 4 edges: 3 broken: 1 cycles: 0
57
+
58
+ BROKEN CHAINS (call target not found):
59
+ notify.sh:8 -> ./cleanup.sh (exec, missing)
60
+ ```
61
+
62
+ What it detects:
63
+
64
+ - **Shell**: `bash foo.sh`, `./foo.sh`, `sh -x foo.sh`, `source lib.sh`,
65
+ `. lib.sh`, `python3 bar.py`, `exec ./foo.sh` (comments ignored,
66
+ line continuations joined).
67
+ - **Python** (parsed with the real AST, not regex): `subprocess.run(...)`,
68
+ `os.system(...)` / `os.popen(...)`, `os.execv(...)` and friends,
69
+ `exec(open("other.py").read())`, `runpy.run_path("other.py")`.
70
+ - **Resolution**: relative to the caller first, then a unique basename match
71
+ anywhere in the tree; absolute paths honored too. Unresolvable targets are
72
+ reported as broken (or ambiguous when several files share the name).
73
+
74
+ ## Differentiation
75
+
76
+ Static analyzers like ShellCheck or Pylint look at **one file at a time** —
77
+ they will happily pass a script that calls `./deploy.sh` when `deploy.sh`
78
+ was deleted last month. `script-chain-walk` is the **call map across files**:
79
+ which script triggers which, where the chain dead-ends, and where it loops
80
+ back on itself.
81
+
82
+ If you run coding agents, this is the missing piece of script-behavior
83
+ auditing: before trusting an agent's shell output, `walk --check` its script
84
+ directory and see the actual invocation graph — dead scripts an agent keeps
85
+ "calling", and accidental recursion — instead of reading each file by hand.
86
+
87
+ ## How it works
88
+
89
+ 1. Recursively finds scripts (by extension or shebang).
90
+ 2. Extracts calls: shell lines via quote-aware tokenizing, Python via `ast`.
91
+ 3. Resolves every target against the directory tree.
92
+ 4. DFS over the graph for cycles; in-degree zero for entry points.
93
+ 5. Renders a text tree or JSON; `--check` turns findings into an exit code.
94
+
95
+ ## License
96
+
97
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,80 @@
1
+ # script-chain-walk
2
+
3
+ Trace **cross-file script call chains**. Point it at a directory of shell and
4
+ Python scripts and it builds the full call graph — `a.sh` calls `b.py` calls
5
+ `c.sh` — then flags **broken chains** (calls to files that don't exist) and
6
+ **call cycles**. Standard library only, zero dependencies.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install script-chain-walk
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ # Text tree of every call chain in ./scripts
18
+ walk ./scripts
19
+
20
+ # Machine-readable output
21
+ walk ./scripts --format json
22
+
23
+ # Only the chain starting from one entry point
24
+ walk ./scripts --entry deploy.sh
25
+
26
+ # CI / guardrail mode: exit 1 if any broken chain or cycle is found
27
+ walk ./scripts --check
28
+ ```
29
+
30
+ Example output:
31
+
32
+ ```
33
+ deploy.sh
34
+ |-- migrate.py (line 3, exec)
35
+ | `-- seed.sh (line 12, exec)
36
+ `-- notify.sh (line 5, exec)
37
+
38
+ ========================================
39
+ scripts: 4 edges: 3 broken: 1 cycles: 0
40
+
41
+ BROKEN CHAINS (call target not found):
42
+ notify.sh:8 -> ./cleanup.sh (exec, missing)
43
+ ```
44
+
45
+ What it detects:
46
+
47
+ - **Shell**: `bash foo.sh`, `./foo.sh`, `sh -x foo.sh`, `source lib.sh`,
48
+ `. lib.sh`, `python3 bar.py`, `exec ./foo.sh` (comments ignored,
49
+ line continuations joined).
50
+ - **Python** (parsed with the real AST, not regex): `subprocess.run(...)`,
51
+ `os.system(...)` / `os.popen(...)`, `os.execv(...)` and friends,
52
+ `exec(open("other.py").read())`, `runpy.run_path("other.py")`.
53
+ - **Resolution**: relative to the caller first, then a unique basename match
54
+ anywhere in the tree; absolute paths honored too. Unresolvable targets are
55
+ reported as broken (or ambiguous when several files share the name).
56
+
57
+ ## Differentiation
58
+
59
+ Static analyzers like ShellCheck or Pylint look at **one file at a time** —
60
+ they will happily pass a script that calls `./deploy.sh` when `deploy.sh`
61
+ was deleted last month. `script-chain-walk` is the **call map across files**:
62
+ which script triggers which, where the chain dead-ends, and where it loops
63
+ back on itself.
64
+
65
+ If you run coding agents, this is the missing piece of script-behavior
66
+ auditing: before trusting an agent's shell output, `walk --check` its script
67
+ directory and see the actual invocation graph — dead scripts an agent keeps
68
+ "calling", and accidental recursion — instead of reading each file by hand.
69
+
70
+ ## How it works
71
+
72
+ 1. Recursively finds scripts (by extension or shebang).
73
+ 2. Extracts calls: shell lines via quote-aware tokenizing, Python via `ast`.
74
+ 3. Resolves every target against the directory tree.
75
+ 4. DFS over the graph for cycles; in-degree zero for entry points.
76
+ 5. Renders a text tree or JSON; `--check` turns findings into an exit code.
77
+
78
+ ## License
79
+
80
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "script-chain-walk"
7
+ version = "0.1.0"
8
+ description = "Trace cross-file script call chains: build a dependency graph of shell/Python scripts, find broken chains and call cycles"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "hao li" }]
12
+ requires-python = ">=3.9"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Environment :: Console",
17
+ "Topic :: Utilities",
18
+ "Topic :: Software Development :: Quality Assurance",
19
+ ]
20
+
21
+ [project.scripts]
22
+ script-chain-walk = "script_chain_walk.cli:main"
23
+ walk = "script_chain_walk.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/hahahahahahahahah6/script-chain-walk"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """script-chain-walk: trace cross-file script call chains."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """`python -m script_chain_walk` entry point."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ """Command-line interface for script-chain-walk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .graph import build_graph, render_json, render_text
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="script-chain-walk",
16
+ description="Trace cross-file script call chains: build a dependency "
17
+ "graph of shell/Python scripts, find broken chains and "
18
+ "call cycles.",
19
+ )
20
+ parser.add_argument("directory", nargs="?",
21
+ help="directory to scan (default: current directory)")
22
+ parser.add_argument("--format", choices=["text", "json"], default="text",
23
+ help="output format (default: text)")
24
+ parser.add_argument("--entry", metavar="SCRIPT",
25
+ help="only show the chain starting from SCRIPT "
26
+ "(path relative to the scanned directory)")
27
+ parser.add_argument("--check", action="store_true",
28
+ help="exit 1 if any broken chain or cycle is found "
29
+ "(for CI / agent guardrails)")
30
+ parser.add_argument("--version", action="version",
31
+ version=f"%(prog)s {__version__}")
32
+ return parser
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ parser = build_parser()
37
+ args = parser.parse_args(argv)
38
+ directory = os.path.abspath(args.directory or os.getcwd())
39
+ if not os.path.isdir(directory):
40
+ print(f"error: not a directory: {directory}", file=sys.stderr)
41
+ return 2
42
+
43
+ graph = build_graph(directory)
44
+
45
+ if args.entry:
46
+ entry = os.path.normpath(args.entry)
47
+ if entry not in graph["nodes"]:
48
+ print(f"error: {args.entry} is not a known script in {directory}",
49
+ file=sys.stderr)
50
+ return 2
51
+ graph["entry_points"] = [entry]
52
+
53
+ if args.format == "json":
54
+ print(render_json(graph), end="")
55
+ else:
56
+ print(render_text(graph), end="")
57
+
58
+ if args.check and (graph["broken"] or graph["cycles"]):
59
+ return 1
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
@@ -0,0 +1,194 @@
1
+ """Build and render the cross-file script call graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ from .parse import SKIP_DIRS, analyze_file, is_script
9
+
10
+
11
+ def scan(root: str) -> dict[str, str]:
12
+ """Find all scripts under *root*; returns {relative_path: absolute_path}."""
13
+ scripts: dict[str, str] = {}
14
+ for dirpath, dirnames, filenames in os.walk(root):
15
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
16
+ for name in filenames:
17
+ full = os.path.join(dirpath, name)
18
+ if is_script(full):
19
+ rel = os.path.relpath(full, root)
20
+ scripts[rel] = full
21
+ return scripts
22
+
23
+
24
+ def resolve_target(root: str, scripts: dict[str, str],
25
+ caller_rel: str, target: str) -> tuple[str | None, str]:
26
+ """Resolve a call target to a path relative to *root*.
27
+
28
+ Returns (resolved_rel_or_None, how) where *how* is one of
29
+ ``relative`` (next to the caller), ``searched`` (unique basename match
30
+ elsewhere in the tree), ``absolute`` (absolute path on disk),
31
+ ``ambiguous`` (several basename matches) or ``missing``.
32
+ """
33
+ if os.path.isabs(target):
34
+ if os.path.isfile(target):
35
+ rel = os.path.relpath(target, root)
36
+ return (rel if not rel.startswith("..") else target), "absolute"
37
+ return None, "missing"
38
+ caller_dir = os.path.dirname(os.path.join(root, caller_rel))
39
+ candidate = os.path.normpath(os.path.join(caller_dir, target))
40
+ if os.path.isfile(candidate):
41
+ return os.path.relpath(candidate, root), "relative"
42
+ base = os.path.basename(target)
43
+ matches = sorted(rel for rel in scripts if os.path.basename(rel) == base)
44
+ if len(matches) == 1:
45
+ return matches[0], "searched"
46
+ if len(matches) > 1:
47
+ return None, "ambiguous"
48
+ return None, "missing"
49
+
50
+
51
+ def build_graph(root: str) -> dict:
52
+ """Scan *root* and build the call graph.
53
+
54
+ Returns a dict with ``nodes``, ``edges``, ``broken``, ``cycles`` and
55
+ ``entry_points``. Paths are relative to *root*.
56
+ """
57
+ root = os.path.abspath(root)
58
+ scripts = scan(root)
59
+ edges: dict[tuple[str, str], dict] = {}
60
+ broken: list[dict] = []
61
+
62
+ for caller_rel in sorted(scripts):
63
+ for target, lineno, kind in analyze_file(scripts[caller_rel]):
64
+ resolved, how = resolve_target(root, scripts, caller_rel, target)
65
+ if resolved is None:
66
+ broken.append({
67
+ "caller": caller_rel,
68
+ "target": target,
69
+ "line": lineno,
70
+ "kind": kind,
71
+ "reason": how, # missing | ambiguous
72
+ })
73
+ continue
74
+ key = (caller_rel, resolved)
75
+ edge = edges.setdefault(key, {
76
+ "from": caller_rel, "to": resolved,
77
+ "lines": [], "kind": kind, "resolved_via": how,
78
+ })
79
+ if lineno not in edge["lines"]:
80
+ edge["lines"].append(lineno)
81
+
82
+ adjacency: dict[str, list[str]] = {rel: [] for rel in scripts}
83
+ for (src, dst) in edges:
84
+ if dst not in adjacency:
85
+ adjacency[dst] = []
86
+ if dst not in adjacency[src]:
87
+ adjacency[src].append(dst)
88
+ for node in adjacency:
89
+ adjacency[node].sort()
90
+
91
+ cycles = find_cycles(adjacency)
92
+
93
+ called = {dst for _, dst in edges}
94
+ entry_points = sorted(rel for rel in scripts if rel not in called)
95
+
96
+ return {
97
+ "root": root,
98
+ "nodes": sorted(scripts),
99
+ "edges": [edges[k] for k in sorted(edges)],
100
+ "broken": sorted(broken, key=lambda b: (b["caller"], b["line"])),
101
+ "cycles": cycles,
102
+ "entry_points": entry_points,
103
+ }
104
+
105
+
106
+ def find_cycles(adjacency: dict[str, list[str]]) -> list[list[str]]:
107
+ """Return simple cycles found via iterative DFS (white/gray/black)."""
108
+ cycles: list[list[str]] = []
109
+ color: dict[str, int] = {} # 1 = gray (on stack), 2 = black (done)
110
+ stack: list[str] = []
111
+
112
+ def visit(node: str) -> None:
113
+ color[node] = 1
114
+ stack.append(node)
115
+ for nxt in adjacency.get(node, []):
116
+ if color.get(nxt) == 1:
117
+ cycle = stack[stack.index(nxt):] + [nxt]
118
+ if cycle not in cycles:
119
+ cycles.append(cycle)
120
+ elif color.get(nxt) != 2:
121
+ visit(nxt)
122
+ stack.pop()
123
+ color[node] = 2
124
+
125
+ for node in sorted(adjacency):
126
+ if color.get(node) != 2:
127
+ visit(node)
128
+ return cycles
129
+
130
+
131
+ def render_text(graph: dict) -> str:
132
+ """Render the call graph as an ASCII tree per entry point, plus a report."""
133
+ lines: list[str] = []
134
+ adjacency: dict[str, list[dict]] = {}
135
+ for edge in graph["edges"]:
136
+ adjacency.setdefault(edge["from"], []).append(edge)
137
+ for lst in adjacency.values():
138
+ lst.sort(key=lambda e: (e["to"], e["lines"]))
139
+
140
+ def tree(node: str, prefix: str, seen: tuple[str, ...]) -> None:
141
+ children = adjacency.get(node, [])
142
+ for idx, edge in enumerate(children):
143
+ last = idx == len(children) - 1
144
+ branch = "`-- " if last else "|-- "
145
+ dst = edge["to"]
146
+ line = edge["lines"][0]
147
+ marker = ""
148
+ if dst in seen:
149
+ marker = " [CYCLE]"
150
+ lines.append(f"{prefix}{branch}{dst} (line {line}, {edge['kind']}){marker}")
151
+ if dst not in seen:
152
+ tree(dst, prefix + (" " if last else "| "), seen + (dst,))
153
+
154
+ entries = graph["entry_points"] or graph["nodes"]
155
+ for entry in entries:
156
+ lines.append(entry)
157
+ tree(entry, "", (entry,))
158
+ lines.append("")
159
+
160
+ lines.append("=" * 40)
161
+ lines.append(f"scripts: {len(graph['nodes'])} "
162
+ f"edges: {len(graph['edges'])} "
163
+ f"broken: {len(graph['broken'])} "
164
+ f"cycles: {len(graph['cycles'])}")
165
+ if graph["broken"]:
166
+ lines.append("")
167
+ lines.append("BROKEN CHAINS (call target not found):")
168
+ for b in graph["broken"]:
169
+ lines.append(f" {b['caller']}:{b['line']} -> {b['target']} "
170
+ f"({b['kind']}, {b['reason']})")
171
+ if graph["cycles"]:
172
+ lines.append("")
173
+ lines.append("CYCLES:")
174
+ for cycle in graph["cycles"]:
175
+ lines.append(" " + " -> ".join(cycle))
176
+ return "\n".join(lines).rstrip() + "\n"
177
+
178
+
179
+ def render_json(graph: dict) -> str:
180
+ payload = {
181
+ "root": graph["root"],
182
+ "nodes": graph["nodes"],
183
+ "edges": graph["edges"],
184
+ "broken": graph["broken"],
185
+ "cycles": graph["cycles"],
186
+ "entry_points": graph["entry_points"],
187
+ "summary": {
188
+ "scripts": len(graph["nodes"]),
189
+ "edges": len(graph["edges"]),
190
+ "broken": len(graph["broken"]),
191
+ "cycles": len(graph["cycles"]),
192
+ },
193
+ }
194
+ return json.dumps(payload, indent=2) + "\n"
@@ -0,0 +1,303 @@
1
+ """Static extraction of script-to-script calls from shell and Python sources.
2
+
3
+ Standard library only. The extraction is heuristic but conservative: a call
4
+ is only recorded when the target token looks like a script path (starts with
5
+ ./, ../ or /, or ends with a known script extension).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ import re
13
+ import shlex
14
+
15
+ SHELL_EXTS = (".sh", ".bash", ".zsh", ".ksh", ".dash")
16
+ SCRIPT_EXTS = SHELL_EXTS + (".py", ".pl", ".rb", ".js")
17
+
18
+ INTERPRETERS = {
19
+ "bash", "sh", "dash", "zsh", "ksh",
20
+ "python", "python2", "python3", "perl", "ruby", "node",
21
+ }
22
+
23
+ SUBPROCESS_FUNCS = {"run", "call", "check_call", "check_output", "Popen"}
24
+ OS_SHELL_FUNCS = {"system", "popen"}
25
+ OS_EXEC_FUNCS = {
26
+ "execv", "execvp", "execve", "execl", "execlp", "execvpe",
27
+ "spawnl", "spawnlp", "spawnlpe", "spawnv", "spawnvp", "spawnvpe",
28
+ }
29
+
30
+ SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules",
31
+ ".hg", ".svn", "dist", "build", "target"}
32
+
33
+
34
+ def strip_comment(line: str) -> str:
35
+ """Remove a trailing ``#`` comment, ignoring ``#`` inside quotes."""
36
+ out: list[str] = []
37
+ quote: str | None = None
38
+ i = 0
39
+ while i < len(line):
40
+ ch = line[i]
41
+ if quote:
42
+ if ch == "\\" and i + 1 < len(line):
43
+ out.append(ch)
44
+ out.append(line[i + 1])
45
+ i += 2
46
+ continue
47
+ if ch == quote:
48
+ quote = None
49
+ out.append(ch)
50
+ else:
51
+ if ch in ("'", '"'):
52
+ quote = ch
53
+ out.append(ch)
54
+ elif ch == "#":
55
+ break
56
+ else:
57
+ out.append(ch)
58
+ i += 1
59
+ return "".join(out)
60
+
61
+
62
+ def join_continuations(text: str) -> list[str]:
63
+ """Join backslash line continuations; returns logical lines."""
64
+ logical: list[str] = []
65
+ buf = ""
66
+ for raw in text.splitlines():
67
+ stripped = raw.rstrip()
68
+ if stripped.endswith("\\") and not stripped.endswith("\\\\"):
69
+ buf += stripped[:-1] + " "
70
+ else:
71
+ logical.append(buf + raw)
72
+ buf = ""
73
+ if buf:
74
+ logical.append(buf)
75
+ return logical
76
+
77
+
78
+ def _split_segments(command: str) -> list[str]:
79
+ """Split a shell command string on ;, &&, || and |, respecting quotes."""
80
+ segments: list[str] = []
81
+ buf: list[str] = []
82
+ quote: str | None = None
83
+ i = 0
84
+ while i < len(command):
85
+ ch = command[i]
86
+ if quote:
87
+ if ch == "\\" and i + 1 < len(command):
88
+ buf.append(ch)
89
+ buf.append(command[i + 1])
90
+ i += 2
91
+ continue
92
+ if ch == quote:
93
+ quote = None
94
+ buf.append(ch)
95
+ i += 1
96
+ continue
97
+ if ch in ("'", '"'):
98
+ quote = ch
99
+ buf.append(ch)
100
+ i += 1
101
+ continue
102
+ two = command[i:i + 2]
103
+ if two in ("&&", "||"):
104
+ segments.append("".join(buf))
105
+ buf = []
106
+ i += 2
107
+ continue
108
+ if ch in (";", "|"):
109
+ segments.append("".join(buf))
110
+ buf = []
111
+ i += 1
112
+ continue
113
+ buf.append(ch)
114
+ i += 1
115
+ segments.append("".join(buf))
116
+ return segments
117
+
118
+
119
+ def looks_like_script(token: str) -> bool:
120
+ """Conservative check: does this token look like a script invocation?"""
121
+ if not token or token.startswith("-"):
122
+ return False
123
+ base = os.path.basename(token)
124
+ if base.endswith(SCRIPT_EXTS):
125
+ return True
126
+ if token.startswith(("./", "../", "/")):
127
+ return True
128
+ return False
129
+
130
+
131
+ def _is_interpreter(token: str) -> bool:
132
+ base = os.path.basename(token)
133
+ return token in INTERPRETERS or base in INTERPRETERS
134
+
135
+
136
+ def calls_from_tokens(tokens: list[str]) -> list[tuple[str, str]]:
137
+ """Extract (target, kind) calls from an argv-style token list."""
138
+ calls: list[tuple[str, str]] = []
139
+ i, n = 0, len(tokens)
140
+ while i < n:
141
+ tok = tokens[i]
142
+ if _is_interpreter(tok):
143
+ # interpreter [flags...] script
144
+ j = i + 1
145
+ while j < n and tokens[j].startswith("-") and tokens[j] != "-":
146
+ j += 1
147
+ if j < n:
148
+ calls.append((tokens[j], "exec"))
149
+ i = j + 1
150
+ continue
151
+ if tok == "source" or tok == ".":
152
+ if i + 1 < n:
153
+ calls.append((tokens[i + 1], "source"))
154
+ i += 2
155
+ continue
156
+ if tok == "exec":
157
+ if i + 1 < n and looks_like_script(tokens[i + 1]):
158
+ calls.append((tokens[i + 1], "exec"))
159
+ i += 2
160
+ continue
161
+ if looks_like_script(tok):
162
+ calls.append((tok, "exec"))
163
+ i += 1
164
+ continue
165
+ i += 1
166
+ return calls
167
+
168
+
169
+ def analyze_command_string(command: str) -> list[tuple[str, str]]:
170
+ """Extract script calls from a shell command string."""
171
+ calls: list[tuple[str, str]] = []
172
+ for segment in _split_segments(command):
173
+ segment = strip_comment(segment).strip()
174
+ if not segment:
175
+ continue
176
+ try:
177
+ tokens = shlex.split(segment, posix=True)
178
+ except ValueError:
179
+ continue
180
+ calls.extend(calls_from_tokens(tokens))
181
+ return calls
182
+
183
+
184
+ def analyze_shell(text: str) -> list[tuple[str, int, str]]:
185
+ """Extract (target, line, kind) calls from shell script source."""
186
+ calls: list[tuple[str, int, str]] = []
187
+ for lineno, line in enumerate(join_continuations(text), start=1):
188
+ for target, kind in analyze_command_string(line):
189
+ calls.append((target, lineno, kind))
190
+ return calls
191
+
192
+
193
+ def _const_str(node: ast.AST) -> str | None:
194
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
195
+ return node.value
196
+ return None
197
+
198
+
199
+ def _const_argv(node: ast.AST) -> list[str] | None:
200
+ if isinstance(node, (ast.List, ast.Tuple)):
201
+ items: list[str] = []
202
+ for elt in node.elts:
203
+ s = _const_str(elt)
204
+ if s is None:
205
+ return None
206
+ items.append(s)
207
+ return items
208
+ return None
209
+
210
+
211
+ class _PythonCallVisitor(ast.NodeVisitor):
212
+ def __init__(self) -> None:
213
+ self.calls: list[tuple[str, int, str]] = []
214
+
215
+ def visit_Call(self, node: ast.Call) -> None:
216
+ self._check_call(node)
217
+ self.generic_visit(node)
218
+
219
+ def _check_call(self, node: ast.Call) -> None:
220
+ func = node.func
221
+ mod: str | None = None
222
+ name: str | None = None
223
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
224
+ mod, name = func.value.id, func.attr
225
+ elif isinstance(func, ast.Name):
226
+ name = func.id
227
+ if not name or not node.args:
228
+ return
229
+ first = node.args[0]
230
+
231
+ if (mod == "subprocess" or mod is None) and name in SUBPROCESS_FUNCS:
232
+ argv = _const_argv(first)
233
+ if argv is not None:
234
+ for target, kind in calls_from_tokens(argv):
235
+ self.calls.append((target, node.lineno, kind))
236
+ else:
237
+ cmd = _const_str(first)
238
+ if cmd:
239
+ for target, kind in analyze_command_string(cmd):
240
+ self.calls.append((target, node.lineno, kind))
241
+ elif mod == "os" and name in OS_SHELL_FUNCS:
242
+ cmd = _const_str(first)
243
+ if cmd:
244
+ for target, kind in analyze_command_string(cmd):
245
+ self.calls.append((target, node.lineno, kind))
246
+ elif mod == "os" and name in OS_EXEC_FUNCS:
247
+ target = _const_str(first)
248
+ if target and looks_like_script(target):
249
+ self.calls.append((target, node.lineno, "exec"))
250
+ elif mod is None and name == "exec":
251
+ # exec(open("other.py").read())
252
+ if (isinstance(first, ast.Call)
253
+ and isinstance(first.func, ast.Name)
254
+ and first.func.id == "open"
255
+ and first.args):
256
+ target = _const_str(first.args[0])
257
+ if target and looks_like_script(target):
258
+ self.calls.append((target, node.lineno, "exec"))
259
+ elif mod == "runpy" and name == "run_path":
260
+ target = _const_str(first)
261
+ if target and looks_like_script(target):
262
+ self.calls.append((target, node.lineno, "exec"))
263
+
264
+
265
+ def analyze_python(text: str) -> list[tuple[str, int, str]]:
266
+ """Extract (target, line, kind) calls from Python source via the AST."""
267
+ try:
268
+ tree = ast.parse(text)
269
+ except SyntaxError:
270
+ return []
271
+ visitor = _PythonCallVisitor()
272
+ visitor.visit(tree)
273
+ return visitor.calls
274
+
275
+
276
+ def is_script(path: str) -> bool:
277
+ """True if the file looks like a script (extension or shebang)."""
278
+ base = os.path.basename(path)
279
+ if base.endswith(SCRIPT_EXTS):
280
+ return True
281
+ try:
282
+ with open(path, "rb") as fh:
283
+ if fh.read(2) == b"#!":
284
+ return True
285
+ except OSError:
286
+ return False
287
+ return False
288
+
289
+
290
+ def analyze_file(path: str) -> list[tuple[str, int, str]]:
291
+ """Dispatch to the right analyzer based on filename / shebang."""
292
+ try:
293
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
294
+ text = fh.read()
295
+ except OSError:
296
+ return []
297
+ base = os.path.basename(path)
298
+ if base.endswith(".py"):
299
+ return analyze_python(text)
300
+ first = text.split("\n", 1)[0] if text else ""
301
+ if "python" in first and first.startswith("#!"):
302
+ return analyze_python(text)
303
+ return analyze_shell(text)
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: script-chain-walk
3
+ Version: 0.1.0
4
+ Summary: Trace cross-file script call chains: build a dependency graph of shell/Python scripts, find broken chains and call cycles
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/script-chain-walk
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Environment :: Console
11
+ Classifier: Topic :: Utilities
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # script-chain-walk
19
+
20
+ Trace **cross-file script call chains**. Point it at a directory of shell and
21
+ Python scripts and it builds the full call graph — `a.sh` calls `b.py` calls
22
+ `c.sh` — then flags **broken chains** (calls to files that don't exist) and
23
+ **call cycles**. Standard library only, zero dependencies.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install script-chain-walk
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Text tree of every call chain in ./scripts
35
+ walk ./scripts
36
+
37
+ # Machine-readable output
38
+ walk ./scripts --format json
39
+
40
+ # Only the chain starting from one entry point
41
+ walk ./scripts --entry deploy.sh
42
+
43
+ # CI / guardrail mode: exit 1 if any broken chain or cycle is found
44
+ walk ./scripts --check
45
+ ```
46
+
47
+ Example output:
48
+
49
+ ```
50
+ deploy.sh
51
+ |-- migrate.py (line 3, exec)
52
+ | `-- seed.sh (line 12, exec)
53
+ `-- notify.sh (line 5, exec)
54
+
55
+ ========================================
56
+ scripts: 4 edges: 3 broken: 1 cycles: 0
57
+
58
+ BROKEN CHAINS (call target not found):
59
+ notify.sh:8 -> ./cleanup.sh (exec, missing)
60
+ ```
61
+
62
+ What it detects:
63
+
64
+ - **Shell**: `bash foo.sh`, `./foo.sh`, `sh -x foo.sh`, `source lib.sh`,
65
+ `. lib.sh`, `python3 bar.py`, `exec ./foo.sh` (comments ignored,
66
+ line continuations joined).
67
+ - **Python** (parsed with the real AST, not regex): `subprocess.run(...)`,
68
+ `os.system(...)` / `os.popen(...)`, `os.execv(...)` and friends,
69
+ `exec(open("other.py").read())`, `runpy.run_path("other.py")`.
70
+ - **Resolution**: relative to the caller first, then a unique basename match
71
+ anywhere in the tree; absolute paths honored too. Unresolvable targets are
72
+ reported as broken (or ambiguous when several files share the name).
73
+
74
+ ## Differentiation
75
+
76
+ Static analyzers like ShellCheck or Pylint look at **one file at a time** —
77
+ they will happily pass a script that calls `./deploy.sh` when `deploy.sh`
78
+ was deleted last month. `script-chain-walk` is the **call map across files**:
79
+ which script triggers which, where the chain dead-ends, and where it loops
80
+ back on itself.
81
+
82
+ If you run coding agents, this is the missing piece of script-behavior
83
+ auditing: before trusting an agent's shell output, `walk --check` its script
84
+ directory and see the actual invocation graph — dead scripts an agent keeps
85
+ "calling", and accidental recursion — instead of reading each file by hand.
86
+
87
+ ## How it works
88
+
89
+ 1. Recursively finds scripts (by extension or shebang).
90
+ 2. Extracts calls: shell lines via quote-aware tokenizing, Python via `ast`.
91
+ 3. Resolves every target against the directory tree.
92
+ 4. DFS over the graph for cycles; in-degree zero for entry points.
93
+ 5. Renders a text tree or JSON; `--check` turns findings into an exit code.
94
+
95
+ ## License
96
+
97
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/script_chain_walk/__init__.py
5
+ src/script_chain_walk/__main__.py
6
+ src/script_chain_walk/cli.py
7
+ src/script_chain_walk/graph.py
8
+ src/script_chain_walk/parse.py
9
+ src/script_chain_walk.egg-info/PKG-INFO
10
+ src/script_chain_walk.egg-info/SOURCES.txt
11
+ src/script_chain_walk.egg-info/dependency_links.txt
12
+ src/script_chain_walk.egg-info/entry_points.txt
13
+ src/script_chain_walk.egg-info/top_level.txt
14
+ tests/test_walk.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ script-chain-walk = script_chain_walk.cli:main
3
+ walk = script_chain_walk.cli:main
@@ -0,0 +1 @@
1
+ script_chain_walk
@@ -0,0 +1,194 @@
1
+ """Smoke tests for script-chain-walk (stdlib unittest only)."""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import shutil
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from contextlib import redirect_stdout
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
13
+
14
+ from script_chain_walk.cli import main
15
+ from script_chain_walk.graph import build_graph
16
+
17
+
18
+ def write_tree(root, files):
19
+ for rel, content in files.items():
20
+ full = os.path.join(root, rel)
21
+ os.makedirs(os.path.dirname(full), exist_ok=True)
22
+ with open(full, "w") as fh:
23
+ fh.write(content)
24
+
25
+
26
+ class ChainWalkTests(unittest.TestCase):
27
+ def setUp(self):
28
+ self.tmp = tempfile.mkdtemp()
29
+
30
+ def tearDown(self):
31
+ shutil.rmtree(self.tmp, ignore_errors=True)
32
+
33
+ def run_cli(self, *argv):
34
+ buf = io.StringIO()
35
+ with redirect_stdout(buf):
36
+ code = main(list(argv))
37
+ return code, buf.getvalue()
38
+
39
+ def test_shell_calls_shell(self):
40
+ write_tree(self.tmp, {
41
+ "a.sh": "#!/bin/bash\n./b.sh\n",
42
+ "b.sh": "#!/bin/bash\necho hi\n",
43
+ })
44
+ graph = build_graph(self.tmp)
45
+ self.assertEqual(len(graph["edges"]), 1)
46
+ self.assertEqual(graph["edges"][0]["from"], "a.sh")
47
+ self.assertEqual(graph["edges"][0]["to"], "b.sh")
48
+
49
+ def test_shell_calls_python_via_interpreter(self):
50
+ write_tree(self.tmp, {
51
+ "deploy.sh": "#!/bin/bash\npython3 migrate.py --all\n",
52
+ "migrate.py": "print('migrating')\n",
53
+ })
54
+ graph = build_graph(self.tmp)
55
+ self.assertEqual(len(graph["edges"]), 1)
56
+ self.assertEqual(graph["edges"][0]["to"], "migrate.py")
57
+
58
+ def test_python_calls_shell_via_subprocess(self):
59
+ write_tree(self.tmp, {
60
+ "run.py": "import subprocess\nsubprocess.run(['./setup.sh'])\n",
61
+ "setup.sh": "#!/bin/bash\necho ok\n",
62
+ })
63
+ graph = build_graph(self.tmp)
64
+ self.assertEqual(len(graph["edges"]), 1)
65
+ self.assertEqual(graph["edges"][0]["from"], "run.py")
66
+ self.assertEqual(graph["edges"][0]["to"], "setup.sh")
67
+
68
+ def test_python_os_system_string(self):
69
+ write_tree(self.tmp, {
70
+ "run.py": "import os\nos.system('bash cleanup.sh && ./verify.sh')\n",
71
+ "cleanup.sh": "#!/bin/bash\necho clean\n",
72
+ "verify.sh": "#!/bin/bash\necho verify\n",
73
+ })
74
+ graph = build_graph(self.tmp)
75
+ targets = sorted(e["to"] for e in graph["edges"])
76
+ self.assertEqual(targets, ["cleanup.sh", "verify.sh"])
77
+
78
+ def test_broken_chain_detected(self):
79
+ write_tree(self.tmp, {
80
+ "a.sh": "#!/bin/bash\n./missing.sh\n",
81
+ })
82
+ graph = build_graph(self.tmp)
83
+ self.assertEqual(len(graph["edges"]), 0)
84
+ self.assertEqual(len(graph["broken"]), 1)
85
+ self.assertEqual(graph["broken"][0]["target"], "./missing.sh")
86
+ self.assertEqual(graph["broken"][0]["caller"], "a.sh")
87
+
88
+ def test_cycle_detected(self):
89
+ write_tree(self.tmp, {
90
+ "a.sh": "#!/bin/bash\n./b.sh\n",
91
+ "b.sh": "#!/bin/bash\n./a.sh\n",
92
+ })
93
+ graph = build_graph(self.tmp)
94
+ self.assertEqual(len(graph["cycles"]), 1)
95
+ self.assertEqual(set(graph["cycles"][0]), {"a.sh", "b.sh"})
96
+
97
+ def test_self_cycle(self):
98
+ write_tree(self.tmp, {
99
+ "loop.sh": "#!/bin/bash\n./loop.sh\n",
100
+ })
101
+ graph = build_graph(self.tmp)
102
+ self.assertEqual(len(graph["cycles"]), 1)
103
+
104
+ def test_comments_are_ignored(self):
105
+ write_tree(self.tmp, {
106
+ "a.sh": "#!/bin/bash\n# ./ghost.sh is not real\n./real.sh # trailing ok\n",
107
+ "real.sh": "#!/bin/bash\necho hi\n",
108
+ })
109
+ graph = build_graph(self.tmp)
110
+ self.assertEqual(len(graph["edges"]), 1)
111
+ self.assertEqual(graph["edges"][0]["to"], "real.sh")
112
+ self.assertEqual(len(graph["broken"]), 0)
113
+
114
+ def test_source_command(self):
115
+ write_tree(self.tmp, {
116
+ "main.sh": "#!/bin/bash\nsource lib.sh\n. ./helpers.sh\n",
117
+ "lib.sh": "FOO=1\n",
118
+ "helpers.sh": "BAR=2\n",
119
+ })
120
+ graph = build_graph(self.tmp)
121
+ targets = sorted(e["to"] for e in graph["edges"])
122
+ self.assertEqual(targets, ["helpers.sh", "lib.sh"])
123
+ self.assertTrue(all(e["kind"] == "source" for e in graph["edges"]))
124
+
125
+ def test_json_output_is_valid(self):
126
+ write_tree(self.tmp, {
127
+ "a.sh": "#!/bin/bash\n./b.sh\n",
128
+ "b.sh": "#!/bin/bash\necho hi\n",
129
+ })
130
+ code, out = self.run_cli(self.tmp, "--format", "json")
131
+ self.assertEqual(code, 0)
132
+ payload = json.loads(out)
133
+ self.assertIn("a.sh", payload["nodes"])
134
+ self.assertEqual(payload["summary"]["edges"], 1)
135
+ self.assertEqual(payload["entry_points"], ["a.sh"])
136
+
137
+ def test_text_output_shows_tree(self):
138
+ write_tree(self.tmp, {
139
+ "a.sh": "#!/bin/bash\n./b.sh\n",
140
+ "b.sh": "#!/bin/bash\necho hi\n",
141
+ })
142
+ code, out = self.run_cli(self.tmp)
143
+ self.assertEqual(code, 0)
144
+ self.assertIn("a.sh", out)
145
+ self.assertIn("b.sh", out)
146
+ self.assertIn("scripts: 2", out)
147
+
148
+ def test_check_flag_fails_on_broken(self):
149
+ write_tree(self.tmp, {
150
+ "a.sh": "#!/bin/bash\n./missing.sh\n",
151
+ })
152
+ code, _ = self.run_cli(self.tmp, "--check")
153
+ self.assertEqual(code, 1)
154
+
155
+ def test_check_flag_fails_on_cycle(self):
156
+ write_tree(self.tmp, {
157
+ "a.sh": "#!/bin/bash\n./b.sh\n",
158
+ "b.sh": "#!/bin/bash\n./a.sh\n",
159
+ })
160
+ code, _ = self.run_cli(self.tmp, "--check")
161
+ self.assertEqual(code, 1)
162
+
163
+ def test_check_flag_passes_on_clean(self):
164
+ write_tree(self.tmp, {
165
+ "a.sh": "#!/bin/bash\n./b.sh\n",
166
+ "b.sh": "#!/bin/bash\necho hi\n",
167
+ })
168
+ code, _ = self.run_cli(self.tmp, "--check")
169
+ self.assertEqual(code, 0)
170
+
171
+ def test_subdirectory_resolution(self):
172
+ write_tree(self.tmp, {
173
+ "bin/run.sh": "#!/bin/bash\n../lib/helper.sh\n",
174
+ "lib/helper.sh": "#!/bin/bash\necho hi\n",
175
+ })
176
+ graph = build_graph(self.tmp)
177
+ self.assertEqual(len(graph["edges"]), 1)
178
+ edge = graph["edges"][0]
179
+ self.assertEqual(edge["from"], os.path.join("bin", "run.sh"))
180
+ self.assertEqual(edge["to"], os.path.join("lib", "helper.sh"))
181
+ self.assertEqual(edge["resolved_via"], "relative")
182
+
183
+ def test_entry_points(self):
184
+ write_tree(self.tmp, {
185
+ "a.sh": "#!/bin/bash\n./b.sh\n",
186
+ "b.sh": "#!/bin/bash\necho hi\n",
187
+ "lonely.py": "print('alone')\n",
188
+ })
189
+ graph = build_graph(self.tmp)
190
+ self.assertEqual(sorted(graph["entry_points"]), ["a.sh", "lonely.py"])
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main(verbosity=2)