pre-commit-bigfix 2.0.1__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.
- pre_commit_bigfix/__init__.py +3 -0
- pre_commit_bigfix/bes_actionscript_lint_schclass.py +937 -0
- pre_commit_bigfix/bes_actionscript_validate_prefetch.py +1258 -0
- pre_commit_bigfix/bes_actionscript_validate_script.py +2871 -0
- pre_commit_bigfix/bes_common.py +271 -0
- pre_commit_bigfix/bes_conventions_check.py +3484 -0
- pre_commit_bigfix/bes_relevance_convert_group.py +407 -0
- pre_commit_bigfix/bes_relevance_lint.py +402 -0
- pre_commit_bigfix/bes_schema_validate.py +84 -0
- pre_commit_bigfix/bes_siterepo_validate_layout.py +1203 -0
- pre_commit_bigfix/run_all.py +305 -0
- pre_commit_bigfix/schclass.py +284 -0
- pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +412 -0
- pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +43 -0
- pre_commit_bigfix/schclass_tokenizer.py +321 -0
- pre_commit_bigfix-2.0.1.dist-info/METADATA +821 -0
- pre_commit_bigfix-2.0.1.dist-info/RECORD +21 -0
- pre_commit_bigfix-2.0.1.dist-info/WHEEL +5 -0
- pre_commit_bigfix-2.0.1.dist-info/entry_points.txt +10 -0
- pre_commit_bigfix-2.0.1.dist-info/licenses/LICENSE +21 -0
- pre_commit_bigfix-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2871 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-commit hook: validate ActionScript block balance in BES files.
|
|
3
|
+
|
|
4
|
+
Checks every <ActionScript> body of a BES file (and, for non-.bes/.ojo paths,
|
|
5
|
+
the whole file read as one raw ActionScript body) for balanced `if`/`endif`
|
|
6
|
+
and `begin prefetch block`/`end prefetch block` pairing. This is the sibling
|
|
7
|
+
hook named in bes_actionscript_lint_schclass.py's SCOPE note: checks that need
|
|
8
|
+
knowledge the lexical grammar does not carry -- pairing, ordering, and block
|
|
9
|
+
interleaving -- live here instead. It is meant to grow more per-script checks
|
|
10
|
+
over time; the balance walk is the first.
|
|
11
|
+
|
|
12
|
+
An unbalanced block is a real defect, not a style nit: the BigFix agent fails
|
|
13
|
+
the action at runtime on a dangling `if`, and a missing `endif` silently
|
|
14
|
+
changes which statements are conditional.
|
|
15
|
+
|
|
16
|
+
Checks:
|
|
17
|
+
E500 an `if` is never closed by a matching `endif`
|
|
18
|
+
E501 an `endif` with no open `if`
|
|
19
|
+
E502 a `begin prefetch block` is never closed by `end prefetch block`
|
|
20
|
+
E503 an `end prefetch block` with no open `begin prefetch block`
|
|
21
|
+
E504 a `begin prefetch block` nested inside another one (prefetch blocks
|
|
22
|
+
do not nest)
|
|
23
|
+
E505 an `else` or `elseif` outside any open `if`
|
|
24
|
+
E506 an `elseif` after `else`, or a second `else` for the same `if`
|
|
25
|
+
E507 an `if` opened inside a prefetch block is still open when that
|
|
26
|
+
block's `end prefetch block` is reached -- blocks interleave, they
|
|
27
|
+
do not nest, so this cannot close cleanly
|
|
28
|
+
E508 a `{` relevance substitution has no closing `}` before the end of
|
|
29
|
+
its line (a substitution cannot span lines); `}}` inside the
|
|
30
|
+
substitution is a literal `}` and does not close it. Also checked
|
|
31
|
+
on `createfile until` content lines, which the agent substitutes
|
|
32
|
+
too (a literal `{` there must be `{{`; a lone `}` is literal)
|
|
33
|
+
E509 a `}` with no `{` relevance substitution open on that line.
|
|
34
|
+
`appendfile <content>` lines are exempt from both E508 and E509 --
|
|
35
|
+
everything after the verb is one line of raw file content written
|
|
36
|
+
out verbatim (`appendfile }` appends a literal `}`), not
|
|
37
|
+
ActionScript. A regex interval quantifier's braces (`{40}`,
|
|
38
|
+
`{1,3}`, `{2,}` -- common in a `regex "..."` literal used to pull
|
|
39
|
+
a sha1/sha256/size/url back out of a matched line) are skipped
|
|
40
|
+
whole and never count as an open or a close
|
|
41
|
+
E510 an `add prefetch item` / `add nohash prefetch item` outside an open
|
|
42
|
+
`begin prefetch block` (the agent rejects these outside a block)
|
|
43
|
+
E511 a `collect prefetch items` outside an open prefetch block
|
|
44
|
+
E512 two prefetch/download producers declare the same download name in
|
|
45
|
+
the exact same conditional context (both unconditional, or both
|
|
46
|
+
reached through identical if/elseif/else branch choices) -- the
|
|
47
|
+
second silently overwrites the first. Declarations reached via
|
|
48
|
+
different `if`s (even ones that look platform-exclusive, like a
|
|
49
|
+
separate `if` per OS) are NOT compared: this checker cannot verify
|
|
50
|
+
their conditions are mutually exclusive, and BigFix content
|
|
51
|
+
routinely relies on exactly that pattern for cross-platform
|
|
52
|
+
prefetch blocks. Also NOT flagged: two declarations whose sha1/
|
|
53
|
+
sha256/size all match -- mirror URLs for the identical file,
|
|
54
|
+
where either one satisfies the download
|
|
55
|
+
E514 an `if` or `elseif` whose condition is not a `{...}` relevance
|
|
56
|
+
substitution (`if true`, bare `if`; the agent requires one)
|
|
57
|
+
E515 a `begin prefetch block` that is not at the top of the script --
|
|
58
|
+
only blank lines, `//` comments, `action parameter query` lines,
|
|
59
|
+
and `parameter` assignments may precede it. Plain `prefetch`
|
|
60
|
+
statements are NOT placement-checked; they are legal anywhere
|
|
61
|
+
E516 two `parameter "name" = ...` assignments to the same name that can
|
|
62
|
+
co-execute (see E512's co-executable rule -- assignments in
|
|
63
|
+
different `if`/`elseif`/`else` branches are not compared): action
|
|
64
|
+
parameters are write-once, and the second assignment silently
|
|
65
|
+
overwrites the first
|
|
66
|
+
E517 a `parameter "name"` substitution is referenced before the line
|
|
67
|
+
that assigns it -- the assignment exists later in the same body,
|
|
68
|
+
so this is an ordering bug, not a parameter supplied from outside
|
|
69
|
+
the script (which this hook cannot see and does not flag)
|
|
70
|
+
E518 a `continue if` or `pause while` condition is not a `{...}`
|
|
71
|
+
relevance substitution -- the same E514 rule extended to these two
|
|
72
|
+
other condition-bearing verbs. One exception: a literal
|
|
73
|
+
`continue if false` (any case) is accepted as a documented idiom
|
|
74
|
+
for forcing a branch to fail unconditionally. `continue if true`
|
|
75
|
+
is still flagged (it always continues, so it does nothing), and
|
|
76
|
+
`pause while` has no literal exception at all (`true` hangs
|
|
77
|
+
forever, `false` never pauses)
|
|
78
|
+
E519 a command references `__createfile` or `__appendfile` but the body
|
|
79
|
+
never has a matching `createfile until` / `appendfile` line
|
|
80
|
+
earlier; `delete`/`folder delete` lines are cleanup, not
|
|
81
|
+
consumption, and never count (W507's rule, reapplied)
|
|
82
|
+
E520 a `setting` line is not the documented
|
|
83
|
+
`setting "name"="value" on "{...}" for client|user|action` or
|
|
84
|
+
`setting delete "name" on "{...}" for client|user|action` shape
|
|
85
|
+
-- a missing effective-date clause fails at runtime
|
|
86
|
+
E521 a `regset`/`regset64`/`regdelete`/`regdelete64` key is not a
|
|
87
|
+
quoted, bracketed `"[HKEY_...]..."` keyname (fixable only when the
|
|
88
|
+
key is bracketed but unquoted -> the quotes are added; a key with
|
|
89
|
+
no brackets is left alone)
|
|
90
|
+
E522 an `override wait` / `override run` block is not terminated by its
|
|
91
|
+
matching verb -- it hits end of body, is terminated by the *other*
|
|
92
|
+
override verb's command, or is immediately reopened by another
|
|
93
|
+
`override` before any command runs. A `{...}` relevance
|
|
94
|
+
substitution line counts as an open option line, not a closing
|
|
95
|
+
command, since it can itself evaluate to one. `bes-actionscript-
|
|
96
|
+
lint-schclass` validates the option lines inside a block (E303);
|
|
97
|
+
this is the pairing check that block's state machine cannot
|
|
98
|
+
express
|
|
99
|
+
E523 an `action uses wow64 redirection` argument that is not `true`,
|
|
100
|
+
`false`, or a `{...}` relevance substitution
|
|
101
|
+
E525 a `folder create` / `folder delete` path that is not double-quoted.
|
|
102
|
+
Quoted even when today's value has no spaces: a user profile, a
|
|
103
|
+
parameter, or a relevance result can hold one, and then the
|
|
104
|
+
command acts on the wrong path (fixable -> the path is wrapped in
|
|
105
|
+
quotes, `"` in an entity-escaped body; a path already holding
|
|
106
|
+
a stray `"` is reported but not fixed)
|
|
107
|
+
E524 `else if` (two words) instead of `elseif`: the agent does not read
|
|
108
|
+
it as a chained branch. It is tracked as the `elseif` it was meant
|
|
109
|
+
to be, so the rest of the if/endif pairing does not cascade
|
|
110
|
+
(fixable -> joined into `elseif`, unless the body has a stray
|
|
111
|
+
`endif` (E501), which means an `if` really was nested in the `else`)
|
|
112
|
+
E526 an auto-fix pass (W503, W506, E525, E528, E527, or the
|
|
113
|
+
E524/W505/E521 line rewrites together) was not written, because it
|
|
114
|
+
would make a schema-valid file fail BES.xsd validation; the other
|
|
115
|
+
passes are still written (--disable E526 writes it anyway)
|
|
116
|
+
E527 a `parameter "<name>" = <value>` whose value is not double-quoted.
|
|
117
|
+
The agent substitutes `{...}` first and then parses the line, so
|
|
118
|
+
an unquoted value -- even a singular `{"a"}` -- is invalid
|
|
119
|
+
`parameter` syntax and the line fails at runtime (fixable -> the
|
|
120
|
+
value is wrapped in quotes, `"` in an entity-escaped body; a
|
|
121
|
+
value holding a `"` outside `{...}`, or `%22`, is reported but not
|
|
122
|
+
fixed, and so is one with a trailing comment until E528's fix has
|
|
123
|
+
moved the comment off the line)
|
|
124
|
+
E528 a trailing `// comment` on a `parameter` line: only fixed-syntax
|
|
125
|
+
lines (if/elseif/else/endif, the prefetch block, `override`)
|
|
126
|
+
honor one, so the line fails at runtime even with a quoted value
|
|
127
|
+
(fixable -> the comment is moved onto its own line directly above,
|
|
128
|
+
same indentation, text kept as written)
|
|
129
|
+
W500 the file is not parseable BES XML; skipped (advisory --
|
|
130
|
+
bes-schema-validate is the authority on file validity)
|
|
131
|
+
W501 unreachable command: a line after an unconditional `exit`,
|
|
132
|
+
`restart`, or `shutdown` (one outside any `if`) can never run;
|
|
133
|
+
only the first unreachable line is reported
|
|
134
|
+
W502 an `action parameter query` after the first execution command --
|
|
135
|
+
these are console-time prompts and belong at the top
|
|
136
|
+
W503 a `__Download`, `__createfile`, or `__appendfile` reference is not
|
|
137
|
+
exactly that case (e.g. `__download\\x.exe`) -- Windows tolerates
|
|
138
|
+
this, but a Linux/macOS agent's case-sensitive filesystem does not.
|
|
139
|
+
Auto-fixable: see AUTO-FIXES below
|
|
140
|
+
W505 a `wait`/`run` of cmd.exe passes a command line but no `/c` (cmd.exe
|
|
141
|
+
opens a shell and never runs the command), or uses `/k` instead (the
|
|
142
|
+
command runs but the shell never exits, so an action that waits for it
|
|
143
|
+
hangs -- `run`/`runhidden` do not wait, so `/k` there is fine).
|
|
144
|
+
Fixable -> a leading `/k` becomes `/c`, and a missing `/c` is
|
|
145
|
+
inserted after cmd's own leading switches (`/q`, `/d`, `/e:on`, ...);
|
|
146
|
+
a line with `/r` or an unknown leading switch is left alone
|
|
147
|
+
W506 a `move`/`copy` (of any source: a scratch file, a download, ...)
|
|
148
|
+
onto a destination that is not cleared earlier in the body -- by a
|
|
149
|
+
`delete` (also one that looks the same file up by relevance), a
|
|
150
|
+
`folder delete` of an ancestor, a `move` of it elsewhere, or a
|
|
151
|
+
shell `rm`/`del` naming it. Both verbs fail when the destination
|
|
152
|
+
already exists, so the action works once and fails on every later
|
|
153
|
+
run; a destination inside the action's own download folder is
|
|
154
|
+
exempt, being action-scoped rather than persistent (fixable ->
|
|
155
|
+
`delete <destination>` inserted before the command)
|
|
156
|
+
W507 a command references `__Download\\<name>` but nothing prefetches or
|
|
157
|
+
downloads a file of that name (a typo catcher; skipped entirely
|
|
158
|
+
when any producer's names are unknowable -- see below. `delete` and
|
|
159
|
+
`folder delete` lines are cleanup, not consumption, and never
|
|
160
|
+
count. A reference containing a glob wildcard, e.g.
|
|
161
|
+
`__Download\\mysql*rpm`, is never flagged -- the shell matches it
|
|
162
|
+
at runtime, not this checker). Downgraded from E513 to advisory
|
|
163
|
+
(2026-08-26): validation against real content threw too many false
|
|
164
|
+
positives for a hard failure
|
|
165
|
+
W508 a `parameter "X"` reference whose name occurs nowhere else in the
|
|
166
|
+
whole file -- not assigned, queried, or mentioned in a Description
|
|
167
|
+
form -- so the substitution fails at runtime; usually copy-paste
|
|
168
|
+
from a sibling fixlet that set it. A name the file mentions
|
|
169
|
+
anywhere else is assumed to be supplied from outside
|
|
170
|
+
|
|
171
|
+
W507 is conservative: it is skipped for a whole body whenever any producer's
|
|
172
|
+
names cannot be known statically -- an `extract`/`unarchive`/`archive now`/
|
|
173
|
+
`utility` command, a `download` with no `as <name>` whose URL is not a literal
|
|
174
|
+
with a basename, or any producer name/URL containing a `{` substitution.
|
|
175
|
+
|
|
176
|
+
E517 is likewise conservative: a `parameter "name"` reference is only checked
|
|
177
|
+
against assignments made elsewhere in the *same* body. A name never assigned
|
|
178
|
+
in-script is not flagged at all -- real content routinely supplies parameters
|
|
179
|
+
from the action's Description page (secure parameters, REST credentials) that
|
|
180
|
+
this hook cannot see, and flagging every such reference would be pure noise.
|
|
181
|
+
|
|
182
|
+
E-codes are real issues and fail the hook. W-codes are advisory and do NOT
|
|
183
|
+
fail the hook unless --strict is given.
|
|
184
|
+
|
|
185
|
+
AUTO-FIXES. Only a few checks here are safe to auto-fix, each with exactly
|
|
186
|
+
one right rewrite: every other issue is a missing/misplaced piece of block
|
|
187
|
+
structure (an `endif`, an `end prefetch block`, ...) a hook has no way to
|
|
188
|
+
know the right place for, and guessing could silently change what the action
|
|
189
|
+
does.
|
|
190
|
+
|
|
191
|
+
--auto-fix (W503, W506, E525, E528, E527, E524, W505, E521), on by default (yes when files are given, as
|
|
192
|
+
pre-commit does; no when auto-discovering). Every wrong-case `__download`,
|
|
193
|
+
`__createfile`, or `__appendfile` reference is rewritten in place to its
|
|
194
|
+
canonical spelling -- purely a case correction of a reference this hook
|
|
195
|
+
already resolved to a known scratch-file token, so there is nothing to
|
|
196
|
+
guess -- and a `delete <destination>` line (same indentation, same XML
|
|
197
|
+
escaping as the body) is inserted before each W506 move/copy, the
|
|
198
|
+
documented pattern -- and an unquoted `folder create|delete` path is
|
|
199
|
+
wrapped in quotes (E525); a `parameter` line's trailing comment is moved
|
|
200
|
+
onto its own line above (E528) and an unquoted `parameter` value is wrapped
|
|
201
|
+
in quotes (E527); an `else if` is joined into `elseif` (E524); a
|
|
202
|
+
cmd.exe `/k` becomes `/c` or a missing `/c` is inserted (W505); and a
|
|
203
|
+
bracketed-but-unquoted registry key is quoted (E521). An auto-fixed file
|
|
204
|
+
fails the hook so the change is reviewed and re-staged.
|
|
205
|
+
|
|
206
|
+
Only <ActionScript> elements with MIMEType application/x-Fixlet-Windows-Shell
|
|
207
|
+
(matched case-insensitively; a mixed-case MIMEType is still valid BigFix
|
|
208
|
+
content) or no MIMEType (which defaults to it) are checked; other bodies are
|
|
209
|
+
other languages and are skipped silently. Bodies are extracted with lxml, so
|
|
210
|
+
entities are decoded and adjacent CDATA sections merged, with lxml's
|
|
211
|
+
sourceline mapping issues back to file line numbers.
|
|
212
|
+
|
|
213
|
+
Lines inside a `createfile until <MARKER>` block are raw file content, not
|
|
214
|
+
ActionScript, and are not scanned -- an `if` or `endif` appearing in such a
|
|
215
|
+
block's content does not count. That block's own well-formedness (does it
|
|
216
|
+
reach its marker) is bes-actionscript-lint-schclass's E302, not this hook's;
|
|
217
|
+
it is not re-reported here.
|
|
218
|
+
|
|
219
|
+
Usage:
|
|
220
|
+
bes_actionscript_validate_script.py [--strict] [--disable E501]
|
|
221
|
+
[--auto-fix=yes|no] [file ...]
|
|
222
|
+
|
|
223
|
+
With no file arguments, all *.bes files in the current folder and below are
|
|
224
|
+
checked. Non-.bes/.ojo paths given explicitly are checked as raw ActionScript
|
|
225
|
+
text.
|
|
226
|
+
|
|
227
|
+
A file can opt out of all checks with a comment anywhere in it:
|
|
228
|
+
<!-- pre-commit-skip: bes-actionscript-validate-script -->
|
|
229
|
+
or out of a single check family with the matching marker anywhere in the file:
|
|
230
|
+
actionscript-if-ok (E500, E501, E505, E506, E514, E518, E524)
|
|
231
|
+
actionscript-prefetch-block-ok (E502, E503, E504)
|
|
232
|
+
actionscript-block-nesting-ok (E507)
|
|
233
|
+
actionscript-substitution-ok (E508, E509)
|
|
234
|
+
actionscript-prefetch-placement-ok (E510, E511, E515)
|
|
235
|
+
actionscript-download-ok (E512, W507)
|
|
236
|
+
actionscript-parameter-ok (E516, E517, E527, E528, W508)
|
|
237
|
+
actionscript-scratch-ok (E519, W503)
|
|
238
|
+
actionscript-scratch-dest-ok (W506)
|
|
239
|
+
actionscript-command-shape-ok (E520, E521, E523, E525)
|
|
240
|
+
actionscript-cmd-ok (W505)
|
|
241
|
+
actionscript-override-ok (E522 -- shared with bes-actionscript-lint-schclass's E303)
|
|
242
|
+
actionscript-unreachable-ok (W501)
|
|
243
|
+
actionscript-parameter-query-ok (W502)
|
|
244
|
+
|
|
245
|
+
(E514 and E518 belong to the `actionscript-if-ok` family above: they are
|
|
246
|
+
if/continue-if/pause-while condition-shape checks.)
|
|
247
|
+
|
|
248
|
+
Files that look like mustache templates (containing a `{{ placeholder }}`)
|
|
249
|
+
are skipped silently: they are not real content until rendered. Only an
|
|
250
|
+
identifier-like placeholder counts -- `{{` is also the ActionScript escape
|
|
251
|
+
for a literal `{`, so a heredoc payload containing it is real content.
|
|
252
|
+
|
|
253
|
+
Exit codes:
|
|
254
|
+
0 no E-code issues and nothing auto-fixed (and, without --strict,
|
|
255
|
+
regardless of warnings)
|
|
256
|
+
1 an E-code issue was found, a file was auto-fixed, or a warning was
|
|
257
|
+
found while --strict is set
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
import argparse
|
|
261
|
+
import html
|
|
262
|
+
import os
|
|
263
|
+
import re
|
|
264
|
+
import sys
|
|
265
|
+
|
|
266
|
+
from lxml import etree
|
|
267
|
+
|
|
268
|
+
if __package__ in (None, ""): # run directly as a script, not as a module
|
|
269
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
270
|
+
|
|
271
|
+
# `_mask_heredocs` is shared with the sibling ActionScript hook on purpose:
|
|
272
|
+
# both need to know that the lines inside a `createfile until` block are file
|
|
273
|
+
# content rather than commands, and one implementation of that rule is enough.
|
|
274
|
+
from pre_commit_bigfix import bes_common
|
|
275
|
+
from pre_commit_bigfix.bes_actionscript_lint_schclass import _mask_heredocs
|
|
276
|
+
from pre_commit_bigfix.bes_common import MUSTACHE_RE, encode, read_source
|
|
277
|
+
|
|
278
|
+
# prefetch line-shape prefixes (matched against lowercased lines). These
|
|
279
|
+
# mirror the same-named constants in bes_actionscript_validate_prefetch.py --
|
|
280
|
+
# NOT imported from there, because that module imports the bigfix_prefetch
|
|
281
|
+
# package, which this hook's isolated pre-commit environment does not carry.
|
|
282
|
+
# tests/test_bes_actionscript_validate_script.py asserts they stay in lockstep.
|
|
283
|
+
NOHASH_PREFETCH = "add nohash prefetch item"
|
|
284
|
+
BLOCK_PREFETCH = "add prefetch item"
|
|
285
|
+
STATEMENT_PREFETCH = "prefetch "
|
|
286
|
+
|
|
287
|
+
SKIP_MARKER = "pre-commit-skip: bes-actionscript-validate-script"
|
|
288
|
+
|
|
289
|
+
# per-check-family opt-out markers (matched anywhere in the file text)
|
|
290
|
+
IF_MARKER = "actionscript-if-ok" # E500, E501, E505, E506
|
|
291
|
+
PREFETCH_BLOCK_MARKER = "actionscript-prefetch-block-ok" # E502, E503, E504
|
|
292
|
+
BLOCK_NESTING_MARKER = "actionscript-block-nesting-ok" # E507
|
|
293
|
+
# shared with the sibling schclass hook's E301 on purpose: one marker turns
|
|
294
|
+
# off substitution complaints in both hooks, which is what a file that really
|
|
295
|
+
# does contain odd braces wants.
|
|
296
|
+
SUBSTITUTION_MARKER = "actionscript-substitution-ok" # E508, E509
|
|
297
|
+
PREFETCH_PLACEMENT_MARKER = "actionscript-prefetch-placement-ok" # E510, E511, E515
|
|
298
|
+
DOWNLOAD_MARKER = "actionscript-download-ok" # E512, W507
|
|
299
|
+
PARAMETER_MARKER = "actionscript-parameter-ok" # E516, E517, E527, E528, W508
|
|
300
|
+
SCRATCH_MARKER = "actionscript-scratch-ok" # E519, W503
|
|
301
|
+
COMMAND_SHAPE_MARKER = "actionscript-command-shape-ok" # E520, E521, E523, E525
|
|
302
|
+
CMD_MARKER = "actionscript-cmd-ok" # W505
|
|
303
|
+
SCRATCH_DEST_MARKER = "actionscript-scratch-dest-ok" # W506
|
|
304
|
+
# shared with the sibling schclass hook's E303 on purpose: one marker turns
|
|
305
|
+
# off override-block complaints in both hooks.
|
|
306
|
+
OVERRIDE_BLOCK_MARKER = "actionscript-override-ok" # E522
|
|
307
|
+
UNREACHABLE_MARKER = "actionscript-unreachable-ok" # W501
|
|
308
|
+
PARAMETER_QUERY_MARKER = "actionscript-parameter-query-ok" # W502
|
|
309
|
+
|
|
310
|
+
CHECK_MARKERS = {
|
|
311
|
+
"E500": IF_MARKER,
|
|
312
|
+
"E501": IF_MARKER,
|
|
313
|
+
"E505": IF_MARKER,
|
|
314
|
+
"E506": IF_MARKER,
|
|
315
|
+
"E502": PREFETCH_BLOCK_MARKER,
|
|
316
|
+
"E503": PREFETCH_BLOCK_MARKER,
|
|
317
|
+
"E504": PREFETCH_BLOCK_MARKER,
|
|
318
|
+
"E507": BLOCK_NESTING_MARKER,
|
|
319
|
+
"E508": SUBSTITUTION_MARKER,
|
|
320
|
+
"E509": SUBSTITUTION_MARKER,
|
|
321
|
+
"E510": PREFETCH_PLACEMENT_MARKER,
|
|
322
|
+
"E511": PREFETCH_PLACEMENT_MARKER,
|
|
323
|
+
"E512": DOWNLOAD_MARKER,
|
|
324
|
+
"E514": IF_MARKER,
|
|
325
|
+
"E515": PREFETCH_PLACEMENT_MARKER,
|
|
326
|
+
"E516": PARAMETER_MARKER,
|
|
327
|
+
"E517": PARAMETER_MARKER,
|
|
328
|
+
"E518": IF_MARKER,
|
|
329
|
+
"E519": SCRATCH_MARKER,
|
|
330
|
+
"E520": COMMAND_SHAPE_MARKER,
|
|
331
|
+
"E521": COMMAND_SHAPE_MARKER,
|
|
332
|
+
"E522": OVERRIDE_BLOCK_MARKER,
|
|
333
|
+
"E523": COMMAND_SHAPE_MARKER,
|
|
334
|
+
"E524": IF_MARKER,
|
|
335
|
+
"E525": COMMAND_SHAPE_MARKER,
|
|
336
|
+
"E527": PARAMETER_MARKER,
|
|
337
|
+
"E528": PARAMETER_MARKER,
|
|
338
|
+
"W501": UNREACHABLE_MARKER,
|
|
339
|
+
"W502": PARAMETER_QUERY_MARKER,
|
|
340
|
+
"W503": SCRATCH_MARKER,
|
|
341
|
+
"W505": CMD_MARKER,
|
|
342
|
+
"W506": SCRATCH_DEST_MARKER,
|
|
343
|
+
"W507": DOWNLOAD_MARKER,
|
|
344
|
+
"W508": PARAMETER_MARKER,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
KNOWN_CODES = frozenset(
|
|
348
|
+
[
|
|
349
|
+
"E500",
|
|
350
|
+
"E501",
|
|
351
|
+
"E502",
|
|
352
|
+
"E503",
|
|
353
|
+
"E504",
|
|
354
|
+
"E505",
|
|
355
|
+
"E506",
|
|
356
|
+
"E507",
|
|
357
|
+
"E508",
|
|
358
|
+
"E509",
|
|
359
|
+
"E510",
|
|
360
|
+
"E511",
|
|
361
|
+
"E512",
|
|
362
|
+
"E514",
|
|
363
|
+
"E515",
|
|
364
|
+
"E516",
|
|
365
|
+
"E517",
|
|
366
|
+
"E518",
|
|
367
|
+
"E519",
|
|
368
|
+
"E520",
|
|
369
|
+
"E521",
|
|
370
|
+
"E522",
|
|
371
|
+
"E523",
|
|
372
|
+
"E524",
|
|
373
|
+
"E525",
|
|
374
|
+
"E526",
|
|
375
|
+
"E527",
|
|
376
|
+
"E528",
|
|
377
|
+
"W500",
|
|
378
|
+
"W501",
|
|
379
|
+
"W502",
|
|
380
|
+
"W503",
|
|
381
|
+
"W505",
|
|
382
|
+
"W506",
|
|
383
|
+
"W507",
|
|
384
|
+
"W508",
|
|
385
|
+
]
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
BES_EXTENSIONS = (".bes", ".ojo")
|
|
389
|
+
ACTIONSCRIPT_MIMETYPE = "application/x-fixlet-windows-shell" # compared lowercased
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# the first token of a line, case-insensitively, anchored to line start so a
|
|
393
|
+
# relevance substitution or argument merely containing one of these words does
|
|
394
|
+
# not match (e.g. `continue if {...}` is not an `if` opener)
|
|
395
|
+
_IF_RE = re.compile(r"^if\b", re.IGNORECASE)
|
|
396
|
+
# `else if` (E524) is matched too, so every if/endif pairing pass treats it
|
|
397
|
+
# as the elseif it was meant to be rather than as an unrelated command
|
|
398
|
+
_ELSEIF_RE = re.compile(r"^else\s*if\b", re.IGNORECASE)
|
|
399
|
+
_ELSE_IF_TWO_WORDS_RE = re.compile(r"^else\s+if\b", re.IGNORECASE)
|
|
400
|
+
_ELSE_RE = re.compile(r"^else\s*$", re.IGNORECASE)
|
|
401
|
+
_ENDIF_RE = re.compile(r"^endif\s*$", re.IGNORECASE)
|
|
402
|
+
_BEGIN_PREFETCH_BLOCK_RE = re.compile(r"^begin\s+prefetch\s+block\s*$", re.IGNORECASE)
|
|
403
|
+
_END_PREFETCH_BLOCK_RE = re.compile(r"^end\s+prefetch\s+block\s*$", re.IGNORECASE)
|
|
404
|
+
_ADD_PREFETCH_ITEM_RE = re.compile(
|
|
405
|
+
r"^add\s+(?:nohash\s+)?prefetch\s+item\b", re.IGNORECASE
|
|
406
|
+
)
|
|
407
|
+
_COLLECT_PREFETCH_ITEMS_RE = re.compile(
|
|
408
|
+
r"^collect\s+prefetch\s+items\s*$", re.IGNORECASE
|
|
409
|
+
)
|
|
410
|
+
_PREFETCH_STATEMENT_RE = re.compile(r"^prefetch\s+(\S+)", re.IGNORECASE)
|
|
411
|
+
_DOWNLOAD_AS_RE = re.compile(r"^download(?:\s+now)?\s+as\s+(\S+)", re.IGNORECASE)
|
|
412
|
+
_DOWNLOAD_RE = re.compile(r"^download(?:\s+now)?\s+(\S+)\s*$", re.IGNORECASE)
|
|
413
|
+
_NAME_KV_RE = re.compile(r"\bname\s*=\s*(\S+)", re.IGNORECASE)
|
|
414
|
+
# `__Download\<name>` (or forward slash) on a command line; the name stops at
|
|
415
|
+
# whitespace, a quote, or another path separator. `__Global\__Download` is
|
|
416
|
+
# the client's shared download cache, not this action's download folder, so a
|
|
417
|
+
# `__Download` right after `__Global\` is not a reference to it.
|
|
418
|
+
_NOT_GLOBAL = r"(?<!__Global\\)(?<!__Global/)"
|
|
419
|
+
_DOWNLOAD_REF_RE = re.compile(
|
|
420
|
+
_NOT_GLOBAL + r'__Download[\\/]([^\s"\'\\/]+)', re.IGNORECASE
|
|
421
|
+
)
|
|
422
|
+
# the same reference, also capturing a path separator right after the name --
|
|
423
|
+
# present when the name is a subdirectory (`__Download\dir\file`), not a file
|
|
424
|
+
_DOWNLOAD_REF_NESTED_RE = re.compile(
|
|
425
|
+
_NOT_GLOBAL + r'__Download[\\/]([^\s"\'\\/]+)([\\/])?', re.IGNORECASE
|
|
426
|
+
)
|
|
427
|
+
# `folder create "__Download\<dir>"` makes a subfolder later lines fill
|
|
428
|
+
_FOLDER_CREATE_RE = re.compile(r"^folder\s+create\b", re.IGNORECASE)
|
|
429
|
+
# `folder create|delete <path>`: the path is captured for the E525 quoting check
|
|
430
|
+
_FOLDER_COMMAND_RE = re.compile(
|
|
431
|
+
r"^(folder\s+(?:create|delete))\s+(\S.*?)\s*$", re.IGNORECASE
|
|
432
|
+
)
|
|
433
|
+
# the same command inside a raw file line, stopping before a CDATA/tag close
|
|
434
|
+
_RAW_FOLDER_COMMAND_RE = re.compile(
|
|
435
|
+
r"(folder\s+(?:create|delete)\s+)(.*?)(\s*(?:\]\]>|</ActionScript>|$))",
|
|
436
|
+
re.IGNORECASE,
|
|
437
|
+
)
|
|
438
|
+
# a launching verb whose program is an archive extractor; run against
|
|
439
|
+
# `__Download` it writes files the script never names, like `extract` does.
|
|
440
|
+
# The executable is matched by basename, bare or quoted inside a relevance
|
|
441
|
+
# substitution (`{ pathname of file "7z.exe" of folder ... }`).
|
|
442
|
+
_LAUNCH_PREFIX_RE = re.compile(
|
|
443
|
+
r"^(?:wait|waithidden|run|runhidden|dos)\s+(.*)$", re.IGNORECASE
|
|
444
|
+
)
|
|
445
|
+
_EXTRACTOR_RE = re.compile(
|
|
446
|
+
r'(?:^|[\\/"\s])(?:unzip|7z|7za|7zr|expand|tar)(?:\.exe)?(?=$|["\s])',
|
|
447
|
+
re.IGNORECASE,
|
|
448
|
+
)
|
|
449
|
+
# a shell glob wildcard (`*` or `?`) in a __Download\ consumer reference --
|
|
450
|
+
# e.g. `__Download\mysql*rpm` to match a versioned filename the script
|
|
451
|
+
# author cannot spell out literally. The shell expands it at runtime, not
|
|
452
|
+
# this checker, so such a name can never be matched against a producer's
|
|
453
|
+
# literal name and is left unchecked, the same as a `{...}` substitution.
|
|
454
|
+
_GLOB_WILDCARD_RE = re.compile(r"[*?]")
|
|
455
|
+
# producers whose output names cannot be known statically; their presence
|
|
456
|
+
# turns W507 off for the whole body
|
|
457
|
+
_UNKNOWABLE_PRODUCER_RE = re.compile(
|
|
458
|
+
r"^(?:extract|unarchive|archive\s+now|utility)\b", re.IGNORECASE
|
|
459
|
+
)
|
|
460
|
+
# a bare `download` verb, any shape -- the fallback when neither download
|
|
461
|
+
# shape above matched (e.g. a URL with a space inside a substitution)
|
|
462
|
+
_DOWNLOAD_VERB_RE = re.compile(r"^download\b", re.IGNORECASE)
|
|
463
|
+
# sha1/sha256/size attributes on a producer line, in either the `prefetch`
|
|
464
|
+
# statement's colon form (`sha1:...`) or `add prefetch item`'s key=value form
|
|
465
|
+
# (`sha1=...`) -- used to tell whether two same-named declarations are
|
|
466
|
+
# actually mirror URLs for the identical file (see `_fingerprint`)
|
|
467
|
+
_HASH_ATTR_RE = re.compile(r"\b(sha1|sha256|size)\s*[:=]\s*(\S+)", re.IGNORECASE)
|
|
468
|
+
# a regex interval quantifier, e.g. `{40}`, `{1,3}`, `{2,}` -- relevance
|
|
469
|
+
# substitutions routinely embed a `regex "..."` literal (to pull a sha1/
|
|
470
|
+
# sha256/size/url back out of a line with `parenthesized part N of first
|
|
471
|
+
# match`), and a quantifier's braces are plain regex syntax, not a nested
|
|
472
|
+
# relevance substitution or its close; see `_check_substitution_braces`
|
|
473
|
+
_REGEX_QUANTIFIER_RE = re.compile(r"\{\d+(?:,\d*)?\}")
|
|
474
|
+
# the same quantifier with its `}` written as the escaped literal `}}`, the
|
|
475
|
+
# spelling a quantifier takes inside an open substitution (`{3}}`)
|
|
476
|
+
_ESCAPED_REGEX_QUANTIFIER_RE = re.compile(r"\{\d+(?:,\d*)?\}\}")
|
|
477
|
+
# `delete` / `folder delete` lines clean up the working directory; a
|
|
478
|
+
# `__Download\<name>` they mention is not a consumption of that file
|
|
479
|
+
_DELETE_RE = re.compile(r"^(?:folder\s+)?delete\b", re.IGNORECASE)
|
|
480
|
+
# the two delete forms with their argument captured, for the W506 destination scan
|
|
481
|
+
_FILE_DELETE_RE = re.compile(r"^delete\s+(.+?)\s*$", re.IGNORECASE)
|
|
482
|
+
_FOLDER_DELETE_RE = re.compile(r"^folder\s+delete\s+(.+?)\s*$", re.IGNORECASE)
|
|
483
|
+
# a move/copy whose source is a scratch file, with the destination captured
|
|
484
|
+
_SCRATCH_MOVE_RE = re.compile(
|
|
485
|
+
r'^(move|copy)\s+"?(__createfile|__appendfile)"?\s+(.+?)\s*$', re.IGNORECASE
|
|
486
|
+
)
|
|
487
|
+
# `move`/`copy <source> <target>` with the verb and argument text captured
|
|
488
|
+
_MOVE_COPY_ARGS_RE = re.compile(r"^(move|copy)\s+(.+?)\s*$", re.IGNORECASE)
|
|
489
|
+
# `move <source> <target>` with the argument text captured, for W506: moving
|
|
490
|
+
# a destination away (to back it up) clears it just as a delete does
|
|
491
|
+
_MOVE_ARGS_RE = re.compile(r"^move\s+(.+?)\s*$", re.IGNORECASE)
|
|
492
|
+
# a launch line that runs a shell/OS delete (`sh -c "rm ..."`, `/bin/rm`,
|
|
493
|
+
# `cmd /c del`); W506 counts it as clearing a destination it names
|
|
494
|
+
_SHELL_DELETE_RE = re.compile(
|
|
495
|
+
r"^(?:wait|waithidden|run|runhidden|dos)\b.*?(?:^|[\s\"'/\\])"
|
|
496
|
+
r"(?:rm|del|erase)(?:\.exe)?(?=[\s\"'])",
|
|
497
|
+
re.IGNORECASE,
|
|
498
|
+
)
|
|
499
|
+
# `{parameter "X" of action}` names the same parameter as `{parameter "X"}`
|
|
500
|
+
_PARAMETER_OF_ACTION_RE = re.compile(
|
|
501
|
+
r'(\bparameter\s+"[^"]*")\s+of\s+action\b', re.IGNORECASE
|
|
502
|
+
)
|
|
503
|
+
# a Windows-style path (drive letter or a backslash): its filesystem is
|
|
504
|
+
# case-insensitive, so W506 compares it case-blind
|
|
505
|
+
_WINDOWS_PATH_RE = re.compile(r'^"?[A-Za-z]:|\\')
|
|
506
|
+
# the action's own download folder is action-scoped, not a persistent location
|
|
507
|
+
_DOWNLOAD_DEST_RE = re.compile(r"__download|download path", re.IGNORECASE)
|
|
508
|
+
# `copy`/`move` lines can themselves populate `__Download`, either from a
|
|
509
|
+
# literal `__createfile`/`__appendfile` source (the sole __Download ref is
|
|
510
|
+
# the destination) or by renaming one download to another (>= 2 refs; the
|
|
511
|
+
# last is the destination, earlier ones are ordinary consumer references)
|
|
512
|
+
_MOVE_COPY_RE = re.compile(r"^(?:copy|move)\b", re.IGNORECASE)
|
|
513
|
+
_CREATEFILE_SOURCE_RE = re.compile(r"__(?:create|append)file\b", re.IGNORECASE)
|
|
514
|
+
# shell redirection into `__Download\<name>` creates that file
|
|
515
|
+
_REDIRECT_TARGET_RE = re.compile(
|
|
516
|
+
r'>>?\s*"?__Download[\\/]([^\s"\'\\/]+)', re.IGNORECASE
|
|
517
|
+
)
|
|
518
|
+
_TERMINATOR_RE = re.compile(r"^(?:exit|restart|shutdown)\b", re.IGNORECASE)
|
|
519
|
+
_ACTION_PARAMETER_QUERY_RE = re.compile(r"^action\s+parameter\s+query\b", re.IGNORECASE)
|
|
520
|
+
# the name an `action parameter query "name" ...` line prompts for
|
|
521
|
+
_ACTION_PARAMETER_QUERY_NAME_RE = re.compile(
|
|
522
|
+
r'^action\s+parameter\s+query\s+"([^"]+)"', re.IGNORECASE
|
|
523
|
+
)
|
|
524
|
+
# text ending in `exists` (optionally `exists (`) right before a `parameter
|
|
525
|
+
# "name"` reference: testing whether the parameter was supplied from outside
|
|
526
|
+
# the script, not reading its value
|
|
527
|
+
_EXISTS_BEFORE_RE = re.compile(r"\bexists\s*\(?\s*$", re.IGNORECASE)
|
|
528
|
+
_PARAMETER_RE = re.compile(r"^parameter\b", re.IGNORECASE)
|
|
529
|
+
# a `parameter "name" = ...` assignment; the value is everything after `=`
|
|
530
|
+
_PARAMETER_ASSIGN_RE = re.compile(r'^parameter\s+"([^"]+)"\s*=', re.IGNORECASE)
|
|
531
|
+
# the same assignment with its value captured, for the E527 quoting check; a
|
|
532
|
+
# bare `parameter "x" =` (no value at all) does not match
|
|
533
|
+
_PARAMETER_VALUE_RE = re.compile(
|
|
534
|
+
r'^(parameter\s+"([^"]+)"\s*=\s*)(\S.*?)\s*$', re.IGNORECASE
|
|
535
|
+
)
|
|
536
|
+
# the same assignment inside a raw file line (the name quoted with `"` or
|
|
537
|
+
# `"`), stopping before a CDATA/tag close
|
|
538
|
+
_RAW_PARAMETER_VALUE_RE = re.compile(
|
|
539
|
+
r'(parameter\s+(?:"[^"]+"|".+?")\s*=\s*)(.*?)'
|
|
540
|
+
r"(\s*(?:\]\]>|</ActionScript>|$))",
|
|
541
|
+
re.IGNORECASE,
|
|
542
|
+
)
|
|
543
|
+
# a `parameter "name"` reference anywhere on a line, assignments included --
|
|
544
|
+
# callers distinguish an assignment's own name from a reference to another
|
|
545
|
+
_PARAMETER_REF_RE = re.compile(r'\bparameter\s+"([^"]+)"', re.IGNORECASE)
|
|
546
|
+
_CONTINUE_IF_RE = re.compile(r"^continue\s+if\b(.*)$", re.IGNORECASE)
|
|
547
|
+
_PAUSE_WHILE_RE = re.compile(r"^pause\s+while\b(.*)$", re.IGNORECASE)
|
|
548
|
+
# `continue if false` (any case) is a documented idiom -- an unconditional
|
|
549
|
+
# literal used to force a failure on a branch (e.g. the `else` of an
|
|
550
|
+
# `if`/`else`/`endif`), not a mistyped relevance substitution. `continue if
|
|
551
|
+
# true` is NOT included: it always continues, so the check does nothing.
|
|
552
|
+
# `pause while` gets no such exception: `pause while true` hangs forever and
|
|
553
|
+
# `pause while false` never pauses, so neither is a sane literal there.
|
|
554
|
+
_LITERAL_FALSE_RE = re.compile(r"^false\s*$", re.IGNORECASE)
|
|
555
|
+
_CREATEFILE_UNTIL_RE = re.compile(r"^createfile\s+until\b", re.IGNORECASE)
|
|
556
|
+
_APPENDFILE_VERB_RE = re.compile(r"^appendfile\b", re.IGNORECASE)
|
|
557
|
+
# `__createfile`/`__appendfile` references, any case; callers compare the
|
|
558
|
+
# matched text against the canonical spelling to catch a wrong-case use
|
|
559
|
+
_CREATEFILE_REF_RE = re.compile(r"__createfile\b", re.IGNORECASE)
|
|
560
|
+
_APPENDFILE_REF_RE = re.compile(r"__appendfile\b", re.IGNORECASE)
|
|
561
|
+
# `__Download`/`__createfile`/`__appendfile` in any case, for the W503
|
|
562
|
+
# wrong-case scan; grouped so one search finds all three spellings at once
|
|
563
|
+
_SCRATCH_REF_RE = re.compile(r"__(download|createfile|appendfile)\b", re.IGNORECASE)
|
|
564
|
+
_SCRATCH_CANONICAL = {
|
|
565
|
+
"download": "__Download",
|
|
566
|
+
"createfile": "__createfile",
|
|
567
|
+
"appendfile": "__appendfile",
|
|
568
|
+
}
|
|
569
|
+
_SETTING_RE = re.compile(r"^setting\s+(.*)$", re.IGNORECASE | re.DOTALL)
|
|
570
|
+
_SETTING_SHAPE_RE = re.compile(
|
|
571
|
+
r'^setting\s+(?:delete\s+".+"|".+"\s*=\s*".*")'
|
|
572
|
+
r'\s+on\s+"\{.*\}"\s+for\s+(client\b|user\b|action\b)',
|
|
573
|
+
re.IGNORECASE | re.DOTALL,
|
|
574
|
+
)
|
|
575
|
+
_REGSET_RE = re.compile(
|
|
576
|
+
r"^(regset64|regset|regdelete64|regdelete)\s+(.*)$", re.IGNORECASE
|
|
577
|
+
)
|
|
578
|
+
_REGSET_KEY_RE = re.compile(r'^"\[', re.IGNORECASE)
|
|
579
|
+
_WOW64_RE = re.compile(r"^action\s+uses\s+wow64\s+redirection\b(.*)$", re.IGNORECASE)
|
|
580
|
+
# the agent accepts a boolean literal (any case) or a `{...}` substitution
|
|
581
|
+
_WOW64_ARG_RE = re.compile(r"^(?:true|false|\{.*\})$", re.IGNORECASE | re.DOTALL)
|
|
582
|
+
# a launching verb plus everything after it; the executable token is split off
|
|
583
|
+
# by _leading_token because it may be quoted (and may contain substitutions)
|
|
584
|
+
_LAUNCH_VERB_RE = re.compile(
|
|
585
|
+
r"^(wait|waithidden|run|runhidden)\s+(\S.*)$", re.IGNORECASE
|
|
586
|
+
)
|
|
587
|
+
# cmd.exe interprets its payload only after /c or /k; anywhere in the arguments
|
|
588
|
+
_CMD_RUN_SWITCH_RE = re.compile(r"(?:^|\s)/c\b", re.IGNORECASE)
|
|
589
|
+
# /k runs the command but leaves the shell alive, so the action never returns
|
|
590
|
+
_CMD_PERSIST_SWITCH_RE = re.compile(r"(?:^|\s)/k\b", re.IGNORECASE)
|
|
591
|
+
_OVERRIDE_VERB_RE = re.compile(r"^override\s+(wait|run)\s*$", re.IGNORECASE)
|
|
592
|
+
# lines whose syntax is fixed -- a keyword and at most a `{...}` condition --
|
|
593
|
+
# so a trailing `// comment` on them is just a comment. Command lines are NOT
|
|
594
|
+
# included: the rest of a launch line goes to the program verbatim, and
|
|
595
|
+
# `cscript //Nologo` uses `//` for its own options.
|
|
596
|
+
_FIXED_SYNTAX_RE = re.compile(
|
|
597
|
+
r"^(?:if|elseif|else|endif|begin\s+prefetch\s+block|end\s+prefetch\s+block"
|
|
598
|
+
r"|override)\b",
|
|
599
|
+
re.IGNORECASE,
|
|
600
|
+
)
|
|
601
|
+
_OVERRIDE_OPTION_LINE_RE = re.compile(r"^\w+\s*=")
|
|
602
|
+
|
|
603
|
+
# lines that do not count as "execution has started" for W502: declarations,
|
|
604
|
+
# prompts, structure, and prefetching
|
|
605
|
+
_NON_EXECUTION_RES = (
|
|
606
|
+
_BEGIN_PREFETCH_BLOCK_RE,
|
|
607
|
+
_END_PREFETCH_BLOCK_RE,
|
|
608
|
+
_ADD_PREFETCH_ITEM_RE,
|
|
609
|
+
_COLLECT_PREFETCH_ITEMS_RE,
|
|
610
|
+
_PREFETCH_STATEMENT_RE,
|
|
611
|
+
_PARAMETER_RE,
|
|
612
|
+
_ACTION_PARAMETER_QUERY_RE,
|
|
613
|
+
_IF_RE,
|
|
614
|
+
_ELSEIF_RE,
|
|
615
|
+
_ELSE_RE,
|
|
616
|
+
_ENDIF_RE,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _check_substitution_braces(lineno, line):
|
|
621
|
+
"""Check one line's `{...}` relevance substitutions for balance.
|
|
622
|
+
|
|
623
|
+
A substitution must open and close on the same line: the agent evaluates
|
|
624
|
+
it per line, so a `{` left open at end of line is not a substitution that
|
|
625
|
+
continues, it is a broken one (E508). A `}` reached with none open is the
|
|
626
|
+
mirror image (E509).
|
|
627
|
+
|
|
628
|
+
`{{` is an escape, not an opener: it passes a literal `{` through to the
|
|
629
|
+
command, so it neither opens a substitution nor makes the `}` that
|
|
630
|
+
follows a substitution close -- that `}` pairs with the escape instead.
|
|
631
|
+
`}}` is likewise a literal `}`, and the same escape holds *inside* an
|
|
632
|
+
open substitution too: `{ ... "@{'k'='v'}}" ... }` (a PowerShell hashtable
|
|
633
|
+
literal quoted inside the substitution) does not close on the first `}`
|
|
634
|
+
of that `}}` -- the pair is a literal `}` in the string, and the
|
|
635
|
+
substitution stays open for its real closing `}`. Escapes outside a
|
|
636
|
+
substitution are counted so an escaped brace absorbs a later lone `}`
|
|
637
|
+
rather than being reported as stray, which keeps this quiet on the
|
|
638
|
+
escaping styles seen in real content.
|
|
639
|
+
|
|
640
|
+
Inside an open substitution, an escaped regex quantifier `{3}}` /
|
|
641
|
+
`{1,3}}` (a `regex "..."` literal's `{3}` with its `}` written `}}`) is
|
|
642
|
+
consumed as one unit, so its trailing `}` is not misread as the
|
|
643
|
+
substitution's close. Outside a substitution it is handled as before.
|
|
644
|
+
"""
|
|
645
|
+
issues = []
|
|
646
|
+
open_col = None # column of the `{` that opened the current substitution
|
|
647
|
+
pending_escapes = 0 # `{{`/`}}` literals a lone `}` may pair with
|
|
648
|
+
index = 0
|
|
649
|
+
while index < len(line):
|
|
650
|
+
if open_col is not None:
|
|
651
|
+
# checked before the plain quantifier, which would otherwise
|
|
652
|
+
# take `{3}` and leave the escape's second `}` to close early
|
|
653
|
+
match = _ESCAPED_REGEX_QUANTIFIER_RE.match(line, index)
|
|
654
|
+
if match:
|
|
655
|
+
index = match.end()
|
|
656
|
+
continue
|
|
657
|
+
match = _REGEX_QUANTIFIER_RE.match(line, index)
|
|
658
|
+
if match:
|
|
659
|
+
# `{40}` etc -- a regex quantifier's braces, not a nested
|
|
660
|
+
# substitution or its close; skip over the whole span untouched
|
|
661
|
+
index = match.end()
|
|
662
|
+
continue
|
|
663
|
+
char = line[index]
|
|
664
|
+
if open_col is None:
|
|
665
|
+
if line.startswith("{{", index) or line.startswith("}}", index):
|
|
666
|
+
pending_escapes += 1
|
|
667
|
+
index += 2
|
|
668
|
+
continue
|
|
669
|
+
if char == "{":
|
|
670
|
+
open_col = index + 1
|
|
671
|
+
elif char == "}":
|
|
672
|
+
if pending_escapes:
|
|
673
|
+
pending_escapes -= 1
|
|
674
|
+
else:
|
|
675
|
+
issues.append(
|
|
676
|
+
(
|
|
677
|
+
lineno,
|
|
678
|
+
"E509",
|
|
679
|
+
(
|
|
680
|
+
f"unbalanced }} at column {index + 1} -- no `{{` "
|
|
681
|
+
"relevance substitution is open on this line "
|
|
682
|
+
f"(`{{{{` passes a literal brace through); add "
|
|
683
|
+
f"`{SUBSTITUTION_MARKER}` if intentional"
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
elif char == "}":
|
|
688
|
+
if line.startswith("}}", index):
|
|
689
|
+
index += 2 # escaped literal }; the substitution stays open
|
|
690
|
+
continue
|
|
691
|
+
open_col = None
|
|
692
|
+
index += 1
|
|
693
|
+
|
|
694
|
+
if open_col is not None:
|
|
695
|
+
issues.append(
|
|
696
|
+
(
|
|
697
|
+
lineno,
|
|
698
|
+
"E508",
|
|
699
|
+
(
|
|
700
|
+
f"unbalanced {{ at column {open_col} -- the relevance "
|
|
701
|
+
"substitution has no closing } before the end of the "
|
|
702
|
+
"line, and a substitution cannot span lines (`{{` passes "
|
|
703
|
+
f"a literal brace through); add `{SUBSTITUTION_MARKER}` "
|
|
704
|
+
"if intentional"
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
return issues
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _url_basename(url):
|
|
712
|
+
"""Return the last path segment of a literal URL, or None if unknowable."""
|
|
713
|
+
if "{" in url:
|
|
714
|
+
return None
|
|
715
|
+
base = url.rstrip("/").rsplit("/", 1)[-1]
|
|
716
|
+
if not base or "://" in base or base == url.rstrip("/"):
|
|
717
|
+
# no path at all (bare host), or nothing after the last slash
|
|
718
|
+
return None
|
|
719
|
+
return base
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _fingerprint(line):
|
|
723
|
+
"""Return a hashable `{attr: value}` snapshot of a producer line's
|
|
724
|
+
sha1/sha256/size attributes, lowercased and order-independent, or None.
|
|
725
|
+
|
|
726
|
+
if the line declares none of them.
|
|
727
|
+
|
|
728
|
+
Two producer declarations of the same download name with matching
|
|
729
|
+
fingerprints are the same file offered from multiple mirror URLs --
|
|
730
|
+
a common BigFix pattern -- not a real duplicate; see `_co_executable`'s
|
|
731
|
+
caller in `_check_download_names`. A line with no hash attributes at
|
|
732
|
+
all (a `download as`, `move`/`copy`, or redirect producer) fingerprints
|
|
733
|
+
to None, which never matches another None -- those stay flagged as
|
|
734
|
+
before, since there is nothing here to confirm they are the same file.
|
|
735
|
+
"""
|
|
736
|
+
attrs = tuple(
|
|
737
|
+
sorted((k.lower(), v.lower()) for k, v in _HASH_ATTR_RE.findall(line))
|
|
738
|
+
)
|
|
739
|
+
return attrs or None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _co_executable(path_a, path_b):
|
|
743
|
+
"""Return True only when two declarations are known to run together.
|
|
744
|
+
|
|
745
|
+
Each path is a `{if_id: branch_index}` snapshot of which `if`/`elseif`/
|
|
746
|
+
`else` branch was open, for every still-open `if`, at the moment a name
|
|
747
|
+
was declared. Real BigFix content routinely guards platform-specific
|
|
748
|
+
prefetch items with a *separate* `if` per platform (Windows, then a
|
|
749
|
+
second unrelated `if` for mac, then another for Linux) rather than one
|
|
750
|
+
`if`/`elseif` chain -- and this checker has no way to know those
|
|
751
|
+
conditions are mutually exclusive. So this only calls two declarations
|
|
752
|
+
co-executable -- a real E512 duplicate -- when their paths are exactly
|
|
753
|
+
equal: both unconditional (empty path), or both reached through the
|
|
754
|
+
identical sequence of branch choices. Anything reached via a different
|
|
755
|
+
`if` (even one that looks platform-exclusive) is treated as potentially
|
|
756
|
+
mutually exclusive and left unflagged, on the same false-alarm-is-worse
|
|
757
|
+
principle W507's gating uses.
|
|
758
|
+
"""
|
|
759
|
+
return path_a == path_b
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _is_extractor_run(line):
|
|
763
|
+
"""True if `line` launches an archive extractor against `__Download`.
|
|
764
|
+
|
|
765
|
+
Only the program itself is inspected -- the leading `{...}` substitution
|
|
766
|
+
when the executable is looked up by relevance, else the leading token --
|
|
767
|
+
so an extractor merely named in the arguments does not count.
|
|
768
|
+
"""
|
|
769
|
+
match = _LAUNCH_PREFIX_RE.match(line)
|
|
770
|
+
if not match or "__download" not in line.lower():
|
|
771
|
+
return False
|
|
772
|
+
rest = match.group(1).lstrip()
|
|
773
|
+
unquoted = rest.lstrip('"')
|
|
774
|
+
if unquoted.startswith("{"):
|
|
775
|
+
end = unquoted.find("}")
|
|
776
|
+
program = unquoted[: end + 1] if end != -1 else unquoted
|
|
777
|
+
else:
|
|
778
|
+
program = _leading_token(rest)[0]
|
|
779
|
+
return bool(_EXTRACTOR_RE.search(program))
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _download_is_destination(line):
|
|
783
|
+
"""True if a copy/move `line` ends with its only `__Download` ref.
|
|
784
|
+
|
|
785
|
+
That ref is then the destination -- a file being put into `__Download`
|
|
786
|
+
from somewhere else (`move {parameter "Temp"}\\x.png __Download\\x.png`) --
|
|
787
|
+
rather than a download being consumed. Something must precede it besides
|
|
788
|
+
the verb, so a bare `copy __Download\\x` is not mistaken for one.
|
|
789
|
+
"""
|
|
790
|
+
matches = list(_DOWNLOAD_REF_NESTED_RE.finditer(line))
|
|
791
|
+
if len(matches) != 1:
|
|
792
|
+
return False
|
|
793
|
+
match = matches[0]
|
|
794
|
+
start = match.start()
|
|
795
|
+
# widen to the full path token: back to the whitespace or quote before it
|
|
796
|
+
while start > 0 and not line[start - 1].isspace() and line[start - 1] != '"':
|
|
797
|
+
start -= 1
|
|
798
|
+
tail = line[match.end() :]
|
|
799
|
+
if match.group(2):
|
|
800
|
+
# nested: the path continues to the end of its token
|
|
801
|
+
tail = re.sub(r'^[^\s"]*', "", tail)
|
|
802
|
+
before = _MOVE_COPY_RE.sub("", line[:start], count=1).strip(" \t\"'")
|
|
803
|
+
return bool(before) and tail.strip(" \t\"'") == ""
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _check_download_names(lines, first_line=1):
|
|
807
|
+
"""Check prefetch/download producer names against `__Download\\` consumers.
|
|
808
|
+
|
|
809
|
+
Returns E512 issues for a duplicate producer name that can co-execute
|
|
810
|
+
with an earlier declaration of the same name (the second declaration
|
|
811
|
+
would silently overwrite the first at runtime) -- declarations of the
|
|
812
|
+
same name in mutually exclusive `if`/`elseif`/`else` branches (see
|
|
813
|
+
`_co_executable`) are not flagged, since only one of them ever runs.
|
|
814
|
+
Also returns W507 issues for a `__Download\\<name>` consumer no producer
|
|
815
|
+
creates. W507 is conservative: the moment any producer's names are
|
|
816
|
+
unknowable (an extract/unarchive/archive now/utility command, a
|
|
817
|
+
`download` with no `as <name>` and no literal URL basename, or a
|
|
818
|
+
name/URL containing a `{` substitution), the whole consumer check is
|
|
819
|
+
skipped -- a missed typo is better than a false alarm. E512 still runs on
|
|
820
|
+
the literal names that were collected. A single consumer reference
|
|
821
|
+
containing a shell glob wildcard (`*` or `?`, e.g.
|
|
822
|
+
`__Download\\mysql*rpm` for a versioned filename the script author
|
|
823
|
+
cannot spell out literally) is skipped on its own, without disabling
|
|
824
|
+
the check for the rest of the body -- the shell expands it at runtime,
|
|
825
|
+
not this checker, so it can never be matched against a producer's
|
|
826
|
+
literal name.
|
|
827
|
+
|
|
828
|
+
`copy`/`move` and shell-redirection (`>`, `>>`) lines can themselves
|
|
829
|
+
create a file under `__Download`, not just consume one -- see
|
|
830
|
+
`_MOVE_COPY_RE`/`_REDIRECT_TARGET_RE` below -- so they are also treated
|
|
831
|
+
as producers where the destination name is determinable.
|
|
832
|
+
|
|
833
|
+
A `copy`/`move` destination nested below a subdirectory
|
|
834
|
+
(`__Download\\dir\\file`) produces the subdirectory, not a file named
|
|
835
|
+
after it: several files moved into one directory do not overwrite each
|
|
836
|
+
other, so the directory name satisfies W507 consumers but is never an
|
|
837
|
+
E512 duplicate.
|
|
838
|
+
"""
|
|
839
|
+
issues = []
|
|
840
|
+
producers = {} # lowercased name -> [(lineno, if-branch path, fingerprint), ...]
|
|
841
|
+
directories = set() # lowercased __Download subdirectories copy/move fill
|
|
842
|
+
knowable = True
|
|
843
|
+
if_stack = [] # each entry: [if_id, branch_index]
|
|
844
|
+
next_if_id = 0
|
|
845
|
+
|
|
846
|
+
def current_path():
|
|
847
|
+
return {if_id: branch for if_id, branch in if_stack}
|
|
848
|
+
|
|
849
|
+
def produce(lineno, name, fingerprint=None):
|
|
850
|
+
nonlocal knowable
|
|
851
|
+
if name is None or "{" in name:
|
|
852
|
+
knowable = False
|
|
853
|
+
return
|
|
854
|
+
name = name.strip("\"'").lower()
|
|
855
|
+
if not name:
|
|
856
|
+
knowable = False
|
|
857
|
+
return
|
|
858
|
+
path = current_path()
|
|
859
|
+
declarations = producers.setdefault(name, [])
|
|
860
|
+
for existing_lineno, existing_path, existing_fingerprint in declarations:
|
|
861
|
+
if not _co_executable(path, existing_path):
|
|
862
|
+
continue
|
|
863
|
+
if fingerprint is not None and fingerprint == existing_fingerprint:
|
|
864
|
+
# same name, same sha1/sha256/size -- mirror URLs for the
|
|
865
|
+
# identical file, not a real duplicate; whichever download
|
|
866
|
+
# succeeds satisfies both
|
|
867
|
+
continue
|
|
868
|
+
issues.append(
|
|
869
|
+
(
|
|
870
|
+
lineno,
|
|
871
|
+
"E512",
|
|
872
|
+
(
|
|
873
|
+
f'duplicate download name "{name}" (first '
|
|
874
|
+
f"declared on line {existing_lineno + first_line - 1}, and both "
|
|
875
|
+
"can run in the same execution); the second "
|
|
876
|
+
"declaration silently overwrites the first; add "
|
|
877
|
+
f"`{DOWNLOAD_MARKER}` if intentional"
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
break
|
|
882
|
+
declarations.append((lineno, path, fingerprint))
|
|
883
|
+
|
|
884
|
+
for index, raw_line in enumerate(lines):
|
|
885
|
+
lineno = index + 1
|
|
886
|
+
stripped = raw_line.strip()
|
|
887
|
+
if not stripped or stripped.startswith("//"):
|
|
888
|
+
continue
|
|
889
|
+
|
|
890
|
+
# if/elseif/else/endif bookkeeping only -- pairing itself is E500/
|
|
891
|
+
# E501/E505/E506's job elsewhere; an unbalanced endif here is
|
|
892
|
+
# ignored rather than double-reported
|
|
893
|
+
if _IF_RE.match(stripped):
|
|
894
|
+
next_if_id += 1
|
|
895
|
+
if_stack.append([next_if_id, 0])
|
|
896
|
+
continue
|
|
897
|
+
if _ELSEIF_RE.match(stripped) or _ELSE_RE.match(stripped):
|
|
898
|
+
if if_stack:
|
|
899
|
+
if_stack[-1][1] += 1
|
|
900
|
+
continue
|
|
901
|
+
if _ENDIF_RE.match(stripped):
|
|
902
|
+
if if_stack:
|
|
903
|
+
if_stack.pop()
|
|
904
|
+
continue
|
|
905
|
+
|
|
906
|
+
lowered = stripped.lower()
|
|
907
|
+
if _UNKNOWABLE_PRODUCER_RE.match(stripped):
|
|
908
|
+
knowable = False
|
|
909
|
+
continue
|
|
910
|
+
if lowered.startswith((BLOCK_PREFETCH, NOHASH_PREFETCH)):
|
|
911
|
+
match = _NAME_KV_RE.search(stripped)
|
|
912
|
+
produce(lineno, match.group(1) if match else None, _fingerprint(stripped))
|
|
913
|
+
continue
|
|
914
|
+
if lowered.startswith(STATEMENT_PREFETCH):
|
|
915
|
+
match = _PREFETCH_STATEMENT_RE.match(stripped)
|
|
916
|
+
produce(lineno, match.group(1) if match else None, _fingerprint(stripped))
|
|
917
|
+
continue
|
|
918
|
+
match = _DOWNLOAD_AS_RE.match(stripped)
|
|
919
|
+
if match:
|
|
920
|
+
produce(lineno, match.group(1))
|
|
921
|
+
continue
|
|
922
|
+
match = _DOWNLOAD_RE.match(stripped)
|
|
923
|
+
if match:
|
|
924
|
+
produce(lineno, _url_basename(match.group(1)))
|
|
925
|
+
continue
|
|
926
|
+
if _DOWNLOAD_VERB_RE.match(stripped):
|
|
927
|
+
# a download of some other shape (a URL with a space inside a
|
|
928
|
+
# `{...}` substitution does not match either regex above); its
|
|
929
|
+
# target name is unknowable
|
|
930
|
+
knowable = False
|
|
931
|
+
continue
|
|
932
|
+
|
|
933
|
+
if _FOLDER_CREATE_RE.match(stripped):
|
|
934
|
+
match = None
|
|
935
|
+
for match in _DOWNLOAD_REF_NESTED_RE.finditer(stripped):
|
|
936
|
+
pass
|
|
937
|
+
if match and "{" not in match.group(1):
|
|
938
|
+
directories.add(match.group(1).lower())
|
|
939
|
+
continue
|
|
940
|
+
if _is_extractor_run(stripped):
|
|
941
|
+
knowable = False
|
|
942
|
+
continue
|
|
943
|
+
|
|
944
|
+
if _MOVE_COPY_RE.match(stripped):
|
|
945
|
+
refs = _DOWNLOAD_REF_RE.findall(stripped)
|
|
946
|
+
has_createfile_source = bool(_CREATEFILE_SOURCE_RE.search(stripped))
|
|
947
|
+
nested = _DOWNLOAD_REF_NESTED_RE.findall(stripped)
|
|
948
|
+
into_download = _download_is_destination(stripped)
|
|
949
|
+
if (
|
|
950
|
+
nested
|
|
951
|
+
and nested[-1][1]
|
|
952
|
+
and (has_createfile_source or len(refs) >= 2 or into_download)
|
|
953
|
+
):
|
|
954
|
+
# destination is `__Download\<dir>\...` -- a file inside a
|
|
955
|
+
# subdirectory, not a download named <dir>; record the
|
|
956
|
+
# directory for consumers, but never as an E512 duplicate
|
|
957
|
+
if "{" not in nested[-1][0]:
|
|
958
|
+
directories.add(nested[-1][0].lower())
|
|
959
|
+
elif has_createfile_source and refs:
|
|
960
|
+
# source is __createfile/__appendfile, not a __Download ref;
|
|
961
|
+
# the one __Download ref on the line is the destination
|
|
962
|
+
produce(lineno, refs[-1])
|
|
963
|
+
elif has_createfile_source and "{" in stripped:
|
|
964
|
+
# `move __createfile "{download path "X"}"` -- a substituted
|
|
965
|
+
# destination with no literal __Download ref to read back
|
|
966
|
+
knowable = False
|
|
967
|
+
elif not has_createfile_source and (len(refs) >= 2 or into_download):
|
|
968
|
+
# renaming one download to another, or moving a file into
|
|
969
|
+
# __Download from elsewhere; the last ref is the new name,
|
|
970
|
+
# earlier refs are ordinary consumer references
|
|
971
|
+
produce(lineno, refs[-1])
|
|
972
|
+
# a single __Download ref with no __createfile source is left
|
|
973
|
+
# alone here -- it is an ordinary consumer reference (e.g.
|
|
974
|
+
# `move __Download\typo.exe elsewhere`), checked as one below
|
|
975
|
+
continue
|
|
976
|
+
|
|
977
|
+
match = _REDIRECT_TARGET_RE.search(stripped)
|
|
978
|
+
if match:
|
|
979
|
+
appends = match.group(0).startswith(">>")
|
|
980
|
+
if not (appends and match.group(1).strip("\"'").lower() in producers):
|
|
981
|
+
# `>>` onto a file that already exists adds to it rather
|
|
982
|
+
# than replacing it, so it is not a second declaration
|
|
983
|
+
produce(lineno, match.group(1))
|
|
984
|
+
# fall through: the line may still hold other __Download refs
|
|
985
|
+
# (a command being redirected, say) to check as consumers below
|
|
986
|
+
|
|
987
|
+
if knowable:
|
|
988
|
+
for index, raw_line in enumerate(lines):
|
|
989
|
+
lineno = index + 1
|
|
990
|
+
stripped = raw_line.strip()
|
|
991
|
+
if not stripped or stripped.startswith("//"):
|
|
992
|
+
continue
|
|
993
|
+
if _DELETE_RE.match(stripped):
|
|
994
|
+
# deleting `__Download\<name>` is cleanup, not consumption --
|
|
995
|
+
# ensuring the working directory is clean before proceeding
|
|
996
|
+
# is normal even when nothing downloads that name
|
|
997
|
+
continue
|
|
998
|
+
for name in _DOWNLOAD_REF_RE.findall(stripped):
|
|
999
|
+
if "{" in name: # a substituted name is unknowable; skip it
|
|
1000
|
+
continue
|
|
1001
|
+
if _GLOB_WILDCARD_RE.search(name):
|
|
1002
|
+
# a wildcard reference (e.g. `__Download\mysql*rpm` for
|
|
1003
|
+
# a versioned filename) matches by shell glob at
|
|
1004
|
+
# runtime, not by literal name; skip it
|
|
1005
|
+
continue
|
|
1006
|
+
if name.lower() not in producers and name.lower() not in directories:
|
|
1007
|
+
issues.append(
|
|
1008
|
+
(
|
|
1009
|
+
lineno,
|
|
1010
|
+
"W507",
|
|
1011
|
+
(
|
|
1012
|
+
f"`__Download\\{name}` is referenced but nothing "
|
|
1013
|
+
"prefetches or downloads a file of that name; "
|
|
1014
|
+
f"add `{DOWNLOAD_MARKER}` if intentional"
|
|
1015
|
+
),
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
return issues
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _check_parameters(lines, first_line=1):
|
|
1022
|
+
"""Check `parameter "name" = ...` assignments and references.
|
|
1023
|
+
|
|
1024
|
+
Returns E516 issues for a second assignment to a name that can
|
|
1025
|
+
co-execute with an earlier one (the same `_co_executable` if-branch-path
|
|
1026
|
+
rule E512 uses -- assignments in mutually exclusive `if`/`elseif`/`else`
|
|
1027
|
+
branches are not compared), since action parameters are write-once and
|
|
1028
|
+
the second assignment would silently overwrite the first at runtime.
|
|
1029
|
+
|
|
1030
|
+
Also returns E517 issues for a `parameter "name"` reference on a line
|
|
1031
|
+
before that name's (first) assignment elsewhere in the body -- an
|
|
1032
|
+
ordering bug, since the substitution evaluates to empty at that point.
|
|
1033
|
+
A name never assigned anywhere in the body is not flagged: it may be
|
|
1034
|
+
supplied from outside the script (a secure parameter, say), which this
|
|
1035
|
+
hook cannot see. For the same reason an `exists parameter "name"` test
|
|
1036
|
+
is not a reference -- it asks whether the value was supplied, the
|
|
1037
|
+
supported way to give a parameter a default -- and a name prompted for
|
|
1038
|
+
by `action parameter query "name"` is defined from the first line on.
|
|
1039
|
+
"""
|
|
1040
|
+
issues = []
|
|
1041
|
+
if_stack = [] # each entry: [if_id, branch_index], mirrors _check_download_names
|
|
1042
|
+
next_if_id = 0
|
|
1043
|
+
declarations = {} # lowercased name -> [(lineno, if-branch path), ...]
|
|
1044
|
+
first_assign_lineno = {} # lowercased name -> earliest assignment lineno
|
|
1045
|
+
deferred_refs = [] # (lineno, name, raw_name) references seen so far
|
|
1046
|
+
|
|
1047
|
+
def current_path():
|
|
1048
|
+
return {if_id: branch for if_id, branch in if_stack}
|
|
1049
|
+
|
|
1050
|
+
# names an `action parameter query` prompts for: set before line 1 runs
|
|
1051
|
+
queried = set()
|
|
1052
|
+
# first pass: record every assignment (for E516 and to know where each
|
|
1053
|
+
# name is first assigned), in source order
|
|
1054
|
+
for index, raw_line in enumerate(lines):
|
|
1055
|
+
lineno = index + 1
|
|
1056
|
+
stripped = raw_line.strip()
|
|
1057
|
+
if not stripped or stripped.startswith("//"):
|
|
1058
|
+
continue
|
|
1059
|
+
match = _ACTION_PARAMETER_QUERY_NAME_RE.match(stripped)
|
|
1060
|
+
if match:
|
|
1061
|
+
queried.add(match.group(1).lower())
|
|
1062
|
+
continue
|
|
1063
|
+
|
|
1064
|
+
if _IF_RE.match(stripped):
|
|
1065
|
+
next_if_id += 1
|
|
1066
|
+
if_stack.append([next_if_id, 0])
|
|
1067
|
+
continue
|
|
1068
|
+
if _ELSEIF_RE.match(stripped) or _ELSE_RE.match(stripped):
|
|
1069
|
+
if if_stack:
|
|
1070
|
+
if_stack[-1][1] += 1
|
|
1071
|
+
continue
|
|
1072
|
+
if _ENDIF_RE.match(stripped):
|
|
1073
|
+
if if_stack:
|
|
1074
|
+
if_stack.pop()
|
|
1075
|
+
continue
|
|
1076
|
+
|
|
1077
|
+
match = _PARAMETER_ASSIGN_RE.match(stripped)
|
|
1078
|
+
if match:
|
|
1079
|
+
name = match.group(1)
|
|
1080
|
+
key = name.lower()
|
|
1081
|
+
if key not in first_assign_lineno:
|
|
1082
|
+
first_assign_lineno[key] = lineno
|
|
1083
|
+
path = current_path()
|
|
1084
|
+
existing = declarations.setdefault(key, [])
|
|
1085
|
+
for existing_lineno, existing_path in existing:
|
|
1086
|
+
if _co_executable(path, existing_path):
|
|
1087
|
+
issues.append(
|
|
1088
|
+
(
|
|
1089
|
+
lineno,
|
|
1090
|
+
"E516",
|
|
1091
|
+
(
|
|
1092
|
+
f'duplicate assignment to parameter "{name}" '
|
|
1093
|
+
f"(first assigned on line {existing_lineno + first_line - 1}, "
|
|
1094
|
+
"and both can run in the same execution); "
|
|
1095
|
+
"action parameters are write-once and the "
|
|
1096
|
+
"second assignment silently overwrites the "
|
|
1097
|
+
f"first; add `{PARAMETER_MARKER}` if "
|
|
1098
|
+
"intentional"
|
|
1099
|
+
),
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
break
|
|
1103
|
+
existing.append((lineno, path))
|
|
1104
|
+
|
|
1105
|
+
# second pass: every `parameter "name"` reference, including inside an
|
|
1106
|
+
# assignment's own value (self-reference is a real ordering bug too, and
|
|
1107
|
+
# the assignment's own name= part is excluded by scanning after it)
|
|
1108
|
+
for index, raw_line in enumerate(lines):
|
|
1109
|
+
lineno = index + 1
|
|
1110
|
+
stripped = raw_line.strip()
|
|
1111
|
+
if not stripped or stripped.startswith("//"):
|
|
1112
|
+
continue
|
|
1113
|
+
assign_match = _PARAMETER_ASSIGN_RE.match(stripped)
|
|
1114
|
+
scan_from = assign_match.end() if assign_match else 0
|
|
1115
|
+
for match in _PARAMETER_REF_RE.finditer(stripped, scan_from):
|
|
1116
|
+
if _EXISTS_BEFORE_RE.search(stripped[: match.start()]):
|
|
1117
|
+
continue # `exists parameter "x"`: a presence test, not a read
|
|
1118
|
+
deferred_refs.append((lineno, match.group(1)))
|
|
1119
|
+
|
|
1120
|
+
for lineno, name in deferred_refs:
|
|
1121
|
+
key = name.lower()
|
|
1122
|
+
if key in queried:
|
|
1123
|
+
continue
|
|
1124
|
+
assign_lineno = first_assign_lineno.get(key)
|
|
1125
|
+
if assign_lineno is not None and lineno < assign_lineno:
|
|
1126
|
+
issues.append(
|
|
1127
|
+
(
|
|
1128
|
+
lineno,
|
|
1129
|
+
"E517",
|
|
1130
|
+
(
|
|
1131
|
+
f'`parameter "{name}"` is referenced here but not '
|
|
1132
|
+
f"assigned until line {assign_lineno + first_line - 1}; the "
|
|
1133
|
+
"substitution evaluates to empty at this point; add "
|
|
1134
|
+
f"`{PARAMETER_MARKER}` if intentional"
|
|
1135
|
+
),
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
return issues
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _normalize_path(text):
|
|
1142
|
+
"""Normalize a path argument for comparison: unquote, unify separators.
|
|
1143
|
+
|
|
1144
|
+
Comparison is textual -- two spellings of the same path that differ by
|
|
1145
|
+
quoting, separator or a trailing slash are treated as equal, but a
|
|
1146
|
+
`{...}` substitution is compared verbatim (its value is unknowable here,
|
|
1147
|
+
so only an identical substitution counts as the same path), except that
|
|
1148
|
+
`{parameter "X" of action}` and `{parameter "X"}` are the same parameter.
|
|
1149
|
+
A Windows-style path (drive letter or backslash) is case-folded, its
|
|
1150
|
+
filesystem being case-insensitive; any other path keeps its case, since
|
|
1151
|
+
on a Linux/macOS agent `App.conf` and `app.conf` are two files.
|
|
1152
|
+
"""
|
|
1153
|
+
text = text.strip().strip('"')
|
|
1154
|
+
windows = bool(_WINDOWS_PATH_RE.search(text))
|
|
1155
|
+
text = _PARAMETER_OF_ACTION_RE.sub(r"\1", text).replace("\\", "/").rstrip("/")
|
|
1156
|
+
# `{ parameter "x" }` and `{parameter "x"}` are the same substitution
|
|
1157
|
+
text = re.sub(r"\{\s+", "{", re.sub(r"\s+\}", "}", text))
|
|
1158
|
+
return text.lower() if windows else text
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _deletes_by_lookup(deleted, destination):
|
|
1162
|
+
"""True if `deleted` is a relevance lookup of the destination's file.
|
|
1163
|
+
|
|
1164
|
+
`{pathname of file "x.jar" of folder (parameter "Dir")}` names the file
|
|
1165
|
+
`{parameter "Dir"}/x.jar`: the substitution must name the destination's
|
|
1166
|
+
file and contain its folder expression, so a lookup of another file, or
|
|
1167
|
+
of the same name in some other folder, does not count.
|
|
1168
|
+
"""
|
|
1169
|
+
match = re.fullmatch(r"\{(.*)\}", deleted)
|
|
1170
|
+
if not match:
|
|
1171
|
+
return False
|
|
1172
|
+
lookup = match.group(1).lower()
|
|
1173
|
+
folder, _sep, name = destination.lower().rpartition("/")
|
|
1174
|
+
names = re.findall(r'\bfiles?\s+"([^"]+)"', lookup)
|
|
1175
|
+
return name in names and folder.strip("{}") in lookup
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _split_arguments(text):
|
|
1179
|
+
"""Split a command's argument text on whitespace, keeping quoted runs and
|
|
1180
|
+
`{...}` substitutions (which may contain quotes and spaces) whole.
|
|
1181
|
+
"""
|
|
1182
|
+
args, current, depth, quoted = [], [], 0, False
|
|
1183
|
+
for char in text:
|
|
1184
|
+
if char == "{":
|
|
1185
|
+
depth += 1
|
|
1186
|
+
elif char == "}" and depth:
|
|
1187
|
+
depth -= 1
|
|
1188
|
+
elif char == '"' and not depth:
|
|
1189
|
+
quoted = not quoted
|
|
1190
|
+
elif char.isspace() and not depth and not quoted:
|
|
1191
|
+
if current:
|
|
1192
|
+
args.append("".join(current))
|
|
1193
|
+
current = []
|
|
1194
|
+
continue
|
|
1195
|
+
current.append(char)
|
|
1196
|
+
if current:
|
|
1197
|
+
args.append("".join(current))
|
|
1198
|
+
return args
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _clears_destination(line, destination):
|
|
1202
|
+
"""True if `line` deletes `destination` or a folder containing it."""
|
|
1203
|
+
stripped = line.strip()
|
|
1204
|
+
match = _FOLDER_DELETE_RE.match(stripped)
|
|
1205
|
+
if match:
|
|
1206
|
+
folder = _normalize_path(match.group(1))
|
|
1207
|
+
# a substituted prefix is unknowable, so compare the trailing literal
|
|
1208
|
+
# segment: `folder delete "{client folder...}/__Local/Upgrade"` covers
|
|
1209
|
+
# `__Local/Upgrade/besclientupgrade`
|
|
1210
|
+
# the whole folder, substitutions and all, contains it; a Windows-style
|
|
1211
|
+
# destination was case-folded by _normalize_path, so fold to match
|
|
1212
|
+
if folder and (destination.startswith((folder + "/", folder.lower() + "/"))):
|
|
1213
|
+
return True
|
|
1214
|
+
tail = folder.split("}")[-1].strip("/")
|
|
1215
|
+
return bool(tail) and "/" + tail + "/" in "/" + destination
|
|
1216
|
+
match = _FILE_DELETE_RE.match(stripped)
|
|
1217
|
+
if match:
|
|
1218
|
+
deleted = _normalize_path(match.group(1))
|
|
1219
|
+
return deleted == destination or _deletes_by_lookup(deleted, destination)
|
|
1220
|
+
if _SHELL_DELETE_RE.match(stripped):
|
|
1221
|
+
# the destination named anywhere on the delete command line; macOS
|
|
1222
|
+
# `/tmp` is a symlink to `/private/tmp`, so either spelling counts
|
|
1223
|
+
def unquoted(text):
|
|
1224
|
+
return text.replace('"', "").replace("'", "")
|
|
1225
|
+
|
|
1226
|
+
text = unquoted(_normalize_path(stripped))
|
|
1227
|
+
if _WINDOWS_PATH_RE.search(stripped):
|
|
1228
|
+
text = text.lower()
|
|
1229
|
+
spellings = {unquoted(destination)}
|
|
1230
|
+
if destination.startswith("/tmp/"):
|
|
1231
|
+
spellings.add("/private" + unquoted(destination))
|
|
1232
|
+
# the whole path, not a prefix of a longer name (`a.pkg` vs `a.pkg.old`)
|
|
1233
|
+
return any(
|
|
1234
|
+
re.search(re.escape(spelling) + r"(?![\w.-])", text)
|
|
1235
|
+
for spelling in spellings
|
|
1236
|
+
)
|
|
1237
|
+
# `move <destination> <elsewhere>` -- backing the old file up -- leaves
|
|
1238
|
+
# the destination empty (a `copy` does not: its source stays in place)
|
|
1239
|
+
match = _MOVE_ARGS_RE.match(stripped)
|
|
1240
|
+
if match and not _SCRATCH_MOVE_RE.match(stripped):
|
|
1241
|
+
args = _split_arguments(match.group(1))
|
|
1242
|
+
return len(args) == 2 and _normalize_path(args[0]) == destination
|
|
1243
|
+
return False
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _uncleared_destinations(lines):
|
|
1247
|
+
"""Yield (index, verb, destination_text) for each W506 target in `lines`.
|
|
1248
|
+
|
|
1249
|
+
A `move`/`copy` of any source whose destination (the second of exactly
|
|
1250
|
+
two arguments) is not deleted -- or moved away, or inside a deleted
|
|
1251
|
+
folder -- on an earlier line. `destination_text` is the argument as
|
|
1252
|
+
written, quotes included. Shared by the check and its auto-fix.
|
|
1253
|
+
"""
|
|
1254
|
+
for index, raw_line in enumerate(lines):
|
|
1255
|
+
stripped = raw_line.strip()
|
|
1256
|
+
if not stripped or stripped.startswith("//"):
|
|
1257
|
+
continue
|
|
1258
|
+
match = _MOVE_COPY_ARGS_RE.match(stripped)
|
|
1259
|
+
if not match:
|
|
1260
|
+
continue
|
|
1261
|
+
args = _split_arguments(match.group(2))
|
|
1262
|
+
if len(args) != 2:
|
|
1263
|
+
continue # e.g. an unquoted path with spaces: which is the target?
|
|
1264
|
+
destination = _normalize_path(args[1])
|
|
1265
|
+
if not destination or _DOWNLOAD_DEST_RE.search(destination):
|
|
1266
|
+
continue
|
|
1267
|
+
if any(_clears_destination(line, destination) for line in lines[:index]):
|
|
1268
|
+
continue
|
|
1269
|
+
yield index, match.group(1).lower(), args[1]
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _check_scratch_destinations(lines):
|
|
1273
|
+
"""Check that a move/copy lands on a cleared destination.
|
|
1274
|
+
|
|
1275
|
+
Returns W506 for a `move`/`copy` -- of a `__createfile`/`__appendfile`
|
|
1276
|
+
scratch file, a download, or anything else -- whose destination is not
|
|
1277
|
+
deleted earlier in the body. Both verbs fail when the destination already
|
|
1278
|
+
exists, so such an action works once and then fails on every later run;
|
|
1279
|
+
the documented pattern is `delete <dest>` first (auto-fixable).
|
|
1280
|
+
|
|
1281
|
+
A destination inside the action's own download folder is exempt: that
|
|
1282
|
+
folder is action-scoped rather than a persistent location.
|
|
1283
|
+
"""
|
|
1284
|
+
issues = []
|
|
1285
|
+
for index, verb, destination_text in _uncleared_destinations(lines):
|
|
1286
|
+
shown = destination_text.strip().strip('"')
|
|
1287
|
+
issues.append(
|
|
1288
|
+
(
|
|
1289
|
+
index + 1,
|
|
1290
|
+
"W506",
|
|
1291
|
+
(
|
|
1292
|
+
f'`{verb}` onto "{shown}" without deleting it first; '
|
|
1293
|
+
f"`{verb}` fails when the destination already exists, so this "
|
|
1294
|
+
f"action cannot run twice (auto-fixable); add "
|
|
1295
|
+
f"`{SCRATCH_DEST_MARKER}` if intentional"
|
|
1296
|
+
),
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
return issues
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _check_scratch_references(lines):
|
|
1303
|
+
"""Check `__createfile`/`__appendfile` production and reference case.
|
|
1304
|
+
|
|
1305
|
+
Returns E519 issues for a command referencing `__createfile` (resp.
|
|
1306
|
+
`__appendfile`) when the body has no `createfile until` (resp.
|
|
1307
|
+
`appendfile`) line anywhere -- the W507 rule reapplied to these two
|
|
1308
|
+
scratch-file verbs, with the same `delete`/`folder delete` exemption
|
|
1309
|
+
(clearing scratch output is normal housekeeping, not consumption).
|
|
1310
|
+
|
|
1311
|
+
Also returns W503 issues for any `__download`, `__createfile`, or
|
|
1312
|
+
`__appendfile` reference whose case does not match the canonical
|
|
1313
|
+
spelling -- Windows tolerates this, a case-sensitive Linux/macOS
|
|
1314
|
+
filesystem does not.
|
|
1315
|
+
"""
|
|
1316
|
+
issues = []
|
|
1317
|
+
has_createfile = any(
|
|
1318
|
+
_CREATEFILE_UNTIL_RE.match(line.strip())
|
|
1319
|
+
for line in lines
|
|
1320
|
+
if line.strip() and not line.strip().startswith("//")
|
|
1321
|
+
)
|
|
1322
|
+
has_appendfile = any(
|
|
1323
|
+
_APPENDFILE_VERB_RE.match(line.strip())
|
|
1324
|
+
for line in lines
|
|
1325
|
+
if line.strip() and not line.strip().startswith("//")
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
for index, raw_line in enumerate(lines):
|
|
1329
|
+
lineno = index + 1
|
|
1330
|
+
stripped = raw_line.strip()
|
|
1331
|
+
if not stripped or stripped.startswith("//"):
|
|
1332
|
+
continue
|
|
1333
|
+
|
|
1334
|
+
for match in _SCRATCH_REF_RE.finditer(raw_line):
|
|
1335
|
+
matched_text = match.group(0)
|
|
1336
|
+
canonical = _SCRATCH_CANONICAL[match.group(1).lower()]
|
|
1337
|
+
if matched_text != canonical:
|
|
1338
|
+
issues.append(
|
|
1339
|
+
(
|
|
1340
|
+
lineno,
|
|
1341
|
+
"W503",
|
|
1342
|
+
(
|
|
1343
|
+
f'"{matched_text}" is not exactly "{canonical}"; '
|
|
1344
|
+
"Windows tolerates the case mismatch but a "
|
|
1345
|
+
"case-sensitive Linux/macOS filesystem does not; "
|
|
1346
|
+
f"add `{SCRATCH_MARKER}` if intentional"
|
|
1347
|
+
),
|
|
1348
|
+
)
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
if _DELETE_RE.match(stripped):
|
|
1352
|
+
continue # cleanup, not consumption -- same exemption as W507
|
|
1353
|
+
|
|
1354
|
+
if not has_createfile and _CREATEFILE_REF_RE.search(stripped):
|
|
1355
|
+
issues.append(
|
|
1356
|
+
(
|
|
1357
|
+
lineno,
|
|
1358
|
+
"E519",
|
|
1359
|
+
(
|
|
1360
|
+
"`__createfile` is referenced but the body has no "
|
|
1361
|
+
f"`createfile until` line; add `{SCRATCH_MARKER}` if intentional"
|
|
1362
|
+
),
|
|
1363
|
+
)
|
|
1364
|
+
)
|
|
1365
|
+
if not has_appendfile and _APPENDFILE_REF_RE.search(stripped):
|
|
1366
|
+
issues.append(
|
|
1367
|
+
(
|
|
1368
|
+
lineno,
|
|
1369
|
+
"E519",
|
|
1370
|
+
(
|
|
1371
|
+
"`__appendfile` is referenced but the body has no "
|
|
1372
|
+
"`appendfile` line; add "
|
|
1373
|
+
f"`{SCRATCH_MARKER}` if intentional"
|
|
1374
|
+
),
|
|
1375
|
+
)
|
|
1376
|
+
)
|
|
1377
|
+
return issues
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _scratch_case_targets(raw, src, is_bes):
|
|
1381
|
+
"""Yield (file_lineno, wrong_text, canonical) for every W503 fix target.
|
|
1382
|
+
|
|
1383
|
+
Mirrors what `_check_scratch_references` itself flags: `body.split("\n")`
|
|
1384
|
+
is run through `_mask_heredocs` first, so a wrong-case reference sitting
|
|
1385
|
+
inside `createfile until` block content (raw file text, not ActionScript)
|
|
1386
|
+
is not a target here either. `file_lineno` is 1-based into the whole
|
|
1387
|
+
file, matching `_validate_bes_xml`'s sourceline offset for a .bes file.
|
|
1388
|
+
"""
|
|
1389
|
+
if is_bes:
|
|
1390
|
+
try:
|
|
1391
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1392
|
+
except etree.XMLSyntaxError:
|
|
1393
|
+
return # already reported as W500; nothing safe to rewrite
|
|
1394
|
+
else:
|
|
1395
|
+
bodies = [(1, src)]
|
|
1396
|
+
|
|
1397
|
+
for sourceline, body in bodies:
|
|
1398
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1399
|
+
for index, masked_line in enumerate(masked_lines):
|
|
1400
|
+
stripped = masked_line.strip()
|
|
1401
|
+
if not stripped or stripped.startswith("//"):
|
|
1402
|
+
continue
|
|
1403
|
+
for match in _SCRATCH_REF_RE.finditer(masked_line):
|
|
1404
|
+
matched_text = match.group(0)
|
|
1405
|
+
canonical = _SCRATCH_CANONICAL[match.group(1).lower()]
|
|
1406
|
+
if matched_text != canonical:
|
|
1407
|
+
yield sourceline + index, matched_text, canonical
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _destination_targets(raw, src, is_bes):
|
|
1411
|
+
"""Yield (file_lineno, verb, destination_text) for every W506 fix target.
|
|
1412
|
+
|
|
1413
|
+
The same `_uncleared_destinations` walk the check itself uses, over the
|
|
1414
|
+
same heredoc-masked, comment-normalized lines, mapped to file lines the
|
|
1415
|
+
way `_scratch_case_targets` does.
|
|
1416
|
+
"""
|
|
1417
|
+
if is_bes:
|
|
1418
|
+
try:
|
|
1419
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1420
|
+
except etree.XMLSyntaxError:
|
|
1421
|
+
return # already reported as W500; nothing safe to rewrite
|
|
1422
|
+
else:
|
|
1423
|
+
bodies = [(1, src)]
|
|
1424
|
+
for sourceline, body in bodies:
|
|
1425
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1426
|
+
lines = _ignore_structural_comments(masked_lines)
|
|
1427
|
+
for index, verb, destination in _uncleared_destinations(lines):
|
|
1428
|
+
yield sourceline + index, verb, destination
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
# an entity reference: the line sits in an entity-escaped (non-CDATA) body
|
|
1432
|
+
_ENTITY_RE = re.compile(r"&(?:quot|amp|lt|gt|apos|#\d+|#x[0-9a-fA-F]+);")
|
|
1433
|
+
# the opening tag (and CDATA start) a body's first line shares with its text
|
|
1434
|
+
_ACTIONSCRIPT_OPEN_RE = re.compile(r"<ActionScript\b[^>]*>(?:<!\[CDATA\[)?")
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def fix_scratch_destinations(src, targets):
|
|
1438
|
+
"""W506: insert `delete <destination>` before each uncleared move/copy.
|
|
1439
|
+
|
|
1440
|
+
`targets` is `_destination_targets`' (file_lineno, verb, destination)
|
|
1441
|
+
triples. The delete takes the command line's indentation. A destination
|
|
1442
|
+
that appears byte-for-byte on the file line (a CDATA body, or one with
|
|
1443
|
+
nothing to escape) is copied as written; otherwise, in an entity-escaped
|
|
1444
|
+
body it is escaped the same way, and a destination whose escaping cannot
|
|
1445
|
+
be told is left alone. When the
|
|
1446
|
+
command shares its line with the `<ActionScript>` opening tag, the delete
|
|
1447
|
+
goes right after the tag. Returns (new_src, fixed).
|
|
1448
|
+
"""
|
|
1449
|
+
lines = src.split("\n")
|
|
1450
|
+
fixed = []
|
|
1451
|
+
# bottom-up, so an insertion does not shift the lines still to be fixed
|
|
1452
|
+
for lineno, verb, destination in sorted(targets, reverse=True):
|
|
1453
|
+
if not 1 <= lineno <= len(lines):
|
|
1454
|
+
continue
|
|
1455
|
+
raw_line = lines[lineno - 1]
|
|
1456
|
+
text = destination
|
|
1457
|
+
if destination in raw_line:
|
|
1458
|
+
pass # already written this way on the line (e.g. CDATA): copy it
|
|
1459
|
+
elif _ENTITY_RE.search(raw_line):
|
|
1460
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
1461
|
+
if """ in raw_line:
|
|
1462
|
+
text = text.replace('"', """)
|
|
1463
|
+
elif any(char in text for char in "&<>"):
|
|
1464
|
+
continue
|
|
1465
|
+
opening = _ACTIONSCRIPT_OPEN_RE.search(raw_line)
|
|
1466
|
+
if opening:
|
|
1467
|
+
cut = opening.end()
|
|
1468
|
+
command = raw_line[cut:]
|
|
1469
|
+
indent = command[: len(command) - len(command.lstrip())]
|
|
1470
|
+
lines[lineno - 1] = raw_line[:cut] + f"{indent}delete {text}\n" + command
|
|
1471
|
+
else:
|
|
1472
|
+
indent = raw_line[: len(raw_line) - len(raw_line.lstrip())]
|
|
1473
|
+
lines.insert(lineno - 1, f"{indent}delete {text}")
|
|
1474
|
+
fixed.append(
|
|
1475
|
+
(lineno, "W506", f"inserted `delete {destination}` before the `{verb}`")
|
|
1476
|
+
)
|
|
1477
|
+
return "\n".join(lines), sorted(fixed)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _folder_quote_targets(raw, src, is_bes):
|
|
1481
|
+
"""Yield the file line of every fixable E525 `folder create|delete`."""
|
|
1482
|
+
if is_bes:
|
|
1483
|
+
try:
|
|
1484
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1485
|
+
except etree.XMLSyntaxError:
|
|
1486
|
+
return
|
|
1487
|
+
else:
|
|
1488
|
+
bodies = [(1, src)]
|
|
1489
|
+
for sourceline, body in bodies:
|
|
1490
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1491
|
+
for index, _command, _path, fixable in _unquoted_folder_paths(masked_lines):
|
|
1492
|
+
if fixable:
|
|
1493
|
+
yield sourceline + index
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def fix_folder_quoting(src, targets):
|
|
1497
|
+
"""E525: wrap each unquoted `folder create|delete` path in double quotes.
|
|
1498
|
+
|
|
1499
|
+
The quote is `"` when the line is entity-escaped that way, else a
|
|
1500
|
+
literal `"` (valid both in CDATA and in plain element text). Returns
|
|
1501
|
+
(new_src, fixed).
|
|
1502
|
+
"""
|
|
1503
|
+
lines = src.split("\n")
|
|
1504
|
+
fixed = []
|
|
1505
|
+
for lineno in sorted(set(targets)):
|
|
1506
|
+
if not 1 <= lineno <= len(lines):
|
|
1507
|
+
continue
|
|
1508
|
+
raw_line = lines[lineno - 1]
|
|
1509
|
+
quote = """ if """ in raw_line else '"'
|
|
1510
|
+
match = _RAW_FOLDER_COMMAND_RE.search(raw_line)
|
|
1511
|
+
if not match or not match.group(2) or match.group(2).startswith(quote):
|
|
1512
|
+
continue
|
|
1513
|
+
lines[lineno - 1] = (
|
|
1514
|
+
raw_line[: match.start(2)]
|
|
1515
|
+
+ quote
|
|
1516
|
+
+ match.group(2)
|
|
1517
|
+
+ quote
|
|
1518
|
+
+ raw_line[match.end(2) :]
|
|
1519
|
+
)
|
|
1520
|
+
fixed.append((lineno, "E525", f"quoted the {match.group(1).strip()} path"))
|
|
1521
|
+
return "\n".join(lines), fixed
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _parameter_quote_targets(raw, src, is_bes):
|
|
1525
|
+
"""Yield the file line of every fixable E527 `parameter` value."""
|
|
1526
|
+
if is_bes:
|
|
1527
|
+
try:
|
|
1528
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1529
|
+
except etree.XMLSyntaxError:
|
|
1530
|
+
return
|
|
1531
|
+
else:
|
|
1532
|
+
bodies = [(1, src)]
|
|
1533
|
+
for sourceline, body in bodies:
|
|
1534
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1535
|
+
for index, _name, _value, fixable in _unquoted_parameter_values(masked_lines):
|
|
1536
|
+
if fixable:
|
|
1537
|
+
yield sourceline + index
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def fix_parameter_quoting(src, targets):
|
|
1541
|
+
"""E527: wrap each unquoted `parameter` value in double quotes.
|
|
1542
|
+
|
|
1543
|
+
The quote is `"` when the line is entity-escaped that way, else a
|
|
1544
|
+
literal `"`, as for E525. Indentation and the spacing around `=` are
|
|
1545
|
+
kept. Returns (new_src, fixed).
|
|
1546
|
+
"""
|
|
1547
|
+
lines = src.split("\n")
|
|
1548
|
+
fixed = []
|
|
1549
|
+
for lineno in sorted(set(targets)):
|
|
1550
|
+
if not 1 <= lineno <= len(lines):
|
|
1551
|
+
continue
|
|
1552
|
+
raw_line = lines[lineno - 1]
|
|
1553
|
+
quote = """ if """ in raw_line else '"'
|
|
1554
|
+
match = _RAW_PARAMETER_VALUE_RE.search(raw_line)
|
|
1555
|
+
if not match or not match.group(2) or match.group(2).startswith(quote):
|
|
1556
|
+
continue
|
|
1557
|
+
lines[lineno - 1] = (
|
|
1558
|
+
raw_line[: match.start(2)]
|
|
1559
|
+
+ quote
|
|
1560
|
+
+ match.group(2)
|
|
1561
|
+
+ quote
|
|
1562
|
+
+ raw_line[match.end(2) :]
|
|
1563
|
+
)
|
|
1564
|
+
fixed.append((lineno, "E527", "quoted the parameter value"))
|
|
1565
|
+
return "\n".join(lines), fixed
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _parameter_comment_targets(raw, src, is_bes):
|
|
1569
|
+
"""Yield (file_lineno, comment) for every E528 fix target."""
|
|
1570
|
+
if is_bes:
|
|
1571
|
+
try:
|
|
1572
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1573
|
+
except etree.XMLSyntaxError:
|
|
1574
|
+
return
|
|
1575
|
+
else:
|
|
1576
|
+
bodies = [(1, src)]
|
|
1577
|
+
for sourceline, body in bodies:
|
|
1578
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1579
|
+
for index, comment in _parameter_comments(masked_lines):
|
|
1580
|
+
yield sourceline + index, comment
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def fix_parameter_comments(src, targets):
|
|
1584
|
+
"""E528: move a `parameter` line's trailing `// comment` onto its own line.
|
|
1585
|
+
|
|
1586
|
+
The comment goes directly above, with the command's indentation; its
|
|
1587
|
+
text and the value are kept exactly as written (the comment is found on
|
|
1588
|
+
the file line as written, or escaped to match an entity-escaped body).
|
|
1589
|
+
When the command shares its line with the `<ActionScript>` opening tag,
|
|
1590
|
+
the comment goes right after the tag. Returns (new_src, fixed).
|
|
1591
|
+
"""
|
|
1592
|
+
lines = src.split("\n")
|
|
1593
|
+
fixed = []
|
|
1594
|
+
# bottom-up, so an insertion does not shift the lines still to be fixed
|
|
1595
|
+
for lineno, comment in sorted(targets, reverse=True):
|
|
1596
|
+
if not 1 <= lineno <= len(lines):
|
|
1597
|
+
continue
|
|
1598
|
+
raw_line = lines[lineno - 1]
|
|
1599
|
+
text = comment
|
|
1600
|
+
if comment not in raw_line and _ENTITY_RE.search(raw_line):
|
|
1601
|
+
text = _escape_like(raw_line, comment)
|
|
1602
|
+
cut = raw_line.rfind(text)
|
|
1603
|
+
if cut < 0:
|
|
1604
|
+
continue
|
|
1605
|
+
command, rest = raw_line[:cut].rstrip(), raw_line[cut + len(text) :]
|
|
1606
|
+
opening = _ACTIONSCRIPT_OPEN_RE.search(command)
|
|
1607
|
+
if opening:
|
|
1608
|
+
head, command = command[: opening.end()], command[opening.end() :]
|
|
1609
|
+
indent = command[: len(command) - len(command.lstrip())]
|
|
1610
|
+
lines[lineno - 1] = f"{head}{indent}{text}\n{command}{rest}"
|
|
1611
|
+
else:
|
|
1612
|
+
indent = raw_line[: len(raw_line) - len(raw_line.lstrip())]
|
|
1613
|
+
lines[lineno - 1] = command + rest
|
|
1614
|
+
lines.insert(lineno - 1, f"{indent}{text}")
|
|
1615
|
+
fixed.append((lineno, "E528", "moved the trailing comment above the line"))
|
|
1616
|
+
return "\n".join(lines), sorted(fixed)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _replace_on_line(lines, lineno, text, replacement):
|
|
1620
|
+
"""Substitute one `text` on 1-based `lineno` of `lines`; say whether it landed.
|
|
1621
|
+
|
|
1622
|
+
The reference is replaced inside the file line rather than the line being
|
|
1623
|
+
rebuilt, so indentation and anything else sharing the line survive. A
|
|
1624
|
+
target whose text is no longer found on its line (already fixed by an
|
|
1625
|
+
earlier, overlapping match, say) is left alone rather than guessed at.
|
|
1626
|
+
"""
|
|
1627
|
+
if not 1 <= lineno <= len(lines):
|
|
1628
|
+
return False
|
|
1629
|
+
raw_line = lines[lineno - 1]
|
|
1630
|
+
if text not in raw_line:
|
|
1631
|
+
return False
|
|
1632
|
+
lines[lineno - 1] = raw_line.replace(text, replacement, 1)
|
|
1633
|
+
return True
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def fix_scratch_case(src, targets):
|
|
1637
|
+
"""Rewrite each wrong-case scratch reference to its canonical spelling.
|
|
1638
|
+
|
|
1639
|
+
`targets` is `_scratch_case_targets`'s (file_lineno, wrong_text,
|
|
1640
|
+
canonical) triples. Returns (new_src, fixed), `fixed` a list of
|
|
1641
|
+
(lineno, "W503", message).
|
|
1642
|
+
"""
|
|
1643
|
+
lines = src.split("\n")
|
|
1644
|
+
fixed = []
|
|
1645
|
+
for lineno, text, canonical in targets:
|
|
1646
|
+
if _replace_on_line(lines, lineno, text, canonical):
|
|
1647
|
+
fixed.append(
|
|
1648
|
+
(
|
|
1649
|
+
lineno,
|
|
1650
|
+
"W503",
|
|
1651
|
+
f'"{text}" replaced with "{canonical}"',
|
|
1652
|
+
)
|
|
1653
|
+
)
|
|
1654
|
+
return "\n".join(lines), fixed
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def _leading_token(text):
|
|
1658
|
+
"""Split `text` into its first whitespace-delimited token and the rest.
|
|
1659
|
+
|
|
1660
|
+
A double-quoted token is kept whole (quotes stripped) so a quoted
|
|
1661
|
+
executable path containing spaces -- or a `{...}` substitution that may
|
|
1662
|
+
expand to one -- stays a single token.
|
|
1663
|
+
"""
|
|
1664
|
+
text = text.lstrip()
|
|
1665
|
+
if text.startswith('"'):
|
|
1666
|
+
end = text.find('"', 1)
|
|
1667
|
+
if end != -1:
|
|
1668
|
+
return text[1:end], text[end + 1 :].strip()
|
|
1669
|
+
token, _sep, rest = text.partition(" ")
|
|
1670
|
+
return token, rest.strip()
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _is_cmd_shell(executable):
|
|
1674
|
+
"""True if `executable` names the Windows command interpreter.
|
|
1675
|
+
|
|
1676
|
+
Compares the trailing path component so both `cmd.exe` and a full path
|
|
1677
|
+
like `{windows folder}\\system32\\cmd.exe` are recognized, while an
|
|
1678
|
+
unrelated program whose name merely ends in "cmd" is not.
|
|
1679
|
+
"""
|
|
1680
|
+
basename = re.split(r"[\\/]", executable)[-1].strip().lower()
|
|
1681
|
+
return basename in ("cmd", "cmd.exe")
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
# W505 fix: the cmd.exe switches that may precede `/c` without being taken as
|
|
1685
|
+
# the command itself (`cmd /q /c dir`); anything else starting with `/` there
|
|
1686
|
+
# -- `/r`, cmd's own synonym for `/c`, or an unknown switch -- is not guessed at
|
|
1687
|
+
_CMD_LEADING_SWITCH_RE = re.compile(
|
|
1688
|
+
r"^/(?:[qdaus]|[efv]:(?:on|off)|t:[0-9a-f]{1,2})$", re.IGNORECASE
|
|
1689
|
+
)
|
|
1690
|
+
_CMD_K_SWITCH_RE = re.compile(r"^/k$", re.IGNORECASE)
|
|
1691
|
+
# E521 fix: a bracketed registry key written without its quotes, closed by a
|
|
1692
|
+
# `]` that ends the key token
|
|
1693
|
+
_UNQUOTED_BRACKET_KEY_RE = re.compile(r'^\[[^"\]]*\](?=\s|$)')
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _cmd_switch_rewrite(verb, arguments):
|
|
1697
|
+
"""Return `arguments` with `/c` put right, or None if that is unclear.
|
|
1698
|
+
|
|
1699
|
+
A leading `/k` becomes `/c` on a verb that waits (`run` does not hang on
|
|
1700
|
+
it, and W505 does not report it there); a command line with no `/c` gets
|
|
1701
|
+
one inserted after cmd's leading switches, where cmd expects it.
|
|
1702
|
+
"""
|
|
1703
|
+
if _CMD_RUN_SWITCH_RE.search(arguments):
|
|
1704
|
+
return None
|
|
1705
|
+
tokens = arguments.split(" ")
|
|
1706
|
+
for index, token in enumerate(tokens):
|
|
1707
|
+
if not token or _CMD_LEADING_SWITCH_RE.match(token):
|
|
1708
|
+
continue
|
|
1709
|
+
if _CMD_K_SWITCH_RE.match(token):
|
|
1710
|
+
if verb.lower().startswith("run"):
|
|
1711
|
+
return None
|
|
1712
|
+
return " ".join(tokens[:index] + ["/c"] + tokens[index + 1 :])
|
|
1713
|
+
if token.startswith("/") or _CMD_PERSIST_SWITCH_RE.search(arguments):
|
|
1714
|
+
return None
|
|
1715
|
+
return " ".join(tokens[:index] + ["/c"] + tokens[index:])
|
|
1716
|
+
return None
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _line_rewrite(stripped, codes):
|
|
1720
|
+
"""Return (code, old, new) for a fixable E521/W505 line, or None.
|
|
1721
|
+
|
|
1722
|
+
`old` is a leading slice of the stripped line and `new` its replacement,
|
|
1723
|
+
so the rewrite lands on the file line without rebuilding it.
|
|
1724
|
+
"""
|
|
1725
|
+
if "W505" in codes:
|
|
1726
|
+
match = _LAUNCH_VERB_RE.match(stripped)
|
|
1727
|
+
if match:
|
|
1728
|
+
executable, arguments = _leading_token(match.group(2))
|
|
1729
|
+
if _is_cmd_shell(executable) and arguments and stripped.endswith(arguments):
|
|
1730
|
+
rewritten = _cmd_switch_rewrite(match.group(1), arguments)
|
|
1731
|
+
if rewritten is not None:
|
|
1732
|
+
prefix = stripped[: len(stripped) - len(arguments)]
|
|
1733
|
+
return "W505", stripped, prefix + rewritten
|
|
1734
|
+
if "E521" in codes:
|
|
1735
|
+
match = _REGSET_RE.match(stripped)
|
|
1736
|
+
if match:
|
|
1737
|
+
rest = match.group(2).strip()
|
|
1738
|
+
key = _UNQUOTED_BRACKET_KEY_RE.match(rest)
|
|
1739
|
+
if key and not _REGSET_KEY_RE.match(rest):
|
|
1740
|
+
prefix = stripped[: len(stripped) - len(rest)]
|
|
1741
|
+
return "E521", prefix + key.group(0), f'{prefix}"{key.group(0)}"'
|
|
1742
|
+
return None
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _line_fix_targets(raw, src, is_bes, codes):
|
|
1746
|
+
"""Yield (file_lineno, code, old, new) for every E524/W505/E521 fix target.
|
|
1747
|
+
|
|
1748
|
+
Lines are heredoc-masked the way the checks see them. An `else if` is
|
|
1749
|
+
joined only in a body with no stray `endif` (E501): a second `endif`
|
|
1750
|
+
means the author really did nest an `if` inside the `else`.
|
|
1751
|
+
"""
|
|
1752
|
+
if is_bes:
|
|
1753
|
+
try:
|
|
1754
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
1755
|
+
except etree.XMLSyntaxError:
|
|
1756
|
+
return
|
|
1757
|
+
else:
|
|
1758
|
+
bodies = [(1, src)]
|
|
1759
|
+
for sourceline, body in bodies:
|
|
1760
|
+
join_else_if = "E524" in codes and "E501" not in {
|
|
1761
|
+
code for _lineno, code, _message in check_actionscript(body)
|
|
1762
|
+
}
|
|
1763
|
+
masked_lines, _createfile_issues = _mask_heredocs(body.split("\n"))
|
|
1764
|
+
for index, masked_line in enumerate(masked_lines):
|
|
1765
|
+
stripped = masked_line.strip()
|
|
1766
|
+
if not stripped or stripped.startswith("//"):
|
|
1767
|
+
continue
|
|
1768
|
+
else_if = _ELSE_IF_TWO_WORDS_RE.match(stripped)
|
|
1769
|
+
if else_if:
|
|
1770
|
+
if join_else_if:
|
|
1771
|
+
yield sourceline + index, "E524", else_if.group(0), "elseif"
|
|
1772
|
+
continue
|
|
1773
|
+
rewrite = _line_rewrite(stripped, codes)
|
|
1774
|
+
if rewrite:
|
|
1775
|
+
yield (sourceline + index,) + rewrite
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
_LINE_FIX_MESSAGES = {
|
|
1779
|
+
"E524": "joined `else if` into `elseif`",
|
|
1780
|
+
"W505": "put `/c` on the cmd.exe command line",
|
|
1781
|
+
"E521": "quoted the bracketed registry key",
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def _escape_like(raw_line, text):
|
|
1786
|
+
"""`text` escaped the way an entity-escaped (non-CDATA) `raw_line` is."""
|
|
1787
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
1788
|
+
return text.replace('"', """) if """ in raw_line else text
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def fix_line_rewrites(src, targets):
|
|
1792
|
+
"""E524/W505/E521: apply `_line_fix_targets`' rewrites in place.
|
|
1793
|
+
|
|
1794
|
+
Each `old` slice is replaced once on its file line -- as written (a CDATA
|
|
1795
|
+
body), or escaped to match an entity-escaped body. A target whose text is
|
|
1796
|
+
not found is left alone rather than guessed at. Returns (new_src, fixed).
|
|
1797
|
+
"""
|
|
1798
|
+
lines = src.split("\n")
|
|
1799
|
+
fixed = []
|
|
1800
|
+
for lineno, code, old, new in targets:
|
|
1801
|
+
if not 1 <= lineno <= len(lines):
|
|
1802
|
+
continue
|
|
1803
|
+
raw_line = lines[lineno - 1]
|
|
1804
|
+
if old not in raw_line and _ENTITY_RE.search(raw_line):
|
|
1805
|
+
old, new = _escape_like(raw_line, old), _escape_like(raw_line, new)
|
|
1806
|
+
if _replace_on_line(lines, lineno, old, new):
|
|
1807
|
+
fixed.append((lineno, code, _LINE_FIX_MESSAGES[code]))
|
|
1808
|
+
return "\n".join(lines), fixed
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _check_command_shapes(lines):
|
|
1812
|
+
"""Check per-line command shapes independent of block structure.
|
|
1813
|
+
|
|
1814
|
+
Returns E520 for a `setting` line that is not the documented
|
|
1815
|
+
`setting "name"="value" on "{...}" for client|user|action` or
|
|
1816
|
+
`setting delete "name" on "{...}" for client|user|action` shape (a
|
|
1817
|
+
missing effective-date `on` clause fails at runtime); E521 for a
|
|
1818
|
+
`regset`/`regset64`/`regdelete`/`regdelete64` key that is not a quoted,
|
|
1819
|
+
bracketed `"[HKEY_...]..."` keyname; and W505 for a `wait`/`run` of cmd.exe that passes a command line without `/c`
|
|
1820
|
+
(cmd.exe needs it to execute the command), or that uses `/k` instead
|
|
1821
|
+
(cmd.exe runs the command but the shell never exits, so the action hangs).
|
|
1822
|
+
"""
|
|
1823
|
+
issues = []
|
|
1824
|
+
for index, raw_line in enumerate(lines):
|
|
1825
|
+
lineno = index + 1
|
|
1826
|
+
stripped = raw_line.strip()
|
|
1827
|
+
if not stripped or stripped.startswith("//"):
|
|
1828
|
+
continue
|
|
1829
|
+
|
|
1830
|
+
if _SETTING_RE.match(stripped) and not _SETTING_SHAPE_RE.match(stripped):
|
|
1831
|
+
issues.append(
|
|
1832
|
+
(
|
|
1833
|
+
lineno,
|
|
1834
|
+
"E520",
|
|
1835
|
+
(
|
|
1836
|
+
"`setting` line does not match the documented "
|
|
1837
|
+
'`setting "name"="value" on "{...}" for '
|
|
1838
|
+
"client|user|action` shape; add "
|
|
1839
|
+
f"`{COMMAND_SHAPE_MARKER}` if intentional"
|
|
1840
|
+
),
|
|
1841
|
+
)
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1844
|
+
match = _REGSET_RE.match(stripped)
|
|
1845
|
+
if match:
|
|
1846
|
+
verb, rest = match.group(1), match.group(2).strip()
|
|
1847
|
+
if not _REGSET_KEY_RE.match(rest):
|
|
1848
|
+
issues.append(
|
|
1849
|
+
(
|
|
1850
|
+
lineno,
|
|
1851
|
+
"E521",
|
|
1852
|
+
(
|
|
1853
|
+
f'"{verb}" key is not a quoted, bracketed '
|
|
1854
|
+
'"[HKEY_...]..." keyname; add '
|
|
1855
|
+
f"`{COMMAND_SHAPE_MARKER}` if intentional"
|
|
1856
|
+
),
|
|
1857
|
+
)
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
match = _WOW64_RE.match(stripped)
|
|
1861
|
+
if match and not _WOW64_ARG_RE.match(match.group(1).strip()):
|
|
1862
|
+
issues.append(
|
|
1863
|
+
(
|
|
1864
|
+
lineno,
|
|
1865
|
+
"E523",
|
|
1866
|
+
(
|
|
1867
|
+
"`action uses wow64 redirection` takes `true`, `false`, or a "
|
|
1868
|
+
"`{...}` relevance substitution; add "
|
|
1869
|
+
f"`{COMMAND_SHAPE_MARKER}` if intentional"
|
|
1870
|
+
),
|
|
1871
|
+
)
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
match = _LAUNCH_VERB_RE.match(stripped)
|
|
1875
|
+
if match:
|
|
1876
|
+
executable, arguments = _leading_token(match.group(2))
|
|
1877
|
+
if _is_cmd_shell(executable) and arguments:
|
|
1878
|
+
if _CMD_RUN_SWITCH_RE.search(arguments):
|
|
1879
|
+
pass # /c (with or without /k alongside it) runs and exits
|
|
1880
|
+
elif _CMD_PERSIST_SWITCH_RE.search(arguments):
|
|
1881
|
+
if match.group(1).lower().startswith("run"):
|
|
1882
|
+
pass # `run` does not wait, so /k cannot hang it
|
|
1883
|
+
else:
|
|
1884
|
+
issues.append(
|
|
1885
|
+
(
|
|
1886
|
+
lineno,
|
|
1887
|
+
"W505",
|
|
1888
|
+
(
|
|
1889
|
+
"`cmd.exe` is run with `/k`, which leaves the "
|
|
1890
|
+
"shell open after the command finishes, so the "
|
|
1891
|
+
f"action never completes; use `/c`; add "
|
|
1892
|
+
f"`{CMD_MARKER}` if intentional"
|
|
1893
|
+
),
|
|
1894
|
+
)
|
|
1895
|
+
)
|
|
1896
|
+
else:
|
|
1897
|
+
issues.append(
|
|
1898
|
+
(
|
|
1899
|
+
lineno,
|
|
1900
|
+
"W505",
|
|
1901
|
+
(
|
|
1902
|
+
"cmd.exe is passed a command line but no `/c`; "
|
|
1903
|
+
"without it cmd opens a shell and never runs "
|
|
1904
|
+
f"the command; add `{CMD_MARKER}` if intentional"
|
|
1905
|
+
),
|
|
1906
|
+
)
|
|
1907
|
+
)
|
|
1908
|
+
return issues
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _check_condition_shapes(lines):
|
|
1912
|
+
"""Check `continue if` / `pause while` condition shape.
|
|
1913
|
+
|
|
1914
|
+
Returns E518 for a `continue if` or `pause while` whose condition is not
|
|
1915
|
+
a `{...}` relevance substitution -- the same rule E514 applies to `if`/
|
|
1916
|
+
`elseif`, extended to these two other condition-bearing verbs. The one
|
|
1917
|
+
exception: a literal `continue if false` (any case) is accepted as a
|
|
1918
|
+
documented idiom for forcing a branch to fail unconditionally, e.g. in
|
|
1919
|
+
the `else` of an `if`/`else`/`endif`. `continue if true` is still
|
|
1920
|
+
flagged -- it always continues, so the check does nothing -- and
|
|
1921
|
+
`pause while` gets no literal exception at all: `pause while true`
|
|
1922
|
+
hangs forever and `pause while false` never pauses, so neither literal
|
|
1923
|
+
is ever the right thing to write.
|
|
1924
|
+
"""
|
|
1925
|
+
issues = []
|
|
1926
|
+
for index, raw_line in enumerate(lines):
|
|
1927
|
+
lineno = index + 1
|
|
1928
|
+
stripped = raw_line.strip()
|
|
1929
|
+
if not stripped or stripped.startswith("//"):
|
|
1930
|
+
continue
|
|
1931
|
+
|
|
1932
|
+
for regex, verb, allow_literal_false in (
|
|
1933
|
+
(_CONTINUE_IF_RE, "continue if", True),
|
|
1934
|
+
(_PAUSE_WHILE_RE, "pause while", False),
|
|
1935
|
+
):
|
|
1936
|
+
match = regex.match(stripped)
|
|
1937
|
+
if not match:
|
|
1938
|
+
continue
|
|
1939
|
+
condition = match.group(1).lstrip()
|
|
1940
|
+
if condition.startswith("{"):
|
|
1941
|
+
continue
|
|
1942
|
+
if allow_literal_false and _LITERAL_FALSE_RE.match(condition):
|
|
1943
|
+
continue
|
|
1944
|
+
issues.append(
|
|
1945
|
+
(
|
|
1946
|
+
lineno,
|
|
1947
|
+
"E518",
|
|
1948
|
+
(
|
|
1949
|
+
f"`{verb}` condition is not a `{{...}}` "
|
|
1950
|
+
"relevance substitution; the agent requires "
|
|
1951
|
+
f"one; add `{IF_MARKER}` if intentional"
|
|
1952
|
+
),
|
|
1953
|
+
)
|
|
1954
|
+
)
|
|
1955
|
+
return issues
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def _check_override_blocks(lines, first_line=1):
|
|
1959
|
+
"""Check `override wait` / `override run` block termination.
|
|
1960
|
+
|
|
1961
|
+
`bes_actionscript_lint_schclass.py` validates the keyword=value option
|
|
1962
|
+
lines inside a block (E303) but its state machine never notices a block
|
|
1963
|
+
that is left open: hitting end of body, being reopened by another
|
|
1964
|
+
`override` before any command runs, or being closed by the *wrong*
|
|
1965
|
+
verb's command (an `override wait` block whose next real command is
|
|
1966
|
+
`run ...` instead of `wait ...`, or vice versa). This is that pairing
|
|
1967
|
+
check.
|
|
1968
|
+
|
|
1969
|
+
A line that starts with `{` (after stripping leading whitespace) is
|
|
1970
|
+
also treated as an open option line, not a closing command: a `{...}`
|
|
1971
|
+
relevance substitution can itself evaluate to a keyword=value option
|
|
1972
|
+
(e.g. choosing `hidden=true` vs `completion=none` by OS), so it keeps
|
|
1973
|
+
the block open exactly like a literal option line would.
|
|
1974
|
+
"""
|
|
1975
|
+
issues = []
|
|
1976
|
+
open_verb = None
|
|
1977
|
+
open_lineno = None
|
|
1978
|
+
for index, raw_line in enumerate(lines):
|
|
1979
|
+
lineno = index + 1
|
|
1980
|
+
stripped = raw_line.strip()
|
|
1981
|
+
if not stripped or stripped.startswith("//"):
|
|
1982
|
+
continue
|
|
1983
|
+
|
|
1984
|
+
match = _OVERRIDE_VERB_RE.match(stripped)
|
|
1985
|
+
if match:
|
|
1986
|
+
if open_verb is not None:
|
|
1987
|
+
issues.append(
|
|
1988
|
+
(
|
|
1989
|
+
open_lineno,
|
|
1990
|
+
"E522",
|
|
1991
|
+
(
|
|
1992
|
+
f"`override {open_verb}` is reopened by another "
|
|
1993
|
+
f"`override` on line {lineno + first_line - 1} before any "
|
|
1994
|
+
f"command runs; add `{OVERRIDE_BLOCK_MARKER}` if "
|
|
1995
|
+
"intentional"
|
|
1996
|
+
),
|
|
1997
|
+
)
|
|
1998
|
+
)
|
|
1999
|
+
open_verb = match.group(1).lower()
|
|
2000
|
+
open_lineno = lineno
|
|
2001
|
+
continue
|
|
2002
|
+
|
|
2003
|
+
if open_verb is None:
|
|
2004
|
+
continue
|
|
2005
|
+
if _OVERRIDE_OPTION_LINE_RE.match(stripped) or stripped.startswith("{"):
|
|
2006
|
+
# a keyword=value option line, or a `{...}` relevance
|
|
2007
|
+
# substitution that evaluates to one at runtime (e.g. picking
|
|
2008
|
+
# `hidden=true` vs `completion=none` by OS) -- the block is
|
|
2009
|
+
# still open either way
|
|
2010
|
+
continue
|
|
2011
|
+
|
|
2012
|
+
# the first non-option line closes the block -- its own verb must
|
|
2013
|
+
# match the one `override` opened
|
|
2014
|
+
verb = re.match(r"[A-Za-z_]+", stripped)
|
|
2015
|
+
command_verb = verb.group(0).lower() if verb else ""
|
|
2016
|
+
if command_verb != open_verb:
|
|
2017
|
+
issues.append(
|
|
2018
|
+
(
|
|
2019
|
+
open_lineno,
|
|
2020
|
+
"E522",
|
|
2021
|
+
(
|
|
2022
|
+
f"`override {open_verb}` is terminated by `{command_verb}` "
|
|
2023
|
+
f"on line {lineno + first_line - 1}, not `{open_verb}`; add "
|
|
2024
|
+
f"`{OVERRIDE_BLOCK_MARKER}` if intentional"
|
|
2025
|
+
),
|
|
2026
|
+
)
|
|
2027
|
+
)
|
|
2028
|
+
open_verb = None
|
|
2029
|
+
|
|
2030
|
+
if open_verb is not None:
|
|
2031
|
+
issues.append(
|
|
2032
|
+
(
|
|
2033
|
+
open_lineno,
|
|
2034
|
+
"E522",
|
|
2035
|
+
(
|
|
2036
|
+
f"`override {open_verb}` is never terminated by a "
|
|
2037
|
+
f"matching `{open_verb}` command; add "
|
|
2038
|
+
f"`{OVERRIDE_BLOCK_MARKER}` if intentional"
|
|
2039
|
+
),
|
|
2040
|
+
)
|
|
2041
|
+
)
|
|
2042
|
+
return issues
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def _strip_trailing_comment(line):
|
|
2046
|
+
"""Return `line` without a trailing `// comment` and trailing whitespace.
|
|
2047
|
+
|
|
2048
|
+
Only a `//` that follows whitespace and sits outside a "..." string and
|
|
2049
|
+
outside a `{...}` relevance substitution starts a comment, so a path or
|
|
2050
|
+
URL containing `//` (and `else//x`) is left alone. `{{` outside a
|
|
2051
|
+
substitution is the literal-brace escape, not an opener.
|
|
2052
|
+
"""
|
|
2053
|
+
quoted, depth, index = False, 0, 0
|
|
2054
|
+
while index < len(line):
|
|
2055
|
+
char = line[index]
|
|
2056
|
+
if char == '"' and not depth:
|
|
2057
|
+
quoted = not quoted
|
|
2058
|
+
elif not quoted and char == "{":
|
|
2059
|
+
if not depth and line.startswith("{{", index):
|
|
2060
|
+
index += 2
|
|
2061
|
+
continue
|
|
2062
|
+
depth += 1
|
|
2063
|
+
elif not quoted and char == "}" and depth:
|
|
2064
|
+
depth -= 1
|
|
2065
|
+
elif (
|
|
2066
|
+
not quoted
|
|
2067
|
+
and not depth
|
|
2068
|
+
and line.startswith("//", index)
|
|
2069
|
+
and index
|
|
2070
|
+
and line[index - 1] in " \t"
|
|
2071
|
+
):
|
|
2072
|
+
return line[:index].rstrip()
|
|
2073
|
+
index += 1
|
|
2074
|
+
return line.rstrip()
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
def _ignore_structural_comments(lines):
|
|
2078
|
+
"""Strip trailing `// comments` from fixed-syntax lines (see
|
|
2079
|
+
_FIXED_SYNTAX_RE), keeping each line's indentation.
|
|
2080
|
+
"""
|
|
2081
|
+
result = []
|
|
2082
|
+
for line in lines:
|
|
2083
|
+
stripped = line.strip()
|
|
2084
|
+
if _FIXED_SYNTAX_RE.match(stripped):
|
|
2085
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
2086
|
+
line = indent + _strip_trailing_comment(stripped)
|
|
2087
|
+
result.append(line)
|
|
2088
|
+
return result
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _has_quote_outside_braces(text):
|
|
2092
|
+
"""True if `text` has a `"` outside any `{...}` substitution."""
|
|
2093
|
+
depth = 0
|
|
2094
|
+
for char in text:
|
|
2095
|
+
if char == "{":
|
|
2096
|
+
depth += 1
|
|
2097
|
+
elif char == "}" and depth:
|
|
2098
|
+
depth -= 1
|
|
2099
|
+
elif char == '"' and not depth:
|
|
2100
|
+
return True
|
|
2101
|
+
return False
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
def _unquoted_folder_paths(lines):
|
|
2105
|
+
"""Yield (index, command, path, fixable) for each E525 target in `lines`.
|
|
2106
|
+
|
|
2107
|
+
`fixable` is False when the path already holds a `"` outside a
|
|
2108
|
+
substitution (`C:\\x\\"`): where the quotes were meant to go is unknowable.
|
|
2109
|
+
"""
|
|
2110
|
+
for index, raw_line in enumerate(lines):
|
|
2111
|
+
stripped = raw_line.strip()
|
|
2112
|
+
if not stripped or stripped.startswith("//"):
|
|
2113
|
+
continue
|
|
2114
|
+
match = _FOLDER_COMMAND_RE.match(stripped)
|
|
2115
|
+
if not match or match.group(2).startswith('"'):
|
|
2116
|
+
continue
|
|
2117
|
+
path = match.group(2)
|
|
2118
|
+
yield index, match.group(1).lower(), path, not _has_quote_outside_braces(path)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def _check_folder_quoting(lines):
|
|
2122
|
+
"""E525: a `folder create`/`folder delete` path that is not quoted.
|
|
2123
|
+
|
|
2124
|
+
Quoted even when today's value has no spaces: a user profile, a
|
|
2125
|
+
parameter, or a relevance result can contain one, and then the command
|
|
2126
|
+
silently acts on the wrong path.
|
|
2127
|
+
"""
|
|
2128
|
+
return [
|
|
2129
|
+
(
|
|
2130
|
+
index + 1,
|
|
2131
|
+
"E525",
|
|
2132
|
+
(
|
|
2133
|
+
f"`{' '.join(command.split())}` path is not double-quoted; a "
|
|
2134
|
+
"path with a space (a user profile, a parameter value) breaks "
|
|
2135
|
+
f'it -- write `{" ".join(command.split())} "{path}"`'
|
|
2136
|
+
f"{' (auto-fixable)' if fixable else ''}; add "
|
|
2137
|
+
f"`{COMMAND_SHAPE_MARKER}` if intentional"
|
|
2138
|
+
),
|
|
2139
|
+
)
|
|
2140
|
+
for index, command, path, fixable in _unquoted_folder_paths(lines)
|
|
2141
|
+
]
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def _unquoted_parameter_values(lines):
|
|
2145
|
+
"""Yield (index, name, value, fixable) for each E527 target in `lines`.
|
|
2146
|
+
|
|
2147
|
+
`value` is shown without any trailing `// comment`. `fixable` is False
|
|
2148
|
+
when the value holds a `"` outside a substitution (where the quotes were
|
|
2149
|
+
meant to go is unknowable), holds `%22` (a substitution that renders its
|
|
2150
|
+
own quotes, which wrapping would double), or the line has a trailing
|
|
2151
|
+
comment (quoting the value alone leaves a line that still fails -- E528's
|
|
2152
|
+
fix moves the comment off first).
|
|
2153
|
+
"""
|
|
2154
|
+
for index, raw_line in enumerate(lines):
|
|
2155
|
+
stripped = raw_line.strip()
|
|
2156
|
+
match = _PARAMETER_VALUE_RE.match(stripped)
|
|
2157
|
+
if not match or match.group(3).startswith('"'):
|
|
2158
|
+
continue
|
|
2159
|
+
value = match.group(3)
|
|
2160
|
+
uncommented = _strip_trailing_comment(stripped)
|
|
2161
|
+
fixable = (
|
|
2162
|
+
not _has_quote_outside_braces(value)
|
|
2163
|
+
and "%22" not in value
|
|
2164
|
+
and uncommented == stripped
|
|
2165
|
+
)
|
|
2166
|
+
shown = uncommented[len(match.group(1)) :] or value
|
|
2167
|
+
yield index, match.group(2), shown, fixable
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def _check_parameter_quoting(lines):
|
|
2171
|
+
"""E527: a `parameter "name" = value` whose value is not quoted.
|
|
2172
|
+
|
|
2173
|
+
The agent substitutes `{...}` first and then parses the line, so even a
|
|
2174
|
+
singular substitution (`{"a"}`) leaves invalid `parameter` syntax and the
|
|
2175
|
+
line fails at runtime.
|
|
2176
|
+
"""
|
|
2177
|
+
return [
|
|
2178
|
+
(
|
|
2179
|
+
index + 1,
|
|
2180
|
+
"E527",
|
|
2181
|
+
(
|
|
2182
|
+
"`parameter` value is not double-quoted; the agent parses the "
|
|
2183
|
+
"line after `{...}` substitution, so this line fails at "
|
|
2184
|
+
f'runtime -- quote it: parameter "{name}" = "{value}"'
|
|
2185
|
+
f"{' (auto-fixable)' if fixable else ''}; add "
|
|
2186
|
+
f"`{PARAMETER_MARKER}` if intentional"
|
|
2187
|
+
),
|
|
2188
|
+
)
|
|
2189
|
+
for index, name, value, fixable in _unquoted_parameter_values(lines)
|
|
2190
|
+
]
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def _parameter_comments(lines):
|
|
2194
|
+
"""Yield (index, comment) for each E528 `parameter` line in `lines`.
|
|
2195
|
+
|
|
2196
|
+
`comment` is the trailing `// ...` text as `_strip_trailing_comment`
|
|
2197
|
+
splits it: a `//` inside "..." or `{...}`, or with no whitespace before
|
|
2198
|
+
it (a URL), is not a comment.
|
|
2199
|
+
"""
|
|
2200
|
+
for index, raw_line in enumerate(lines):
|
|
2201
|
+
stripped = raw_line.strip()
|
|
2202
|
+
if not _PARAMETER_ASSIGN_RE.match(stripped):
|
|
2203
|
+
continue
|
|
2204
|
+
code = _strip_trailing_comment(stripped)
|
|
2205
|
+
if code != stripped:
|
|
2206
|
+
yield index, stripped[len(code) :].strip()
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
def _check_parameter_comments(lines):
|
|
2210
|
+
"""E528: a trailing `// comment` on a `parameter` line.
|
|
2211
|
+
|
|
2212
|
+
Only fixed-syntax lines (`_FIXED_SYNTAX_RE`) honor a trailing comment;
|
|
2213
|
+
on a `parameter` line it fails the action, quoted value or not.
|
|
2214
|
+
"""
|
|
2215
|
+
return [
|
|
2216
|
+
(
|
|
2217
|
+
index + 1,
|
|
2218
|
+
"E528",
|
|
2219
|
+
(
|
|
2220
|
+
"trailing `// comment` on a `parameter` line; only "
|
|
2221
|
+
"if/elseif/else/endif-style lines accept one, so this line "
|
|
2222
|
+
"fails at runtime -- move the comment onto its own line "
|
|
2223
|
+
f"(auto-fixable); add `{PARAMETER_MARKER}` if intentional"
|
|
2224
|
+
),
|
|
2225
|
+
)
|
|
2226
|
+
for index, _comment in _parameter_comments(lines)
|
|
2227
|
+
]
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def _check_heredoc_braces(raw_lines, masked_lines):
|
|
2231
|
+
"""E508 for a `{` left open on a `createfile until` content line.
|
|
2232
|
+
|
|
2233
|
+
Heredoc content is blanked out for every other check (it is file text,
|
|
2234
|
+
not commands), but the agent still performs relevance substitution on
|
|
2235
|
+
it line by line, so a literal `{` there must be written `{{` just as on
|
|
2236
|
+
a command line. Only an unclosed `{` is reported: a `}` with nothing
|
|
2237
|
+
open is literal text in createfile content, so E509 does not apply.
|
|
2238
|
+
"""
|
|
2239
|
+
issues = []
|
|
2240
|
+
for index, (raw, masked) in enumerate(zip(raw_lines, masked_lines)):
|
|
2241
|
+
if raw == masked:
|
|
2242
|
+
continue # not heredoc content
|
|
2243
|
+
for lineno, code, message in _check_substitution_braces(index + 1, raw):
|
|
2244
|
+
if code != "E508":
|
|
2245
|
+
continue
|
|
2246
|
+
column = message.split("column ", 1)[1].split(" ", 1)[0]
|
|
2247
|
+
issues.append(
|
|
2248
|
+
(
|
|
2249
|
+
lineno,
|
|
2250
|
+
"E508",
|
|
2251
|
+
(
|
|
2252
|
+
f"unbalanced {{ at column {column} inside `createfile "
|
|
2253
|
+
"until` content -- the agent substitutes relevance "
|
|
2254
|
+
"there too, so this opens a substitution that never "
|
|
2255
|
+
"closes; write a literal brace as `{{`; add "
|
|
2256
|
+
f"`{SUBSTITUTION_MARKER}` if intentional"
|
|
2257
|
+
),
|
|
2258
|
+
)
|
|
2259
|
+
)
|
|
2260
|
+
return issues
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
def check_actionscript(body, first_line=1):
|
|
2264
|
+
"""Check a single ActionScript body for balanced blocks and substitutions.
|
|
2265
|
+
|
|
2266
|
+
Walks `if`/`endif` and `begin`/`end prefetch block` pairing across lines,
|
|
2267
|
+
and `{...}` relevance-substitution braces within each line.
|
|
2268
|
+
|
|
2269
|
+
Returns a sorted list of (lineno, code, message), lineno 1-based into
|
|
2270
|
+
`body`. `first_line` is the file line the body starts on; it is used only
|
|
2271
|
+
for the line numbers a message quotes about *other* lines ("first
|
|
2272
|
+
declared on line N"), so they match the file numbering the caller gives
|
|
2273
|
+
the issue itself. `_mask_heredocs` blanks out `createfile until` block content
|
|
2274
|
+
first, so lines that only look like commands inside one are ignored (its
|
|
2275
|
+
own E302 belongs to the sibling schclass hook, not here).
|
|
2276
|
+
"""
|
|
2277
|
+
raw_lines = body.split("\n")
|
|
2278
|
+
lines, _createfile_issues = _mask_heredocs(raw_lines)
|
|
2279
|
+
# a trailing `// comment` is valid ActionScript; on a fixed-syntax line
|
|
2280
|
+
# (endif, else, ...) it must not change how the line is parsed
|
|
2281
|
+
lines = _ignore_structural_comments(lines)
|
|
2282
|
+
issues = _check_heredoc_braces(raw_lines, lines) # E508 in createfile content
|
|
2283
|
+
issues.extend(_check_download_names(lines, first_line)) # E512 / W507
|
|
2284
|
+
issues.extend(_check_parameters(lines, first_line)) # E516 / E517
|
|
2285
|
+
issues.extend(_check_scratch_references(lines)) # E519 / W503
|
|
2286
|
+
issues.extend(_check_scratch_destinations(lines)) # W506
|
|
2287
|
+
issues.extend(_check_command_shapes(lines)) # E520 / E521 / E523 / W505
|
|
2288
|
+
issues.extend(_check_condition_shapes(lines)) # E518
|
|
2289
|
+
issues.extend(_check_override_blocks(lines, first_line)) # E522
|
|
2290
|
+
issues.extend(_check_folder_quoting(lines)) # E525
|
|
2291
|
+
issues.extend(_check_parameter_quoting(lines)) # E527
|
|
2292
|
+
issues.extend(_check_parameter_comments(lines)) # E528
|
|
2293
|
+
if_stack = [] # each entry: [lineno, seen_else]
|
|
2294
|
+
prefetch_stack = [] # each entry: [lineno, if_depth_at_open]
|
|
2295
|
+
preamble_over = False # True once anything a prefetch block may not follow
|
|
2296
|
+
first_execution_lineno = None # first line that starts real execution
|
|
2297
|
+
terminated_lineno = None # unconditional exit/restart/shutdown, if any
|
|
2298
|
+
unreachable_reported = False # W501 fires once, on the first dead line
|
|
2299
|
+
|
|
2300
|
+
for index, raw_line in enumerate(lines):
|
|
2301
|
+
lineno = index + 1
|
|
2302
|
+
stripped = raw_line.strip()
|
|
2303
|
+
if not stripped or stripped.startswith("//"):
|
|
2304
|
+
continue
|
|
2305
|
+
|
|
2306
|
+
# brace balance is per line and independent of the block walk below,
|
|
2307
|
+
# so it runs before any of the `continue`s that dispatch on the verb.
|
|
2308
|
+
# The raw line is passed, not `stripped`, so reported columns line up
|
|
2309
|
+
# with the file. An `appendfile <content>` line is skipped entirely:
|
|
2310
|
+
# everything after the verb is one line of raw file content written
|
|
2311
|
+
# out verbatim, the same as `createfile until` heredoc content, not
|
|
2312
|
+
# ActionScript -- `appendfile }` is a literal `}` appended to the
|
|
2313
|
+
# file, not a stray relevance-substitution close.
|
|
2314
|
+
if not _APPENDFILE_VERB_RE.match(stripped):
|
|
2315
|
+
issues.extend(_check_substitution_braces(lineno, raw_line))
|
|
2316
|
+
|
|
2317
|
+
# unreachable code (W501): anything after an unconditional
|
|
2318
|
+
# exit/restart/shutdown never runs; report the first such line only
|
|
2319
|
+
if terminated_lineno is not None and not unreachable_reported:
|
|
2320
|
+
unreachable_reported = True
|
|
2321
|
+
issues.append(
|
|
2322
|
+
(
|
|
2323
|
+
lineno,
|
|
2324
|
+
"W501",
|
|
2325
|
+
(
|
|
2326
|
+
"unreachable: the unconditional "
|
|
2327
|
+
f"exit/restart/shutdown on line {terminated_lineno + first_line - 1} "
|
|
2328
|
+
"means this line can never run; add "
|
|
2329
|
+
f"`{UNREACHABLE_MARKER}` if intentional"
|
|
2330
|
+
),
|
|
2331
|
+
)
|
|
2332
|
+
)
|
|
2333
|
+
|
|
2334
|
+
if _ACTION_PARAMETER_QUERY_RE.match(stripped):
|
|
2335
|
+
if first_execution_lineno is not None:
|
|
2336
|
+
issues.append(
|
|
2337
|
+
(
|
|
2338
|
+
lineno,
|
|
2339
|
+
"W502",
|
|
2340
|
+
(
|
|
2341
|
+
"`action parameter query` after execution began "
|
|
2342
|
+
f"on line {first_execution_lineno + first_line - 1}; these are "
|
|
2343
|
+
"console-time prompts and belong at the top; add "
|
|
2344
|
+
f"`{PARAMETER_QUERY_MARKER}` if intentional"
|
|
2345
|
+
),
|
|
2346
|
+
)
|
|
2347
|
+
)
|
|
2348
|
+
continue # a prompt is preamble: opens/closes/starts nothing below
|
|
2349
|
+
|
|
2350
|
+
# placement bookkeeping for E515 and W502. `parameter` assignments
|
|
2351
|
+
# and `action parameter query` (handled above) are preamble; anything
|
|
2352
|
+
# else -- a prefetch block included -- ends the preamble.
|
|
2353
|
+
is_preamble_line = bool(_PARAMETER_RE.match(stripped))
|
|
2354
|
+
if _TERMINATOR_RE.match(stripped) and not if_stack:
|
|
2355
|
+
terminated_lineno = lineno
|
|
2356
|
+
if first_execution_lineno is None and not any(
|
|
2357
|
+
regex.match(stripped) for regex in _NON_EXECUTION_RES
|
|
2358
|
+
):
|
|
2359
|
+
first_execution_lineno = lineno
|
|
2360
|
+
# any non-preamble line ends the preamble -- except the
|
|
2361
|
+
# `begin prefetch block` line itself, whose own branch below must see
|
|
2362
|
+
# the preamble state as it was BEFORE the block (and then ends it)
|
|
2363
|
+
if not is_preamble_line and not _BEGIN_PREFETCH_BLOCK_RE.match(stripped):
|
|
2364
|
+
preamble_over = True
|
|
2365
|
+
|
|
2366
|
+
if _BEGIN_PREFETCH_BLOCK_RE.match(stripped):
|
|
2367
|
+
# a nested block is E504 below; E515 on top of it would be noise
|
|
2368
|
+
if preamble_over and not prefetch_stack:
|
|
2369
|
+
issues.append(
|
|
2370
|
+
(
|
|
2371
|
+
lineno,
|
|
2372
|
+
"E515",
|
|
2373
|
+
(
|
|
2374
|
+
"`begin prefetch block` is not at the top of the "
|
|
2375
|
+
"script -- only blank lines, // comments, `action "
|
|
2376
|
+
"parameter query`, and `parameter` assignments "
|
|
2377
|
+
"may precede it; add "
|
|
2378
|
+
f"`{PREFETCH_PLACEMENT_MARKER}` if intentional"
|
|
2379
|
+
),
|
|
2380
|
+
)
|
|
2381
|
+
)
|
|
2382
|
+
preamble_over = True
|
|
2383
|
+
if prefetch_stack:
|
|
2384
|
+
issues.append(
|
|
2385
|
+
(
|
|
2386
|
+
lineno,
|
|
2387
|
+
"E504",
|
|
2388
|
+
(
|
|
2389
|
+
"`begin prefetch block` nested inside another "
|
|
2390
|
+
"prefetch block; prefetch blocks do not nest; "
|
|
2391
|
+
f"add `{PREFETCH_BLOCK_MARKER}` if intentional"
|
|
2392
|
+
),
|
|
2393
|
+
)
|
|
2394
|
+
)
|
|
2395
|
+
# pushed regardless of nesting, so a nested pair's own
|
|
2396
|
+
# `end prefetch block` still matches up and does not also
|
|
2397
|
+
# misreport as a stray E503
|
|
2398
|
+
prefetch_stack.append([lineno, len(if_stack)])
|
|
2399
|
+
continue
|
|
2400
|
+
|
|
2401
|
+
if _END_PREFETCH_BLOCK_RE.match(stripped):
|
|
2402
|
+
if not prefetch_stack:
|
|
2403
|
+
issues.append(
|
|
2404
|
+
(
|
|
2405
|
+
lineno,
|
|
2406
|
+
"E503",
|
|
2407
|
+
(
|
|
2408
|
+
"`end prefetch block` with no open `begin prefetch "
|
|
2409
|
+
f"block`; add `{PREFETCH_BLOCK_MARKER}` if "
|
|
2410
|
+
"intentional"
|
|
2411
|
+
),
|
|
2412
|
+
)
|
|
2413
|
+
)
|
|
2414
|
+
else:
|
|
2415
|
+
open_lineno, if_depth_at_open = prefetch_stack.pop()
|
|
2416
|
+
if len(if_stack) > if_depth_at_open:
|
|
2417
|
+
issues.append(
|
|
2418
|
+
(
|
|
2419
|
+
lineno,
|
|
2420
|
+
"E507",
|
|
2421
|
+
(
|
|
2422
|
+
"`if` opened inside a prefetch block is still "
|
|
2423
|
+
"open at `end prefetch block`; blocks "
|
|
2424
|
+
"interleave rather than nest; add "
|
|
2425
|
+
f"`{BLOCK_NESTING_MARKER}` if intentional"
|
|
2426
|
+
),
|
|
2427
|
+
)
|
|
2428
|
+
)
|
|
2429
|
+
continue
|
|
2430
|
+
|
|
2431
|
+
if _ADD_PREFETCH_ITEM_RE.match(stripped):
|
|
2432
|
+
if not prefetch_stack:
|
|
2433
|
+
issues.append(
|
|
2434
|
+
(
|
|
2435
|
+
lineno,
|
|
2436
|
+
"E510",
|
|
2437
|
+
(
|
|
2438
|
+
"`add prefetch item` outside an open `begin "
|
|
2439
|
+
"prefetch block`; the agent rejects it there; add "
|
|
2440
|
+
f"`{PREFETCH_PLACEMENT_MARKER}` if intentional"
|
|
2441
|
+
),
|
|
2442
|
+
)
|
|
2443
|
+
)
|
|
2444
|
+
continue
|
|
2445
|
+
|
|
2446
|
+
if _COLLECT_PREFETCH_ITEMS_RE.match(stripped):
|
|
2447
|
+
if not prefetch_stack:
|
|
2448
|
+
issues.append(
|
|
2449
|
+
(
|
|
2450
|
+
lineno,
|
|
2451
|
+
"E511",
|
|
2452
|
+
(
|
|
2453
|
+
"`collect prefetch items` outside an open `begin "
|
|
2454
|
+
"prefetch block`; the agent rejects it there; add "
|
|
2455
|
+
f"`{PREFETCH_PLACEMENT_MARKER}` if intentional"
|
|
2456
|
+
),
|
|
2457
|
+
)
|
|
2458
|
+
)
|
|
2459
|
+
continue
|
|
2460
|
+
|
|
2461
|
+
if _IF_RE.match(stripped):
|
|
2462
|
+
condition = stripped[_IF_RE.match(stripped).end() :].lstrip()
|
|
2463
|
+
if not condition.startswith("{"):
|
|
2464
|
+
issues.append(
|
|
2465
|
+
(
|
|
2466
|
+
lineno,
|
|
2467
|
+
"E514",
|
|
2468
|
+
(
|
|
2469
|
+
"`if` condition is not a `{...}` relevance "
|
|
2470
|
+
"substitution; the agent requires one; add "
|
|
2471
|
+
f"`{IF_MARKER}` if intentional"
|
|
2472
|
+
),
|
|
2473
|
+
)
|
|
2474
|
+
)
|
|
2475
|
+
if_stack.append([lineno, False])
|
|
2476
|
+
continue
|
|
2477
|
+
|
|
2478
|
+
if _ELSEIF_RE.match(stripped):
|
|
2479
|
+
if _ELSE_IF_TWO_WORDS_RE.match(stripped):
|
|
2480
|
+
issues.append(
|
|
2481
|
+
(
|
|
2482
|
+
lineno,
|
|
2483
|
+
"E524",
|
|
2484
|
+
(
|
|
2485
|
+
"`else if` is not a chained branch -- write "
|
|
2486
|
+
"`elseif` (one word); add "
|
|
2487
|
+
f"`{IF_MARKER}` if intentional"
|
|
2488
|
+
),
|
|
2489
|
+
)
|
|
2490
|
+
)
|
|
2491
|
+
condition = stripped[_ELSEIF_RE.match(stripped).end() :].lstrip()
|
|
2492
|
+
if not condition.startswith("{"):
|
|
2493
|
+
issues.append(
|
|
2494
|
+
(
|
|
2495
|
+
lineno,
|
|
2496
|
+
"E514",
|
|
2497
|
+
(
|
|
2498
|
+
"`elseif` condition is not a `{...}` relevance "
|
|
2499
|
+
"substitution; the agent requires one; add "
|
|
2500
|
+
f"`{IF_MARKER}` if intentional"
|
|
2501
|
+
),
|
|
2502
|
+
)
|
|
2503
|
+
)
|
|
2504
|
+
if not if_stack:
|
|
2505
|
+
issues.append(
|
|
2506
|
+
(
|
|
2507
|
+
lineno,
|
|
2508
|
+
"E505",
|
|
2509
|
+
(
|
|
2510
|
+
f"`elseif` outside any open `if`; add "
|
|
2511
|
+
f"`{IF_MARKER}` if intentional"
|
|
2512
|
+
),
|
|
2513
|
+
)
|
|
2514
|
+
)
|
|
2515
|
+
elif if_stack[-1][1]:
|
|
2516
|
+
issues.append(
|
|
2517
|
+
(
|
|
2518
|
+
lineno,
|
|
2519
|
+
"E506",
|
|
2520
|
+
(
|
|
2521
|
+
"`elseif` after `else` for the same `if`; add "
|
|
2522
|
+
f"`{IF_MARKER}` if intentional"
|
|
2523
|
+
),
|
|
2524
|
+
)
|
|
2525
|
+
)
|
|
2526
|
+
continue
|
|
2527
|
+
|
|
2528
|
+
if _ELSE_RE.match(stripped):
|
|
2529
|
+
if not if_stack:
|
|
2530
|
+
issues.append(
|
|
2531
|
+
(
|
|
2532
|
+
lineno,
|
|
2533
|
+
"E505",
|
|
2534
|
+
(
|
|
2535
|
+
f"`else` outside any open `if`; add "
|
|
2536
|
+
f"`{IF_MARKER}` if intentional"
|
|
2537
|
+
),
|
|
2538
|
+
)
|
|
2539
|
+
)
|
|
2540
|
+
elif if_stack[-1][1]:
|
|
2541
|
+
issues.append(
|
|
2542
|
+
(
|
|
2543
|
+
lineno,
|
|
2544
|
+
"E506",
|
|
2545
|
+
(
|
|
2546
|
+
"second `else` for the same `if`; add "
|
|
2547
|
+
f"`{IF_MARKER}` if intentional"
|
|
2548
|
+
),
|
|
2549
|
+
)
|
|
2550
|
+
)
|
|
2551
|
+
else:
|
|
2552
|
+
if_stack[-1][1] = True
|
|
2553
|
+
continue
|
|
2554
|
+
|
|
2555
|
+
if _ENDIF_RE.match(stripped):
|
|
2556
|
+
if not if_stack:
|
|
2557
|
+
issues.append(
|
|
2558
|
+
(
|
|
2559
|
+
lineno,
|
|
2560
|
+
"E501",
|
|
2561
|
+
(
|
|
2562
|
+
f"`endif` with no open `if`; add "
|
|
2563
|
+
f"`{IF_MARKER}` if intentional"
|
|
2564
|
+
),
|
|
2565
|
+
)
|
|
2566
|
+
)
|
|
2567
|
+
else:
|
|
2568
|
+
if_stack.pop()
|
|
2569
|
+
continue
|
|
2570
|
+
|
|
2571
|
+
for open_lineno, _seen_else in if_stack:
|
|
2572
|
+
issues.append(
|
|
2573
|
+
(
|
|
2574
|
+
open_lineno,
|
|
2575
|
+
"E500",
|
|
2576
|
+
(
|
|
2577
|
+
f"`if` is never closed by `endif`; add "
|
|
2578
|
+
f"`{IF_MARKER}` if intentional"
|
|
2579
|
+
),
|
|
2580
|
+
)
|
|
2581
|
+
)
|
|
2582
|
+
for open_lineno, _if_depth_at_open in prefetch_stack:
|
|
2583
|
+
issues.append(
|
|
2584
|
+
(
|
|
2585
|
+
open_lineno,
|
|
2586
|
+
"E502",
|
|
2587
|
+
(
|
|
2588
|
+
"`begin prefetch block` is never closed by `end prefetch "
|
|
2589
|
+
f"block`; add `{PREFETCH_BLOCK_MARKER}` if intentional"
|
|
2590
|
+
),
|
|
2591
|
+
)
|
|
2592
|
+
)
|
|
2593
|
+
return sorted(issues)
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
def _iter_actionscript_bodies(raw):
|
|
2597
|
+
"""Yield (sourceline, body) for every BigFix ActionScript in a BES document.
|
|
2598
|
+
|
|
2599
|
+
Raises etree.XMLSyntaxError if the document does not parse; the callers
|
|
2600
|
+
turn that into W500. MIMEType is compared case-insensitively: it is valid
|
|
2601
|
+
BigFix content either way.
|
|
2602
|
+
"""
|
|
2603
|
+
root = etree.fromstring(raw)
|
|
2604
|
+
for element in root.iter("ActionScript"):
|
|
2605
|
+
mimetype = element.get("MIMEType")
|
|
2606
|
+
if mimetype is not None and mimetype.strip().lower() != ACTIONSCRIPT_MIMETYPE:
|
|
2607
|
+
continue
|
|
2608
|
+
yield element.sourceline, element.text or ""
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
# action parameters the platform itself supplies to every action; the console
|
|
2612
|
+
# writes `setting ... on "{parameter "action issue date" of action}"`
|
|
2613
|
+
_BUILTIN_ACTION_PARAMETERS = frozenset(["action issue date"])
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
def _check_lone_parameters(bodies, document):
|
|
2617
|
+
"""W508 for a `parameter "X"` reference whose name occurs nowhere else.
|
|
2618
|
+
|
|
2619
|
+
`bodies` is [(sourceline, body)], `document` the whole file's decoded
|
|
2620
|
+
text. A parameter that is referenced but never assigned or queried is
|
|
2621
|
+
normally supplied from outside the script (a secure parameter, a
|
|
2622
|
+
Description-page form, a REST-created action), so E517 does not flag it
|
|
2623
|
+
-- but then its name shows up somewhere else in the file too. A name that
|
|
2624
|
+
occurs exactly once in the entire file is almost always copy-paste from
|
|
2625
|
+
a sibling that set it, and the substitution fails at runtime. An
|
|
2626
|
+
`exists parameter "X"` presence test is not counted as a reference.
|
|
2627
|
+
"""
|
|
2628
|
+
issues = []
|
|
2629
|
+
for sourceline, body in bodies:
|
|
2630
|
+
for index, line in enumerate(body.split("\n")):
|
|
2631
|
+
stripped = line.strip()
|
|
2632
|
+
if not stripped or stripped.startswith("//"):
|
|
2633
|
+
continue
|
|
2634
|
+
assign = _PARAMETER_ASSIGN_RE.match(stripped)
|
|
2635
|
+
for match in _PARAMETER_REF_RE.finditer(
|
|
2636
|
+
stripped, assign.end() if assign else 0
|
|
2637
|
+
):
|
|
2638
|
+
if _EXISTS_BEFORE_RE.search(stripped[: match.start()]):
|
|
2639
|
+
continue
|
|
2640
|
+
name = match.group(1)
|
|
2641
|
+
if name.lower() in _BUILTIN_ACTION_PARAMETERS:
|
|
2642
|
+
continue
|
|
2643
|
+
pattern = r"(?<![\w-])" + re.escape(name) + r"(?![\w-])"
|
|
2644
|
+
if len(re.findall(pattern, document, re.IGNORECASE)) != 1:
|
|
2645
|
+
continue
|
|
2646
|
+
issues.append(
|
|
2647
|
+
(
|
|
2648
|
+
sourceline + index,
|
|
2649
|
+
"W508",
|
|
2650
|
+
(
|
|
2651
|
+
f'`parameter "{name}"` is never assigned, queried, or '
|
|
2652
|
+
"named anywhere else in this file, so the "
|
|
2653
|
+
"substitution fails at runtime; add "
|
|
2654
|
+
f"`{PARAMETER_MARKER}` if it is supplied from outside"
|
|
2655
|
+
),
|
|
2656
|
+
)
|
|
2657
|
+
)
|
|
2658
|
+
return issues
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
def _validate_bes_xml(raw):
|
|
2662
|
+
"""Check block balance in every ActionScript of a BES document."""
|
|
2663
|
+
try:
|
|
2664
|
+
bodies = list(_iter_actionscript_bodies(raw))
|
|
2665
|
+
except etree.XMLSyntaxError as err:
|
|
2666
|
+
return [(1, "W500", f"not parseable BES XML ({err}); skipping")]
|
|
2667
|
+
issues = []
|
|
2668
|
+
for sourceline, body in bodies:
|
|
2669
|
+
for lineno, code, message in check_actionscript(body, sourceline):
|
|
2670
|
+
issues.append((sourceline + lineno - 1, code, message))
|
|
2671
|
+
document = html.unescape(raw.decode("utf-8", errors="replace"))
|
|
2672
|
+
issues.extend(_check_lone_parameters(bodies, document))
|
|
2673
|
+
return issues
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
def is_bes_file(path):
|
|
2677
|
+
"""Return True if `path` has a BES XML extension."""
|
|
2678
|
+
return path.endswith(BES_EXTENSIONS)
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
def check_file(path, disabled=frozenset(), auto_fix=False):
|
|
2682
|
+
"""Check a single file; return (issues, fixed).
|
|
2683
|
+
|
|
2684
|
+
With `auto_fix`, every wrong-case `__download`/`__createfile`/
|
|
2685
|
+
`__appendfile` reference (W503) is rewritten in place to its canonical
|
|
2686
|
+
spelling, unless "W503" is in `disabled` or the file opts out with the
|
|
2687
|
+
`actionscript-scratch-ok` marker; and a `delete <destination>` is
|
|
2688
|
+
inserted before each W506 move/copy, unless "W506" is disabled or the
|
|
2689
|
+
`actionscript-scratch-dest-ok` marker is present; each unquoted
|
|
2690
|
+
`folder create|delete` path is quoted (E525); a `parameter` line's
|
|
2691
|
+
trailing comment is moved above it (E528) and an unquoted `parameter`
|
|
2692
|
+
value is quoted (E527); an `else if` is joined
|
|
2693
|
+
(E524), a cmd.exe `/k` or missing `/c` is put right (W505), and a
|
|
2694
|
+
bracketed registry key is quoted (E521) -- each unless its code is
|
|
2695
|
+
disabled or its family marker is present. The file's line endings are
|
|
2696
|
+
preserved (CRLF in, CRLF out). No other check has an auto-fix.
|
|
2697
|
+
"""
|
|
2698
|
+
if not os.path.isfile(path):
|
|
2699
|
+
return [(1, "W500", "file not found; skipping")], []
|
|
2700
|
+
|
|
2701
|
+
original, src, was_crlf = read_source(path)
|
|
2702
|
+
|
|
2703
|
+
if SKIP_MARKER in src:
|
|
2704
|
+
return [], []
|
|
2705
|
+
if MUSTACHE_RE.search(src):
|
|
2706
|
+
return [], []
|
|
2707
|
+
|
|
2708
|
+
is_bes = is_bes_file(path)
|
|
2709
|
+
raw = original
|
|
2710
|
+
# each fix pass is kept only if it leaves a schema-valid file valid (E526)
|
|
2711
|
+
guard = bes_common.SchemaGuard(
|
|
2712
|
+
src, "E526", path, validate=is_bes and "E526" not in disabled
|
|
2713
|
+
)
|
|
2714
|
+
if auto_fix and "W503" not in disabled and SCRATCH_MARKER not in src:
|
|
2715
|
+
new_src, got = fix_scratch_case(src, _scratch_case_targets(raw, src, is_bes))
|
|
2716
|
+
if guard.apply(new_src, got):
|
|
2717
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2718
|
+
if auto_fix and "W506" not in disabled and SCRATCH_DEST_MARKER not in src:
|
|
2719
|
+
new_src, got = fix_scratch_destinations(
|
|
2720
|
+
src, list(_destination_targets(raw, src, is_bes))
|
|
2721
|
+
)
|
|
2722
|
+
if guard.apply(new_src, got):
|
|
2723
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2724
|
+
if auto_fix and "E525" not in disabled and COMMAND_SHAPE_MARKER not in src:
|
|
2725
|
+
new_src, got = fix_folder_quoting(
|
|
2726
|
+
src, list(_folder_quote_targets(raw, src, is_bes))
|
|
2727
|
+
)
|
|
2728
|
+
if guard.apply(new_src, got):
|
|
2729
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2730
|
+
# E528 first: an unquoted value with a trailing comment is only safe to
|
|
2731
|
+
# quote (E527) once the comment is off the line
|
|
2732
|
+
if auto_fix and "E528" not in disabled and PARAMETER_MARKER not in src:
|
|
2733
|
+
new_src, got = fix_parameter_comments(
|
|
2734
|
+
src, list(_parameter_comment_targets(raw, src, is_bes))
|
|
2735
|
+
)
|
|
2736
|
+
if guard.apply(new_src, got):
|
|
2737
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2738
|
+
if auto_fix and "E527" not in disabled and PARAMETER_MARKER not in src:
|
|
2739
|
+
new_src, got = fix_parameter_quoting(
|
|
2740
|
+
src, list(_parameter_quote_targets(raw, src, is_bes))
|
|
2741
|
+
)
|
|
2742
|
+
if guard.apply(new_src, got):
|
|
2743
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2744
|
+
line_fix_codes = {
|
|
2745
|
+
code
|
|
2746
|
+
for code in ("E524", "W505", "E521")
|
|
2747
|
+
if auto_fix and code not in disabled and CHECK_MARKERS[code] not in src
|
|
2748
|
+
}
|
|
2749
|
+
if line_fix_codes:
|
|
2750
|
+
new_src, got = fix_line_rewrites(
|
|
2751
|
+
src, list(_line_fix_targets(raw, src, is_bes, line_fix_codes))
|
|
2752
|
+
)
|
|
2753
|
+
if guard.apply(new_src, got):
|
|
2754
|
+
src, raw = new_src, encode(new_src, was_crlf)
|
|
2755
|
+
fixed = guard.fixed
|
|
2756
|
+
if raw != original:
|
|
2757
|
+
with open(path, "wb") as handle:
|
|
2758
|
+
handle.write(raw)
|
|
2759
|
+
|
|
2760
|
+
if is_bes:
|
|
2761
|
+
issues = _validate_bes_xml(raw)
|
|
2762
|
+
else:
|
|
2763
|
+
issues = check_actionscript(src)
|
|
2764
|
+
issues += guard.refused
|
|
2765
|
+
|
|
2766
|
+
opt_outs = {marker for code, marker in CHECK_MARKERS.items() if marker in src}
|
|
2767
|
+
issues = [
|
|
2768
|
+
(lineno, code, message)
|
|
2769
|
+
for lineno, code, message in issues
|
|
2770
|
+
if code not in disabled and CHECK_MARKERS.get(code) not in opt_outs
|
|
2771
|
+
]
|
|
2772
|
+
return sorted(issues), fixed
|
|
2773
|
+
|
|
2774
|
+
|
|
2775
|
+
def check_files(paths, disabled=frozenset(), auto_fix=False):
|
|
2776
|
+
"""Check several files; return a list of (path, issues, fixed) tuples.
|
|
2777
|
+
|
|
2778
|
+
This is the programmatic entry point: it does no printing.
|
|
2779
|
+
"""
|
|
2780
|
+
results = []
|
|
2781
|
+
for path in paths:
|
|
2782
|
+
issues, fixed = check_file(path, disabled=disabled, auto_fix=auto_fix)
|
|
2783
|
+
results.append((path, issues, fixed))
|
|
2784
|
+
return results
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def discover_bes_files(root="."):
|
|
2788
|
+
"""Return all .bes files under `root`, pruning hidden and noise directories."""
|
|
2789
|
+
return bes_common.discover_bes_files(root)
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
def main(argv=None):
|
|
2793
|
+
"""Execution starts here.
|
|
2794
|
+
|
|
2795
|
+
argv defaults to None so this works both as a console_scripts entry point
|
|
2796
|
+
(pre-commit calls it with no arguments; argparse then reads sys.argv) and
|
|
2797
|
+
when called directly as `main(sys.argv[1:])`.
|
|
2798
|
+
"""
|
|
2799
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
2800
|
+
parser.add_argument(
|
|
2801
|
+
"--strict",
|
|
2802
|
+
action="store_true",
|
|
2803
|
+
help="treat warnings as failures (non-zero exit); default: advisory",
|
|
2804
|
+
)
|
|
2805
|
+
parser.add_argument(
|
|
2806
|
+
"--disable",
|
|
2807
|
+
default="",
|
|
2808
|
+
metavar="CODES",
|
|
2809
|
+
help="comma-separated check IDs to skip entirely, e.g. --disable E501",
|
|
2810
|
+
)
|
|
2811
|
+
parser.add_argument(
|
|
2812
|
+
"--auto-fix",
|
|
2813
|
+
choices=["yes", "no"],
|
|
2814
|
+
default=None,
|
|
2815
|
+
help=(
|
|
2816
|
+
"rewrite wrong-case __download/__createfile/__appendfile "
|
|
2817
|
+
"references (W503) to their canonical spelling and insert a "
|
|
2818
|
+
"`delete` before each uncleared move/copy (W506) and quote "
|
|
2819
|
+
"folder create/delete paths (E525), move a `parameter` line's "
|
|
2820
|
+
"trailing comment above it (E528), quote unquoted parameter "
|
|
2821
|
+
"values (E527), join `else if` (E524), put "
|
|
2822
|
+
"cmd.exe's `/c` right (W505), and quote bracketed registry keys "
|
|
2823
|
+
"(E521), in place "
|
|
2824
|
+
"(default: yes when files are given, no when auto-discovering)"
|
|
2825
|
+
),
|
|
2826
|
+
)
|
|
2827
|
+
parser.add_argument(
|
|
2828
|
+
"files",
|
|
2829
|
+
nargs="*",
|
|
2830
|
+
help=(
|
|
2831
|
+
"files to check (.bes/.ojo are read as BES XML, anything else as "
|
|
2832
|
+
"raw ActionScript); if omitted, all *.bes files in the current "
|
|
2833
|
+
"folder and below are checked"
|
|
2834
|
+
),
|
|
2835
|
+
)
|
|
2836
|
+
args = parser.parse_args(argv)
|
|
2837
|
+
|
|
2838
|
+
disabled = {
|
|
2839
|
+
code.strip().upper() for code in args.disable.split(",") if code.strip()
|
|
2840
|
+
}
|
|
2841
|
+
unknown = disabled - KNOWN_CODES
|
|
2842
|
+
if unknown:
|
|
2843
|
+
print(
|
|
2844
|
+
f"warning: ignoring unknown --disable code(s): {', '.join(sorted(unknown))}"
|
|
2845
|
+
)
|
|
2846
|
+
|
|
2847
|
+
# auto-fix defaults to yes for explicit files, no when auto-discovering; an
|
|
2848
|
+
# explicit --auto-fix always wins. This is the sibling hooks' rule, and
|
|
2849
|
+
# pre-commit always passes files, so under pre-commit the default is yes.
|
|
2850
|
+
if args.auto_fix is not None:
|
|
2851
|
+
auto_fix = args.auto_fix == "yes"
|
|
2852
|
+
else:
|
|
2853
|
+
auto_fix = bool(args.files)
|
|
2854
|
+
paths = args.files if args.files else discover_bes_files(".")
|
|
2855
|
+
|
|
2856
|
+
issue_count, warning_count, fix_count = bes_common.report(
|
|
2857
|
+
check_files(paths, disabled=disabled, auto_fix=auto_fix)
|
|
2858
|
+
)
|
|
2859
|
+
|
|
2860
|
+
if fix_count:
|
|
2861
|
+
print(f"\nauto-fixed {fix_count} issue(s); review and re-stage the changes.")
|
|
2862
|
+
if warning_count:
|
|
2863
|
+
print(f"{warning_count} warning(s).")
|
|
2864
|
+
if issue_count:
|
|
2865
|
+
print(f"{issue_count} issue(s).")
|
|
2866
|
+
# E-codes and any fix always fail; warnings fail only under --strict
|
|
2867
|
+
return 1 if (issue_count or fix_count or (warning_count and args.strict)) else 0
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
if __name__ == "__main__":
|
|
2871
|
+
sys.exit(main(sys.argv[1:]))
|