sqlanvil-sqlx-lint 0.2.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.
@@ -0,0 +1,16 @@
1
+ """sqlanvil-sqlx-lint: convention linter for SQLAnvil .sqlx files."""
2
+
3
+ from .config import Config, DirPolicy, detect_warehouse, load_config, load_config_file
4
+ from .linter import Finding, lint_file, lint_text
5
+
6
+ __all__ = [
7
+ "Config",
8
+ "DirPolicy",
9
+ "Finding",
10
+ "detect_warehouse",
11
+ "lint_file",
12
+ "lint_text",
13
+ "load_config",
14
+ "load_config_file",
15
+ ]
16
+ __version__ = "0.2.0"
@@ -0,0 +1,93 @@
1
+ """Command-line interface.
2
+
3
+ Usage: sqlanvil-sqlx-lint [--config PATH] [--warehouse NAME]
4
+ [--definitions-root DIR] FILE [FILE ...]
5
+ Exit codes: 0 = clean or warnings only, 1 = errors found, 2 = usage error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+
15
+ from .config import WAREHOUSES, Config, load_config, load_config_file, with_detected_warehouse
16
+ from .linter import lint_file
17
+
18
+
19
+ def _repo_resolver(root: Path):
20
+ """Resolve an action name to the text of <root>/**/<name>.sqlx.
21
+ Used to follow `select *` through ${ref()} for E010."""
22
+ if not root.is_dir():
23
+ return lambda name: None
24
+ index = {p.stem: p for p in root.rglob("*.sqlx")}
25
+
26
+ def resolve(name):
27
+ p = index.get(name)
28
+ try:
29
+ return p.read_text(encoding="utf-8") if p else None
30
+ except OSError:
31
+ return None
32
+
33
+ return resolve
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = argparse.ArgumentParser(
38
+ prog="sqlanvil-sqlx-lint",
39
+ description="Convention linter for SQLAnvil .sqlx files",
40
+ )
41
+ parser.add_argument("files", nargs="+", help=".sqlx files to lint")
42
+ parser.add_argument(
43
+ "--config",
44
+ help="path to a TOML config file (default: .sqlx-lint.toml or "
45
+ "[tool.sqlx-lint] in ./pyproject.toml)",
46
+ )
47
+ parser.add_argument(
48
+ "--warehouse",
49
+ choices=WAREHOUSES,
50
+ help="target warehouse (default: `warehouse` in the config, else "
51
+ "workflow_settings.yaml in the working directory, else postgres)",
52
+ )
53
+ parser.add_argument(
54
+ "--definitions-root",
55
+ default="definitions",
56
+ help="directory indexed to resolve ${ref()} targets for the E010 "
57
+ "coverage rule (default: ./definitions)",
58
+ )
59
+ args = parser.parse_args(argv)
60
+
61
+ try:
62
+ if args.config:
63
+ cfg: Config = with_detected_warehouse(load_config_file(args.config), ".")
64
+ else:
65
+ cfg = load_config(".")
66
+ except (OSError, ValueError) as exc:
67
+ print(f"sqlx-lint: bad config: {exc}", file=sys.stderr)
68
+ return 2
69
+ if args.warehouse:
70
+ cfg = replace(cfg, warehouse=args.warehouse)
71
+ resolver = _repo_resolver(Path(args.definitions_root))
72
+
73
+ errors = warnings = 0
74
+ for path in args.files:
75
+ try:
76
+ findings = lint_file(path, config=cfg, resolver=resolver)
77
+ except OSError as exc:
78
+ print(f"{path}: cannot read: {exc}")
79
+ errors += 1
80
+ continue
81
+ for f in sorted(findings, key=lambda f: f.line):
82
+ print(f"{path}:{f.line}: {f.code} [{f.severity}] {f.message}")
83
+ if f.severity == "error":
84
+ errors += 1
85
+ else:
86
+ warnings += 1
87
+ if errors or warnings:
88
+ print(f"sqlx-lint: {errors} error(s), {warnings} warning(s)")
89
+ return 1 if errors else 0
90
+
91
+
92
+ def entrypoint() -> None:
93
+ sys.exit(main())
@@ -0,0 +1,217 @@
1
+ """Configuration model and TOML loading.
2
+
3
+ Precedence: .sqlx-lint.toml in the working directory, else the
4
+ [tool.sqlx-lint] table of pyproject.toml, else built-in defaults.
5
+
6
+ The target warehouse is resolved separately (see `detect_warehouse`):
7
+ an explicit `warehouse` config key wins, then `workflow_settings.yaml`
8
+ in the working directory, then "postgres".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ #: Rules that run unless disabled.
19
+ DEFAULT_ENABLED = {
20
+ "E001",
21
+ "E002",
22
+ "E003",
23
+ "E004",
24
+ "E006",
25
+ "E007",
26
+ "E010",
27
+ "S101",
28
+ "S102",
29
+ "S103",
30
+ "S104",
31
+ "S105",
32
+ "S106",
33
+ "S108",
34
+ }
35
+ #: Opt-in rules (house-style checks): enable via `enable = [...]`.
36
+ OPT_IN = {"E005", "W008"}
37
+
38
+ #: Warehouse names sqlanvil accepts in workflow_settings.yaml.
39
+ WAREHOUSES = ("postgres", "supabase", "mysql", "bigquery")
40
+ DEFAULT_WAREHOUSE = "postgres"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DirPolicy:
45
+ """Naming/type policy applied to files whose path contains a substring."""
46
+
47
+ path_contains: str
48
+ require_prefix: str | None = None
49
+ require_types: tuple[str, ...] | None = None
50
+ severity: str = "error"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Config:
55
+ #: Target warehouse; None = detect from workflow_settings.yaml, else postgres.
56
+ warehouse: str | None = None
57
+ schema_suffixes: list[str] = field(
58
+ default_factory=lambda: ["_prod", "_dev", "_test"]
59
+ )
60
+ documented_types: set[str] = field(
61
+ default_factory=lambda: {"table", "view", "incremental", "declaration"}
62
+ )
63
+ #: E010 applies only to files whose path contains one of these; empty = all.
64
+ coverage_paths: list[str] = field(default_factory=list)
65
+ #: Schemas E006 may reference directly (e.g. system catalogs, extensions).
66
+ allowed_schemas: list[str] = field(default_factory=list)
67
+ dir_policies: list[DirPolicy] = field(default_factory=list)
68
+ enabled_extra: set[str] = field(default_factory=set)
69
+ disabled: set[str] = field(default_factory=set)
70
+
71
+ def rule_on(self, code: str) -> bool:
72
+ if code in self.disabled:
73
+ return False
74
+ return code in DEFAULT_ENABLED or code in self.enabled_extra
75
+
76
+ @property
77
+ def effective_warehouse(self) -> str:
78
+ return (self.warehouse or DEFAULT_WAREHOUSE).lower()
79
+
80
+ def __eq__(self, other):
81
+ if not isinstance(other, Config):
82
+ return NotImplemented
83
+ return (
84
+ self.warehouse == other.warehouse
85
+ and self.schema_suffixes == other.schema_suffixes
86
+ and self.documented_types == other.documented_types
87
+ and self.coverage_paths == other.coverage_paths
88
+ and self.allowed_schemas == other.allowed_schemas
89
+ and self.dir_policies == other.dir_policies
90
+ and self.enabled_extra == other.enabled_extra
91
+ and self.disabled == other.disabled
92
+ )
93
+
94
+
95
+ _KNOWN_KEYS = {
96
+ "warehouse",
97
+ "schema_suffixes",
98
+ "documented_types",
99
+ "coverage_paths",
100
+ "allowed_schemas",
101
+ "dir_policies",
102
+ "enable",
103
+ "disable",
104
+ }
105
+
106
+
107
+ def _from_dict(raw: dict) -> Config:
108
+ unknown = set(raw) - _KNOWN_KEYS
109
+ if unknown:
110
+ raise ValueError(
111
+ f"unknown sqlx-lint config key(s): {', '.join(sorted(unknown))}"
112
+ )
113
+ policies = [
114
+ DirPolicy(
115
+ path_contains=p["path_contains"],
116
+ require_prefix=p.get("require_prefix"),
117
+ require_types=tuple(p["require_types"]) if p.get("require_types") else None,
118
+ severity=p.get("severity", "error"),
119
+ )
120
+ for p in raw.get("dir_policies", [])
121
+ ]
122
+ kwargs = {}
123
+ if "warehouse" in raw:
124
+ wh = str(raw["warehouse"]).lower()
125
+ if wh not in WAREHOUSES:
126
+ raise ValueError(
127
+ f"unknown warehouse {wh!r}; expected one of {', '.join(WAREHOUSES)}"
128
+ )
129
+ kwargs["warehouse"] = wh
130
+ if "schema_suffixes" in raw:
131
+ kwargs["schema_suffixes"] = list(raw["schema_suffixes"])
132
+ if "documented_types" in raw:
133
+ kwargs["documented_types"] = set(raw["documented_types"])
134
+ if "coverage_paths" in raw:
135
+ kwargs["coverage_paths"] = list(raw["coverage_paths"])
136
+ if "allowed_schemas" in raw:
137
+ kwargs["allowed_schemas"] = list(raw["allowed_schemas"])
138
+ return Config(
139
+ dir_policies=policies,
140
+ enabled_extra=set(raw.get("enable", [])),
141
+ disabled=set(raw.get("disable", [])),
142
+ **kwargs,
143
+ )
144
+
145
+
146
+ def load_config_file(path: str | Path) -> Config:
147
+ """Load a standalone TOML config file (the whole document is the config)."""
148
+ return _from_dict(tomllib.loads(Path(path).read_text(encoding="utf-8")))
149
+
150
+
151
+ def detect_warehouse(root: str | Path = ".") -> str | None:
152
+ """Read `warehouse:` from <root>/workflow_settings.yaml without PyYAML.
153
+
154
+ Accepts both the scalar form (`warehouse: postgres`) and the mapping form
155
+ (`warehouse:` followed by an indented `kind: postgres`). Returns None when
156
+ the file is absent or carries no recognisable value.
157
+ """
158
+ settings = Path(root) / "workflow_settings.yaml"
159
+ if not settings.is_file():
160
+ return None
161
+ try:
162
+ lines = settings.read_text(encoding="utf-8").split("\n")
163
+ except OSError:
164
+ return None
165
+ for i, line in enumerate(lines):
166
+ m = re.match(r"^warehouse\s*:\s*(?:[\"']?([A-Za-z]+)[\"']?)?\s*(?:#.*)?$", line)
167
+ if not m:
168
+ continue
169
+ if m.group(1):
170
+ return m.group(1).lower()
171
+ for nxt in lines[i + 1 :]:
172
+ if not nxt.strip() or nxt.lstrip().startswith("#"):
173
+ continue
174
+ if not nxt.startswith((" ", "\t")):
175
+ break
176
+ km = re.match(r"^\s+kind\s*:\s*[\"']?([A-Za-z]+)", nxt)
177
+ if km:
178
+ return km.group(1).lower()
179
+ return None
180
+ return None
181
+
182
+
183
+ def load_config(root: str | Path = ".") -> Config:
184
+ root = Path(root)
185
+ standalone = root / ".sqlx-lint.toml"
186
+ cfg = None
187
+ if standalone.is_file():
188
+ cfg = load_config_file(standalone)
189
+ else:
190
+ pyproject = root / "pyproject.toml"
191
+ if pyproject.is_file():
192
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
193
+ tool = data.get("tool", {}).get("sqlx-lint")
194
+ if tool is not None:
195
+ cfg = _from_dict(tool)
196
+ if cfg is None:
197
+ cfg = Config()
198
+ return with_detected_warehouse(cfg, root)
199
+
200
+
201
+ def with_detected_warehouse(cfg: Config, root: str | Path = ".") -> Config:
202
+ """Fill in `warehouse` from workflow_settings.yaml when the config leaves it unset."""
203
+ if cfg.warehouse is not None:
204
+ return cfg
205
+ detected = detect_warehouse(root)
206
+ if detected is None:
207
+ return cfg
208
+ return Config(
209
+ warehouse=detected,
210
+ schema_suffixes=cfg.schema_suffixes,
211
+ documented_types=cfg.documented_types,
212
+ coverage_paths=cfg.coverage_paths,
213
+ allowed_schemas=cfg.allowed_schemas,
214
+ dir_policies=cfg.dir_policies,
215
+ enabled_extra=cfg.enabled_extra,
216
+ disabled=cfg.disabled,
217
+ )
@@ -0,0 +1,623 @@
1
+ """Core linter: parses the .sqlx config block and SQL body, emits Findings.
2
+
3
+ Rules (codes are stable; suppress with `-- sqlx-lint: disable=E006` on the
4
+ offending line or `-- sqlx-lint: disable-file=E006` anywhere in the file):
5
+
6
+ E001 config {} block missing or unbalanced
7
+ E002 columns: {} missing or empty on documented types
8
+ (declarations may carry columnTypes: {} instead)
9
+ E003 schema: hardcodes an environment suffix (suffix-doubling trap)
10
+ E004 name: redundantly matches the filename (non-declarations)
11
+ E005 schema: set on operations/assertion configs [opt-in]
12
+ E006 hardcoded table path instead of ${ref()} (warehouse-aware)
13
+ E007 directory policy violation (configured prefix/type per path)
14
+ W008 post_operations block appears before the main SELECT [opt-in]
15
+ E010 columns:{} does not cover every determinable output column
16
+
17
+ sqlanvil-specific rules (the deltas that bite when Dataform habits carry over):
18
+
19
+ S101 BigQuery-only config (bigquery:{}, partitionBy, clusterBy, ...) on a
20
+ non-BigQuery warehouse — silently ignored, never applied
21
+ S102 `;` used to separate statements in operations / pre_operations /
22
+ post_operations — sqlanvil splits on `---`, so the block runs as one
23
+ statement and fails at run time
24
+ S103 postgres.indexes[].method given as a string — it is a numeric enum
25
+ S104 incrementalStrategy on a non-BigQuery warehouse
26
+ S105 ADD PRIMARY KEY / ADD CONSTRAINT in an incremental's operations block
27
+ not wrapped in when(!incremental(), ...) — errors on the second run
28
+ S106 assertions: sets both uniqueKey and uniqueKeys
29
+ S108 .jitCode() / jitData() — no runtime in sqlanvil
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass
36
+
37
+ from .config import Config
38
+
39
+ NO_SCHEMA_TYPES = {"operations", "assertion", "test"}
40
+
41
+ #: Schemas that are never a hardcoded *source* (system catalogs).
42
+ SYSTEM_SCHEMAS = {"pg_catalog", "information_schema", "pg_temp", "mysql", "sys",
43
+ "performance_schema"}
44
+
45
+ #: Config keys that only BigQuery understands (S101).
46
+ BIGQUERY_ONLY_KEYS = ("bigquery", "partitionBy", "clusterBy", "bigqueryPolicyTags")
47
+
48
+
49
+ @dataclass
50
+ class Finding:
51
+ code: str
52
+ severity: str # "error" | "warning"
53
+ line: int
54
+ message: str
55
+
56
+
57
+ def _line_of(text, pos):
58
+ return text.count("\n", 0, pos) + 1
59
+
60
+
61
+ def _balanced_block(text, open_brace_pos):
62
+ """Return (content, end_pos) of the brace block at open_brace_pos, else None."""
63
+ depth = 0
64
+ in_str = None
65
+ i = open_brace_pos
66
+ while i < len(text):
67
+ ch = text[i]
68
+ if in_str:
69
+ if ch == "\\":
70
+ i += 2
71
+ continue
72
+ if ch == in_str:
73
+ in_str = None
74
+ elif ch in "\"'`":
75
+ in_str = ch
76
+ elif ch == "{":
77
+ depth += 1
78
+ elif ch == "}":
79
+ depth -= 1
80
+ if depth == 0:
81
+ return text[open_brace_pos + 1 : i], i + 1
82
+ i += 1
83
+ return None
84
+
85
+
86
+ def _config_value(config, key):
87
+ m = re.search(rf"\b{key}\s*:\s*([\"'])(.*?)\1", config)
88
+ return m.group(2) if m else None
89
+
90
+
91
+ def _config_key_line(config, key, config_line):
92
+ m = re.search(rf"\b{key}\s*:", config)
93
+ return config_line + config[: m.start()].count("\n") if m else config_line
94
+
95
+
96
+ def _strip_comments(text):
97
+ text = re.sub(
98
+ r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), text, flags=re.S
99
+ )
100
+ return re.sub(r"--[^\n]*", lambda m: " " * len(m.group(0)), text)
101
+
102
+
103
+ def _split_top_level(text, sep=","):
104
+ """Split text on sep occurring outside (), [], {}, and string literals."""
105
+ parts, depth, start, in_str, i = [], 0, 0, None, 0
106
+ while i < len(text):
107
+ ch = text[i]
108
+ if in_str:
109
+ if ch == "\\":
110
+ i += 2
111
+ continue
112
+ if ch == in_str:
113
+ in_str = None
114
+ elif ch in "\"'`":
115
+ in_str = ch
116
+ elif ch in "([{":
117
+ depth += 1
118
+ elif ch in ")]}":
119
+ depth -= 1
120
+ elif ch == sep and depth == 0:
121
+ parts.append(text[start:i])
122
+ start = i + 1
123
+ i += 1
124
+ parts.append(text[start:])
125
+ return parts
126
+
127
+
128
+ def _scan_sql(text):
129
+ """Walk SQL text and return (semicolons, js_regions).
130
+
131
+ semicolons: positions of `;` that sit outside string literals, dollar-quoted
132
+ bodies, and `${ ... }` JavaScript regions.
133
+ js_regions: (start, end) spans of `${ ... }` regions, so callers can tell
134
+ whether a match is wrapped in a template expression.
135
+ """
136
+ semis, regions = [], []
137
+ i, n = 0, len(text)
138
+ in_str = None
139
+ while i < n:
140
+ ch = text[i]
141
+ if in_str:
142
+ if ch == "\\":
143
+ i += 2
144
+ continue
145
+ if ch == in_str:
146
+ in_str = None
147
+ i += 1
148
+ continue
149
+ if ch == "$" and text.startswith("$$", i):
150
+ end = text.find("$$", i + 2)
151
+ i = n if end < 0 else end + 2
152
+ continue
153
+ if ch == "$" and text.startswith("${", i):
154
+ start = i
155
+ depth, j, js_str = 0, i + 1, None
156
+ while j < n:
157
+ c = text[j]
158
+ if js_str:
159
+ if c == "\\":
160
+ j += 2
161
+ continue
162
+ if c == js_str:
163
+ js_str = None
164
+ elif c in "\"'`":
165
+ js_str = c
166
+ elif c == "{":
167
+ depth += 1
168
+ elif c == "}":
169
+ depth -= 1
170
+ if depth == 0:
171
+ break
172
+ j += 1
173
+ regions.append((start, j + 1))
174
+ i = j + 1
175
+ continue
176
+ if ch in "\"'":
177
+ in_str = ch
178
+ elif ch == ";":
179
+ semis.append(i)
180
+ i += 1
181
+ return semis, regions
182
+
183
+
184
+ def _statement_blocks(body):
185
+ """Yield (kind, start, content) for each pre_operations/post_operations block."""
186
+ for m in re.finditer(r"\b(pre_operations|post_operations)\s*({)", body):
187
+ block = _balanced_block(body, m.start(2))
188
+ if block:
189
+ yield m.group(1), m.start(2) + 1, block[0]
190
+
191
+
192
+ def _main_projection(clean_body):
193
+ """Return (projection_text, from_clause_text, select_pos) of the first
194
+ top-level SELECT (CTE bodies and sub-selects sit inside parens/braces),
195
+ or None if no top-level SELECT exists."""
196
+ depth, in_str, i = 0, None, 0
197
+ select_pos = None
198
+ while i < len(clean_body):
199
+ ch = clean_body[i]
200
+ if in_str:
201
+ if ch == "\\":
202
+ i += 2
203
+ continue
204
+ if ch == in_str:
205
+ in_str = None
206
+ elif ch in "\"'`":
207
+ in_str = ch
208
+ elif ch in "([{":
209
+ depth += 1
210
+ elif ch in ")]}":
211
+ depth -= 1
212
+ elif depth == 0 and ch in "sSfF":
213
+ word = re.match(r"(select|from)\b", clean_body[i:], re.I)
214
+ if word and (
215
+ i == 0 or not (clean_body[i - 1].isalnum() or clean_body[i - 1] in "_$")
216
+ ):
217
+ if word.group(1).lower() == "select" and select_pos is None:
218
+ select_pos = i
219
+ elif word.group(1).lower() == "from" and select_pos is not None:
220
+ return (clean_body[select_pos + 6 : i], clean_body[i:], select_pos)
221
+ i += 1
222
+ if select_pos is not None: # SELECT without FROM (constants)
223
+ return (clean_body[select_pos + 6 :], "", select_pos)
224
+ return None
225
+
226
+
227
+ _SIMPLE_IDENT = re.compile(r"^`?[A-Za-z_]\w*`?(?:\.`?([A-Za-z_]\w*)`?)?$")
228
+ _TRAILING_ALIAS = re.compile(r"\bas\s+`?([A-Za-z_]\w*)`?\s*$", re.I | re.S)
229
+ _STAR_ITEM = re.compile(
230
+ r"^(?:[A-Za-z_]\w*\.)?\*\s*(?:except\s*\(([^)]*)\))?\s*(?:replace\s*\(.*\))?$",
231
+ re.I | re.S,
232
+ )
233
+ _SINGLE_REF_FROM = re.compile(
234
+ r"^from\s+\$\{\s*ref\(\s*(?:\"[^\"]*\"\s*,\s*)?\"([^\"]+)\"\s*\)\s*\}\s*"
235
+ r"(?:as\s+\w+\s*)?"
236
+ r"(?:where\b|group\b|order\b|qualify\b|limit\b|window\b|post_operations\b|$)",
237
+ re.I | re.S,
238
+ )
239
+
240
+
241
+ def _known_output_columns(clean_body, resolver, _depth=0, _seen=None):
242
+ """Best-effort set of output column names of a file's main SELECT.
243
+ Unparseable items are silently skipped; `select *` is followed through a
244
+ single plain ${ref()} when a resolver is provided. Never raises."""
245
+ parsed = _main_projection(clean_body)
246
+ if parsed is None:
247
+ return set()
248
+ projection, from_clause, _ = parsed
249
+ names, star_except = set(), None
250
+ items = _split_top_level(projection)
251
+ if items:
252
+ items[0] = re.sub(r"^\s*(distinct|all)\b", "", items[0], flags=re.I)
253
+ for item in items:
254
+ item = item.strip()
255
+ if not item:
256
+ continue
257
+ sm = _STAR_ITEM.match(item)
258
+ if sm:
259
+ star_except = {
260
+ n.strip().strip("`").lower()
261
+ for n in (sm.group(1) or "").split(",")
262
+ if n.strip()
263
+ }
264
+ continue
265
+ am = _TRAILING_ALIAS.search(item)
266
+ if am:
267
+ names.add(am.group(1).lower())
268
+ continue
269
+ im = _SIMPLE_IDENT.match(item)
270
+ if im:
271
+ names.add((im.group(1) or item.strip("`")).lower())
272
+ if star_except is not None and resolver is not None and _depth < 3:
273
+ rm = _SINGLE_REF_FROM.match(from_clause.strip())
274
+ if rm:
275
+ ref_name = rm.group(1)
276
+ _seen = _seen or set()
277
+ if ref_name not in _seen:
278
+ _seen.add(ref_name)
279
+ upstream = resolver(ref_name)
280
+ if upstream is not None:
281
+ up_names = _known_output_columns(
282
+ _strip_comments(upstream), resolver, _depth + 1, _seen
283
+ )
284
+ names |= up_names - star_except
285
+ return names
286
+
287
+
288
+ def _block_keys(config, key):
289
+ """Top-level keys of the `<key>: {}` block, lowercased; empty set if none."""
290
+ cm = re.search(rf"\b{key}\s*:\s*({{)", config)
291
+ block = _balanced_block(config, cm.start(1)) if cm else None
292
+ if not block:
293
+ return set()
294
+ keys = set()
295
+ for item in _split_top_level(block[0]):
296
+ km = re.match(r'\s*"?([A-Za-z_]\w*)"?\s*:', item)
297
+ if km:
298
+ keys.add(km.group(1).lower())
299
+ return keys
300
+
301
+
302
+ def _documented_keys(config):
303
+ return _block_keys(config, "columns")
304
+
305
+
306
+ # --- E006: hardcoded table paths, per warehouse -------------------------------
307
+
308
+ _IDENT = r'(?:"[^"\n]+"|`[^`\n]+`|[A-Za-z_]\w*)'
309
+ #: `schema.table` (any quoting per part) right after a table-introducing keyword.
310
+ _TWO_PART_AFTER_KEYWORD = re.compile(
311
+ rf"\b(?:from|join|into|update)\s+({_IDENT})\s*\.\s*({_IDENT})(?![\w.\"`(])",
312
+ re.I,
313
+ )
314
+ #: BigQuery-style whole-path in one pair of backticks: `proj.dataset.table`
315
+ _BQ_BACKTICK_3 = re.compile(r"`([A-Za-z][\w-]*\.[A-Za-z]\w*\.[A-Za-z]\w*)`")
316
+ #: `dataset.table` in one pair of backticks (BigQuery shorthand)
317
+ _BQ_BACKTICK_2 = re.compile(r"`([A-Za-z][\w-]*\.[A-Za-z]\w*)`")
318
+
319
+
320
+ #: An open extract/substring/trim/overlay/position call with no `)` yet.
321
+ _FUNCTION_FROM = re.compile(
322
+ r"\b(?:extract|substring|trim|overlay|position)\s*\([^()]*$", re.I
323
+ )
324
+
325
+
326
+ def _unquote(ident):
327
+ return ident.strip('"`').lower()
328
+
329
+
330
+ def _hardcoded_paths(clean_body, warehouse, allowed_schemas):
331
+ """Yield (pos, path) for table references that bypass ${ref()}."""
332
+ allowed = {s.lower() for s in allowed_schemas} | SYSTEM_SCHEMAS
333
+ for pm in _BQ_BACKTICK_3.finditer(clean_body):
334
+ yield pm.start(), f"`{pm.group(1)}`"
335
+ if warehouse == "bigquery":
336
+ for pm in _BQ_BACKTICK_2.finditer(clean_body):
337
+ yield pm.start(), f"`{pm.group(1)}`"
338
+ for pm in _TWO_PART_AFTER_KEYWORD.finditer(clean_body):
339
+ first, second = pm.group(1), pm.group(2)
340
+ schema = _unquote(first)
341
+ if schema in allowed or schema.startswith("pg_temp"):
342
+ continue
343
+ # `extract(dow from d.date)`, `substring(x from 1)`, `trim(both from x)`:
344
+ # FROM as function syntax, inside an open call paren.
345
+ if _FUNCTION_FROM.search(clean_body[max(0, pm.start() - 200) : pm.start()]):
346
+ continue
347
+ yield pm.start(1), f"{first}.{second}"
348
+
349
+
350
+ def lint_text(text, path, config: Config | None = None, resolver=None):
351
+ cfg = config or Config()
352
+ warehouse = cfg.effective_warehouse
353
+ findings: list[Finding] = []
354
+ path = path.replace("\\", "/")
355
+ stem = re.sub(r"\.sqlx$", "", path.rsplit("/", 1)[-1])
356
+ file_disabled = set(re.findall(r"sqlx-lint:\s*disable-file=([EWS]\d+)\b", text))
357
+ lines = text.split("\n")
358
+
359
+ def suppressed(code, line):
360
+ if code in file_disabled:
361
+ return True
362
+ if not (0 < line <= len(lines)):
363
+ return False
364
+ src = lines[line - 1]
365
+ return "sqlx-lint:" in src and re.search(rf"disable={code}\b", src) is not None
366
+
367
+ def add(code, severity, line, message):
368
+ if cfg.rule_on(code) and not suppressed(code, line):
369
+ findings.append(Finding(code, severity, line, message))
370
+
371
+ # --- S108: JIT compilation has no runtime in sqlanvil (checked pre-config) ---
372
+ for jm in re.finditer(r"\.jitCode\s*\(|\bjitData\s*\(", text):
373
+ add(
374
+ "S108",
375
+ "error",
376
+ _line_of(text, jm.start()),
377
+ "jitCode()/jitData() have no runtime in sqlanvil (compile error since "
378
+ "1.30) — generate the SQL at compile time instead",
379
+ )
380
+
381
+ # --- E001: locate and parse config block ---
382
+ m = re.search(r"\bconfig\s*({)", text)
383
+ if not m:
384
+ add("E001", "error", 1, "no config {} block found")
385
+ return findings
386
+ block = _balanced_block(text, m.start(1))
387
+ if block is None:
388
+ add(
389
+ "E001",
390
+ "error",
391
+ _line_of(text, m.start()),
392
+ "config {} block braces are unbalanced",
393
+ )
394
+ return findings
395
+ config_body, config_end = block
396
+ config_line = _line_of(text, m.start())
397
+ body = text[config_end:]
398
+ body_offset = config_end
399
+
400
+ ctype = _config_value(config_body, "type") or ""
401
+ schema = _config_value(config_body, "schema") or _config_value(config_body, "dataset")
402
+ name = _config_value(config_body, "name")
403
+ documented = _documented_keys(config_body)
404
+ column_types = _block_keys(config_body, "columnTypes")
405
+
406
+ # --- E002: columns documentation ---
407
+ # Declarations scaffolded by `sqlanvil introspect` carry columnTypes instead.
408
+ if ctype in cfg.documented_types and not documented:
409
+ if not (ctype == "declaration" and column_types):
410
+ add(
411
+ "E002",
412
+ "error",
413
+ config_line,
414
+ f'type "{ctype}" requires a non-empty columns: {{}} block',
415
+ )
416
+
417
+ # --- E003: schema suffix (declarations exempt: raw datasets may carry one) ---
418
+ suffix_re = "|".join(re.escape(s) for s in cfg.schema_suffixes)
419
+ if (
420
+ schema
421
+ and cfg.schema_suffixes
422
+ and ctype != "declaration"
423
+ and re.search(rf"(?:{suffix_re})$", schema)
424
+ ):
425
+ base = re.sub(rf"(?:{suffix_re})$", "", schema)
426
+ key = "schema" if _config_value(config_body, "schema") is not None else "dataset"
427
+ add(
428
+ "E003",
429
+ "error",
430
+ _config_key_line(config_body, key, config_line),
431
+ f'{key}: "{schema}" hardcodes an environment suffix; use the base '
432
+ f'name ("{base}") and let --schema-suffix or the environment\'s '
433
+ "schemaSuffix append it",
434
+ )
435
+
436
+ # --- E004: redundant name (declarations conventionally repeat it) ---
437
+ if name and ctype != "declaration" and name == stem:
438
+ add(
439
+ "E004",
440
+ "error",
441
+ _config_key_line(config_body, "name", config_line),
442
+ f'name: "{name}" matches the filename and is redundant — remove it',
443
+ )
444
+
445
+ # --- E005 (opt-in): schema on operations/assertions ---
446
+ # hasOutput: true operations are exempt: schema+name define ${self()}.
447
+ has_output = re.search(r"\bhasOutput\s*:\s*true\b", config_body) is not None
448
+ if schema and ctype in NO_SCHEMA_TYPES and not has_output:
449
+ add(
450
+ "E005",
451
+ "error",
452
+ config_line,
453
+ f'type "{ctype}" must not set schema: — '
454
+ "it uses the workflow_settings.yaml default",
455
+ )
456
+
457
+ # --- E006: hardcoded table paths in the SQL body ---
458
+ clean_body = _strip_comments(body)
459
+ for pos, table_path in _hardcoded_paths(clean_body, warehouse, cfg.allowed_schemas):
460
+ add(
461
+ "E006",
462
+ "error",
463
+ _line_of(text, body_offset + pos),
464
+ f"hardcoded table path {table_path} — declare a source and use ${{ref()}}",
465
+ )
466
+
467
+ # --- E007: directory policies ---
468
+ for policy in cfg.dir_policies:
469
+ if policy.path_contains not in path:
470
+ continue
471
+ if policy.require_prefix and not stem.startswith(policy.require_prefix):
472
+ add(
473
+ "E007",
474
+ policy.severity,
475
+ 1,
476
+ f"files in {policy.path_contains} must be prefixed "
477
+ f'"{policy.require_prefix}" (got "{stem}")',
478
+ )
479
+ if policy.require_types and ctype not in policy.require_types:
480
+ add(
481
+ "E007",
482
+ policy.severity,
483
+ config_line,
484
+ f"files in {policy.path_contains} must be type "
485
+ f'{" or ".join(policy.require_types)} (got "{ctype}")',
486
+ )
487
+
488
+ # --- E010: columns coverage (skip when E002 already owns the file) ---
489
+ in_scope = not cfg.coverage_paths or any(
490
+ p in path for p in cfg.coverage_paths
491
+ )
492
+ if ctype in ("table", "incremental", "view") and documented and in_scope:
493
+ known = _known_output_columns(clean_body, resolver)
494
+ missing = sorted(known - documented)
495
+ if missing:
496
+ parsed = _main_projection(clean_body)
497
+ sel_line = (
498
+ _line_of(text, body_offset + parsed[2]) if parsed else config_line
499
+ )
500
+ shown = ", ".join(f'"{n}"' for n in missing[:10])
501
+ more = f" (+{len(missing) - 10} more)" if len(missing) > 10 else ""
502
+ add(
503
+ "E010",
504
+ "error",
505
+ sel_line,
506
+ f"columns: {{}} is missing documentation for output "
507
+ f"column(s): {shown}{more}",
508
+ )
509
+
510
+ # --- W008 (opt-in): post_operations placement ---
511
+ pm = re.search(r"\bpost_operations\s*{", body)
512
+ if pm:
513
+ sm = re.search(r"(?im)^\s*select\b", _strip_comments(body[: pm.start()]))
514
+ if sm is None:
515
+ add(
516
+ "W008",
517
+ "warning",
518
+ _line_of(text, body_offset + pm.start()),
519
+ "post_operations {} placed before the main SELECT statement",
520
+ )
521
+
522
+ # --- S101 / S104: BigQuery-only config on another warehouse ---
523
+ if warehouse != "bigquery":
524
+ # Keys nested inside a bigquery: {} block are reported once, via the block.
525
+ bqm = re.search(r"\bbigquery\s*:\s*({)", config_body)
526
+ bq_block = _balanced_block(config_body, bqm.start(1)) if bqm else None
527
+ scan = config_body
528
+ if bq_block:
529
+ s0, s1 = bqm.start(1), bqm.start(1) + len(bq_block[0]) + 2
530
+ scan = config_body[:s0] + re.sub(r"[^\n]", " ", config_body[s0:s1]) + config_body[s1:]
531
+ for key in BIGQUERY_ONLY_KEYS:
532
+ if re.search(rf"\b{key}\s*:", scan):
533
+ add(
534
+ "S101",
535
+ "error",
536
+ _config_key_line(config_body, key, config_line),
537
+ f"{key}: is BigQuery-only and is ignored on {warehouse} — "
538
+ "use the postgres: {} block (indexes, partition) instead",
539
+ )
540
+ if re.search(r"\bincrementalStrategy\s*:", config_body):
541
+ add(
542
+ "S104",
543
+ "error",
544
+ _config_key_line(config_body, "incrementalStrategy", config_line),
545
+ f"incrementalStrategy: is BigQuery-only (compile error on {warehouse}) "
546
+ "— use the default merge with uniqueKey, or delete the range in "
547
+ "pre_operations",
548
+ )
549
+
550
+ # --- S103: postgres index method must be the numeric enum ---
551
+ pgm = re.search(r"\bpostgres\s*:\s*({)", config_body)
552
+ pg_block = _balanced_block(config_body, pgm.start(1)) if pgm else None
553
+ if pg_block:
554
+ for mm in re.finditer(r"\bmethod\s*:\s*([\"'])(\w+)\1", pg_block[0]):
555
+ add(
556
+ "S103",
557
+ "error",
558
+ config_line + config_body[: pgm.start() + 1 + mm.start()].count("\n"),
559
+ f'postgres.indexes[].method: "{mm.group(2)}" must be the numeric enum '
560
+ "(BTREE=0, HASH=1, GIN=2, GIST=3, BRIN=4); omit it for btree",
561
+ )
562
+
563
+ # --- S106: uniqueKey and uniqueKeys are mutually exclusive in assertions ---
564
+ am = re.search(r"\bassertions\s*:\s*({)", config_body)
565
+ a_block = _balanced_block(config_body, am.start(1)) if am else None
566
+ if a_block:
567
+ keys = _block_keys(config_body, "assertions")
568
+ if "uniquekey" in keys and "uniquekeys" in keys:
569
+ add(
570
+ "S106",
571
+ "error",
572
+ _config_key_line(config_body, "assertions", config_line),
573
+ "assertions: sets both uniqueKey and uniqueKeys — they are mutually "
574
+ "exclusive; fold the single key into uniqueKeys",
575
+ )
576
+
577
+ # --- S102 / S105: statement blocks (operations body, pre/post_operations) ---
578
+ blocks = list(_statement_blocks(body))
579
+ if ctype == "operations":
580
+ blocks.append(("operations", 0, body))
581
+
582
+ for kind, start, content in blocks:
583
+ semis, regions = _scan_sql(content)
584
+ for sp in semis:
585
+ rest = content[sp + 1 :]
586
+ # Ignore a trailing `;` — only `;` followed by another statement is a
587
+ # separator. Comments after it do not count as a statement.
588
+ if re.search(r"\S", _strip_comments(rest)):
589
+ add(
590
+ "S102",
591
+ "error",
592
+ _line_of(text, body_offset + start + sp),
593
+ f"`;` separates statements in {kind} — sqlanvil splits on a "
594
+ "`---` line, so this runs as one statement and fails at run time",
595
+ )
596
+ break
597
+ if ctype == "incremental" and kind != "operations":
598
+ for dm in re.finditer(
599
+ r"\bADD\s+(?:PRIMARY\s+KEY|CONSTRAINT)\b", content, re.I
600
+ ):
601
+ guarded = any(
602
+ s <= dm.start() < e
603
+ and re.search(
604
+ r"when\s*\(\s*!\s*incremental\s*\(\s*\)", content[s:e]
605
+ )
606
+ for s, e in regions
607
+ )
608
+ if not guarded:
609
+ add(
610
+ "S105",
611
+ "error",
612
+ _line_of(text, body_offset + start + dm.start()),
613
+ f"{dm.group(0)} in {kind} of an incremental runs on every "
614
+ "append and fails the second time — wrap it in "
615
+ "${when(!incremental(), `...`)}",
616
+ )
617
+
618
+ return findings
619
+
620
+
621
+ def lint_file(path, config: Config | None = None, resolver=None):
622
+ with open(path, encoding="utf-8") as fh:
623
+ return lint_text(fh.read(), path, config=config, resolver=resolver)
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlanvil-sqlx-lint
3
+ Version: 0.2.0
4
+ Summary: Convention linter for SQLAnvil .sqlx files — config-block, project-convention, and sqlanvil-delta checks that SQL linters and compile cannot see
5
+ Author-email: Ivan Histand <ivan@histand.net>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SQLAnvil/sqlanvil-sqlx-lint
8
+ Project-URL: Documentation, https://sqlanvil.com/docs
9
+ Project-URL: Upstream, https://github.com/acuantia/dataform-sqlx-lint
10
+ Keywords: sqlanvil,sqlx,lint,postgres,supabase,mysql,bigquery,dataform,pre-commit
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # sqlanvil-sqlx-lint
21
+
22
+ A convention linter for [SQLAnvil](https://sqlanvil.com) `.sqlx` files. SQL
23
+ linters (sqlfluff) check the SQL body; `sqlanvil compile` checks syntax and
24
+ `sqlanvil validate` checks against the warehouse. None of them see the
25
+ **config-block and project conventions** that keep a sqlanvil repo healthy, and
26
+ several Dataform habits that sqlanvil silently ignores or fails on at run time.
27
+ This tool does, in milliseconds, with no warehouse connection.
28
+
29
+ Zero dependencies (Python ≥ 3.11 standard library only). Designed for
30
+ [pre-commit](https://pre-commit.com). Warehouse-aware: PostgreSQL, Supabase,
31
+ MySQL/MariaDB, and BigQuery-target projects.
32
+
33
+ ```bash
34
+ pip install sqlanvil-sqlx-lint
35
+ ```
36
+
37
+ Forked from [acuantia/dataform-sqlx-lint](https://github.com/acuantia/dataform-sqlx-lint)
38
+ (MIT), which was extracted from a production Dataform repository.
39
+
40
+ ## Rules
41
+
42
+ ### Conventions
43
+
44
+ | Code | Default | Checks |
45
+ |------|---------|--------|
46
+ | E001 | on | `config {}` block present and balanced |
47
+ | E002 | on | non-empty `columns: {}` on tables/views/incrementals/declarations (declarations may carry `columnTypes: {}` from `sqlanvil introspect` instead) |
48
+ | E003 | on | `schema:` must not hardcode an environment suffix (`_prod`/`_dev`/`_test` by default) — `--schema-suffix` and `environments.<name>.schemaSuffix` append it, so a literal doubles up (`analytics_test_test`) |
49
+ | E004 | on | `name:` matching the filename is redundant (declarations exempt) |
50
+ | E005 | opt-in | operations/assertions must not set `schema:` (`hasOutput: true` operations exempt — schema+name define `${self()}`) |
51
+ | E006 | on | hardcoded table paths instead of `${ref()}` — these silently break the dependency graph. Warehouse-aware: `public.orders`, `"schema"."table"`, `` `db`.`table` ``, `` `project.dataset.table` `` |
52
+ | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
53
+ | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference) |
54
+ | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
55
+
56
+ Why E002/E010 matter: sqlanvil writes `description:` and `columns: {}` into the
57
+ warehouse catalog (`COMMENT ON TABLE` / `COMMENT ON COLUMN` on Postgres, table
58
+ and column descriptions on BigQuery). That is the metadata data catalogs, BI
59
+ tools, and AI analytics agents read. Partial blocks leave silent gaps.
60
+
61
+ ### sqlanvil deltas
62
+
63
+ The places where a Dataform/BigQuery habit produces a sqlanvil project that
64
+ compiles but does the wrong thing. The first three are failures no compiler
65
+ catches.
66
+
67
+ | Code | Default | Checks | `sqlanvil compile` catches it? |
68
+ |------|---------|--------|-------------------------------|
69
+ | S101 | on | `bigquery: {}`, `partitionBy`, `clusterBy`, `bigqueryPolicyTags` on a non-BigQuery warehouse — ignored silently, never applied | no |
70
+ | S102 | on | `;` separating statements in `operations` / `pre_operations` / `post_operations` — sqlanvil splits on a `---` line, so the block runs as one statement and fails at run time | no |
71
+ | S105 | on | `ADD PRIMARY KEY` / `ADD CONSTRAINT` in an incremental's operations block not wrapped in `${when(!incremental(), …)}` — runs on every append and errors the second time | no |
72
+ | S103 | on | `postgres.indexes[].method` given as a string — it is a numeric enum (`BTREE=0, HASH=1, GIN=2, GIST=3, BRIN=4`) | yes |
73
+ | S104 | on | `incrementalStrategy` on a non-BigQuery warehouse | yes (≥1.29) |
74
+ | S106 | on | `assertions:` sets both `uniqueKey` and `uniqueKeys` | yes |
75
+ | S108 | on | `.jitCode()` / `jitData()` — no runtime in sqlanvil | yes (≥1.30) |
76
+
77
+ ## Usage
78
+
79
+ ```bash
80
+ sqlanvil-sqlx-lint definitions/outputs/my_table.sqlx [...]
81
+ # exit 0 = clean or warnings only; 1 = errors; 2 = bad config
82
+ ```
83
+
84
+ Run from the project root. The target warehouse is read from
85
+ `workflow_settings.yaml` there (`warehouse: postgres|supabase|mysql|bigquery`),
86
+ and `--definitions-root` (default `./definitions`) is indexed so `${ref()}`
87
+ targets resolve for E010's star-resolution. Override with `--warehouse` or the
88
+ `warehouse` config key; the default with nothing configured is `postgres`.
89
+
90
+ ### pre-commit
91
+
92
+ ```yaml
93
+ repos:
94
+ - repo: https://github.com/SQLAnvil/sqlanvil-sqlx-lint
95
+ rev: v0.2.0
96
+ hooks:
97
+ - id: sqlanvil-sqlx-lint
98
+ ```
99
+
100
+ ### Configuration
101
+
102
+ `.sqlx-lint.toml` in the project root, or a `[tool.sqlx-lint]` table in
103
+ `pyproject.toml` (the standalone file wins). All keys optional:
104
+
105
+ ```toml
106
+ warehouse = "postgres" # else workflow_settings.yaml, else postgres
107
+ schema_suffixes = ["_prod", "_dev", "_test"] # E003 suffix list ([] disables)
108
+ documented_types = ["table", "view", "incremental", "declaration"] # E002
109
+ coverage_paths = ["definitions/outputs/"] # E010 scope; empty = everywhere
110
+ allowed_schemas = ["extensions"] # E006 may reference these directly
111
+ enable = ["E005", "W008"] # switch on opt-in rules
112
+ disable = ["E004"] # switch off default rules
113
+
114
+ [[dir_policies]] # E007 (repeatable)
115
+ path_contains = "definitions/outputs/"
116
+ require_prefix = "rpt_"
117
+ require_types = ["table", "incremental"]
118
+ severity = "error" # or "warning"
119
+ ```
120
+
121
+ See [`examples/sqlx-lint.toml`](examples/sqlx-lint.toml) for a complete
122
+ sources → intermediate → outputs layout. System catalogs (`pg_catalog`,
123
+ `information_schema`, `mysql`, `sys`, …) are always allowed.
124
+
125
+ ### Adopting on an existing project
126
+
127
+ A migrated repository will be loud on the first run: E002 on every undocumented
128
+ model and E010 wherever documentation is partial. Adopt in layers rather than
129
+ suppressing:
130
+
131
+ 1. Start with `disable = ["E002", "E010"]` so the S-series and E006 findings,
132
+ which are actual defects, land first.
133
+ 2. Re-enable E002 and scope E010 with `coverage_paths` to the BI-facing layer.
134
+ 3. Widen `coverage_paths` as documentation catches up.
135
+
136
+ ### Suppressing findings
137
+
138
+ ```sql
139
+ from public.legacy_events -- sqlx-lint: disable=E006 (declaration repoints at cutover)
140
+ ```
141
+
142
+ or file-wide, anywhere in the file:
143
+
144
+ ```sql
145
+ -- sqlx-lint: disable-file=E006
146
+ ```
147
+
148
+ Suppress with a reason, sparingly — the convention is usually the fix.
149
+
150
+ ## Design notes
151
+
152
+ - **Conservative by construction**: the SQL projection parser only claims
153
+ column names it can determine (aliases, simple identifiers, resolvable
154
+ `select *`); anything ambiguous is skipped, so E010 never false-flags.
155
+ - **E006 looks only at table positions** (`FROM`, `JOIN`, `INTO`, `UPDATE`),
156
+ ignores `FROM` used as function syntax (`extract(dow from d.date)`), function
157
+ calls (`public.my_func(1)`), and anything inside `${…}`.
158
+ - **S102 understands sqlanvil's text**: `;` inside string literals, `$$`
159
+ dollar-quoted PL/pgSQL bodies, and `${…}` JavaScript regions never count; a
160
+ single trailing `;` is fine.
161
+ - **Declarations are exempt** from E003/E004 deliberately: raw source schemas
162
+ legitimately carry environment-suffixed names, and `name:` is required.
163
+ - Rule codes are stable; gaps in the numbering are historical.
164
+
165
+ ## Agent Skill
166
+
167
+ The `sqlanvil-sqlx-lint` Agent Skill teaches AI coding agents (Claude Code,
168
+ Codex CLI, Cursor, or any tool supporting the open
169
+ [Agent Skills](https://agentskills.io/) format) to run this linter on every
170
+ `.sqlx` file they create or modify. It lives with the other SQLAnvil skills:
171
+
172
+ ```bash
173
+ npx skills add SQLAnvil/agent-skills
174
+ ```
175
+
176
+ Source: https://github.com/SQLAnvil/agent-skills
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ python3 -m venv .venv && .venv/bin/pip install -e . pytest
182
+ .venv/bin/pytest # 103 tests
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE](https://github.com/SQLAnvil/sqlanvil-sqlx-lint/blob/main/LICENSE).
@@ -0,0 +1,10 @@
1
+ sqlanvil_sqlx_lint/__init__.py,sha256=vZ2G-6qui9zFl1uP3z4kexym87fWLyb_8dhREUszDs0,392
2
+ sqlanvil_sqlx_lint/cli.py,sha256=MyGUcUk7O6NUvW8TnzvzKQ4zic3Kknz_M_NQOeuPNl8,2988
3
+ sqlanvil_sqlx_lint/config.py,sha256=aWr25wHD-dUDWHUksFBCTG3G7cAS32NzlUQcsoGTDR4,7102
4
+ sqlanvil_sqlx_lint/linter.py,sha256=wTqqhKddVFUoDb-AtjBVQvsTLNsUfeyFistOHHunhU8,23520
5
+ sqlanvil_sqlx_lint-0.2.0.dist-info/licenses/LICENSE,sha256=sEjSXURI0VV68CEdvtVXe2Kfa-ML9me5UvEuYzjwIHI,1164
6
+ sqlanvil_sqlx_lint-0.2.0.dist-info/METADATA,sha256=OJC5nnwUAG1IFsDpcWggN5aSOtZiPQ8jEXwqbegwxqU,8687
7
+ sqlanvil_sqlx_lint-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ sqlanvil_sqlx_lint-0.2.0.dist-info/entry_points.txt,sha256=PZM-WS2yaoWrqLOqziFhVui0IkdzVIBlD_xv4rTPwiQ,73
9
+ sqlanvil_sqlx_lint-0.2.0.dist-info/top_level.txt,sha256=jxsJtUo4ZiGgVLLX6pGy9vxqv6ssKiaNQbj0tzKp3HM,19
10
+ sqlanvil_sqlx_lint-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlanvil-sqlx-lint = sqlanvil_sqlx_lint.cli:entrypoint
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Acuantia (original dataform-sqlx-lint)
4
+ Copyright (c) 2026 Ivan Histand / SQLAnvil (sqlanvil-sqlx-lint fork)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sqlanvil_sqlx_lint