@1aboveio/skills 1.0.1 → 1.0.3

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 (91) hide show
  1. package/README.md +25 -8
  2. package/bin/select-core.mjs +182 -50
  3. package/extensions/hide-copies.ts +21 -0
  4. package/package.json +116 -3
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +1 -1
  7. package/skills/app-dev/app-debug/SKILL.md +1 -1
  8. package/skills/app-dev/better-auth/SKILL.md +1 -1
  9. package/skills/app-dev/data-catalog/SKILL.md +2 -1
  10. package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +2 -1
  11. package/skills/app-dev/data-catalog/references/field-definitions.md +2 -1
  12. package/skills/app-dev/data-catalog/references/format.md +6 -2
  13. package/skills/app-dev/data-catalog/scripts/data_catalog.py +15 -3
  14. package/skills/app-dev/data-catalog/scripts/persist-conventions-core.mjs +3 -2
  15. package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +15 -0
  16. package/skills/app-dev/data-quality/LICENSE +3 -0
  17. package/skills/app-dev/data-quality/SKILL.md +96 -0
  18. package/skills/app-dev/data-quality/agents/openai.yaml +3 -0
  19. package/skills/app-dev/data-quality/references/checks.md +56 -0
  20. package/skills/app-dev/data-quality/release-check.mjs +93 -0
  21. package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +12 -0
  22. package/skills/app-dev/data-quality/scripts/data_quality.py +405 -0
  23. package/skills/app-dev/data-quality/scripts/test_data_quality.py +277 -0
  24. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  25. package/skills/app-dev/monorepo/SKILL.md +1 -1
  26. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  27. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  28. package/skills/app-dev/pyspark/SKILL.md +1 -1
  29. package/skills/app-dev/python-backend/SKILL.md +1 -1
  30. package/skills/app-dev/shadcn/SKILL.md +1 -1
  31. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  32. package/skills/cicd/cloud-build/SKILL.md +1 -1
  33. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  34. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  35. package/skills/cicd/docker/SKILL.md +1 -1
  36. package/skills/cicd/github-actions/SKILL.md +1 -1
  37. package/skills/cicd/github-flow/SKILL.md +1 -1
  38. package/skills/cicd/github-flow/scripts/persist-conventions-core.mjs +3 -2
  39. package/skills/cicd/google-cloud/SKILL.md +1 -1
  40. package/skills/cicd/mergify/SKILL.md +1 -1
  41. package/skills/cicd/podman/SKILL.md +1 -1
  42. package/skills/content/article-review/SKILL.md +1 -1
  43. package/skills/content/article-to-image/SKILL.md +1 -1
  44. package/skills/content/flowchart-svg/SKILL.md +1 -1
  45. package/skills/content/image-gen/SKILL.md +1 -1
  46. package/skills/content/mermaid/SKILL.md +1 -1
  47. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  48. package/skills/content/wechat-publisher/SKILL.md +1 -1
  49. package/skills/content/wechat-search/SKILL.md +1 -1
  50. package/skills/content/xai-search/SKILL.md +1 -1
  51. package/skills/core/distill-writing/SKILL.md +1 -1
  52. package/skills/core/doc-to-md/SKILL.md +1 -1
  53. package/skills/core/editorial-review/SKILL.md +1 -1
  54. package/skills/core/house-style/SKILL.md +1 -1
  55. package/skills/core/plain-language/LICENSE +3 -0
  56. package/skills/core/plain-language/SKILL.md +57 -0
  57. package/skills/core/plain-language/release-check.mjs +93 -0
  58. package/skills/core/polish-cn/SKILL.md +1 -1
  59. package/skills/core/reg-parser/SKILL.md +1 -1
  60. package/skills/core/terminology/SKILL.md +1 -1
  61. package/skills/core/write-doc/SKILL.md +1 -1
  62. package/skills/engineering/better-review/SKILL.md +1 -1
  63. package/skills/engineering/contributing/LICENSE +3 -0
  64. package/skills/engineering/contributing/SKILL.md +70 -0
  65. package/skills/engineering/contributing/references/tracker-conventions.md +18 -0
  66. package/skills/engineering/contributing/release-check.mjs +93 -0
  67. package/skills/engineering/contributing/scripts/persist-conventions-core.mjs +118 -0
  68. package/skills/engineering/contributing/scripts/persist-conventions.mjs +84 -0
  69. package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +118 -0
  70. package/skills/engineering/e2e-test/SKILL.md +1 -1
  71. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  72. package/skills/engineering/engineering-runtime/coherence/workflow.json +29 -14
  73. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -0
  74. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  75. package/skills/engineering/golden-path/SKILL.md +1 -1
  76. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  77. package/skills/engineering/resolve-issues/SKILL.md +1 -1
  78. package/skills/engineering/resolve-release/SKILL.md +1 -1
  79. package/skills/engineering/rush-issues/SKILL.md +1 -1
  80. package/skills/engineering/rush-release/SKILL.md +1 -1
  81. package/skills/engineering/test-quality/SKILL.md +1 -1
  82. package/skills/engineering/test-quality/scripts/persist-conventions-core.mjs +3 -2
  83. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  84. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  85. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  86. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  87. package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
  88. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  89. package/skills/risk-compliance/report-writing/SKILL.md +1 -1
  90. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  91. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """Data quality engine: check a published table's data against its data-catalog definitions.
3
+
4
+ Commands:
5
+ plan write the read-only SELECT checks for documented tables (JSON)
6
+ judge turn query results into findings
7
+ run plan, execute through a runner, and judge in one step
8
+ version print the engine identity
9
+
10
+ Definitions come from the repo's data-catalog engine (scripts/data-catalog/data_catalog.py),
11
+ so a table is checkable once its script carries CATALOG_TABLE / CATALOG_FIELDS or a
12
+ `-- @field` block. Every query is a single SELECT; nothing is ever written.
13
+
14
+ Runner contract (run --runner CMD): CMD reads one SQL statement on stdin and prints the
15
+ result rows on stdout as a JSON array of objects, or as CSV with a header row.
16
+
17
+ Standard library only.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import csv
23
+ import hashlib
24
+ import importlib.util
25
+ import io
26
+ import json
27
+ import re
28
+ import shlex
29
+ import sqlite3
30
+ import subprocess
31
+ import sys
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ ENGINE_VERSION = "1.0.0"
36
+ CATALOG_ENGINE = "scripts/data-catalog/data_catalog.py"
37
+ NUMERIC_UNITS = {"count", "usd", "cny", "eur", "gbp", "hkd", "amount", "ratio", "percent", "seconds", "days", "score"}
38
+ NON_NEGATIVE_UNITS = {"count", "seconds", "days"}
39
+ RANGES = {"ratio": (0.0, 1.0), "percent": (0.0, 100.0)}
40
+ DOCUMENTED_CONSTANT_RE = re.compile(r"始终|恒为|目前.{0,8}(为空|不填)|always|constant|never (filled|set)|not filled", re.I)
41
+ PROVENANCE_RE = re.compile(r"(^|_)(version|sha\d*|hash|checksum|source_table|compute_time|etl_time)$", re.I)
42
+ FLAG_RE = re.compile(r"(_missing|_flag)$|^(is|has)_", re.I) # healthy when constant at 0 / N
43
+ CLEAR_FLAG = {"0", "0.0", "false", "n", "no"}
44
+ PROFILE_BATCH = 40 # columns per profile query: each COUNT(DISTINCT) is a stage, and engines cap stages
45
+ MOSTLY_EMPTY = 0.01
46
+ CONSTANT_MIN_ROWS = 30 # below this, one distinct value says little
47
+ NULL_KEY = "<null>"
48
+
49
+
50
+ class QualityError(Exception):
51
+ """A usage or setup problem; exit code 2."""
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Definitions, through the repo's data-catalog engine
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def load_catalog_engine(root: Path, path: str | None):
59
+ engine = Path(path) if path else root / CATALOG_ENGINE
60
+ if not engine.is_file():
61
+ raise QualityError(f"no data-catalog engine at {engine}; set the repo up with the data-catalog skill first "
62
+ "(its init copies the engine), or pass --catalog-engine")
63
+ spec = importlib.util.spec_from_file_location("data_catalog_for_quality", engine)
64
+ module = importlib.util.module_from_spec(spec)
65
+ sys.modules[spec.name] = module # dataclasses resolve string annotations through sys.modules
66
+ spec.loader.exec_module(module)
67
+ return module
68
+
69
+
70
+ def documented_tables(root: Path, engine_path: str | None, wanted: list) -> list:
71
+ dc = load_catalog_engine(root, engine_path)
72
+ try:
73
+ config = dc.load_config(root)
74
+ tables, errors, _ = dc.collect(root, config)
75
+ except dc.CatalogError as exc:
76
+ raise QualityError(f"data-catalog: {exc}") from exc
77
+ if errors:
78
+ raise QualityError("data-catalog metadata has errors; run its generate first:\n " + "\n ".join(errors))
79
+ if wanted:
80
+ missing = [w for w in wanted if w not in {t.name for t in tables}]
81
+ if missing:
82
+ raise QualityError(f"not documented in the catalog: {', '.join(missing)}")
83
+ tables = [t for t in tables if t.name in wanted]
84
+ if not tables:
85
+ raise QualityError("no documented tables to check")
86
+ return tables
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Plan: one profile query per table, one value query per enumerated column
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def quote(name: str, dialect: str) -> str:
94
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
95
+ raise QualityError(f"column name {name!r} cannot be quoted safely")
96
+ return f"`{name}`" if dialect == "spark" else f'"{name}"'
97
+
98
+
99
+ def table_ref(name: str) -> str:
100
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}", name):
101
+ raise QualityError(f"table name {name!r} is not a plain identifier")
102
+ return name
103
+
104
+
105
+ def allowed(f) -> tuple:
106
+ """Declared values; empty for engine copies older than `Field.values`."""
107
+ return tuple(getattr(f, "values", ()) or ())
108
+
109
+
110
+ def is_numeric(f) -> bool:
111
+ return (f.unit or "").strip().lower() in NUMERIC_UNITS
112
+
113
+
114
+ def plan_queries(tables: list, where: str | None, dialect: str, skip: set, batch: int = PROFILE_BATCH,
115
+ key: list | None = None) -> list:
116
+ cond = f" WHERE {where}" if where else ""
117
+ queries = []
118
+ for t in tables:
119
+ if key:
120
+ unknown = [k for k in key if k not in {f.name for f in t.fields}]
121
+ if unknown:
122
+ raise QualityError(f"--key column(s) not documented in {t.name}: {', '.join(unknown)}")
123
+ cols = ", ".join(quote(k, dialect) for k in key)
124
+ sql = (f"SELECT COUNT(*) AS dup_keys, COALESCE(SUM(c), 0) AS dup_rows FROM "
125
+ f"(SELECT COUNT(*) AS c FROM {table_ref(t.name)}{cond} GROUP BY {cols} HAVING COUNT(*) > 1) dup")
126
+ queries.append({"id": f"key:{t.name}", "table": t.name, "kind": "key", "columns": list(key), "sql": sql})
127
+ cols = [f for f in t.fields if f.name not in skip]
128
+ chunks = [cols[i:i + batch] for i in range(0, len(cols), batch)] or [[]]
129
+ for k, chunk in enumerate(chunks):
130
+ parts = ["COUNT(*) AS n"]
131
+ for i, f in enumerate(chunk):
132
+ q = quote(f.name, dialect)
133
+ parts += [f"COUNT({q}) AS nn_{i}", f"COUNT(DISTINCT {q}) AS d_{i}"]
134
+ if is_numeric(f) or FLAG_RE.search(f.name):
135
+ parts += [f"MIN({q}) AS mn_{i}", f"MAX({q}) AS mx_{i}"]
136
+ sql = f"SELECT {', '.join(parts)} FROM {table_ref(t.name)}{cond}"
137
+ suffix = f"#{k + 1}" if len(chunks) > 1 else ""
138
+ queries.append({"id": f"profile:{t.name}{suffix}", "table": t.name, "kind": "profile", "part": k,
139
+ "columns": [f.name for f in chunk], "sql": sql})
140
+ for f in cols:
141
+ if allowed(f):
142
+ q = quote(f.name, dialect)
143
+ sql = f"SELECT {q} AS value, COUNT(*) AS n FROM {table_ref(t.name)}{cond} GROUP BY {q}"
144
+ queries.append({"id": f"values:{t.name}.{f.name}", "table": t.name, "kind": "values",
145
+ "column": f.name, "sql": sql})
146
+ for q in queries:
147
+ assert_read_only(q["sql"])
148
+ return queries
149
+
150
+
151
+ def assert_read_only(sql: str) -> None:
152
+ if not re.match(r"\s*SELECT\b", sql, re.I) or ";" in sql:
153
+ raise QualityError(f"refusing to run a statement that is not a single SELECT: {sql[:80]}")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Judge: results -> findings
158
+ # ---------------------------------------------------------------------------
159
+
160
+ @dataclass
161
+ class Finding:
162
+ table: str
163
+ column: str | None
164
+ kind: str
165
+ message: str
166
+ documented: bool = False # the definition already says so: report as info, not a problem
167
+
168
+
169
+ def number(value):
170
+ if value is None or value == "":
171
+ return None
172
+ try:
173
+ return float(value)
174
+ except (TypeError, ValueError):
175
+ return None
176
+
177
+
178
+ def where_columns(where: str | None) -> set:
179
+ """Identifiers a --where condition names: those columns are constant by construction."""
180
+ if not where:
181
+ return set()
182
+ bare = re.sub(r"'(?:[^']|'')*'", " ", where)
183
+ return {w.strip('`"').lower() for w in re.findall(r'[`"]?[A-Za-z_][A-Za-z0-9_]*[`"]?', bare)}
184
+
185
+
186
+ def judge(tables: list, queries: list, results: dict, where: str | None = None,
187
+ constant_min_rows: int = CONSTANT_MIN_ROWS) -> list:
188
+ by_table = {t.name: t for t in tables}
189
+ pinned = where_columns(where)
190
+ findings = []
191
+ for q in queries:
192
+ rows = results.get(q["id"])
193
+ if rows is None:
194
+ findings.append(Finding(q["table"], q.get("column"), "not-run", f"no result for {q['id']}"))
195
+ continue
196
+ t = by_table[q["table"]]
197
+ if q["kind"] == "key":
198
+ row = {k.lower(): v for k, v in (rows[0] if rows else {}).items()}
199
+ keys = number(row.get("dup_keys")) or 0
200
+ if keys:
201
+ findings.append(Finding(t.name, None, "duplicate-key",
202
+ f"{int(keys)} ({', '.join(q['columns'])}) value(s) repeat across "
203
+ f"{int(number(row.get('dup_rows')) or 0)} rows; the grain allows one each"))
204
+ continue
205
+ if q["kind"] == "profile":
206
+ part = judge_profile(t, q["columns"], rows, pinned, constant_min_rows)
207
+ if q.get("part", 0) > 0: # every batch sees the same row count; report an empty range once
208
+ part = [f for f in part if f.kind != "empty-table"]
209
+ findings += part
210
+ else:
211
+ findings += judge_values(t, q["column"], rows)
212
+ return findings
213
+
214
+
215
+ def judge_profile(t, columns: list, rows: list, pinned: set, constant_min_rows: int) -> list:
216
+ if len(rows) != 1:
217
+ return [Finding(t.name, None, "not-run", f"profile returned {len(rows)} rows, expected 1")]
218
+ row = {k.lower(): v for k, v in rows[0].items()}
219
+ n = number(row.get("n")) or 0
220
+ if n == 0:
221
+ return [Finding(t.name, None, "empty-table", "no rows in the checked range; nothing else can be judged")]
222
+ fields = {f.name: f for f in t.fields}
223
+ out = []
224
+ for i, name in enumerate(columns):
225
+ f = fields[name]
226
+ text = " ".join([*f.text.values(), f.source])
227
+ documented = bool(DOCUMENTED_CONSTANT_RE.search(text))
228
+ filled = number(row.get(f"nn_{i}")) or 0
229
+ distinct = number(row.get(f"d_{i}")) or 0
230
+ lo, hi = number(row.get(f"mn_{i}")), number(row.get(f"mx_{i}"))
231
+ if filled == 0:
232
+ out.append(Finding(t.name, name, "never-filled", f"empty in all {int(n)} rows", documented))
233
+ continue
234
+ if filled / n < MOSTLY_EMPTY:
235
+ out.append(Finding(t.name, name, "mostly-empty", f"filled in {int(filled)} of {int(n)} rows"))
236
+ if distinct == 1 and n >= constant_min_rows and name.lower() not in pinned:
237
+ value = row.get(f"mn_{i}")
238
+ shown = f" ({value})" if value is not None else ""
239
+ clear_flag = FLAG_RE.search(name) and str(row.get(f"mn_{i}")).strip().lower() in CLEAR_FLAG
240
+ # a build's version is the same on every row; a missing-flag at 0 means nothing was missing
241
+ expected = documented or bool(PROVENANCE_RE.search(name)) or bool(clear_flag)
242
+ out.append(Finding(t.name, name, "constant", f"one value{shown} in all {int(filled)} filled rows", expected))
243
+ unit = (f.unit or "").strip().lower()
244
+ if unit in NON_NEGATIVE_UNITS and lo is not None and lo < 0:
245
+ out.append(Finding(t.name, name, "negative-value", f"minimum {lo:g}, but the unit is {f.unit}"))
246
+ if unit in RANGES and lo is not None and hi is not None:
247
+ low, high = RANGES[unit]
248
+ if lo < low or hi > high:
249
+ out.append(Finding(t.name, name, "out-of-range",
250
+ f"values from {lo:g} to {hi:g}, outside {low:g}–{high:g} for unit {f.unit}"))
251
+ return out
252
+
253
+
254
+ def judge_values(t, column: str, rows: list) -> list:
255
+ f = next(f for f in t.fields if f.name == column)
256
+ seen = {}
257
+ for r in rows:
258
+ r = {k.lower(): v for k, v in r.items()}
259
+ value = r.get("value")
260
+ empty = value is None or (value == "" and "" not in allowed(f))
261
+ key = NULL_KEY if empty else str(value)
262
+ seen[key] = seen.get(key, 0) + (number(r.get("n")) or 0)
263
+ declared = set(allowed(f))
264
+ out = []
265
+ unseen = [v for v in allowed(f) if v not in seen]
266
+ if unseen and seen:
267
+ out.append(Finding(t.name, column, "unseen-value", f"declared but never present: {', '.join(unseen)}"))
268
+ extra = sorted(k for k in seen if k not in declared and k != NULL_KEY)
269
+ if extra:
270
+ counts = ", ".join(f"{k} ({int(seen[k])})" for k in extra[:10])
271
+ out.append(Finding(t.name, column, "undeclared-value", f"present but not declared: {counts}"))
272
+ return out
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Runners
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def parse_rows(text: str) -> list:
280
+ text = text.strip()
281
+ if not text:
282
+ return []
283
+ if text[0] == "[":
284
+ return json.loads(text)
285
+ if text[0] == "{": # one object, or JSON lines (trino/presto --output-format JSON)
286
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
287
+ return [dict(r) for r in csv.DictReader(io.StringIO(text))]
288
+
289
+
290
+ def run_command(cmd: str, sql: str) -> list:
291
+ proc = subprocess.run(shlex.split(cmd), input=sql, capture_output=True, text=True)
292
+ if proc.returncode != 0:
293
+ raise QualityError(f"runner failed ({proc.returncode}) on: {sql[:80]}\n{proc.stderr.strip()[-400:]}")
294
+ return parse_rows(proc.stdout)
295
+
296
+
297
+ def run_sqlite(db: str, sql: str) -> list:
298
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) # read-only by construction
299
+ try:
300
+ cur = con.execute(sql)
301
+ names = [d[0] for d in cur.description]
302
+ return [dict(zip(names, row)) for row in cur.fetchall()]
303
+ finally:
304
+ con.close()
305
+
306
+
307
+ def execute(queries: list, runner: str | None, sqlite_db: str | None) -> dict:
308
+ results = {}
309
+ for q in queries:
310
+ assert_read_only(q["sql"])
311
+ results[q["id"]] = run_sqlite(sqlite_db, q["sql"]) if sqlite_db else run_command(runner, q["sql"])
312
+ return results
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Report and CLI
317
+ # ---------------------------------------------------------------------------
318
+
319
+ def report(findings: list, tables: list, where: str | None, as_json: bool) -> int:
320
+ problems = [f for f in findings if not f.documented]
321
+ if as_json:
322
+ print(json.dumps({"engine": ENGINE_VERSION, "where": where, "tables": [t.name for t in tables],
323
+ "problems": len(problems),
324
+ "findings": [vars(f) for f in findings]}, ensure_ascii=False, indent=2))
325
+ return 1 if problems else 0
326
+ scope = f" where {where}" if where else " (all rows)"
327
+ print(f"data-quality: {len(tables)} table(s){scope}; {len(problems)} problem(s), "
328
+ f"{len(findings) - len(problems)} already documented")
329
+ for t in tables:
330
+ mine = [f for f in findings if f.table == t.name]
331
+ if not mine:
332
+ continue
333
+ print(f"\n{t.name}")
334
+ for f in mine:
335
+ tag = " (documented)" if f.documented else ""
336
+ print(f" - {f.kind}{tag}: {f.column + ': ' if f.column else ''}{f.message}")
337
+ return 1 if problems else 0
338
+
339
+
340
+ def engine_identity() -> str:
341
+ return f"{ENGINE_VERSION}+{hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]}"
342
+
343
+
344
+ def find_root(start: Path) -> Path:
345
+ for candidate in [start, *start.parents]:
346
+ if (candidate / "data-catalog.json").exists() or (candidate / ".git").exists():
347
+ return candidate
348
+ return start
349
+
350
+
351
+ def main(argv: list | None = None) -> int:
352
+ parser = argparse.ArgumentParser(prog="data_quality.py", description=__doc__.split("\n\n")[0])
353
+ parser.add_argument("--root", type=Path, help="repo root (default: nearest dir with data-catalog.json or .git)")
354
+ parser.add_argument("--catalog-engine", help=f"path to data_catalog.py (default <root>/{CATALOG_ENGINE})")
355
+ sub = parser.add_subparsers(dest="command", required=True)
356
+
357
+ def scoped(p):
358
+ p.add_argument("--table", action="append", default=[], help="documented table to check; repeatable (default all)")
359
+ p.add_argument("--where", help="SQL condition that bounds the scan, e.g. \"dt >= '2026-10-01'\"")
360
+ p.add_argument("--dialect", choices=["ansi", "spark"], default="ansi", help="identifier quoting: ansi \"x\", spark `x`")
361
+ p.add_argument("--skip", action="append", default=[], help="column to leave out (e.g. arrays); repeatable")
362
+ p.add_argument("--key", action="append", default=[],
363
+ help="grain column; repeat for a composite key: --key dt --key cust_id")
364
+ p.add_argument("--batch", type=int, default=PROFILE_BATCH,
365
+ help=f"columns per profile query (default {PROFILE_BATCH}); lower it if the engine caps stages")
366
+ p.add_argument("--constant-min-rows", type=int, default=CONSTANT_MIN_ROWS,
367
+ help=f"rows needed before a single value counts as constant (default {CONSTANT_MIN_ROWS})")
368
+
369
+ p_plan = sub.add_parser("plan", help="print the SELECT checks as JSON")
370
+ scoped(p_plan)
371
+ p_judge = sub.add_parser("judge", help="judge results; --results maps query id to rows")
372
+ scoped(p_judge)
373
+ p_judge.add_argument("--results", required=True, help="JSON file: {query id: [row objects]}")
374
+ p_judge.add_argument("--json", action="store_true")
375
+ p_run = sub.add_parser("run", help="plan, execute, judge")
376
+ scoped(p_run)
377
+ group = p_run.add_mutually_exclusive_group(required=True)
378
+ group.add_argument("--runner", help="command that reads SQL on stdin and prints JSON or CSV rows")
379
+ group.add_argument("--sqlite", help="SQLite database file (opened read-only)")
380
+ p_run.add_argument("--json", action="store_true")
381
+ sub.add_parser("version", help="print the engine identity")
382
+ args = parser.parse_args(argv)
383
+ if args.command == "version":
384
+ print(engine_identity())
385
+ return 0
386
+ root = (args.root or find_root(Path.cwd())).resolve()
387
+ try:
388
+ tables = documented_tables(root, args.catalog_engine, args.table)
389
+ queries = plan_queries(tables, args.where, args.dialect, set(args.skip), args.batch, args.key)
390
+ if args.command == "plan":
391
+ print(json.dumps(queries, ensure_ascii=False, indent=2))
392
+ return 0
393
+ if args.command == "judge":
394
+ results = json.loads(Path(args.results).read_text(encoding="utf-8"))
395
+ else:
396
+ results = execute(queries, args.runner, args.sqlite)
397
+ found = judge(tables, queries, results, args.where, args.constant_min_rows)
398
+ return report(found, tables, args.where, args.json)
399
+ except QualityError as exc:
400
+ print(f"data-quality: {exc}", file=sys.stderr)
401
+ return 2
402
+
403
+
404
+ if __name__ == "__main__":
405
+ sys.exit(main())