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.
@@ -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, `&quot;` 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, `&quot;` 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
+ # `&quot;`), stopping before a CDATA/tag close
538
+ _RAW_PARAMETER_VALUE_RE = re.compile(
539
+ r'(parameter\s+(?:"[^"]+"|&quot;.+?&quot;)\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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1461
+ if "&quot;" in raw_line:
1462
+ text = text.replace('"', "&quot;")
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 `&quot;` 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 = "&quot;" if "&quot;" 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 `&quot;` 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 = "&quot;" if "&quot;" 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1788
+ return text.replace('"', "&quot;") if "&quot;" 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:]))