execsql2 2.15.8__py3-none-any.whl → 2.16.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.
Files changed (66) hide show
  1. execsql/__init__.py +8 -3
  2. execsql/api.py +580 -0
  3. execsql/cli/__init__.py +123 -0
  4. execsql/cli/lint_ast.py +439 -0
  5. execsql/cli/run.py +113 -102
  6. execsql/config.py +29 -4
  7. execsql/db/access.py +1 -0
  8. execsql/db/base.py +4 -1
  9. execsql/db/dsn.py +3 -2
  10. execsql/db/duckdb.py +1 -1
  11. execsql/db/factory.py +3 -0
  12. execsql/db/firebird.py +2 -1
  13. execsql/db/mysql.py +2 -1
  14. execsql/db/oracle.py +2 -1
  15. execsql/db/postgres.py +2 -1
  16. execsql/db/sqlite.py +1 -1
  17. execsql/db/sqlserver.py +3 -2
  18. execsql/debug/repl.py +27 -10
  19. execsql/exporters/base.py +6 -4
  20. execsql/exporters/delimited.py +11 -3
  21. execsql/exporters/pretty.py +9 -12
  22. execsql/gui/tui.py +59 -2
  23. execsql/metacommands/__init__.py +3 -0
  24. execsql/metacommands/conditions.py +20 -2
  25. execsql/metacommands/connect.py +1 -1
  26. execsql/metacommands/control.py +8 -14
  27. execsql/metacommands/debug.py +6 -4
  28. execsql/metacommands/io_export.py +117 -315
  29. execsql/metacommands/io_fileops.py +7 -13
  30. execsql/metacommands/io_write.py +1 -1
  31. execsql/metacommands/script_ext.py +8 -5
  32. execsql/metacommands/upsert.py +40 -0
  33. execsql/models.py +8 -12
  34. execsql/plugins.py +414 -0
  35. execsql/script/__init__.py +36 -12
  36. execsql/script/ast.py +562 -0
  37. execsql/script/engine.py +59 -368
  38. execsql/script/executor.py +833 -0
  39. execsql/script/parser.py +663 -0
  40. execsql/script/variables.py +11 -0
  41. execsql/state.py +55 -2
  42. execsql/utils/crypto.py +14 -10
  43. execsql/utils/errors.py +31 -8
  44. execsql/utils/gui.py +139 -17
  45. execsql/utils/mail.py +15 -12
  46. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
  47. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
  48. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
  49. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  50. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  51. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
  52. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  53. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  54. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  55. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  56. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  57. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  58. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  59. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
  60. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  61. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  62. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  63. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
  64. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
  65. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
  66. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py CHANGED
@@ -273,6 +273,11 @@ def main(
273
273
  "--dump-keywords",
274
274
  help="Dump all metacommand keywords as JSON and exit.",
275
275
  ),
276
+ list_plugins: bool = typer.Option(
277
+ False,
278
+ "--list-plugins",
279
+ help="List all discovered plugins (metacommands, exporters, importers) and exit.",
280
+ ),
276
281
  profile: bool = typer.Option(
277
282
  False,
278
283
  "--profile",
@@ -283,6 +288,24 @@ def main(
283
288
  "--profile-limit",
284
289
  help="Number of top statements to show in the --profile timing summary (default: 20).",
285
290
  ),
291
+ config_file: str | None = typer.Option(
292
+ None,
293
+ "--config",
294
+ metavar="FILE",
295
+ help=(
296
+ "Path to an execsql configuration file. "
297
+ "Loaded after the implicit search paths so its values take precedence. "
298
+ "The file may chain additional configs via its [cyan][config][/cyan] section."
299
+ ),
300
+ ),
301
+ parse_tree: bool = typer.Option(
302
+ False,
303
+ "--parse-tree",
304
+ help=(
305
+ "Parse the script into an abstract syntax tree and print the tree structure. "
306
+ "Does not connect to a database or execute anything."
307
+ ),
308
+ ),
286
309
  debug: bool = typer.Option(
287
310
  False,
288
311
  "--debug",
@@ -370,12 +393,56 @@ def main(
370
393
  _console.print_json(_json.dumps(data, indent=2))
371
394
  raise typer.Exit()
372
395
 
396
+ if list_plugins:
397
+ from execsql.plugins import (
398
+ EXPORTER_GROUP,
399
+ IMPORTER_GROUP,
400
+ METACOMMAND_GROUP,
401
+ _load_entry_points,
402
+ )
403
+
404
+ _console.print("\n[bold cyan]Installed plugins:[/bold cyan]\n")
405
+
406
+ mc_plugins = _load_entry_points(METACOMMAND_GROUP)
407
+ ex_plugins = _load_entry_points(EXPORTER_GROUP)
408
+ im_plugins = _load_entry_points(IMPORTER_GROUP)
409
+
410
+ if not mc_plugins and not ex_plugins and not im_plugins:
411
+ _console.print(" [dim]No plugins found.[/dim]")
412
+ _console.print()
413
+ _console.print(
414
+ " Plugins are discovered via Python entry points.\n"
415
+ " See the execsql documentation for how to create plugins.",
416
+ )
417
+ else:
418
+ if mc_plugins:
419
+ _console.print(f" [bold]Metacommands[/bold] ({len(mc_plugins)}):")
420
+ for name, _ in mc_plugins:
421
+ _console.print(f" - {name}")
422
+ if ex_plugins:
423
+ _console.print(f" [bold]Exporters[/bold] ({len(ex_plugins)}):")
424
+ for name, _ in ex_plugins:
425
+ _console.print(f" - {name}")
426
+ if im_plugins:
427
+ _console.print(f" [bold]Importers[/bold] ({len(im_plugins)}):")
428
+ for name, _ in im_plugins:
429
+ _console.print(f" - {name}")
430
+
431
+ _console.print()
432
+ raise typer.Exit()
433
+
373
434
  if online_help:
374
435
  import webbrowser
375
436
 
376
437
  webbrowser.open("https://execsql2.readthedocs.io/en/latest/", new=2, autoraise=True)
377
438
  raise typer.Exit()
378
439
 
440
+ if config_file and not Path(config_file).is_file():
441
+ _err_console.print(
442
+ f"[bold red]Error:[/bold red] Config file [cyan]{config_file!r}[/cyan] does not exist.",
443
+ )
444
+ raise typer.Exit(code=2)
445
+
379
446
  positional = args or []
380
447
  if command is not None:
381
448
  script_name = None # inline mode — no script file
@@ -425,6 +492,61 @@ def main(
425
492
  )
426
493
  raise typer.Exit(code=2)
427
494
 
495
+ # ------------------------------------------------------------------
496
+ # Parse tree: parse script into AST and print tree structure
497
+ # ------------------------------------------------------------------
498
+ if parse_tree:
499
+ from execsql.script.ast import format_tree
500
+ from execsql.script.parser import parse_script, parse_string
501
+
502
+ try:
503
+ if command is not None:
504
+ tree = parse_string(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
505
+ elif script_name is not None:
506
+ encoding = script_encoding or "utf-8"
507
+ tree = parse_script(script_name, encoding=encoding)
508
+ else:
509
+ _err_console.print(
510
+ "[bold red]Error:[/bold red] --parse-tree requires a script file or -c command.",
511
+ )
512
+ raise typer.Exit(code=1)
513
+ except ErrInfo as exc:
514
+ _err_console.print(f"[bold red]Parse error:[/bold red] {exc.errmsg()}")
515
+ raise typer.Exit(code=1) from exc
516
+
517
+ _console.print(format_tree(tree))
518
+ raise typer.Exit()
519
+
520
+ # ------------------------------------------------------------------
521
+ # Lint: AST-based static analysis (no DB connection needed)
522
+ # ------------------------------------------------------------------
523
+ if lint:
524
+ from execsql.cli.lint import _print_lint_results
525
+ from execsql.cli.lint_ast import lint_ast
526
+ from execsql.script.parser import parse_script, parse_string
527
+
528
+ label = script_name or "<inline>"
529
+ try:
530
+ if command is not None:
531
+ tree = parse_string(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
532
+ elif script_name is not None:
533
+ encoding = script_encoding or "utf-8"
534
+ tree = parse_script(script_name, encoding=encoding)
535
+ else:
536
+ _err_console.print(
537
+ "[bold red]Error:[/bold red] --lint requires a script file or -c command.",
538
+ )
539
+ raise typer.Exit(code=1)
540
+ except ErrInfo as exc:
541
+ # Parse failure IS a lint error — report it
542
+ issues = [("error", label, 0, f"Parse error: {exc.errmsg()}")]
543
+ exit_code = _print_lint_results(issues, label)
544
+ raise typer.Exit(code=exit_code) from exc
545
+
546
+ issues = lint_ast(tree, script_path=script_name)
547
+ exit_code = _print_lint_results(issues, label)
548
+ raise typer.Exit(code=exit_code)
549
+
428
550
  # ------------------------------------------------------------------
429
551
  # Delegate to the real main implementation
430
552
  # ------------------------------------------------------------------
@@ -458,6 +580,7 @@ def main(
458
580
  ping=ping,
459
581
  lint=lint,
460
582
  debug=debug,
583
+ config_file=config_file,
461
584
  )
462
585
 
463
586
 
@@ -0,0 +1,439 @@
1
+ """AST-based static analysis (lint) for execsql scripts.
2
+
3
+ Performs the same checks as :mod:`execsql.cli.lint` but operates on the
4
+ :class:`~execsql.script.ast.Script` tree instead of a flat
5
+ :class:`~execsql.script.engine.CommandList`.
6
+
7
+ Advantages over the flat linter:
8
+
9
+ - **No runtime state required** — works with the AST parser alone, so it
10
+ can run as an early exit in the CLI without initialising ``_state``.
11
+ - **Structural validation is free** — the AST parser already rejects
12
+ unmatched IF/LOOP/BATCH/SCRIPT blocks at parse time with precise source
13
+ spans. This linter only needs to report variable and INCLUDE issues.
14
+ - **Script blocks are in the tree** — ``EXECUTE SCRIPT`` targets are
15
+ resolved by finding :class:`ScriptBlock` nodes, not by looking up
16
+ ``_state.savedscripts``.
17
+
18
+ Checks performed
19
+ ----------------
20
+ 1. **Parse errors** — the AST parser rejects unmatched blocks, so any
21
+ parse failure is reported as an error with the parser's message.
22
+ 2. **Potentially undefined variables** — same heuristic as the flat linter.
23
+ 3. **EXECUTE SCRIPT target resolution** — warns when a target name does
24
+ not correspond to a :class:`ScriptBlock` in the same file.
25
+ 4. **Missing INCLUDE files** — warns when the file does not exist on disk.
26
+ 5. **Empty script** — warns when no nodes were parsed.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from pathlib import Path
33
+
34
+ from execsql.script.ast import (
35
+ BatchBlock,
36
+ IfBlock,
37
+ IncludeDirective,
38
+ LoopBlock,
39
+ MetaCommandStatement,
40
+ Node,
41
+ Script,
42
+ ScriptBlock,
43
+ SqlBlock,
44
+ SqlStatement,
45
+ )
46
+
47
+ __all__ = ["lint_ast"]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Variable-related patterns (shared with the flat linter)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
55
+ _RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
56
+ _RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
57
+ _RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
58
+ _RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
59
+ _RX_SUB_INI = re.compile(
60
+ r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
61
+ r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
62
+ re.I,
63
+ )
64
+ _RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
65
+ _RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
66
+ _RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
67
+ _RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
68
+ _RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
69
+ _RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
70
+
71
+ _RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Issue tuple helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
79
+
80
+
81
+ def _error(source: str, line_no: int, message: str) -> _Issue:
82
+ return ("error", source, line_no, message)
83
+
84
+
85
+ def _warning(source: str, line_no: int, message: str) -> _Issue:
86
+ return ("warning", source, line_no, message)
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Built-in variable discovery (reuse from flat linter)
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _discover_builtin_vars() -> frozenset[str]:
95
+ """Scan the execsql package source for ``$VARNAME`` system variables."""
96
+ import importlib.util
97
+
98
+ _rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
99
+ _rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
100
+
101
+ names: set[str] = set()
102
+
103
+ spec = importlib.util.find_spec("execsql")
104
+ if spec is None or spec.submodule_search_locations is None:
105
+ return frozenset(names)
106
+
107
+ pkg_dir = Path(spec.submodule_search_locations[0])
108
+ for src_file in pkg_dir.rglob("*.py"):
109
+ try:
110
+ text = src_file.read_text(encoding="utf-8")
111
+ except OSError:
112
+ continue
113
+ for m in _rx_add_sub.finditer(text):
114
+ names.add(m.group(1).lstrip("$").upper())
115
+ for m in _rx_lazy.finditer(text):
116
+ names.add(m.group(1).lstrip("$").upper())
117
+
118
+ return frozenset(names)
119
+
120
+
121
+ _BUILTIN_VARS: frozenset[str] | None = None
122
+
123
+
124
+ def _get_builtin_vars() -> frozenset[str]:
125
+ """Return the cached set of built-in variable names, discovering on first call."""
126
+ global _BUILTIN_VARS
127
+ if _BUILTIN_VARS is None:
128
+ _BUILTIN_VARS = _discover_builtin_vars()
129
+ return _BUILTIN_VARS
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # AST walker helpers
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
138
+ """Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
139
+ blocks: dict[str, ScriptBlock] = {}
140
+ for node in script.walk():
141
+ if isinstance(node, ScriptBlock):
142
+ blocks[node.name] = node
143
+ return blocks
144
+
145
+
146
+ def _collect_defined_vars_from_nodes(
147
+ nodes: list[Node],
148
+ script_blocks: dict[str, ScriptBlock],
149
+ script_dir: Path | None,
150
+ defined: set[str],
151
+ visited: set[str] | None = None,
152
+ ) -> None:
153
+ """Walk nodes and collect variable definitions into *defined*."""
154
+ if visited is None:
155
+ visited = set()
156
+
157
+ for node in nodes:
158
+ if isinstance(node, MetaCommandStatement):
159
+ _extract_var_definition(node.command, script_dir, defined)
160
+
161
+ elif isinstance(node, IncludeDirective) and node.is_execute_script:
162
+ target = node.target.lower()
163
+ if target in script_blocks and target not in visited:
164
+ visited.add(target)
165
+ _collect_defined_vars_from_nodes(
166
+ script_blocks[target].body,
167
+ script_blocks,
168
+ script_dir,
169
+ defined,
170
+ visited,
171
+ )
172
+
173
+ # Recurse into block children
174
+ if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
175
+ _collect_defined_vars_from_nodes(
176
+ list(node.children()),
177
+ script_blocks,
178
+ script_dir,
179
+ defined,
180
+ visited,
181
+ )
182
+
183
+
184
+ def _extract_var_definition(
185
+ command: str,
186
+ script_dir: Path | None,
187
+ defined: set[str],
188
+ ) -> None:
189
+ """Extract variable name from a SUB-family metacommand into *defined*."""
190
+ for rx in (
191
+ _RX_SUB,
192
+ _RX_SUB_EMPTY,
193
+ _RX_SUB_ADD,
194
+ _RX_SUB_APPEND,
195
+ _RX_SUBDATA,
196
+ _RX_SUB_LOCAL,
197
+ _RX_SUB_TEMPFILE,
198
+ _RX_SUB_DECRYPT,
199
+ _RX_SUB_ENCRYPT,
200
+ _RX_SUB_QUERYSTRING,
201
+ ):
202
+ m = rx.match(command)
203
+ if m:
204
+ defined.add(m.group("name").lstrip("+~").upper())
205
+ return
206
+
207
+ # SUB_INI bulk-defines from INI file — read keys at lint time
208
+ ini_m = _RX_SUB_INI.match(command)
209
+ if ini_m:
210
+ ini_file = ini_m.group("qfile") or ini_m.group("file")
211
+ ini_section = ini_m.group("section")
212
+ if ini_file and not _RX_VAR_REF.search(ini_file):
213
+ _read_ini_vars(ini_file, ini_section, script_dir, defined)
214
+
215
+
216
+ def _read_ini_vars(
217
+ ini_file: str,
218
+ section: str,
219
+ script_dir: Path | None,
220
+ defined_vars: set[str],
221
+ ) -> None:
222
+ """Read an INI file and register its section keys as defined variables."""
223
+ from configparser import ConfigParser
224
+
225
+ p = Path(ini_file)
226
+ if not p.is_absolute() and script_dir is not None:
227
+ p = script_dir / p
228
+
229
+ if not p.exists():
230
+ return
231
+
232
+ cp = ConfigParser()
233
+ cp.read(p)
234
+ if cp.has_section(section):
235
+ for key, _value in cp.items(section):
236
+ defined_vars.add(key.upper())
237
+
238
+
239
+ def _check_var_ref(
240
+ raw_name: str,
241
+ source: str,
242
+ line_no: int,
243
+ defined_vars: set[str],
244
+ issues: list[_Issue],
245
+ ) -> None:
246
+ """Emit a warning if *raw_name* looks like an undefined user variable."""
247
+ if not raw_name:
248
+ return
249
+
250
+ sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
251
+ name = raw_name[len(sigil) :]
252
+
253
+ # Skip non-$ sigil prefixes — resolved at runtime
254
+ if sigil in ("@", "&", "~", "#", "+"):
255
+ return
256
+
257
+ # $ARG_N is set via -a/--assign-arg at invocation time
258
+ if re.match(r"^ARG_\d+$", name, re.I):
259
+ return
260
+
261
+ # $COUNTER_N is managed by CounterVars
262
+ if re.match(r"^COUNTER_\d+$", name, re.I):
263
+ return
264
+
265
+ # Built-in system variables
266
+ if name.upper() in _get_builtin_vars():
267
+ return
268
+
269
+ # User-defined via SUB
270
+ if name.upper() in defined_vars:
271
+ return
272
+
273
+ issues.append(
274
+ _warning(
275
+ source,
276
+ line_no,
277
+ f"Potentially undefined variable: !!{raw_name}!! "
278
+ "(not defined by a preceding SUB; may be set by a config file or -a arg)",
279
+ ),
280
+ )
281
+
282
+
283
+ def _check_include_path(
284
+ raw_path: str,
285
+ script_dir: Path | None,
286
+ source: str,
287
+ line_no: int,
288
+ issues: list[_Issue],
289
+ ) -> None:
290
+ """Warn if the INCLUDE target does not exist on disk."""
291
+ p = Path(raw_path)
292
+ if not p.is_absolute() and script_dir is not None:
293
+ p = script_dir / p
294
+
295
+ if not p.exists():
296
+ issues.append(
297
+ _warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
298
+ )
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Core lint walk
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ def _lint_nodes(
307
+ nodes: list[Node],
308
+ script_dir: Path | None,
309
+ defined_vars: set[str],
310
+ script_blocks: dict[str, ScriptBlock],
311
+ issues: list[_Issue],
312
+ *,
313
+ visited_scripts: set[str] | None = None,
314
+ ) -> None:
315
+ """Walk a list of AST nodes and collect lint issues."""
316
+ if visited_scripts is None:
317
+ visited_scripts = set()
318
+
319
+ for node in nodes:
320
+ src = node.span.file
321
+ lno = node.span.start_line
322
+
323
+ # -- Variable references in SQL --
324
+ if isinstance(node, SqlStatement):
325
+ for m in _RX_VAR_REF.finditer(node.text):
326
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
327
+
328
+ # -- Metacommand checks --
329
+ elif isinstance(node, MetaCommandStatement):
330
+ for m in _RX_VAR_REF.finditer(node.command):
331
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
332
+
333
+ # -- IncludeDirective checks --
334
+ elif isinstance(node, IncludeDirective):
335
+ if node.is_execute_script:
336
+ target = node.target.lower()
337
+ if target not in script_blocks:
338
+ if not node.if_exists:
339
+ issues.append(
340
+ _warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
341
+ )
342
+ elif target not in visited_scripts:
343
+ visited_scripts.add(target)
344
+ _lint_nodes(
345
+ script_blocks[target].body,
346
+ script_dir,
347
+ defined_vars,
348
+ script_blocks,
349
+ issues,
350
+ visited_scripts=visited_scripts,
351
+ )
352
+ else:
353
+ # INCLUDE file existence check
354
+ if not node.if_exists:
355
+ raw_path = node.target.strip().strip("\"'")
356
+ if not _RX_VAR_REF.search(raw_path):
357
+ _check_include_path(raw_path, script_dir, src, lno, issues)
358
+
359
+ # -- Recurse into block children --
360
+ if isinstance(node, IfBlock):
361
+ _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
362
+ for clause in node.elseif_clauses:
363
+ _lint_nodes(
364
+ clause.body,
365
+ script_dir,
366
+ defined_vars,
367
+ script_blocks,
368
+ issues,
369
+ visited_scripts=visited_scripts,
370
+ )
371
+ _lint_nodes(
372
+ node.else_body,
373
+ script_dir,
374
+ defined_vars,
375
+ script_blocks,
376
+ issues,
377
+ visited_scripts=visited_scripts,
378
+ )
379
+ elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
380
+ _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
381
+ elif isinstance(node, ScriptBlock):
382
+ # Lint script block body (structural errors already caught by parser)
383
+ if node.name not in visited_scripts:
384
+ visited_scripts.add(node.name)
385
+ sub_issues: list[_Issue] = []
386
+ _lint_nodes(
387
+ node.body,
388
+ script_dir,
389
+ defined_vars,
390
+ script_blocks,
391
+ sub_issues,
392
+ visited_scripts=visited_scripts,
393
+ )
394
+ for sev, ssrc, slno, msg in sub_issues:
395
+ issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Public API
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def lint_ast(
404
+ script: Script,
405
+ script_path: str | None = None,
406
+ ) -> list[_Issue]:
407
+ """Perform static analysis on an AST-parsed script.
408
+
409
+ Args:
410
+ script: The parsed :class:`Script` tree.
411
+ script_path: Path to the source file (for resolving relative
412
+ INCLUDE paths). ``None`` for inline scripts.
413
+
414
+ Returns:
415
+ List of ``(severity, source, line_no, message)`` issue tuples.
416
+ """
417
+ issues: list[_Issue] = []
418
+
419
+ if not script.body:
420
+ issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
421
+ return issues
422
+
423
+ script_dir = Path(script_path).resolve().parent if script_path else None
424
+ script_blocks = _collect_script_blocks(script)
425
+
426
+ # Pass 1: collect all variable definitions
427
+ all_defined: set[str] = set()
428
+ _collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
429
+
430
+ # Pass 2: lint for variable and include issues
431
+ _lint_nodes(
432
+ script.body,
433
+ script_dir,
434
+ all_defined,
435
+ script_blocks,
436
+ issues,
437
+ )
438
+
439
+ return issues