pureshellcheck 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.
- pureshellcheck/__init__.py +58 -0
- pureshellcheck/analyzer.py +403 -0
- pureshellcheck/astlib.py +302 -0
- pureshellcheck/checks/__init__.py +6 -0
- pureshellcheck/checks/commands.py +1003 -0
- pureshellcheck/checks/misc.py +233 -0
- pureshellcheck/checks/quoting.py +440 -0
- pureshellcheck/checks/variables.py +348 -0
- pureshellcheck/cli.py +162 -0
- pureshellcheck/parser.py +2050 -0
- pureshellcheck/shast.py +106 -0
- pureshellcheck/varflow.py +584 -0
- pureshellcheck/varscan.py +511 -0
- pureshellcheck-0.1.0.dist-info/METADATA +184 -0
- pureshellcheck-0.1.0.dist-info/RECORD +19 -0
- pureshellcheck-0.1.0.dist-info/WHEEL +5 -0
- pureshellcheck-0.1.0.dist-info/entry_points.txt +2 -0
- pureshellcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- pureshellcheck-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Whole-script variable reference/assignment collection.
|
|
2
|
+
|
|
3
|
+
Approximates ShellCheck's getReferencedVariables/getModifiedVariables
|
|
4
|
+
"variable flow" used by SC2034 (unused), SC2154/SC2153 (unassigned), and
|
|
5
|
+
the array checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .astlib import braced_modifier, braced_reference
|
|
11
|
+
from .parser import ParseError, Parser, literal_text, quoted_literal_text
|
|
12
|
+
from .shast import walk
|
|
13
|
+
|
|
14
|
+
NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
15
|
+
SQ_REF_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)")
|
|
16
|
+
|
|
17
|
+
DECLARING = {"declare", "typeset", "local", "export", "readonly"}
|
|
18
|
+
|
|
19
|
+
READ_ARG_FLAGS = set("dinNptu")
|
|
20
|
+
MAPFILE_ARG_FLAGS = set("CcdnOsu")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Ref:
|
|
24
|
+
__slots__ = ("name", "node", "kind")
|
|
25
|
+
|
|
26
|
+
def __init__(self, name, node, kind="normal"):
|
|
27
|
+
self.name = name
|
|
28
|
+
self.node = node
|
|
29
|
+
self.kind = kind # normal | guarded | index-of-other | prefix
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Assign:
|
|
33
|
+
__slots__ = ("name", "node", "exported", "is_array", "append", "kind")
|
|
34
|
+
|
|
35
|
+
def __init__(self, name, node, exported=False, is_array=False,
|
|
36
|
+
append=False, kind="var"):
|
|
37
|
+
self.name = name
|
|
38
|
+
self.node = node
|
|
39
|
+
self.exported = exported
|
|
40
|
+
self.is_array = is_array
|
|
41
|
+
self.append = append
|
|
42
|
+
self.kind = kind # var | checked | declare
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VarScan:
|
|
46
|
+
def __init__(self, root, shell="bash"):
|
|
47
|
+
self.refs = []
|
|
48
|
+
self.assigns = []
|
|
49
|
+
self.assoc_arrays = set()
|
|
50
|
+
self._suppressed = set()
|
|
51
|
+
self.root = root
|
|
52
|
+
self.shell = shell
|
|
53
|
+
self._prescan_assoc()
|
|
54
|
+
self._scan()
|
|
55
|
+
|
|
56
|
+
def _prescan_assoc(self):
|
|
57
|
+
"""Find `declare -A name` declarations before the main scan."""
|
|
58
|
+
for node in walk(self.root):
|
|
59
|
+
if node.kind != "T_SimpleCommand" or not node.words:
|
|
60
|
+
continue
|
|
61
|
+
cmd = literal_text(node.words[0])
|
|
62
|
+
if cmd not in ("declare", "typeset", "local"):
|
|
63
|
+
continue
|
|
64
|
+
lits = [literal_text(w) for w in node.words[1:]]
|
|
65
|
+
if not any(t and t.startswith("-") and "A" in t for t in lits):
|
|
66
|
+
continue
|
|
67
|
+
for t in lits:
|
|
68
|
+
if t and not t.startswith(("-", "+")) \
|
|
69
|
+
and NAME_RE.match(t.split("[", 1)[0]):
|
|
70
|
+
self.assoc_arrays.add(t.split("[", 1)[0])
|
|
71
|
+
for a in node.assigns:
|
|
72
|
+
self.assoc_arrays.add(a.name)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _scan(self):
|
|
77
|
+
for node in walk(self.root):
|
|
78
|
+
k = node.kind
|
|
79
|
+
method = getattr(self, "_scan_" + k, None)
|
|
80
|
+
if method is not None:
|
|
81
|
+
method(node)
|
|
82
|
+
|
|
83
|
+
def _ref(self, name, node, kind="normal"):
|
|
84
|
+
self.refs.append(Ref(name, node, kind))
|
|
85
|
+
|
|
86
|
+
def _assign(self, name, node, **kw):
|
|
87
|
+
self.assigns.append(Assign(name, node, **kw))
|
|
88
|
+
|
|
89
|
+
# -- expansions --------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _scan_T_DollarBraced(self, node):
|
|
92
|
+
content = node.content
|
|
93
|
+
name = braced_reference(content)
|
|
94
|
+
if not name:
|
|
95
|
+
return
|
|
96
|
+
kind = "normal"
|
|
97
|
+
if content.startswith("!") and len(content) > 1:
|
|
98
|
+
# indirection ${!name} or prefix match ${!pre*}
|
|
99
|
+
if content.endswith("*") or content.endswith("@"):
|
|
100
|
+
self._ref(name, node, "prefix")
|
|
101
|
+
return
|
|
102
|
+
# enclosed in a different variable's ${...}? => index, not a ref
|
|
103
|
+
parent = node.parent
|
|
104
|
+
while parent is not None:
|
|
105
|
+
if parent.kind == "T_DollarBraced":
|
|
106
|
+
if braced_reference(parent.content) != name:
|
|
107
|
+
kind = "index-of-other"
|
|
108
|
+
break
|
|
109
|
+
if parent.kind in ("T_SimpleCommand", "T_Script"):
|
|
110
|
+
break
|
|
111
|
+
parent = parent.parent
|
|
112
|
+
mod = braced_modifier(content)
|
|
113
|
+
if re.match(r"^(\[.*\])?:?[-?]", mod):
|
|
114
|
+
kind = "guarded"
|
|
115
|
+
if mod.startswith("+") or mod.startswith(":+"):
|
|
116
|
+
kind = "guarded"
|
|
117
|
+
if mod.startswith("=") or mod.startswith(":="):
|
|
118
|
+
self._assign(name, node)
|
|
119
|
+
self._ref(name, node, kind)
|
|
120
|
+
# ${a:offset:length} -- offset/length are arithmetic
|
|
121
|
+
if mod.startswith(":") and not mod[1:2] in ("-", "=", "?", "+"):
|
|
122
|
+
self._arith_text_refs(mod[1:], node)
|
|
123
|
+
# indices are arithmetic for non-associative arrays; bare names in
|
|
124
|
+
# them count as uses but not as warnable unassigned references
|
|
125
|
+
idx = _braced_index_text(content)
|
|
126
|
+
if idx is not None and name not in self.assoc_arrays:
|
|
127
|
+
self._arith_text_refs(idx, node, kind="index-of-other")
|
|
128
|
+
elif idx is not None:
|
|
129
|
+
# associative array: bare names in the index are string keys
|
|
130
|
+
for sub in node.get("indices", ()):
|
|
131
|
+
for d in walk(sub):
|
|
132
|
+
if d.kind == "TA_Variable":
|
|
133
|
+
self._suppressed.add(id(d))
|
|
134
|
+
|
|
135
|
+
def _arith_text_refs(self, text, node, kind="normal",
|
|
136
|
+
with_dollar=False):
|
|
137
|
+
if not text or (not with_dollar and "$" in text):
|
|
138
|
+
return # $-refs inside are collected by the normal walk
|
|
139
|
+
try:
|
|
140
|
+
sub = Parser(text)
|
|
141
|
+
expr = sub.read_arith_seq()
|
|
142
|
+
if not sub.at_end():
|
|
143
|
+
return
|
|
144
|
+
except (ParseError, RecursionError):
|
|
145
|
+
return
|
|
146
|
+
self._collect_arith(expr, node, kind)
|
|
147
|
+
|
|
148
|
+
def _collect_arith(self, expr, origin, kind="normal"):
|
|
149
|
+
skip = set()
|
|
150
|
+
for n in walk(expr):
|
|
151
|
+
if n.kind == "TA_Variable":
|
|
152
|
+
if id(n) not in skip:
|
|
153
|
+
self._ref(n.name, origin, kind)
|
|
154
|
+
elif n.kind == "TA_Assignment" and n.left.kind == "TA_Variable":
|
|
155
|
+
self._assign(n.left.name, origin)
|
|
156
|
+
if n.op == "=":
|
|
157
|
+
skip.add(id(n.left))
|
|
158
|
+
elif n.kind == "T_DollarBraced":
|
|
159
|
+
name = braced_reference(n.content)
|
|
160
|
+
if name:
|
|
161
|
+
self._ref(name, origin, kind)
|
|
162
|
+
|
|
163
|
+
# -- arithmetic --------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _scan_TA_Variable(self, node):
|
|
166
|
+
if id(node) in self._suppressed:
|
|
167
|
+
return
|
|
168
|
+
self._ref(node.name, node)
|
|
169
|
+
if node.name in self.assoc_arrays:
|
|
170
|
+
for idx in node.get("indices", ()):
|
|
171
|
+
for d in walk(idx):
|
|
172
|
+
if d.kind == "TA_Variable":
|
|
173
|
+
self._suppressed.add(id(d))
|
|
174
|
+
|
|
175
|
+
def _scan_TA_Assignment(self, node):
|
|
176
|
+
left = node.left
|
|
177
|
+
if left.kind == "TA_Variable":
|
|
178
|
+
self._assign(left.name, node)
|
|
179
|
+
if node.op != "=":
|
|
180
|
+
self._ref(left.name, node)
|
|
181
|
+
else:
|
|
182
|
+
self._suppressed.add(id(left))
|
|
183
|
+
|
|
184
|
+
def _scan_TA_Unary(self, node):
|
|
185
|
+
if node.op.lstrip("|") in ("++", "--") \
|
|
186
|
+
and node.operand.kind == "TA_Variable":
|
|
187
|
+
self._assign(node.operand.name, node)
|
|
188
|
+
self._ref(node.operand.name, node)
|
|
189
|
+
|
|
190
|
+
# -- conditions --------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _scan_TC_Unary(self, node):
|
|
193
|
+
op = node.op
|
|
194
|
+
if op in ("-v", "-R"):
|
|
195
|
+
text = _literal_prefix(node.operand)
|
|
196
|
+
name = text.split("[", 1)[0]
|
|
197
|
+
if NAME_RE.match(name):
|
|
198
|
+
self._ref(name, node)
|
|
199
|
+
self._assign(name, node, kind="checked")
|
|
200
|
+
full = quoted_literal_text(node.operand)
|
|
201
|
+
if full and "[" in full and name not in self.assoc_arrays:
|
|
202
|
+
idx = full.split("[", 1)[1].rstrip("]")
|
|
203
|
+
self._arith_text_refs(idx, node, kind="index-of-other")
|
|
204
|
+
elif op in ("-n", "-z"):
|
|
205
|
+
self._mark_checked(node.operand)
|
|
206
|
+
|
|
207
|
+
def _scan_TC_Nullary(self, node):
|
|
208
|
+
self._mark_checked(node.word)
|
|
209
|
+
|
|
210
|
+
ARITH_TEST_OPS = frozenset({"-eq", "-ne", "-lt", "-le", "-gt", "-ge"})
|
|
211
|
+
|
|
212
|
+
def _scan_TC_Binary(self, node):
|
|
213
|
+
if node.op not in self.ARITH_TEST_OPS:
|
|
214
|
+
return
|
|
215
|
+
cond = node.parent
|
|
216
|
+
while cond is not None and cond.kind not in ("T_Condition",
|
|
217
|
+
"T_SimpleCommand",
|
|
218
|
+
"T_Script"):
|
|
219
|
+
cond = cond.parent
|
|
220
|
+
if cond is None or cond.kind != "T_Condition" or cond.single:
|
|
221
|
+
return
|
|
222
|
+
# in [[ ]], arithmetic comparisons evaluate bare names as variables
|
|
223
|
+
for side in (node.lhs, node.rhs):
|
|
224
|
+
text = quoted_literal_text(side)
|
|
225
|
+
if text:
|
|
226
|
+
self._arith_text_refs(text, side)
|
|
227
|
+
|
|
228
|
+
def _mark_checked(self, word):
|
|
229
|
+
if word is None:
|
|
230
|
+
return
|
|
231
|
+
for n in walk(word):
|
|
232
|
+
if n.kind == "T_DollarBraced":
|
|
233
|
+
name = braced_reference(n.content)
|
|
234
|
+
if name and NAME_RE.match(name):
|
|
235
|
+
self._assign(name, n, kind="checked")
|
|
236
|
+
|
|
237
|
+
# -- commands ----------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def _scan_T_Assignment(self, node):
|
|
240
|
+
parent = node.parent
|
|
241
|
+
exported = False
|
|
242
|
+
is_decl_arg = False
|
|
243
|
+
flags = ""
|
|
244
|
+
if parent is not None and parent.kind == "T_SimpleCommand":
|
|
245
|
+
if node in parent.assigns and parent.words:
|
|
246
|
+
cmd = literal_text(parent.words[0])
|
|
247
|
+
cmd = cmd.rsplit("/", 1)[-1] if cmd else None
|
|
248
|
+
if cmd in DECLARING:
|
|
249
|
+
is_decl_arg = True
|
|
250
|
+
for w in parent.words[1:]:
|
|
251
|
+
t = literal_text(w)
|
|
252
|
+
if t and t.startswith("-"):
|
|
253
|
+
flags += t[1:]
|
|
254
|
+
if cmd == "export" or "x" in flags:
|
|
255
|
+
exported = True
|
|
256
|
+
if "A" in flags:
|
|
257
|
+
self.assoc_arrays.add(node.name)
|
|
258
|
+
value = node.get("value")
|
|
259
|
+
is_array = value is not None and value.kind == "T_Array"
|
|
260
|
+
self._assign(node.name, node, exported=exported, is_array=is_array,
|
|
261
|
+
append=node.get("append", False))
|
|
262
|
+
for idx in node.get("indices", ()):
|
|
263
|
+
if node.name not in self.assoc_arrays and isinstance(idx, str):
|
|
264
|
+
self._arith_text_refs(idx, node, with_dollar=True)
|
|
265
|
+
if is_array:
|
|
266
|
+
for el in value.elements:
|
|
267
|
+
if el.kind == "T_IndexedElement" \
|
|
268
|
+
and node.name not in self.assoc_arrays \
|
|
269
|
+
and isinstance(el.index, str):
|
|
270
|
+
self._arith_text_refs(el.index, el, with_dollar=True)
|
|
271
|
+
|
|
272
|
+
def _scan_T_SimpleCommand(self, node):
|
|
273
|
+
if not node.words:
|
|
274
|
+
return
|
|
275
|
+
cmd = literal_text(node.words[0])
|
|
276
|
+
if cmd is None:
|
|
277
|
+
return
|
|
278
|
+
cmd = cmd.rsplit("/", 1)[-1]
|
|
279
|
+
if cmd in ("builtin", "command"):
|
|
280
|
+
rest = node.words[1:]
|
|
281
|
+
if rest:
|
|
282
|
+
inner = literal_text(rest[0])
|
|
283
|
+
if inner in DECLARING:
|
|
284
|
+
cmd = inner
|
|
285
|
+
node = _Shifted(node, 1)
|
|
286
|
+
args = node.words[1:]
|
|
287
|
+
lits = [literal_text(w) for w in args]
|
|
288
|
+
if cmd in DECLARING:
|
|
289
|
+
flags = "".join(t[1:] for t in lits
|
|
290
|
+
if t and t.startswith("-") and "=" not in t)
|
|
291
|
+
if "f" in flags or "F" in flags:
|
|
292
|
+
return # function operations
|
|
293
|
+
for w, t in zip(args, lits):
|
|
294
|
+
if not t or t.startswith("-") or t.startswith("+") \
|
|
295
|
+
or "=" in t:
|
|
296
|
+
continue
|
|
297
|
+
if not NAME_RE.match(t.split("[", 1)[0]):
|
|
298
|
+
continue
|
|
299
|
+
name = t.split("[", 1)[0]
|
|
300
|
+
if "A" in flags:
|
|
301
|
+
self.assoc_arrays.add(name)
|
|
302
|
+
if "p" in flags:
|
|
303
|
+
self._ref(name, w)
|
|
304
|
+
continue
|
|
305
|
+
exported = cmd == "export" or "x" in flags
|
|
306
|
+
if cmd == "readonly" and "=" not in t:
|
|
307
|
+
# readonly without value: marks existing var
|
|
308
|
+
self._ref(name, w)
|
|
309
|
+
self._assign(name, w, exported=exported,
|
|
310
|
+
is_array="a" in flags or "A" in flags,
|
|
311
|
+
kind="declare")
|
|
312
|
+
if exported or cmd in ("export", "readonly"):
|
|
313
|
+
self._ref(name, w, "guarded" if False else "normal")
|
|
314
|
+
elif cmd == "read":
|
|
315
|
+
self._scan_read(node, args, lits)
|
|
316
|
+
elif cmd in ("mapfile", "readarray"):
|
|
317
|
+
skip = False
|
|
318
|
+
for w, t in zip(args, lits):
|
|
319
|
+
if skip:
|
|
320
|
+
skip = False
|
|
321
|
+
continue
|
|
322
|
+
if t and t.startswith("--"):
|
|
323
|
+
continue
|
|
324
|
+
if t and t.startswith("-"):
|
|
325
|
+
if t[-1] in MAPFILE_ARG_FLAGS:
|
|
326
|
+
skip = True
|
|
327
|
+
continue
|
|
328
|
+
if t and NAME_RE.match(t):
|
|
329
|
+
self._assign(t, w, is_array=True)
|
|
330
|
+
break
|
|
331
|
+
elif cmd == "getopts":
|
|
332
|
+
if len(args) >= 2:
|
|
333
|
+
t = lits[1]
|
|
334
|
+
if t and NAME_RE.match(t):
|
|
335
|
+
self._assign(t, args[1])
|
|
336
|
+
elif cmd == "printf":
|
|
337
|
+
qlits = [quoted_literal_text(w) for w in args]
|
|
338
|
+
for i, t in enumerate(qlits):
|
|
339
|
+
name = None
|
|
340
|
+
if t == "-v" and i + 1 < len(qlits):
|
|
341
|
+
name = qlits[i + 1]
|
|
342
|
+
elif t and t.startswith("-v") and len(t) > 2:
|
|
343
|
+
name = t[2:]
|
|
344
|
+
if name:
|
|
345
|
+
name = name.split("[", 1)[0]
|
|
346
|
+
if NAME_RE.match(name):
|
|
347
|
+
self._assign(name, node)
|
|
348
|
+
break
|
|
349
|
+
elif cmd == "wait":
|
|
350
|
+
for i, t in enumerate(lits):
|
|
351
|
+
if t == "-p" and i + 1 < len(lits) and lits[i + 1]:
|
|
352
|
+
name = lits[i + 1]
|
|
353
|
+
if NAME_RE.match(name):
|
|
354
|
+
self._assign(name, args[i + 1])
|
|
355
|
+
elif cmd == "let":
|
|
356
|
+
for w, t in zip(args, lits):
|
|
357
|
+
text = quoted_literal_text(w)
|
|
358
|
+
if text:
|
|
359
|
+
try:
|
|
360
|
+
sub = Parser(text)
|
|
361
|
+
expr = sub.read_arith_seq()
|
|
362
|
+
if sub.at_end():
|
|
363
|
+
self._collect_arith(expr, w)
|
|
364
|
+
except (ParseError, RecursionError):
|
|
365
|
+
pass
|
|
366
|
+
elif cmd == "unset":
|
|
367
|
+
for w, t in zip(args, lits):
|
|
368
|
+
if t and not t.startswith("-"):
|
|
369
|
+
name = t.split("[", 1)[0]
|
|
370
|
+
if NAME_RE.match(name):
|
|
371
|
+
self._ref(name, w, "guarded")
|
|
372
|
+
elif cmd in ("trap", "alias"):
|
|
373
|
+
self._scan_quoted_refs(args)
|
|
374
|
+
elif cmd.startswith("DEFINE_") and cmd[7:] in (
|
|
375
|
+
"string", "boolean", "integer", "float"):
|
|
376
|
+
if lits and lits[0] and NAME_RE.match(lits[0]):
|
|
377
|
+
self._assign("FLAGS_" + lits[0], node)
|
|
378
|
+
|
|
379
|
+
def _scan_read(self, node, args, lits):
|
|
380
|
+
names = []
|
|
381
|
+
skip = False
|
|
382
|
+
is_array = False
|
|
383
|
+
for w, t in zip(args, lits):
|
|
384
|
+
if skip:
|
|
385
|
+
skip = False
|
|
386
|
+
continue
|
|
387
|
+
if t and t.startswith("-") and len(t) > 1:
|
|
388
|
+
rest = t[1:]
|
|
389
|
+
for j, ch in enumerate(rest):
|
|
390
|
+
if ch == "a":
|
|
391
|
+
is_array = True
|
|
392
|
+
elif ch in READ_ARG_FLAGS:
|
|
393
|
+
if j == len(rest) - 1:
|
|
394
|
+
skip = True # argument is the next word
|
|
395
|
+
break # attached argument: rest is consumed
|
|
396
|
+
continue
|
|
397
|
+
if t and NAME_RE.match(t.split("[", 1)[0]):
|
|
398
|
+
names.append((t.split("[", 1)[0], w))
|
|
399
|
+
if not names:
|
|
400
|
+
self._assign("REPLY", node)
|
|
401
|
+
for name, w in names:
|
|
402
|
+
self._assign(name, w, is_array=is_array and
|
|
403
|
+
(name, w) == names[-1])
|
|
404
|
+
|
|
405
|
+
def _scan_quoted_refs(self, words):
|
|
406
|
+
for w in words:
|
|
407
|
+
for n in walk(w):
|
|
408
|
+
if n.kind == "T_SingleQuoted":
|
|
409
|
+
for m in SQ_REF_RE.finditer(n.text):
|
|
410
|
+
self._ref(m.group(1), n)
|
|
411
|
+
|
|
412
|
+
def _scan_T_ForIn(self, node):
|
|
413
|
+
self._assign(node.variable, node)
|
|
414
|
+
|
|
415
|
+
_scan_T_SelectIn = _scan_T_ForIn
|
|
416
|
+
|
|
417
|
+
def _scan_T_CoProc(self, node):
|
|
418
|
+
name = node.get("name") or "COPROC"
|
|
419
|
+
self._assign(name, node, is_array=True)
|
|
420
|
+
|
|
421
|
+
def _scan_T_FdRedirect(self, node):
|
|
422
|
+
fd = node.get("fd")
|
|
423
|
+
if fd and fd.startswith("{") and fd.endswith("}"):
|
|
424
|
+
name = fd[1:-1]
|
|
425
|
+
op = node.op
|
|
426
|
+
closing = (not isinstance(op, str)
|
|
427
|
+
and op.kind == "T_IoDuplicate"
|
|
428
|
+
and op.target == "-")
|
|
429
|
+
if closing:
|
|
430
|
+
self._ref(name, node)
|
|
431
|
+
else:
|
|
432
|
+
self._assign(name, node)
|
|
433
|
+
|
|
434
|
+
def _scan_T_SingleQuoted(self, node):
|
|
435
|
+
# references in PS1='...$var...' and friends
|
|
436
|
+
parent = node.parent
|
|
437
|
+
seen = 0
|
|
438
|
+
while parent is not None and seen < 3:
|
|
439
|
+
if parent.kind == "T_Assignment" and parent.name in (
|
|
440
|
+
"PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND"):
|
|
441
|
+
for m in SQ_REF_RE.finditer(node.text):
|
|
442
|
+
self._ref(m.group(1), node)
|
|
443
|
+
return
|
|
444
|
+
parent = parent.parent
|
|
445
|
+
seen += 1
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class _Shifted:
|
|
449
|
+
"""View of a simple command with the first word removed."""
|
|
450
|
+
|
|
451
|
+
def __init__(self, node, n):
|
|
452
|
+
self.kind = node.kind
|
|
453
|
+
self.words = node.words[n:]
|
|
454
|
+
self.parent = node.parent
|
|
455
|
+
self.pos = node.pos
|
|
456
|
+
self.end = node.end
|
|
457
|
+
|
|
458
|
+
def get(self, *a, **k):
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _literal_prefix(word):
|
|
463
|
+
"""Concatenated literal text of leading constant parts of a word."""
|
|
464
|
+
if word is None:
|
|
465
|
+
return ""
|
|
466
|
+
parts = word.parts if word.kind in ("T_NormalWord",) else [word]
|
|
467
|
+
out = []
|
|
468
|
+
for p in parts:
|
|
469
|
+
if p.kind == "T_Literal":
|
|
470
|
+
out.append(p.text)
|
|
471
|
+
elif p.kind in ("T_SingleQuoted", "T_DollarSingleQuoted"):
|
|
472
|
+
out.append(p.text)
|
|
473
|
+
elif p.kind in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
|
|
474
|
+
for q in p.parts:
|
|
475
|
+
if q.kind == "T_Literal":
|
|
476
|
+
out.append(q.text)
|
|
477
|
+
else:
|
|
478
|
+
return "".join(out)
|
|
479
|
+
else:
|
|
480
|
+
break
|
|
481
|
+
return "".join(out)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _braced_index_text(content):
|
|
485
|
+
m = re.match(r"[!#]?(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)\[(.*?)\]",
|
|
486
|
+
content)
|
|
487
|
+
return m.group(1) if m else None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _flag_arg_attached(flag):
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def levenshtein(a, b, cap=3):
|
|
495
|
+
"""Edit distance, returning `cap` early once it can't be beaten."""
|
|
496
|
+
if a == b:
|
|
497
|
+
return 0
|
|
498
|
+
if abs(len(a) - len(b)) >= cap:
|
|
499
|
+
return cap
|
|
500
|
+
if len(a) > len(b):
|
|
501
|
+
a, b = b, a
|
|
502
|
+
prev = list(range(len(a) + 1))
|
|
503
|
+
for i, cb in enumerate(b):
|
|
504
|
+
cur = [i + 1]
|
|
505
|
+
for j, ca in enumerate(a):
|
|
506
|
+
cur.append(min(prev[j + 1] + 1, cur[j] + 1,
|
|
507
|
+
prev[j] + (ca != cb)))
|
|
508
|
+
if min(cur) >= cap:
|
|
509
|
+
return cap
|
|
510
|
+
prev = cur
|
|
511
|
+
return prev[-1]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pureshellcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pure Python reimplementation of ShellCheck's most common checks
|
|
5
|
+
Author: adam2go
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/adam2go/pureshellcheck
|
|
8
|
+
Keywords: shellcheck,shell,bash,lint,static-analysis,pure-python
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# pureshellcheck
|
|
29
|
+
|
|
30
|
+
[](https://github.com/adam2go/pureshellcheck/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/pureshellcheck/)
|
|
32
|
+
[-brightgreen)](tests/data/expected_failures.txt)
|
|
33
|
+
|
|
34
|
+
A pure Python reimplementation of [ShellCheck](https://github.com/koalaman/shellcheck)'s
|
|
35
|
+
most common checks. No binaries, no Haskell runtime, no compilation —
|
|
36
|
+
`pip install pureshellcheck` and it works anywhere Python runs, including
|
|
37
|
+
AWS Lambda, Pyodide/WASM, and locked-down CI sandboxes where you can't
|
|
38
|
+
install the real ShellCheck binary.
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
$ pip install pureshellcheck
|
|
42
|
+
$ pureshellcheck deploy.sh
|
|
43
|
+
|
|
44
|
+
In deploy.sh line 8:
|
|
45
|
+
rm -rf $BUILD_DIR/*
|
|
46
|
+
^--------^ SC2086 (info): Double quote to prevent globbing and word splitting.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
- **Agent & tooling friendly.** LLM-generated shell scripts fail in
|
|
52
|
+
exactly the ways ShellCheck catches (unquoted expansions, word
|
|
53
|
+
splitting, `cd` without `|| exit`). Existing Python packages such as
|
|
54
|
+
`shellcheck-py` just download the 30 MB Haskell binary — useless in
|
|
55
|
+
WASM, Lambda layers, or hermetic build sandboxes. pureshellcheck is
|
|
56
|
+
~7000 lines of stdlib-only Python.
|
|
57
|
+
- **In-process speed.** Calling `pureshellcheck.check()` takes ~2 ms for a
|
|
58
|
+
typical script vs ~40 ms to spawn the shellcheck binary — and it's
|
|
59
|
+
8–13× faster than the binary even on 1200-line scripts (see
|
|
60
|
+
[Benchmarks](#benchmarks)).
|
|
61
|
+
- **Verified against the real thing.** Test cases are extracted from
|
|
62
|
+
ShellCheck's own test suite and the output is differentially tested
|
|
63
|
+
against the shellcheck binary on real-world scripts.
|
|
64
|
+
|
|
65
|
+
## What it checks
|
|
66
|
+
|
|
67
|
+
71 SC codes are implemented, chosen by real-world frequency — the quoting
|
|
68
|
+
and word-splitting family (SC2086, SC2046, SC2068, SC2206/2207...),
|
|
69
|
+
variable lifecycle (SC2034 unused, SC2154 unassigned, SC2155),
|
|
70
|
+
command pitfalls (SC2164 unchecked `cd`, SC2162 `read` without `-r`,
|
|
71
|
+
useless `cat`/`echo`, `ls | grep`, `find | xargs`, printf argument
|
|
72
|
+
counting, catastrophic `rm -rf`), structural mistakes
|
|
73
|
+
(`A && B || C`, constant test expressions, `$?` anti-patterns), and more.
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary>All implemented codes</summary>
|
|
77
|
+
|
|
78
|
+
SC2002 SC2003 SC2004 SC2005 SC2006 SC2007 SC2009 SC2010 SC2011 SC2012
|
|
79
|
+
SC2015 SC2016 SC2026 SC2027 SC2028 SC2034 SC2035 SC2038 SC2041 SC2042
|
|
80
|
+
SC2043 SC2046 SC2048 SC2050 SC2059 SC2064 SC2065 SC2066 SC2068 SC2086
|
|
81
|
+
SC2089 SC2090 SC2093 SC2094 SC2103 SC2114 SC2115 SC2116 SC2126 SC2128
|
|
82
|
+
SC2140 SC2145 SC2148 SC2153 SC2154 SC2155 SC2162 SC2164 SC2174 SC2178
|
|
83
|
+
SC2179 SC2181 SC2182 SC2183 SC2187 SC2188 SC2189 SC2206 SC2207 SC2223
|
|
84
|
+
SC2239 SC2246 SC2248 SC2250 SC2258 SC2304 SC2305 SC2306 SC2307 SC2308
|
|
85
|
+
|
|
86
|
+
</details>
|
|
87
|
+
|
|
88
|
+
## Conformance scoreboard
|
|
89
|
+
|
|
90
|
+
The repository vendors **1508 test cases extracted from ShellCheck's own
|
|
91
|
+
test suite** (`tests/data/corpus.json`), run by pytest on every commit:
|
|
92
|
+
|
|
93
|
+
| metric | result |
|
|
94
|
+
|---|---|
|
|
95
|
+
| official test cases for the implemented checks | **619/620 (99.8%)** |
|
|
96
|
+
| whole official corpus (incl. unimplemented checks) | 1025/1508 (68.0%) |
|
|
97
|
+
| real-world differential test vs `shellcheck` 0.11.0 | **113/113 findings agree, 0 missed, 0 false positives** (48 scripts from Homebrew/npm) |
|
|
98
|
+
|
|
99
|
+
The single implemented-check failure is documented in
|
|
100
|
+
`tests/data/expected_failures.txt` (a test of ShellCheck's non-default
|
|
101
|
+
`check-unassigned-uppercase` mode); every other non-passing corpus case is
|
|
102
|
+
listed there with a reason. Reproduce with:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
$ python tools/conformance.py # scoreboard
|
|
106
|
+
$ python tools/diff_shellcheck.py *.sh # vs the real binary
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
### CLI
|
|
112
|
+
|
|
113
|
+
```console
|
|
114
|
+
$ pureshellcheck [-s bash] [-f tty|gcc|json|json1] [-e SC2086] \
|
|
115
|
+
[-S error|warning|info|style] script.sh [more.sh ...]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Exit status is 0 for a clean script, 1 if there are findings, 2 on file
|
|
119
|
+
errors — same convention as shellcheck. `# shellcheck disable=SC2086`
|
|
120
|
+
and `# shellcheck shell=dash` directives are honored.
|
|
121
|
+
|
|
122
|
+
### Library
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import pureshellcheck
|
|
126
|
+
|
|
127
|
+
for f in pureshellcheck.check('echo $foo', shell='bash'):
|
|
128
|
+
print(f.line, f.column, f.code, f.severity, f.message)
|
|
129
|
+
# 1 6 2086 info Double quote to prevent globbing and word splitting.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`check()` returns a list of findings with `code`, `severity`
|
|
133
|
+
(`error|warning|info|style`), `message`, and 1-based
|
|
134
|
+
`line`/`column`/`end_line`/`end_column`. `pureshellcheck.parse()` exposes
|
|
135
|
+
the bash AST if you want to build your own analyses.
|
|
136
|
+
|
|
137
|
+
## Benchmarks
|
|
138
|
+
|
|
139
|
+
Measured with `python tools/bench.py` (median of 7 runs, after verifying
|
|
140
|
+
both tools report identical findings on the workload; CPython 3.12,
|
|
141
|
+
shellcheck 0.11.0, Apple Silicon):
|
|
142
|
+
|
|
143
|
+
| workload | shellcheck | pureshellcheck | speedup |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| CLI, brew.sh (1216 lines) | 604 ms | 68 ms | **8.9×** |
|
|
146
|
+
| embedded `check()`, brew.sh | 604 ms | 45 ms | **13.3×** |
|
|
147
|
+
| CLI, 75-line script | 42 ms | 24 ms | 1.8× |
|
|
148
|
+
| embedded `check()`, 75-line script | 42 ms | 2.4 ms | **17×** |
|
|
149
|
+
|
|
150
|
+
The embedded rows are what an agent or editor integration pays per call:
|
|
151
|
+
no process spawn, no binary.
|
|
152
|
+
|
|
153
|
+
## Compatibility notes
|
|
154
|
+
|
|
155
|
+
- Targets bash (default), sh/dash/ash and ksh dialects are accepted via
|
|
156
|
+
shebang, directive, or `-s`; sh-specific portability checks (the
|
|
157
|
+
SC2039/SC3xxx family) are not implemented yet.
|
|
158
|
+
- The parser is deliberately lenient: it keeps checking past constructs
|
|
159
|
+
the real shellcheck refuses to parse (e.g. `[ $tar --version ]`).
|
|
160
|
+
- Optional checks (`SC2002`, `SC2248`, `SC2250`) are off by default,
|
|
161
|
+
matching shellcheck 0.11; enable with `-o` / `include_optional=True`.
|
|
162
|
+
- Wiki links work the same: see <https://www.shellcheck.net/wiki/SC2086>
|
|
163
|
+
for any reported code.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```console
|
|
168
|
+
$ pip install -e . pytest
|
|
169
|
+
$ pytest # corpus + unit tests, < 1 s
|
|
170
|
+
$ python tools/extract_corpus.py /path/to/shellcheck # refresh corpus
|
|
171
|
+
$ python tools/update_expected_failures.py # refresh scoreboard
|
|
172
|
+
$ python tools/bench.py # benchmarks
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The package itself is MIT licensed and has zero runtime dependencies
|
|
176
|
+
(CPython 3.9–3.14 and PyPy). The vendored test corpus in `tests/data/` is
|
|
177
|
+
extracted from the GPLv3-licensed ShellCheck project and is used only for
|
|
178
|
+
development-time testing; it is not part of the distributed wheel.
|
|
179
|
+
|
|
180
|
+
## See also
|
|
181
|
+
|
|
182
|
+
- [purejq](https://github.com/adam2go/purejq) — pure Python jq, same
|
|
183
|
+
philosophy: vendored official test suite, differential testing, no
|
|
184
|
+
binaries.
|