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,937 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-commit hook: lint BigFix ActionScript against the console's schclass grammar.
3
+
4
+ Lints every <ActionScript> body in a BES file (and, for non-.bes/.ojo paths,
5
+ the whole file as one raw ActionScript body) against the BigFix console's own
6
+ lexical grammar: the vendored schclass_data/ExpandedActionScript.schclass (the
7
+ lex schema the console's SyntaxEdit editor uses, 323 command verbs) merged
8
+ with schclass_data/bigfix_overrides.schclass (validation corrections the
9
+ display grammar needs: an https: URL class, the `surrender device id` verb the
10
+ console generator drops, `}` as a URL end separator, and the keyword=value
11
+ option lines of `override wait` / `override run` blocks).
12
+
13
+ SCOPE: this hook is deliberately limited to what the schclass grammar can
14
+ decide -- lexical validity of each line -- plus the two BLOCK constructs the
15
+ grammar cannot express but which decide whether a line is a command at all:
16
+ `createfile until` heredocs (E302) and `override run` / `override wait` option
17
+ blocks (E303). ActionScript checks that need knowledge neither carries
18
+ (per-verb argument shapes, if/endif and prefetch-block pairing, the
19
+ `]]></ActionScript>` closing-tag whitespace trap, http-vs-https escalation,
20
+ and any content-changing auto-fixes) belong in a sibling ActionScript hook,
21
+ not here. The one exception is lowercasing (W302/W303): the grammar already
22
+ knows exactly which text is the verb or option, and the rewrite changes only
23
+ its case. Keeping the split means this hook stays a thin,
24
+ mechanical consumer of the grammar files and needs no edits when BigFix ships
25
+ new command verbs -- only the vendored schclass does.
26
+
27
+ The rule (per jgstew/pre-commit-bigfix#3): the first token of every line must
28
+ be a known command verb, a `//` comment, a `{...}` relevance substitution, a
29
+ continuation of a state carried across the line break with a backslash, or the
30
+ line must be blank. Verbs match case-insensitively (the agent accepts `RUN`),
31
+ but a non-lowercase verb is warned about. Lines inside a
32
+ `createfile until <MARKER>` block are raw file content and are not linted;
33
+ the block must reach its bare marker line. An `override run` / `override wait`
34
+ line opens a block whose following `keyword=value` lines are options, not
35
+ commands, and are checked against the documented keywords and values instead.
36
+
37
+ Only <ActionScript> elements with MIMEType application/x-Fixlet-Windows-Shell
38
+ (or no MIMEType, which defaults to it) are BigFix ActionScript; x-sh,
39
+ x-AppleScript, x-Fixlet-Windows-PowerShell, and text/x-uri bodies are other
40
+ languages and are skipped silently (an unknown MIMEType is E200's problem in
41
+ bes-conventions-check).
42
+
43
+ Checks:
44
+ E300 a line's first token is not a known command verb, a // comment, a
45
+ {...} substitution, a continuation, or blank. The message quotes
46
+ the offending line (not just its first token) and, when the
47
+ grammar narrows it to at most three known commands or to one close
48
+ spelling of them, names them as a `did you mean` suggestion --
49
+ e.g. `action log commands` suggests `action log command`
50
+ E301 a {...} relevance substitution has no closing } before line end
51
+ E302 a `createfile until <MARKER>` block never reaches its marker line
52
+ E303 an `override run` / `override wait` option line is wrong: an unknown
53
+ keyword, no value, a value outside the documented set for that
54
+ keyword, a non-integer `timeout_seconds`, or a `keyword=value` option
55
+ line outside any override block
56
+ E304 the case fixes were not written, because together they would make
57
+ a schema-valid file fail BES.xsd validation (--disable E304 writes
58
+ them anyway)
59
+ W300 the file is not parseable BES XML; skipped (advisory --
60
+ bes-schema-validate is the authority on file validity)
61
+ W301 a "..." string has no closing " before line end (often benign in
62
+ ActionScript arguments, so a warning). ActionScript has no escape
63
+ character, so `"C:\\Bes\\"` is closed -- except in a `regset`/
64
+ `regset64` value, written in .reg-file syntax, where `\\"` and `\\\\`
65
+ are escapes. `appendfile` content lines are raw file text and exempt
66
+ W302 a matched command verb is not lowercase (e.g. `RUN`; valid but
67
+ unconventional; fixable -> lowercased)
68
+ W303 an override option keyword or value is not lowercase (e.g. `RunAs`;
69
+ valid but unconventional; fixable -> lowercased, a `{...}` value
70
+ never touched)
71
+
72
+ E-codes are real issues and fail the hook. W-codes are advisory and do NOT
73
+ fail the hook unless --strict is given.
74
+
75
+ --auto-fix (W302, W303), on by default when files are given (as pre-commit
76
+ does) and off when auto-discovering, lowercases each non-lowercase command
77
+ verb and override option keyword/value in place; nothing else is rewritten,
78
+ since anything more is beyond what a lexical grammar can justify (see SCOPE
79
+ above). The file's line endings are kept, and an auto-fixed file fails the
80
+ hook so the change is reviewed and re-staged. `--disable W302` or the
81
+ `actionscript-case-ok` marker (W303: `actionscript-override-case-ok`) turns
82
+ the check and its fix off together.
83
+
84
+ XML bodies are extracted with lxml, so the linted text is the REAL
85
+ ActionScript exactly as the agent sees it: entities decoded, adjacent CDATA
86
+ sections merged, with lxml's sourceline mapping issues back to file line
87
+ numbers.
88
+
89
+ Usage:
90
+ bes_actionscript_lint_schclass.py [--strict] [--disable E300,W302]
91
+ [--auto-fix=yes|no] [file ...]
92
+
93
+ With no file arguments, all *.bes files in the current folder and below are
94
+ checked. Non-.bes/.ojo paths given explicitly are linted as raw ActionScript
95
+ text.
96
+
97
+ A file can opt out of all checks with a comment anywhere in it:
98
+ <!-- pre-commit-skip: bes-actionscript-lint-schclass -->
99
+ or out of a single check family with the matching marker anywhere in the file:
100
+ actionscript-verb-ok (E300)
101
+ actionscript-substitution-ok (E301)
102
+ actionscript-createfile-ok (E302)
103
+ actionscript-override-ok (E303)
104
+ actionscript-string-ok (W301)
105
+ actionscript-case-ok (W302)
106
+ actionscript-override-case-ok (W303)
107
+
108
+ Files that look like mustache templates (containing `{{ ... }}`) are skipped
109
+ silently: they are not real content until rendered.
110
+
111
+ Known limitations: multi-word verbs need single spaces (`add prefetch item`
112
+ does not match -- the console colorizer behaves the same way); a line of a
113
+ multi-line string that happens to start with `createfile until` is mistaken
114
+ for a heredoc opener. A dynamic `download` line is lexically VALID here while
115
+ still advisory-warned (W211) in bes-conventions-check -- different altitudes,
116
+ both intentional. The E300 `did you mean` suggestion is word-based, so a
117
+ first word glued to punctuation (`run"x"`) gets none; a plural or bare
118
+ `action log`/`action launch` line stays E300 by design -- `action log all`,
119
+ `action log command`, and the two `action launch preference` forms are the
120
+ only variants the grammar and the BigFix documentation carry
121
+ (jgstew/pre-commit-bigfix#15).
122
+
123
+ Exit codes:
124
+ 0 no E-code issues and nothing auto-fixed (and, without --strict,
125
+ regardless of warnings)
126
+ 1 an E-code issue was found, a file was auto-fixed, or a warning was found
127
+ while --strict is set
128
+ """
129
+
130
+ import argparse
131
+ import dataclasses
132
+ import difflib
133
+ import os
134
+ import re
135
+ import sys
136
+
137
+ from lxml import etree
138
+
139
+ if __package__ in (None, ""): # run directly as a script, not as a module
140
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
141
+
142
+ from pre_commit_bigfix import bes_common, schclass
143
+ from pre_commit_bigfix.bes_common import MUSTACHE_RE, encode, read_source
144
+ from pre_commit_bigfix.schclass_tokenizer import Tokenizer
145
+
146
+ SKIP_MARKER = "pre-commit-skip: bes-actionscript-lint-schclass"
147
+
148
+ # per-check opt-out markers (matched anywhere in the file text)
149
+ VERB_MARKER = "actionscript-verb-ok" # E300
150
+ SUBSTITUTION_MARKER = "actionscript-substitution-ok" # E301
151
+ CREATEFILE_MARKER = "actionscript-createfile-ok" # E302
152
+ OVERRIDE_MARKER = "actionscript-override-ok" # E303
153
+ STRING_MARKER = "actionscript-string-ok" # W301
154
+ CASE_MARKER = "actionscript-case-ok" # W302
155
+ OVERRIDE_CASE_MARKER = "actionscript-override-case-ok" # W303
156
+
157
+ CHECK_MARKERS = {
158
+ "E300": VERB_MARKER,
159
+ "E301": SUBSTITUTION_MARKER,
160
+ "E302": CREATEFILE_MARKER,
161
+ "E303": OVERRIDE_MARKER,
162
+ "W301": STRING_MARKER,
163
+ "W302": CASE_MARKER,
164
+ "W303": OVERRIDE_CASE_MARKER,
165
+ }
166
+
167
+ KNOWN_CODES = frozenset(
168
+ ["E300", "E301", "E302", "E303", "E304", "W300", "W301", "W302", "W303"]
169
+ )
170
+
171
+ BES_EXTENSIONS = (".bes", ".ojo")
172
+
173
+ # the one MIMEType that IS BigFix ActionScript; a missing MIMEType defaults to
174
+ # it, every other value is some other language and is not linted here.
175
+ ACTIONSCRIPT_MIMETYPE = "application/x-Fixlet-Windows-Shell"
176
+
177
+ HEREDOC_VERB = "createfile until"
178
+
179
+ # --- `override run` / `override wait` option blocks --------------------------
180
+ # An override block is the verb line, then one `keyword=value` option line per
181
+ # option, then the real command line, which closes the block:
182
+ # override wait
183
+ # hidden=true
184
+ # wait notepad.exe
185
+ # None of this is expressible in the schclass grammar (the option keywords are
186
+ # only commands INSIDE a block, and their values may be relevance
187
+ # substitutions), so it lives here as line state -- see SCOPE above.
188
+ #
189
+ # Keyword and value sets are from
190
+ # https://developer.bigfix.com/action-script/reference/execution/override.html
191
+ # and may not be exhaustive; the reference is the place to check when a new
192
+ # option appears. `None` means the reference gives no closed value set, so only
193
+ # the keyword is checked. Keywords are documented case-insensitive, and values
194
+ # "can be enclosed in {curly brackets} for Relevance substitution", so a value
195
+ # holding a `{` is accepted unchecked -- its real value is not known until the
196
+ # agent runs.
197
+ OVERRIDE_VERBS = frozenset(["override run", "override wait"])
198
+
199
+ OVERRIDE_OPTIONS = {
200
+ "completion": frozenset(["none", "process", "job"]),
201
+ "priority": frozenset(["normal", "low"]),
202
+ "hidden": frozenset(["true", "false"]),
203
+ "detached": frozenset(["true", "false"]),
204
+ "runas": frozenset(["agent", "currentuser", "localuser"]),
205
+ "user": None, # a user name or a relevance expression
206
+ "password": frozenset(["required", "empty", "impersonate", "system"]),
207
+ "asadmin": frozenset(["true", "interactive"]),
208
+ "targetuser": None, # a user name
209
+ "timeout_seconds": None, # checked as an integer, not against a value set
210
+ "disposition": frozenset(["terminate", "abandon"]),
211
+ }
212
+
213
+ # 0 is the documented default (meaning "no timeout"), so it must be writable
214
+ OVERRIDE_INTEGER_OPTIONS = frozenset(["timeout_seconds"])
215
+ INTEGER_RE = re.compile(r"[0-9]+\Z")
216
+ # an `appendfile <content>` line: the content is raw file text (W301-exempt)
217
+ _APPENDFILE_RE = re.compile(r"[ \t]*appendfile\b", re.IGNORECASE)
218
+ # a `regset`/`regset64` line: its value is written in .reg-file syntax, where
219
+ # `\"` IS an escaped quote and `\\` an escaped backslash (W301 scans it with
220
+ # _regset_tokenizer, unlike the rest of ActionScript, which has no escapes)
221
+ _REGSET_RE = re.compile(r"[ \t]*regset(?:64)?\b", re.IGNORECASE)
222
+
223
+ OVERRIDE_OPTION_RE = re.compile(r"[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)\Z")
224
+
225
+
226
+ # E300's message quotes the offending LINE, not the first token: a `default`
227
+ # token is one contiguous non-whitespace run (see flush_default in
228
+ # schclass_tokenizer.py), so quoting the token alone turned `action log
229
+ # commands` into a useless `"action"` (jgstew/pre-commit-bigfix#15).
230
+ LINE_QUOTE_LIMIT = 40
231
+
232
+ # E300's "did you mean" suggestion. SUGGEST_LIMIT is both the cap and the
233
+ # relevance gate: a family the grammar has already narrowed to this many
234
+ # commands is named in full (it is a fact about the grammar, not a guess); a
235
+ # wider family is filtered by spelling similarity first and usually stays
236
+ # silent -- see _verb_suggestions.
237
+ SUGGEST_LIMIT = 3
238
+ SUGGEST_CUTOFF = 0.85
239
+
240
+ _TOKENIZER = None
241
+ _REGSET_TOKENIZER = None
242
+ _VERBS = None
243
+
244
+
245
+ def _default_tokenizer():
246
+ """Return the shared Tokenizer over the merged default grammar (lazy)."""
247
+ global _TOKENIZER
248
+ if _TOKENIZER is None:
249
+ _TOKENIZER = Tokenizer(
250
+ schclass.load_default_actionscript_schema(),
251
+ case_insensitive=True,
252
+ relaxed_bol=True,
253
+ )
254
+ return _TOKENIZER
255
+
256
+
257
+ def _regset_tokenizer():
258
+ """Return a Tokenizer whose strings honor .reg-file escapes (lazy).
259
+
260
+ The default grammar deliberately has no string escapes (ActionScript has
261
+ none, see bigfix_overrides.schclass); a regset value is the exception, so
262
+ `\\\\` and `\\"` are added back for it. Longest match wins, so at `\\\\"`
263
+ the escaped backslash is consumed and the quote then closes the string.
264
+ """
265
+ global _REGSET_TOKENIZER
266
+ if _REGSET_TOKENIZER is None:
267
+ schema = schclass.load_default_actionscript_schema()
268
+ string = schema.classes["string"]
269
+ schema.classes["string"] = dataclasses.replace(
270
+ string, skip_tags=("\\\\", '\\"') + tuple(string.skip_tags)
271
+ )
272
+ _REGSET_TOKENIZER = Tokenizer(schema, case_insensitive=True, relaxed_bol=True)
273
+ return _REGSET_TOKENIZER
274
+
275
+
276
+ def _regset_line_has_open_string(line):
277
+ """True if a regset `line` leaves a "..." string open, per .reg escapes."""
278
+ tokens, _errors = _regset_tokenizer().tokenize(line)
279
+ return any(
280
+ token.class_name == "string" and token.end_kind in ("eol", "eof")
281
+ for token in tokens
282
+ )
283
+
284
+
285
+ def _grammar_verbs(tokenizer):
286
+ """Return a tokenizer's command verbs, sorted (cached for the default).
287
+
288
+ Derived from the merged schema the same way _TOKENIZER is, so new BigFix
289
+ verbs arrive with the vendored schclass and there is no list to maintain
290
+ here. A caller-supplied tokenizer (a test path) is not cached.
291
+ """
292
+ global _VERBS
293
+ if tokenizer is not _TOKENIZER:
294
+ return tuple(sorted(tokenizer.schema.all_token_tags()))
295
+ if _VERBS is None:
296
+ _VERBS = tuple(sorted(tokenizer.schema.all_token_tags()))
297
+ return _VERBS
298
+
299
+
300
+ def _default_verbs():
301
+ """Return the default grammar's command verbs, sorted (lazy)."""
302
+ return _grammar_verbs(_default_tokenizer())
303
+
304
+
305
+ def _mask_heredocs(lines):
306
+ """Blank out `createfile until` block content; return (masked, issues).
307
+
308
+ A `createfile until <MARKER>` line starts a block whose following lines
309
+ (up to and including the exact bare marker line) are raw file content, not
310
+ ActionScript -- the lexical grammar cannot express this, so they are
311
+ replaced with empty lines (keeping line numbers aligned) before
312
+ tokenizing. An indented marker line does NOT close the block. A block
313
+ that never reaches its marker is an E302 and masks to the end.
314
+ """
315
+ masked = list(lines)
316
+ issues = []
317
+ index = 0
318
+ while index < len(masked):
319
+ stripped = masked[index].strip()
320
+ lowered = stripped.lower()
321
+ marker = None
322
+ if lowered.startswith(HEREDOC_VERB):
323
+ rest = stripped[len(HEREDOC_VERB) :]
324
+ if rest[:1] in (" ", "\t"):
325
+ marker = rest.strip()
326
+ if not marker:
327
+ index += 1
328
+ continue
329
+ end = index + 1
330
+ while end < len(masked) and masked[end] != marker:
331
+ masked[end] = ""
332
+ end += 1
333
+ if end >= len(masked):
334
+ issues.append(
335
+ (
336
+ index + 1,
337
+ "E302",
338
+ (
339
+ f'createfile until marker "{marker}" is never found before '
340
+ f"the end of the ActionScript; add `{CREATEFILE_MARKER}` "
341
+ "if intentional"
342
+ ),
343
+ )
344
+ )
345
+ break
346
+ masked[end] = "" # the marker line itself is the terminator, not a verb
347
+ index = end + 1
348
+ return masked, issues
349
+
350
+
351
+ def _quote(value, limit=LINE_QUOTE_LIMIT):
352
+ """`value` shortened for use in a message, with an elision marker if cut."""
353
+ value = value.strip()
354
+ if len(value) <= limit:
355
+ return value
356
+ return value[:limit] + "..."
357
+
358
+
359
+ def _verb_ratio(words, verb):
360
+ """Similarity of the line's leading words to `verb`, word-count aligned.
361
+
362
+ The line is truncated to the candidate's own word count first, so a
363
+ verb's trailing arguments (`action log commands --now`) do not dilute
364
+ the score.
365
+ """
366
+ lowered = verb.lower()
367
+ head = " ".join(words[: len(lowered.split())])
368
+ return difflib.SequenceMatcher(None, head, lowered).ratio()
369
+
370
+
371
+ def _verb_suggestions(line, verbs):
372
+ """Return the known verbs a bad line most likely meant, best first.
373
+
374
+ The candidates are the verbs sharing the LONGEST leading-word prefix
375
+ with the line: `action log commands` and a bare `action log` both land
376
+ on {`action log all`, `action log command`}. A line whose first word
377
+ begins no verb at all (`badverb y`, a "quoted" string) gets nothing --
378
+ that gate is what keeps the suggestion from being noise. A family the
379
+ grammar has already narrowed to SUGGEST_LIMIT or fewer is returned
380
+ whole; a wider one is a guess and is filtered to close spellings only.
381
+ A verb the line already spells exactly (at its own word count) is
382
+ dropped -- suggesting back what was already written is not a suggestion.
383
+ """
384
+ words = line.strip().lower().split()
385
+ if not words:
386
+ return []
387
+ family = []
388
+ depth = 0
389
+ for verb in verbs:
390
+ verb_words = verb.lower().split()
391
+ shared = 0
392
+ while (
393
+ shared < len(verb_words)
394
+ and shared < len(words)
395
+ and verb_words[shared] == words[shared]
396
+ ):
397
+ shared += 1
398
+ if shared == 0:
399
+ continue
400
+ if shared > depth:
401
+ family, depth = [verb], shared
402
+ elif shared == depth:
403
+ family.append(verb)
404
+ family = [
405
+ verb for verb in family if " ".join(words[: len(verb.split())]) != verb.lower()
406
+ ]
407
+ if not family:
408
+ return []
409
+ ranked = sorted(family, key=lambda verb: (-_verb_ratio(words, verb), verb))
410
+ if len(family) > SUGGEST_LIMIT:
411
+ ranked = [v for v in ranked if _verb_ratio(words, v) >= SUGGEST_CUTOFF]
412
+ return ranked[:SUGGEST_LIMIT]
413
+
414
+
415
+ def _did_you_mean(suggestions):
416
+ """Render suggested verbs as a message clause, or "" when there are none."""
417
+ quoted = [f"`{verb}`" for verb in suggestions]
418
+ if not quoted:
419
+ return ""
420
+ if len(quoted) == 1:
421
+ body = quoted[0]
422
+ elif len(quoted) == 2:
423
+ body = " or ".join(quoted)
424
+ else:
425
+ body = ", ".join(quoted[:-1]) + ", or " + quoted[-1]
426
+ return f"did you mean {body}?; "
427
+
428
+
429
+ def _override_value(raw):
430
+ """Return an option line's value, less a trailing //comment and quotes."""
431
+ value = raw.strip()
432
+ if "{" not in value: # a substitution may legitimately contain '//'
433
+ value = value.split("//")[0].strip()
434
+ if len(value) > 1 and value[0] == value[-1] and value[0] in "'\"":
435
+ value = value[1:-1].strip()
436
+ return value
437
+
438
+
439
+ def _check_override_option(lineno, keyword, raw_value):
440
+ """Check one override `keyword=value` line; return [(lineno, code, msg)]."""
441
+ canonical = keyword.lower()
442
+ if canonical not in OVERRIDE_OPTIONS:
443
+ return [
444
+ (
445
+ lineno,
446
+ "E303",
447
+ (
448
+ f'"{keyword}" is not a known override option; expected one '
449
+ f"of {', '.join(sorted(OVERRIDE_OPTIONS))}; add "
450
+ f"`{OVERRIDE_MARKER}` if intentional"
451
+ ),
452
+ )
453
+ ]
454
+
455
+ issues = []
456
+ if keyword != canonical:
457
+ issues.append(
458
+ (
459
+ lineno,
460
+ "W303",
461
+ (
462
+ f'override option "{keyword}" is not lowercase; use '
463
+ f'"{canonical}"; add `{OVERRIDE_CASE_MARKER}` if intentional'
464
+ ),
465
+ )
466
+ )
467
+
468
+ value = _override_value(raw_value)
469
+ if not value:
470
+ issues.append(
471
+ (
472
+ lineno,
473
+ "E303",
474
+ (
475
+ f'override option "{canonical}" has no value; add '
476
+ f"`{OVERRIDE_MARKER}` if intentional"
477
+ ),
478
+ )
479
+ )
480
+ return issues
481
+ if "{" in value:
482
+ return issues # a relevance substitution: the value is a runtime matter
483
+
484
+ if canonical in OVERRIDE_INTEGER_OPTIONS:
485
+ if not INTEGER_RE.match(value):
486
+ issues.append(
487
+ (
488
+ lineno,
489
+ "E303",
490
+ (
491
+ f'override option "{canonical}" must be a non-negative '
492
+ f'integer or a {{...}} substitution, not "{value}"; add '
493
+ f"`{OVERRIDE_MARKER}` if intentional"
494
+ ),
495
+ )
496
+ )
497
+ return issues
498
+
499
+ allowed = OVERRIDE_OPTIONS[canonical]
500
+ if allowed is None:
501
+ return issues # no documented value set; the keyword was the check
502
+ if value.lower() not in allowed:
503
+ issues.append(
504
+ (
505
+ lineno,
506
+ "E303",
507
+ (
508
+ f'"{value}" is not a valid value for override option '
509
+ f'"{canonical}"; expected one of {", ".join(sorted(allowed))}'
510
+ f"; add `{OVERRIDE_MARKER}` if intentional"
511
+ ),
512
+ )
513
+ )
514
+ elif value != value.lower():
515
+ issues.append(
516
+ (
517
+ lineno,
518
+ "W303",
519
+ (
520
+ f'override option value "{value}" is not lowercase; use '
521
+ f'"{value.lower()}"; add `{OVERRIDE_CASE_MARKER}` if '
522
+ "intentional"
523
+ ),
524
+ )
525
+ )
526
+ return issues
527
+
528
+
529
+ _UNBALANCED_STRING_MESSAGE = (
530
+ 'unbalanced " -- the string has no closing quote before '
531
+ f"the end of the line; add `{STRING_MARKER}` if intentional"
532
+ )
533
+
534
+
535
+ def _mask_brace_escapes(line):
536
+ """Blank out ActionScript's literal-brace escapes, keeping every column.
537
+
538
+ `{{` outside a substitution is a literal `{`, and `}}` inside one is a
539
+ literal `}`; the display grammar knows neither, so a `{{` would start a
540
+ substitution that never closes (a false E301). Quoted relevance strings
541
+ inside a substitution are skipped so their braces are left alone.
542
+ """
543
+ chars, depth, quoted, index = list(line), 0, False, 0
544
+ while index < len(line):
545
+ char = line[index]
546
+ if depth and char == '"':
547
+ quoted = not quoted
548
+ elif (
549
+ not quoted
550
+ and not depth
551
+ and line.startswith("{{", index)
552
+ or not quoted
553
+ and depth
554
+ and line.startswith("}}", index)
555
+ ):
556
+ chars[index] = chars[index + 1] = " "
557
+ index += 2
558
+ continue
559
+ elif not quoted and char == "{":
560
+ depth += 1
561
+ elif not quoted and char == "}" and depth:
562
+ depth -= 1
563
+ index += 1
564
+ return "".join(chars)
565
+
566
+
567
+ def _override_case_rewrite(line, keyword, raw_value):
568
+ """Return (old, new) lowercasing an override option line, or None.
569
+
570
+ The keyword is lowercased when it is a known option; the value only when
571
+ it is one of that option's documented values (W303's own rule) -- never
572
+ a `{...}` substitution, an integer, or free text like a user name.
573
+ """
574
+ canonical = keyword.lower()
575
+ if canonical not in OVERRIDE_OPTIONS:
576
+ return None
577
+ old = line.strip()
578
+ new = canonical + old[len(keyword) :] if old.startswith(keyword) else old
579
+ value = _override_value(raw_value)
580
+ allowed = OVERRIDE_OPTIONS[canonical]
581
+ if (
582
+ value
583
+ and "{" not in value
584
+ and canonical not in OVERRIDE_INTEGER_OPTIONS
585
+ and allowed is not None
586
+ and value.lower() in allowed
587
+ and value != value.lower()
588
+ ):
589
+ at = new.find(value, new.find("=") + 1)
590
+ if at != -1:
591
+ new = new[:at] + value.lower() + new[at + len(value) :]
592
+ return (old, new) if new != old else None
593
+
594
+
595
+ def lint_actionscript(body, tokenizer=None, fixes=None):
596
+ """Lint one ActionScript body; return sorted [(lineno, code, message)].
597
+
598
+ Line numbers are local to the body, 1-based. `tokenizer` defaults to the
599
+ shared tokenizer over the vendored ActionScript grammar. When `fixes` is
600
+ a list, a (lineno, code, old, new) rewrite is appended to it for every
601
+ W302/W303 case-only finding -- the only auto-fixes this hook makes.
602
+ """
603
+ tokenizer = tokenizer or _default_tokenizer()
604
+ body = body.replace("\r\n", "\n").replace("\r", "\n")
605
+ lines = body.split("\n")
606
+ masked_lines, issues = _mask_heredocs(lines)
607
+ masked = {
608
+ lineno
609
+ for lineno, (raw, now) in enumerate(zip(lines, masked_lines), start=1)
610
+ if raw != now
611
+ }
612
+ tokens, _errors = tokenizer.tokenize(
613
+ "\n".join(_mask_brace_escapes(line) for line in masked_lines)
614
+ )
615
+ # everything after `appendfile` is one line of raw file content (a batch
616
+ # file, a VBScript, JSON...), so its quotes are not ActionScript strings
617
+ appendfile_lines = {
618
+ lineno
619
+ for lineno, line in enumerate(masked_lines, start=1)
620
+ if _APPENDFILE_RE.match(line)
621
+ }
622
+ regset_lines = {
623
+ lineno
624
+ for lineno, line in enumerate(masked_lines, start=1)
625
+ if _REGSET_RE.match(line) and lineno not in masked
626
+ }
627
+
628
+ first_on_line = {}
629
+ continuation = set()
630
+ for token in tokens:
631
+ first_on_line.setdefault(token.line, token)
632
+ continuation.update(range(token.line + 1, token.end_line + 1))
633
+
634
+ in_override = False
635
+ for lineno, line in enumerate(masked_lines, start=1):
636
+ if lineno in masked or not line.strip() or lineno in continuation:
637
+ continue
638
+ token = first_on_line.get(lineno)
639
+ if token is None:
640
+ continue
641
+ if token.class_name in ("comment", "relevance"):
642
+ continue # neither opens nor closes an override block
643
+ if token.keyword is not None:
644
+ if token.text != token.keyword:
645
+ if fixes is not None:
646
+ fixes.append((lineno, "W302", token.text, token.keyword))
647
+ issues.append(
648
+ (
649
+ lineno,
650
+ "W302",
651
+ (
652
+ f'command verb "{token.text}" is not lowercase; use '
653
+ f'"{token.keyword}"; add `{CASE_MARKER}` if intentional'
654
+ ),
655
+ )
656
+ )
657
+ # a real command line closes an override block; the override verbs
658
+ # themselves open one. Checked before the option shape below so a
659
+ # command can never be mistaken for an option (no option keyword is
660
+ # also a command verb).
661
+ in_override = token.keyword in OVERRIDE_VERBS
662
+ continue
663
+ option = OVERRIDE_OPTION_RE.match(line)
664
+ if option is not None:
665
+ keyword, raw_value = option.group(1), option.group(2)
666
+ if in_override:
667
+ found = _check_override_option(lineno, keyword, raw_value)
668
+ issues.extend(found)
669
+ rewrite = _override_case_rewrite(line, keyword, raw_value)
670
+ if (
671
+ fixes is not None
672
+ and rewrite
673
+ and any(code == "W303" for _l, code, _m in found)
674
+ ):
675
+ fixes.append((lineno, "W303") + rewrite)
676
+ continue
677
+ if keyword.lower() in OVERRIDE_OPTIONS:
678
+ issues.append(
679
+ (
680
+ lineno,
681
+ "E303",
682
+ (
683
+ f'override option "{keyword}" appears outside an '
684
+ "`override run` / `override wait` block; add "
685
+ f"`{OVERRIDE_MARKER}` if intentional"
686
+ ),
687
+ )
688
+ )
689
+ continue
690
+ suggestion = _did_you_mean(_verb_suggestions(line, _grammar_verbs(tokenizer)))
691
+ issues.append(
692
+ (
693
+ lineno,
694
+ "E300",
695
+ (
696
+ "line does not start with a known ActionScript command, "
697
+ f'// comment, or {{...}} substitution: "{_quote(line)}"; '
698
+ f"{suggestion}add `{VERB_MARKER}` if intentional"
699
+ ),
700
+ )
701
+ )
702
+
703
+ for token in tokens:
704
+ if token.line in masked:
705
+ continue
706
+ if token.class_name == "relevance" and token.end_kind in ("eol", "eof"):
707
+ issues.append(
708
+ (
709
+ token.line,
710
+ "E301",
711
+ (
712
+ "{...} substitution has no closing } before the end of "
713
+ f"the line; add `{SUBSTITUTION_MARKER}` if intentional"
714
+ ),
715
+ )
716
+ )
717
+ if (
718
+ token.class_name == "string"
719
+ and token.end_kind in ("eol", "eof")
720
+ and token.line not in appendfile_lines
721
+ and token.line not in regset_lines
722
+ ):
723
+ issues.append((token.line, "W301", _UNBALANCED_STRING_MESSAGE))
724
+ for lineno in sorted(regset_lines):
725
+ if _regset_line_has_open_string(masked_lines[lineno - 1]):
726
+ issues.append((lineno, "W301", _UNBALANCED_STRING_MESSAGE))
727
+ return sorted(issues)
728
+
729
+
730
+ def _lint_bes_xml(raw, src, fixes=None):
731
+ """Lint every ActionScript in a BES document; return file-lineno issues.
732
+
733
+ `fixes`, when a list, collects lint_actionscript's rewrites with their
734
+ line numbers mapped to the file the same way.
735
+ """
736
+ try:
737
+ root = etree.fromstring(raw)
738
+ except etree.XMLSyntaxError as err:
739
+ return [(1, "W300", f"not parseable BES XML ({err}); skipping")]
740
+ issues = []
741
+ for element in root.iter("ActionScript"):
742
+ mimetype = element.get("MIMEType")
743
+ if mimetype is not None and mimetype != ACTIONSCRIPT_MIMETYPE:
744
+ continue
745
+ body = element.text or ""
746
+ body_fixes = [] if fixes is not None else None
747
+ for lineno, code, message in lint_actionscript(body, fixes=body_fixes):
748
+ issues.append((element.sourceline + lineno - 1, code, message))
749
+ for lineno, code, old, new in body_fixes or []:
750
+ fixes.append((element.sourceline + lineno - 1, code, old, new))
751
+ return issues
752
+
753
+
754
+ # the opening tag (and CDATA start) a body's first line may share its line with
755
+ _ACTIONSCRIPT_OPEN_RE = re.compile(r"<ActionScript\b[^>]*>(?:<!\[CDATA\[)?")
756
+
757
+ _FIX_MESSAGES = {
758
+ "W302": "lowercased the command verb",
759
+ "W303": "lowercased the override option",
760
+ }
761
+
762
+
763
+ def _apply_fixes(src, fixes, codes):
764
+ """Apply (file_lineno, code, old, new) case rewrites to `src`, in place.
765
+
766
+ Each `old` is replaced once on its line -- as written, or with `"`
767
+ escaped as `&quot;` in an entity-escaped body; a rewrite whose text is
768
+ not found there is skipped rather than guessed at. Only `codes` are
769
+ applied. Returns (new_src, fixed).
770
+ """
771
+ lines = src.split("\n")
772
+ fixed = []
773
+ for lineno, code, old, new in fixes:
774
+ if code not in codes or not 1 <= lineno <= len(lines):
775
+ continue
776
+ line = lines[lineno - 1]
777
+ # a body's first line may share the file line with its opening tag;
778
+ # search only the ActionScript text after it
779
+ opening = _ACTIONSCRIPT_OPEN_RE.search(line)
780
+ start = opening.end() if opening else 0
781
+ if old not in line[start:] and "&quot;" in line:
782
+ old, new = old.replace('"', "&quot;"), new.replace('"', "&quot;")
783
+ at = line.find(old, start)
784
+ if at == -1:
785
+ continue
786
+ lines[lineno - 1] = line[:at] + new + line[at + len(old) :]
787
+ fixed.append((lineno, code, _FIX_MESSAGES[code]))
788
+ return "\n".join(lines), fixed
789
+
790
+
791
+ def check_file(path, disabled=frozenset(), strict=False, auto_fix=False):
792
+ """Check one file; return (issues, fixed) like the sibling checkers.
793
+
794
+ With `auto_fix`, each non-lowercase command verb (W302) and override
795
+ option keyword/value (W303) is lowercased in place, unless the code is in
796
+ `disabled` or the file carries that check's opt-out marker; the file's
797
+ line endings are preserved. Nothing else is ever rewritten. `strict` is
798
+ accepted for parity with the siblings and does not change what is
799
+ reported (the caller decides whether warnings fail).
800
+ """
801
+ del strict # reported issues are the same either way
802
+ if not os.path.isfile(path):
803
+ return [(1, "W300", "file not found; skipping")], []
804
+
805
+ raw, src, was_crlf = read_source(path)
806
+
807
+ if SKIP_MARKER in src:
808
+ return [], []
809
+ if MUSTACHE_RE.search(src):
810
+ return [], []
811
+
812
+ is_bes = path.endswith(BES_EXTENSIONS)
813
+ fix_codes = {
814
+ code
815
+ for code in ("W302", "W303")
816
+ if auto_fix and code not in disabled and CHECK_MARKERS[code] not in src
817
+ }
818
+ # the fixes are kept only if they leave a schema-valid file valid (E304)
819
+ guard = bes_common.SchemaGuard(
820
+ src, "E304", path, validate=is_bes and "E304" not in disabled
821
+ )
822
+ if fix_codes:
823
+ fixes = []
824
+ if is_bes:
825
+ _lint_bes_xml(raw, src, fixes)
826
+ else:
827
+ lint_actionscript(src, fixes=fixes)
828
+ if guard.apply(*_apply_fixes(src, fixes, fix_codes)) and guard.fixed:
829
+ src, raw = guard.src, encode(guard.src, was_crlf)
830
+ with open(path, "wb") as handle:
831
+ handle.write(raw)
832
+ fixed = guard.fixed
833
+
834
+ if is_bes:
835
+ issues = _lint_bes_xml(raw, src)
836
+ else:
837
+ issues = lint_actionscript(src)
838
+ issues += guard.refused
839
+
840
+ issues = [
841
+ (lineno, code, message)
842
+ for lineno, code, message in issues
843
+ if code not in disabled and CHECK_MARKERS.get(code, "\0") not in src
844
+ ]
845
+ return sorted(issues), fixed
846
+
847
+
848
+ def check_files(paths, disabled=frozenset(), strict=False, auto_fix=False):
849
+ """Check several files; return a list of (path, issues, fixed) tuples.
850
+
851
+ This is the programmatic entry point: it does no printing.
852
+ """
853
+ return [
854
+ (path, *check_file(path, disabled=disabled, strict=strict, auto_fix=auto_fix))
855
+ for path in paths
856
+ ]
857
+
858
+
859
+ def discover_bes_files(root="."):
860
+ """Return all .bes files under `root`, pruning hidden and noise directories."""
861
+ return bes_common.discover_bes_files(root)
862
+
863
+
864
+ def main(argv=None):
865
+ """Execution starts here.
866
+
867
+ argv defaults to None so this works both as a console_scripts entry point
868
+ (pre-commit calls it with no arguments; argparse then reads sys.argv) and
869
+ when called directly as `main(sys.argv[1:])`.
870
+ """
871
+ parser = argparse.ArgumentParser(description=__doc__)
872
+ parser.add_argument(
873
+ "--strict",
874
+ action="store_true",
875
+ help="treat warnings as failures (non-zero exit); default: advisory",
876
+ )
877
+ parser.add_argument(
878
+ "--disable",
879
+ default="",
880
+ metavar="CODES",
881
+ help="comma-separated check IDs to skip entirely, e.g. --disable W302",
882
+ )
883
+ parser.add_argument(
884
+ "--auto-fix",
885
+ choices=["yes", "no"],
886
+ default=None,
887
+ help=(
888
+ "lowercase non-lowercase command verbs (W302) and override option "
889
+ "keywords/values (W303), in place (default: yes when files are "
890
+ "given, no when auto-discovering)"
891
+ ),
892
+ )
893
+ parser.add_argument(
894
+ "files",
895
+ nargs="*",
896
+ help=(
897
+ "files to check (.bes/.ojo are linted as BES XML, anything else "
898
+ "as raw ActionScript); if omitted, all *.bes files in the current "
899
+ "folder and below are checked"
900
+ ),
901
+ )
902
+ args = parser.parse_args(argv)
903
+
904
+ disabled = {
905
+ code.strip().upper() for code in args.disable.split(",") if code.strip()
906
+ }
907
+ unknown = disabled - KNOWN_CODES
908
+ if unknown:
909
+ print(
910
+ f"warning: ignoring unknown --disable code(s): {', '.join(sorted(unknown))}"
911
+ )
912
+
913
+ # auto-fix defaults to yes for explicit files, no when auto-discovering; an
914
+ # explicit --auto-fix always wins -- the sibling hooks' rule, and pre-commit
915
+ # always passes files, so under pre-commit the default is yes.
916
+ if args.auto_fix is not None:
917
+ auto_fix = args.auto_fix == "yes"
918
+ else:
919
+ auto_fix = bool(args.files)
920
+ paths = args.files if args.files else discover_bes_files(".")
921
+
922
+ issue_count, warning_count, fix_count = bes_common.report(
923
+ check_files(paths, disabled=disabled, strict=args.strict, auto_fix=auto_fix)
924
+ )
925
+
926
+ if fix_count:
927
+ print(f"\nauto-fixed {fix_count} issue(s); review and re-stage the changes.")
928
+ if warning_count:
929
+ print(f"{warning_count} ActionScript warning(s).")
930
+ if issue_count:
931
+ print(f"{issue_count} ActionScript issue(s).")
932
+ # E-codes and any fix always fail; warnings fail only under --strict
933
+ return 1 if (issue_count or fix_count or (warning_count and args.strict)) else 0
934
+
935
+
936
+ if __name__ == "__main__":
937
+ sys.exit(main(sys.argv[1:]))