script-chain-walk 0.1.0__py3-none-any.whl
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.
- script_chain_walk/__init__.py +4 -0
- script_chain_walk/__main__.py +6 -0
- script_chain_walk/cli.py +64 -0
- script_chain_walk/graph.py +194 -0
- script_chain_walk/parse.py +303 -0
- script_chain_walk-0.1.0.dist-info/METADATA +97 -0
- script_chain_walk-0.1.0.dist-info/RECORD +11 -0
- script_chain_walk-0.1.0.dist-info/WHEEL +5 -0
- script_chain_walk-0.1.0.dist-info/entry_points.txt +3 -0
- script_chain_walk-0.1.0.dist-info/licenses/LICENSE +21 -0
- script_chain_walk-0.1.0.dist-info/top_level.txt +1 -0
script_chain_walk/cli.py
ADDED
|
@@ -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,11 @@
|
|
|
1
|
+
script_chain_walk/__init__.py,sha256=Y9xDA_s_LS4PDofWpDbKlYZV0cCoTv0agoFZMhRYU88,111
|
|
2
|
+
script_chain_walk/__main__.py,sha256=2HwRY9v7u8zzV-iGpTu2nH6YR6zrthSuQY1sEijdS-o,129
|
|
3
|
+
script_chain_walk/cli.py,sha256=qU1yYIpsEXcMXKzCysVRHy7YzHfpdIFhZnZG8aIKj00,2203
|
|
4
|
+
script_chain_walk/graph.py,sha256=WHyqNwpA40sAoThrJK9JH0rw8jufL_rQ2Jb5O5GQax0,6912
|
|
5
|
+
script_chain_walk/parse.py,sha256=HnVPBbAroo96vSiwmsN9P4OPdtL252FUYM5D4nhhUlQ,9661
|
|
6
|
+
script_chain_walk-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
7
|
+
script_chain_walk-0.1.0.dist-info/METADATA,sha256=KGQS9opIit7SzwitPd67GP-TkK9ZAlao49AEYSXgLWw,3278
|
|
8
|
+
script_chain_walk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
script_chain_walk-0.1.0.dist-info/entry_points.txt,sha256=kuWFCzsLxDb4B1C1cdsIW8IKHjrHEgCI5JatfVjDMIY,99
|
|
10
|
+
script_chain_walk-0.1.0.dist-info/top_level.txt,sha256=70PQzqX6rn207DnDS0_Yhcae0zB4Ok-g71F2y8ErzXA,18
|
|
11
|
+
script_chain_walk-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
script_chain_walk
|