pre-commit-bigfix 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pre_commit_bigfix/__init__.py +3 -0
- pre_commit_bigfix/bes_actionscript_lint_schclass.py +937 -0
- pre_commit_bigfix/bes_actionscript_validate_prefetch.py +1258 -0
- pre_commit_bigfix/bes_actionscript_validate_script.py +2871 -0
- pre_commit_bigfix/bes_common.py +271 -0
- pre_commit_bigfix/bes_conventions_check.py +3484 -0
- pre_commit_bigfix/bes_relevance_convert_group.py +407 -0
- pre_commit_bigfix/bes_relevance_lint.py +402 -0
- pre_commit_bigfix/bes_schema_validate.py +84 -0
- pre_commit_bigfix/bes_siterepo_validate_layout.py +1203 -0
- pre_commit_bigfix/run_all.py +305 -0
- pre_commit_bigfix/schclass.py +284 -0
- pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +412 -0
- pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +43 -0
- pre_commit_bigfix/schclass_tokenizer.py +321 -0
- pre_commit_bigfix-2.0.1.dist-info/METADATA +821 -0
- pre_commit_bigfix-2.0.1.dist-info/RECORD +21 -0
- pre_commit_bigfix-2.0.1.dist-info/WHEEL +5 -0
- pre_commit_bigfix-2.0.1.dist-info/entry_points.txt +10 -0
- pre_commit_bigfix-2.0.1.dist-info/licenses/LICENSE +21 -0
- pre_commit_bigfix-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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 `"` 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 """ in line:
|
|
782
|
+
old, new = old.replace('"', """), new.replace('"', """)
|
|
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:]))
|