execsql2 2.23.1__py3-none-any.whl → 2.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execsql/cli/__init__.py +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/factory.py +2 -2
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""AST-based static analysis (
|
|
1
|
+
"""AST-based static analysis (``execsql lint``) for execsql scripts.
|
|
2
2
|
|
|
3
3
|
Operates on the :class:`~execsql.script.ast.Script` tree produced by
|
|
4
4
|
:func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
|
|
@@ -23,24 +23,34 @@ Checks performed:
|
|
|
23
23
|
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
24
|
when ``IF EXISTS`` is present).
|
|
25
25
|
|
|
26
|
+
Every check has a rule code (:data:`RULES`), so a caller can select or
|
|
27
|
+
ignore rules and machine-readable output can name them.
|
|
28
|
+
|
|
26
29
|
Public surface:
|
|
27
30
|
|
|
28
|
-
- :func:`lint` — entry point; returns a list of
|
|
29
|
-
|
|
30
|
-
- :func:`
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- :
|
|
34
|
-
|
|
31
|
+
- :func:`lint` — entry point; returns a list of :class:`Issue`.
|
|
32
|
+
- :data:`RULES` / :class:`Rule` — the rule registry: code, name, severity.
|
|
33
|
+
- :func:`parse_error` — the :class:`Issue` for a script that fails to parse.
|
|
34
|
+
- :func:`resolve_selectors` / :func:`filter_issues` — ``--select`` and
|
|
35
|
+
``--ignore`` handling.
|
|
36
|
+
- :func:`print_text`, :func:`print_concise`, :func:`print_statistics`,
|
|
37
|
+
:func:`render_json` — output formats; :func:`exit_code` — ``1`` when any
|
|
38
|
+
reported issue is an error, ``0`` otherwise.
|
|
35
39
|
"""
|
|
36
40
|
|
|
37
41
|
from __future__ import annotations
|
|
38
42
|
|
|
43
|
+
import json
|
|
39
44
|
import re
|
|
45
|
+
from collections import Counter
|
|
46
|
+
from collections.abc import Iterable
|
|
40
47
|
from pathlib import Path
|
|
48
|
+
from typing import NamedTuple
|
|
41
49
|
|
|
50
|
+
from execsql.exceptions import ErrInfo
|
|
42
51
|
from execsql.script.ast import (
|
|
43
52
|
BatchBlock,
|
|
53
|
+
Comment,
|
|
44
54
|
IfBlock,
|
|
45
55
|
IncludeDirective,
|
|
46
56
|
LoopBlock,
|
|
@@ -52,23 +62,120 @@ from execsql.script.ast import (
|
|
|
52
62
|
SqlStatement,
|
|
53
63
|
)
|
|
54
64
|
|
|
55
|
-
__all__ = [
|
|
65
|
+
__all__ = [
|
|
66
|
+
"RULES",
|
|
67
|
+
"Issue",
|
|
68
|
+
"Rule",
|
|
69
|
+
"exit_code",
|
|
70
|
+
"filter_issues",
|
|
71
|
+
"lint",
|
|
72
|
+
"parse_error",
|
|
73
|
+
"print_concise",
|
|
74
|
+
"print_statistics",
|
|
75
|
+
"print_text",
|
|
76
|
+
"render_json",
|
|
77
|
+
"resolve_selectors",
|
|
78
|
+
"rule_counts",
|
|
79
|
+
]
|
|
56
80
|
|
|
57
81
|
|
|
58
82
|
# ---------------------------------------------------------------------------
|
|
59
|
-
#
|
|
83
|
+
# Rules and issues
|
|
60
84
|
# ---------------------------------------------------------------------------
|
|
61
85
|
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
class Rule(NamedTuple):
|
|
88
|
+
"""One lint check.
|
|
89
|
+
|
|
90
|
+
Codes are grouped by what the rule is about, not by severity, so a rule
|
|
91
|
+
can change severity without breaking anyone's ``--ignore`` list:
|
|
92
|
+
``P`` parsing, ``S`` the script as a whole, ``V`` variables, ``I``
|
|
93
|
+
INCLUDE / EXECUTE SCRIPT targets, ``F`` control flow.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
code: str
|
|
97
|
+
name: str
|
|
98
|
+
severity: str # "error" | "warning"
|
|
99
|
+
summary: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
#: Every rule, by code. Codes are a public contract: never renumber or reuse one.
|
|
103
|
+
RULES: dict[str, Rule] = {
|
|
104
|
+
rule.code: rule
|
|
105
|
+
for rule in (
|
|
106
|
+
Rule(
|
|
107
|
+
"P001",
|
|
108
|
+
"parse-error",
|
|
109
|
+
"error",
|
|
110
|
+
"The script cannot be parsed, for example an IF, LOOP, BATCH or SCRIPT block that is never closed.",
|
|
111
|
+
),
|
|
112
|
+
Rule("S001", "empty-script", "warning", "The script contains no statements."),
|
|
113
|
+
Rule(
|
|
114
|
+
"V001",
|
|
115
|
+
"undefined-variable",
|
|
116
|
+
"warning",
|
|
117
|
+
"A !!variable!! is referenced, but no SUB-family metacommand in the script defines it.",
|
|
118
|
+
),
|
|
119
|
+
Rule(
|
|
120
|
+
"V002",
|
|
121
|
+
"unused-variable",
|
|
122
|
+
"warning",
|
|
123
|
+
"A SUB-family metacommand defines a variable that nothing in the script references.",
|
|
124
|
+
),
|
|
125
|
+
Rule("I001", "missing-include", "warning", "An INCLUDE target file does not exist."),
|
|
126
|
+
Rule(
|
|
127
|
+
"I002",
|
|
128
|
+
"missing-script",
|
|
129
|
+
"warning",
|
|
130
|
+
"EXECUTE SCRIPT names a script that no BEGIN SCRIPT block in the file defines.",
|
|
131
|
+
),
|
|
132
|
+
Rule(
|
|
133
|
+
"F001",
|
|
134
|
+
"constant-condition",
|
|
135
|
+
"warning",
|
|
136
|
+
"An IF condition is always true or always false, so one of its branches can never run.",
|
|
137
|
+
),
|
|
138
|
+
Rule("F002", "unreachable-code", "warning", "A statement follows an unconditional HALT."),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#: Always reported, whatever --select and --ignore say: a script that does not
|
|
143
|
+
#: parse has not been checked, and reporting "no issues" for it would be false.
|
|
144
|
+
_ALWAYS_REPORTED = frozenset({"P001"})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Issue(NamedTuple):
|
|
148
|
+
"""One finding. ``line`` is 0 when the issue has no single line."""
|
|
149
|
+
|
|
150
|
+
severity: str
|
|
151
|
+
source: str
|
|
152
|
+
line: int
|
|
153
|
+
message: str
|
|
154
|
+
code: str
|
|
155
|
+
|
|
64
156
|
|
|
157
|
+
# Kept as the internal name the walker functions were written against.
|
|
158
|
+
_Issue = Issue
|
|
65
159
|
|
|
66
|
-
def _error(source: str, line_no: int, message: str) -> _Issue:
|
|
67
|
-
return ("error", source, line_no, message)
|
|
68
160
|
|
|
161
|
+
def _issue(code: str, source: str, line_no: int, message: str) -> Issue:
|
|
162
|
+
return Issue(RULES[code].severity, source, line_no, message, code)
|
|
69
163
|
|
|
70
|
-
|
|
71
|
-
|
|
164
|
+
|
|
165
|
+
_RX_ON_LINE = re.compile(r"\bon line (\d+)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_error(source: str, exc: ErrInfo) -> Issue:
|
|
169
|
+
"""The issue reported for a script that fails to parse.
|
|
170
|
+
|
|
171
|
+
Uses the parser's own message rather than :meth:`ErrInfo.errmsg`, which
|
|
172
|
+
adds a banner and a timestamp, and folds it onto one line. Every parser
|
|
173
|
+
message names the offending line as "on line N", which becomes the
|
|
174
|
+
issue's line so editors and JSON consumers can jump to it.
|
|
175
|
+
"""
|
|
176
|
+
text = " ".join((exc.other or exc.exception or str(exc)).split())
|
|
177
|
+
found = _RX_ON_LINE.search(text)
|
|
178
|
+
return _issue("P001", source, int(found.group(1)) if found else 0, text.rstrip("."))
|
|
72
179
|
|
|
73
180
|
|
|
74
181
|
# ---------------------------------------------------------------------------
|
|
@@ -280,11 +387,11 @@ def _check_var_ref(
|
|
|
280
387
|
return
|
|
281
388
|
|
|
282
389
|
issues.append(
|
|
283
|
-
|
|
390
|
+
_issue(
|
|
391
|
+
"V001",
|
|
284
392
|
source,
|
|
285
393
|
line_no,
|
|
286
|
-
f"
|
|
287
|
-
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
394
|
+
f"undefined variable !!{raw_name}!!",
|
|
288
395
|
),
|
|
289
396
|
)
|
|
290
397
|
|
|
@@ -303,10 +410,168 @@ def _check_include_path(
|
|
|
303
410
|
|
|
304
411
|
if not p.exists():
|
|
305
412
|
issues.append(
|
|
306
|
-
|
|
413
|
+
_issue("I001", source, line_no, f"INCLUDE file does not exist: {raw_path}"),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Structural rules
|
|
419
|
+
#
|
|
420
|
+
# These are the checks a compiler performs for every other language and that
|
|
421
|
+
# an execsql script has only ever got by being run: a branch that cannot be
|
|
422
|
+
# reached, a statement after a HALT, a variable defined and never used.
|
|
423
|
+
# Today those are found by running the script against a live database and
|
|
424
|
+
# watching it do the wrong thing halfway through.
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
#: Conditions whose value is fixed at parse time. ``IF(True)`` is a real and
|
|
428
|
+
#: reasonable thing to write while developing; leaving it in is what makes
|
|
429
|
+
#: every other branch dead code.
|
|
430
|
+
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|1\s*=\s*1|yes)\s*\)?\s*$", re.I)
|
|
431
|
+
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|0\s*=\s*1|1\s*=\s*0|no)\s*\)?\s*$", re.I)
|
|
432
|
+
|
|
433
|
+
#: Metacommands after which nothing in the same block can run.
|
|
434
|
+
_RX_TERMINAL = re.compile(r"^\s*HALT\b(?!\s+DISPLAY)", re.I)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _is_always_true(node: IfBlock) -> bool:
|
|
438
|
+
"""True when the IF condition is a literal that cannot be false.
|
|
439
|
+
|
|
440
|
+
Compounding modifiers are not analysed — an ANDIF could make the whole
|
|
441
|
+
condition false, so a modified IF is never reported.
|
|
442
|
+
"""
|
|
443
|
+
if node.condition_modifiers:
|
|
444
|
+
return False
|
|
445
|
+
return bool(_RX_CONST_TRUE.match(node.condition))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _is_always_false(node: IfBlock) -> bool:
|
|
449
|
+
if node.condition_modifiers:
|
|
450
|
+
return False
|
|
451
|
+
return bool(_RX_CONST_FALSE.match(node.condition))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _check_constant_condition(node: IfBlock, issues: list[_Issue]) -> None:
|
|
455
|
+
"""Report branches that a constant condition makes unreachable."""
|
|
456
|
+
src, lno = node.span.file, node.span.start_line
|
|
457
|
+
if _is_always_true(node):
|
|
458
|
+
n_elseif = len(node.elseif_clauses)
|
|
459
|
+
dead = []
|
|
460
|
+
if n_elseif:
|
|
461
|
+
dead.append("ELSEIF" if n_elseif == 1 else f"{n_elseif} ELSEIF branches")
|
|
462
|
+
if node.else_body:
|
|
463
|
+
dead.append("ELSE")
|
|
464
|
+
if dead:
|
|
465
|
+
verb = "runs" if len(dead) == 1 and n_elseif <= 1 else "run"
|
|
466
|
+
issues.append(
|
|
467
|
+
_issue(
|
|
468
|
+
"F001",
|
|
469
|
+
src,
|
|
470
|
+
lno,
|
|
471
|
+
f"IF({node.condition}) is always true; its {' and '.join(dead)} never {verb}",
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
elif _is_always_false(node) and node.body:
|
|
475
|
+
issues.append(
|
|
476
|
+
_issue(
|
|
477
|
+
"F001",
|
|
478
|
+
src,
|
|
479
|
+
lno,
|
|
480
|
+
f"IF({node.condition}) is always false; its body never runs",
|
|
481
|
+
),
|
|
307
482
|
)
|
|
308
483
|
|
|
309
484
|
|
|
485
|
+
def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
486
|
+
"""Report statements that follow an unconditional HALT in the same block.
|
|
487
|
+
|
|
488
|
+
``HALT DISPLAY`` shows a message and can be cancelled, so it is not
|
|
489
|
+
treated as terminal.
|
|
490
|
+
"""
|
|
491
|
+
for i, node in enumerate(nodes):
|
|
492
|
+
if not isinstance(node, MetaCommandStatement):
|
|
493
|
+
continue
|
|
494
|
+
if not _RX_TERMINAL.match(node.command):
|
|
495
|
+
continue
|
|
496
|
+
rest = [n for n in nodes[i + 1 :] if not isinstance(n, Comment)]
|
|
497
|
+
if rest:
|
|
498
|
+
after = rest[0]
|
|
499
|
+
issues.append(
|
|
500
|
+
_issue(
|
|
501
|
+
"F002",
|
|
502
|
+
after.span.file,
|
|
503
|
+
after.span.start_line,
|
|
504
|
+
f"unreachable: HALT on line {node.span.start_line} ends the script",
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _collect_var_references(nodes: list[Node], seen: set[str]) -> None:
|
|
511
|
+
"""Record every ``!!var!!`` reference anywhere beneath *nodes*."""
|
|
512
|
+
for node in nodes:
|
|
513
|
+
for text in _referencing_text(node):
|
|
514
|
+
for m in _RX_VAR_REF.finditer(text):
|
|
515
|
+
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
516
|
+
for child in node.children():
|
|
517
|
+
_collect_var_references([child], seen)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _referencing_text(node: Node) -> list[str]:
|
|
521
|
+
"""Every string on *node* in which a variable reference may appear."""
|
|
522
|
+
texts: list[str] = []
|
|
523
|
+
if isinstance(node, SqlStatement):
|
|
524
|
+
texts.append(node.text)
|
|
525
|
+
elif isinstance(node, MetaCommandStatement):
|
|
526
|
+
texts.append(node.command)
|
|
527
|
+
elif isinstance(node, IncludeDirective):
|
|
528
|
+
texts.append(node.target)
|
|
529
|
+
if isinstance(node, IfBlock):
|
|
530
|
+
texts.append(node.condition)
|
|
531
|
+
texts.extend(m.condition for m in node.condition_modifiers)
|
|
532
|
+
texts.extend(c.condition for c in node.elseif_clauses)
|
|
533
|
+
elif isinstance(node, LoopBlock):
|
|
534
|
+
texts.append(node.condition)
|
|
535
|
+
return texts
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _check_unused_variables(script: Script, defined: set[str], issues: list[_Issue]) -> None:
|
|
539
|
+
"""Report variables a SUB defines that nothing ever reads.
|
|
540
|
+
|
|
541
|
+
Almost always a typo in either the definition or the reference — the two
|
|
542
|
+
spellings differ and neither the script nor the database complains.
|
|
543
|
+
Variables a script *exports* for a caller are the false positive here, so
|
|
544
|
+
this is a warning.
|
|
545
|
+
"""
|
|
546
|
+
referenced: set[str] = set()
|
|
547
|
+
_collect_var_references(script.body, referenced)
|
|
548
|
+
unused = sorted(defined - referenced)
|
|
549
|
+
for name in unused:
|
|
550
|
+
line = _definition_line(script.body, name)
|
|
551
|
+
issues.append(
|
|
552
|
+
_issue(
|
|
553
|
+
"V002",
|
|
554
|
+
script.body[0].span.file if script.body else "<script>",
|
|
555
|
+
line,
|
|
556
|
+
f"variable !!{name.lower()}!! is never used",
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _definition_line(nodes: list[Node], name: str) -> int:
|
|
562
|
+
"""Line of the SUB that defines *name*, or 0 when it cannot be located."""
|
|
563
|
+
for node in nodes:
|
|
564
|
+
if isinstance(node, MetaCommandStatement):
|
|
565
|
+
probe: set[str] = set()
|
|
566
|
+
_extract_var_definition(node.command, None, probe)
|
|
567
|
+
if name in probe:
|
|
568
|
+
return node.span.start_line
|
|
569
|
+
found = _definition_line(list(node.children()), name)
|
|
570
|
+
if found:
|
|
571
|
+
return found
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
310
575
|
# ---------------------------------------------------------------------------
|
|
311
576
|
# Core lint walk
|
|
312
577
|
# ---------------------------------------------------------------------------
|
|
@@ -325,6 +590,8 @@ def _lint_nodes(
|
|
|
325
590
|
if visited_scripts is None:
|
|
326
591
|
visited_scripts = set()
|
|
327
592
|
|
|
593
|
+
_check_unreachable_after_halt(nodes, issues)
|
|
594
|
+
|
|
328
595
|
for node in nodes:
|
|
329
596
|
src = node.span.file
|
|
330
597
|
lno = node.span.start_line
|
|
@@ -346,7 +613,7 @@ def _lint_nodes(
|
|
|
346
613
|
if target not in script_blocks:
|
|
347
614
|
if not node.if_exists:
|
|
348
615
|
issues.append(
|
|
349
|
-
|
|
616
|
+
_issue("I002", src, lno, f"no BEGIN SCRIPT block named {target}"),
|
|
350
617
|
)
|
|
351
618
|
elif target not in visited_scripts:
|
|
352
619
|
visited_scripts.add(target)
|
|
@@ -367,6 +634,7 @@ def _lint_nodes(
|
|
|
367
634
|
|
|
368
635
|
# -- Recurse into block children --
|
|
369
636
|
if isinstance(node, IfBlock):
|
|
637
|
+
_check_constant_condition(node, issues)
|
|
370
638
|
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
371
639
|
for clause in node.elseif_clauses:
|
|
372
640
|
_lint_nodes(
|
|
@@ -388,20 +656,12 @@ def _lint_nodes(
|
|
|
388
656
|
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
389
657
|
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
390
658
|
elif isinstance(node, ScriptBlock):
|
|
391
|
-
# Lint
|
|
659
|
+
# Lint the block body once (structural errors are caught by the
|
|
660
|
+
# parser). Issues carry their own line, which already places them
|
|
661
|
+
# inside the block, so the message does not repeat the block name.
|
|
392
662
|
if node.name not in visited_scripts:
|
|
393
663
|
visited_scripts.add(node.name)
|
|
394
|
-
|
|
395
|
-
_lint_nodes(
|
|
396
|
-
node.body,
|
|
397
|
-
script_dir,
|
|
398
|
-
defined_vars,
|
|
399
|
-
script_blocks,
|
|
400
|
-
sub_issues,
|
|
401
|
-
visited_scripts=visited_scripts,
|
|
402
|
-
)
|
|
403
|
-
for sev, ssrc, slno, msg in sub_issues:
|
|
404
|
-
issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
|
|
664
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
405
665
|
|
|
406
666
|
|
|
407
667
|
# ---------------------------------------------------------------------------
|
|
@@ -421,12 +681,12 @@ def lint(
|
|
|
421
681
|
INCLUDE paths). ``None`` for inline scripts.
|
|
422
682
|
|
|
423
683
|
Returns:
|
|
424
|
-
|
|
684
|
+
Every :class:`Issue` found, unfiltered.
|
|
425
685
|
"""
|
|
426
686
|
issues: list[_Issue] = []
|
|
427
687
|
|
|
428
688
|
if not script.body:
|
|
429
|
-
issues.append(
|
|
689
|
+
issues.append(_issue("S001", script_path or script.source, 0, "script is empty"))
|
|
430
690
|
return issues
|
|
431
691
|
|
|
432
692
|
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
@@ -445,62 +705,239 @@ def lint(
|
|
|
445
705
|
issues,
|
|
446
706
|
)
|
|
447
707
|
|
|
708
|
+
# Pass 3: whole-script rules, which need every reference collected first
|
|
709
|
+
_check_unused_variables(script, all_defined, issues)
|
|
710
|
+
|
|
448
711
|
return issues
|
|
449
712
|
|
|
450
713
|
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# Selecting rules
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def resolve_selectors(values: Iterable[str] | None) -> tuple[str, ...]:
|
|
720
|
+
"""Normalize ``--select`` / ``--ignore`` values into code prefixes.
|
|
721
|
+
|
|
722
|
+
Each value may hold several comma-separated entries. An entry is a full
|
|
723
|
+
code (``V001``) or a prefix of one (``V``, ``V0``), matched without
|
|
724
|
+
regard to case.
|
|
725
|
+
|
|
726
|
+
Raises:
|
|
727
|
+
ValueError: An entry matches no rule. A typo in ``--ignore`` would
|
|
728
|
+
otherwise silently ignore nothing.
|
|
729
|
+
"""
|
|
730
|
+
prefixes: list[str] = []
|
|
731
|
+
for value in values or ():
|
|
732
|
+
for entry in value.split(","):
|
|
733
|
+
prefix = entry.strip().upper()
|
|
734
|
+
if not prefix:
|
|
735
|
+
continue
|
|
736
|
+
if not any(code.startswith(prefix) for code in RULES):
|
|
737
|
+
raise ValueError(f"unknown rule code or prefix: {entry.strip()!r}")
|
|
738
|
+
prefixes.append(prefix)
|
|
739
|
+
return tuple(prefixes)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def filter_issues(
|
|
743
|
+
issues: Iterable[Issue],
|
|
744
|
+
select: tuple[str, ...] = (),
|
|
745
|
+
ignore: tuple[str, ...] = (),
|
|
746
|
+
) -> list[Issue]:
|
|
747
|
+
"""Keep the issues *select* asks for, minus those *ignore* removes.
|
|
748
|
+
|
|
749
|
+
An empty *select* means every rule. *ignore* wins over *select*. Parse
|
|
750
|
+
errors (``P001``) are kept regardless.
|
|
751
|
+
"""
|
|
752
|
+
kept = []
|
|
753
|
+
for issue in issues:
|
|
754
|
+
if issue.code in _ALWAYS_REPORTED:
|
|
755
|
+
kept.append(issue)
|
|
756
|
+
continue
|
|
757
|
+
if select and not issue.code.startswith(select):
|
|
758
|
+
continue
|
|
759
|
+
if ignore and issue.code.startswith(ignore):
|
|
760
|
+
continue
|
|
761
|
+
kept.append(issue)
|
|
762
|
+
return kept
|
|
763
|
+
|
|
764
|
+
|
|
451
765
|
# ---------------------------------------------------------------------------
|
|
452
766
|
# Result printing
|
|
453
767
|
# ---------------------------------------------------------------------------
|
|
454
768
|
|
|
455
769
|
|
|
456
|
-
def
|
|
457
|
-
"""
|
|
770
|
+
def exit_code(issues: Iterable[Issue], *, strict: bool = False) -> int:
|
|
771
|
+
"""``1`` when any issue is an error — or, with *strict*, any issue at all — otherwise ``0``."""
|
|
772
|
+
return 1 if any(strict or issue.severity == "error" for issue in issues) else 0
|
|
458
773
|
|
|
459
|
-
Args:
|
|
460
|
-
issues: List of ``(severity, source, line_no, message)`` tuples.
|
|
461
|
-
script_label: Human-readable label for the script (file path or
|
|
462
|
-
``<inline>``), shown in the summary line.
|
|
463
774
|
|
|
464
|
-
|
|
465
|
-
|
|
775
|
+
def _sorted(issues: Iterable[Issue]) -> list[Issue]:
|
|
776
|
+
"""By line, then code: the order a reader works through a file."""
|
|
777
|
+
return sorted(issues, key=lambda i: (i.line, i.code))
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
#: One script's path and the issues reported for it.
|
|
781
|
+
FileResult = tuple[str, list[Issue]]
|
|
782
|
+
|
|
783
|
+
_SEVERITY_STYLE = {"error": "bold red", "warning": "yellow"}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _plural(n: int, word: str) -> str:
|
|
787
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _summary(results: list[FileResult], checked: int) -> str:
|
|
791
|
+
"""The closing line, as Rich markup."""
|
|
792
|
+
issues = [i for _, found in results for i in found]
|
|
793
|
+
checked_note = f"({_plural(checked, 'file')} checked)"
|
|
794
|
+
if not issues:
|
|
795
|
+
return f"[green]No issues found[/green] {checked_note}"
|
|
796
|
+
n_errors = sum(1 for i in issues if i.severity == "error")
|
|
797
|
+
n_files = sum(1 for _, found in results if found)
|
|
798
|
+
counts = []
|
|
799
|
+
if n_errors:
|
|
800
|
+
counts.append(f"[bold red]{_plural(n_errors, 'error')}[/bold red]")
|
|
801
|
+
if len(issues) - n_errors:
|
|
802
|
+
counts.append(f"[yellow]{_plural(len(issues) - n_errors, 'warning')}[/yellow]")
|
|
803
|
+
return f"Found {_plural(len(issues), 'issue')} in {_plural(n_files, 'file')}: {', '.join(counts)} {checked_note}"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def print_text(results: list[FileResult], checked: int) -> None:
|
|
807
|
+
"""Issues grouped under each file, one aligned row per issue.
|
|
808
|
+
|
|
809
|
+
::
|
|
810
|
+
|
|
811
|
+
scripts/validate_orders.sql
|
|
812
|
+
2 warning V002 variable !!report_dir!! is never used
|
|
813
|
+
10 warning V001 undefined variable !!output_path!!
|
|
814
|
+
|
|
815
|
+
Found 2 issues in 1 file: 2 warnings (4 files checked)
|
|
816
|
+
|
|
817
|
+
On a terminal, a message too long for the window wraps under the message
|
|
818
|
+
column rather than back to the left margin. Piped output is never
|
|
819
|
+
wrapped, so each issue stays on one line for grep.
|
|
466
820
|
"""
|
|
821
|
+
import textwrap
|
|
822
|
+
|
|
823
|
+
from rich.markup import escape
|
|
824
|
+
|
|
467
825
|
from execsql.cli.help import _console
|
|
468
826
|
|
|
469
|
-
|
|
470
|
-
|
|
827
|
+
for path, found in results:
|
|
828
|
+
if not found:
|
|
829
|
+
continue
|
|
830
|
+
ordered = _sorted(found)
|
|
831
|
+
line_width = max(len(str(i.line)) for i in ordered)
|
|
832
|
+
indent = 2 + line_width + 2 + 7 + 2 + 4 + 2
|
|
833
|
+
wrap_at = _console.width - indent if _console.is_terminal else 0
|
|
834
|
+
|
|
835
|
+
_console.print(f"[bold]{escape(path)}[/bold]", highlight=False)
|
|
836
|
+
for issue in ordered:
|
|
837
|
+
line = str(issue.line) if issue.line else "-"
|
|
838
|
+
style = _SEVERITY_STYLE[issue.severity]
|
|
839
|
+
lines = (
|
|
840
|
+
# A variable or path is never split: a broken name is harder to
|
|
841
|
+
# read, and to search for, than one that runs past the edge.
|
|
842
|
+
textwrap.wrap(issue.message, wrap_at, break_long_words=False, break_on_hyphens=False)
|
|
843
|
+
if wrap_at >= 30
|
|
844
|
+
else [issue.message]
|
|
845
|
+
)
|
|
846
|
+
message = ("\n" + " " * indent).join(escape(part) for part in lines)
|
|
847
|
+
_console.print(
|
|
848
|
+
f" [dim]{line:>{line_width}}[/dim] [{style}]{issue.severity:<7}[/{style}]"
|
|
849
|
+
f" [dim]{issue.code}[/dim] {message}",
|
|
850
|
+
highlight=False,
|
|
851
|
+
soft_wrap=True,
|
|
852
|
+
)
|
|
853
|
+
_console.print()
|
|
854
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
855
|
+
|
|
471
856
|
|
|
472
|
-
|
|
473
|
-
|
|
857
|
+
def print_concise(results: list[FileResult], checked: int) -> None:
|
|
858
|
+
"""One ``path:line: CODE message`` line per issue, for grep and editors."""
|
|
859
|
+
from rich.markup import escape
|
|
474
860
|
|
|
475
|
-
|
|
476
|
-
|
|
861
|
+
from execsql.cli.help import _console
|
|
862
|
+
|
|
863
|
+
for path, found in results:
|
|
864
|
+
for issue in _sorted(found):
|
|
865
|
+
where = f"{path}:{issue.line}" if issue.line else path
|
|
866
|
+
style = _SEVERITY_STYLE[issue.severity]
|
|
867
|
+
_console.print(
|
|
868
|
+
f"{escape(where)}: [{style}]{issue.code}[/{style}] {escape(issue.message)}",
|
|
869
|
+
highlight=False,
|
|
870
|
+
soft_wrap=True,
|
|
871
|
+
)
|
|
872
|
+
if any(found for _, found in results):
|
|
477
873
|
_console.print()
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
# Sort: errors first, then warnings; within each group sort by line number.
|
|
481
|
-
_sev_order = {"error": 0, "warning": 1}
|
|
482
|
-
sorted_issues = sorted(issues, key=lambda i: (_sev_order.get(i[0], 9), i[2]))
|
|
483
|
-
|
|
484
|
-
# Compute the widest location string so columns align.
|
|
485
|
-
locs: list[str] = []
|
|
486
|
-
for _, source, line_no, _ in sorted_issues:
|
|
487
|
-
locs.append(f"{source}:{line_no}" if line_no else source)
|
|
488
|
-
loc_width = max(len(loc) for loc in locs) if locs else 0
|
|
489
|
-
|
|
490
|
-
for (severity, _source, _line_no, message), loc in zip(sorted_issues, locs):
|
|
491
|
-
pad = " " * (loc_width - len(loc))
|
|
492
|
-
if severity == "error":
|
|
493
|
-
_console.print(f" [bold red]ERROR [/bold red] [dim]{loc}[/dim]{pad} {message}")
|
|
494
|
-
else:
|
|
495
|
-
_console.print(f" [bold yellow]WARNING[/bold yellow] [dim]{loc}[/dim]{pad} {message}")
|
|
496
|
-
|
|
497
|
-
_console.print()
|
|
498
|
-
parts = []
|
|
499
|
-
if n_errors:
|
|
500
|
-
parts.append(f"[bold red]{n_errors} error{'s' if n_errors != 1 else ''}[/bold red]")
|
|
501
|
-
if n_warnings:
|
|
502
|
-
parts.append(f"[bold yellow]{n_warnings} warning{'s' if n_warnings != 1 else ''}[/bold yellow]")
|
|
503
|
-
_console.print(" " + ", ".join(parts))
|
|
504
|
-
_console.print()
|
|
874
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
505
875
|
|
|
506
|
-
|
|
876
|
+
|
|
877
|
+
def _github_data(text: str) -> str:
|
|
878
|
+
"""Escape a workflow-command message, as GitHub's own toolkit does."""
|
|
879
|
+
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _github_property(text: str) -> str:
|
|
883
|
+
"""Escape a workflow-command property value (``file=``, ``title=``)."""
|
|
884
|
+
return _github_data(text).replace(":", "%3A").replace(",", "%2C")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def render_github(results: list[FileResult]) -> str:
|
|
888
|
+
"""Every issue as a GitHub Actions workflow command, one per line.
|
|
889
|
+
|
|
890
|
+
In a GitHub Actions step, each line becomes an annotation on the pull
|
|
891
|
+
request, next to the line it names: ``::warning file=load.sql,line=12,
|
|
892
|
+
title=V001 undefined-variable::undefined variable !!x!!``. An issue with
|
|
893
|
+
no single line annotates the file.
|
|
894
|
+
"""
|
|
895
|
+
lines: list[str] = []
|
|
896
|
+
for path, found in results:
|
|
897
|
+
for issue in _sorted(found):
|
|
898
|
+
props = [f"file={_github_property(path)}"]
|
|
899
|
+
if issue.line:
|
|
900
|
+
props.append(f"line={issue.line}")
|
|
901
|
+
props.append(f"title={_github_property(f'{issue.code} {RULES[issue.code].name}')}")
|
|
902
|
+
lines.append(f"::{issue.severity} {','.join(props)}::{_github_data(issue.message)}")
|
|
903
|
+
return "\n".join(lines)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def print_statistics(results: list[FileResult], checked: int) -> None:
|
|
907
|
+
"""How often each rule fired, most frequent first."""
|
|
908
|
+
from execsql.cli.help import _console
|
|
909
|
+
|
|
910
|
+
counts = rule_counts(i for _, found in results for i in found)
|
|
911
|
+
if counts:
|
|
912
|
+
width = max(len(str(n)) for _, n in counts)
|
|
913
|
+
for rule, n in counts:
|
|
914
|
+
style = _SEVERITY_STYLE[rule.severity]
|
|
915
|
+
_console.print(f" {n:>{width}} [{style}]{rule.code}[/{style}] {rule.name}", highlight=False)
|
|
916
|
+
_console.print()
|
|
917
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def render_json(issues: Iterable[Issue]) -> str:
|
|
921
|
+
"""Every issue as one JSON array, in the order given.
|
|
922
|
+
|
|
923
|
+
Each object has ``file``, ``line`` (``null`` when the issue has no single
|
|
924
|
+
line), ``code``, ``rule``, ``severity`` and ``message``.
|
|
925
|
+
"""
|
|
926
|
+
rows = [
|
|
927
|
+
{
|
|
928
|
+
"file": i.source,
|
|
929
|
+
"line": i.line or None,
|
|
930
|
+
"code": i.code,
|
|
931
|
+
"rule": RULES[i.code].name,
|
|
932
|
+
"severity": i.severity,
|
|
933
|
+
"message": i.message,
|
|
934
|
+
}
|
|
935
|
+
for i in issues
|
|
936
|
+
]
|
|
937
|
+
return json.dumps(rows, indent=2, ensure_ascii=False)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def rule_counts(issues: Iterable[Issue]) -> list[tuple[Rule, int]]:
|
|
941
|
+
"""How often each rule fired, most frequent first, then by code."""
|
|
942
|
+
counts = Counter(i.code for i in issues)
|
|
943
|
+
return sorted(((RULES[code], n) for code, n in counts.items()), key=lambda rc: (-rc[1], rc[0].code))
|