truequery 0.1.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 (254) hide show
  1. truequery/__init__.py +3 -0
  2. truequery/analyzers/__init__.py +0 -0
  3. truequery/analyzers/project_checks.py +220 -0
  4. truequery/analyzers/sql.py +529 -0
  5. truequery/checks/__init__.py +0 -0
  6. truequery/checks/sql.py +1278 -0
  7. truequery/cli.py +710 -0
  8. truequery/engine.py +346 -0
  9. truequery/hook.py +860 -0
  10. truequery/keylookup.py +135 -0
  11. truequery/models.py +32 -0
  12. truequery/project.py +561 -0
  13. truequery/registry_impl.json +127 -0
  14. truequery/report.py +343 -0
  15. truequery/schema.py +206 -0
  16. truequery/settings.py +239 -0
  17. truequery/standard/consume/README.md +111 -0
  18. truequery/standard/consume/adhoc-limit-required.yaml +50 -0
  19. truequery/standard/consume/aggregate-before-export.yaml +39 -0
  20. truequery/standard/consume/consumer-cost-guard.yaml +44 -0
  21. truequery/standard/consume/consumer-reads-marts.yaml +38 -0
  22. truequery/standard/consume/dashboard-query-budget.yaml +40 -0
  23. truequery/standard/consume/dashboard-refresh-schedule.yaml +47 -0
  24. truequery/standard/consume/deterministic-export-order.yaml +44 -0
  25. truequery/standard/consume/explicit-timezone-consumers.yaml +44 -0
  26. truequery/standard/consume/explore-answer-carries-reproducible-query.yaml +56 -0
  27. truequery/standard/consume/explore-caveats-and-unchecked-stated.yaml +52 -0
  28. truequery/standard/consume/explore-day-boundaries-and-partial-periods.yaml +51 -0
  29. truequery/standard/consume/explore-experiment-readout-follows-plan.yaml +63 -0
  30. truequery/standard/consume/explore-freshness-and-coverage-checked.yaml +54 -0
  31. truequery/standard/consume/explore-inspect-distributions-and-examples.yaml +47 -0
  32. truequery/standard/consume/explore-join-row-count-checks.yaml +53 -0
  33. truequery/standard/consume/explore-null-aware-negative-filters.yaml +50 -0
  34. truequery/standard/consume/explore-question-metric-grain-stated.yaml +55 -0
  35. truequery/standard/consume/explore-rates-not-counts-across-segments.yaml +51 -0
  36. truequery/standard/consume/explore-reconcile-to-known-total.yaml +51 -0
  37. truequery/standard/consume/explore-reuse-governed-metric.yaml +52 -0
  38. truequery/standard/consume/export-row-cap.yaml +45 -0
  39. truequery/standard/consume/freshness-displayed.yaml +44 -0
  40. truequery/standard/consume/no-deep-offset.yaml +38 -0
  41. truequery/standard/consume/parameterized-dashboard-filters.yaml +38 -0
  42. truequery/standard/consume/reverse-etl-idempotent-keys.yaml +45 -0
  43. truequery/standard/consume/reverse-etl-pii-consent.yaml +50 -0
  44. truequery/standard/consume/reverse-etl-schema-contract.yaml +47 -0
  45. truequery/standard/dq/README.md +117 -0
  46. truequery/standard/dq/accuracy-proxy-or-unmeasurable.yaml +45 -0
  47. truequery/standard/dq/audits-blocking-every-run.yaml +47 -0
  48. truequery/standard/dq/checks-versioned-with-code.yaml +52 -0
  49. truequery/standard/dq/completeness-declare-missing-sentinels.yaml +44 -0
  50. truequery/standard/dq/completeness-fill-rate-thresholds.yaml +45 -0
  51. truequery/standard/dq/completeness-not-null-required-columns.yaml +60 -0
  52. truequery/standard/dq/completeness-table-not-empty.yaml +48 -0
  53. truequery/standard/dq/consistency-cross-column-row-rules.yaml +49 -0
  54. truequery/standard/dq/consistency-no-orphan-keys.yaml +49 -0
  55. truequery/standard/dq/consistency-paired-aggregate-reconciliation.yaml +48 -0
  56. truequery/standard/dq/schema-conformance-checked-on-read.yaml +48 -0
  57. truequery/standard/dq/unenforced-constraints-are-tested.yaml +51 -0
  58. truequery/standard/dq/uniqueness-composite-grain.yaml +52 -0
  59. truequery/standard/dq/uniqueness-key-no-duplicates.yaml +53 -0
  60. truequery/standard/dq/unit-tests-with-fixtures.yaml +52 -0
  61. truequery/standard/dq/validity-allowed-value-sets.yaml +46 -0
  62. truequery/standard/dq/validity-enforce-model-contracts.yaml +51 -0
  63. truequery/standard/dq/validity-range-format-type-rules.yaml +50 -0
  64. truequery/standard/gov/README.md +94 -0
  65. truequery/standard/gov/audit-columns-or-snapshots.yaml +45 -0
  66. truequery/standard/gov/column-grants-for-pii.yaml +41 -0
  67. truequery/standard/gov/declared-model-owner.yaml +43 -0
  68. truequery/standard/gov/description-coverage-for-shared-models.yaml +40 -0
  69. truequery/standard/gov/erasure-path-documented.yaml +43 -0
  70. truequery/standard/gov/exposures-registered.yaml +73 -0
  71. truequery/standard/gov/group-private-access.yaml +41 -0
  72. truequery/standard/gov/least-privilege-grants.yaml +80 -0
  73. truequery/standard/gov/lineage-from-declared-sources.yaml +41 -0
  74. truequery/standard/gov/mesh-public-contracts-only.yaml +41 -0
  75. truequery/standard/gov/no-bypassrls-roles.yaml +39 -0
  76. truequery/standard/gov/no-public-privileges.yaml +39 -0
  77. truequery/standard/gov/no-raw-pii-in-seeds.yaml +41 -0
  78. truequery/standard/gov/pii-classification-declared.yaml +42 -0
  79. truequery/standard/gov/production-pii-not-in-dev.yaml +43 -0
  80. truequery/standard/gov/redacting-views-for-pii.yaml +41 -0
  81. truequery/standard/gov/retention-declared.yaml +42 -0
  82. truequery/standard/gov/row-policy-for-sensitive-tables.yaml +38 -0
  83. truequery/standard/ingest/README.md +100 -0
  84. truequery/standard/ingest/cap-intake-cost-and-volume.yaml +46 -0
  85. truequery/standard/ingest/cdc-replica-identity-required.yaml +47 -0
  86. truequery/standard/ingest/checkpoint-streaming-intake.yaml +42 -0
  87. truequery/standard/ingest/choose-batch-vs-streaming-deliberately.yaml +45 -0
  88. truequery/standard/ingest/declare-source-freshness.yaml +80 -0
  89. truequery/standard/ingest/dedupe-on-natural-key-before-append.yaml +45 -0
  90. truequery/standard/ingest/handle-schema-drift-explicitly.yaml +45 -0
  91. truequery/standard/ingest/idempotent-batch-loads.yaml +49 -0
  92. truequery/standard/ingest/isolate-source-failures.yaml +50 -0
  93. truequery/standard/ingest/landing-rebuildable-from-source.yaml +43 -0
  94. truequery/standard/ingest/monitor-cdc-lag-and-slots.yaml +47 -0
  95. truequery/standard/ingest/pin-connector-and-schema-versions.yaml +42 -0
  96. truequery/standard/ingest/pin-file-format-options.yaml +42 -0
  97. truequery/standard/ingest/propagate-deletes-explicitly.yaml +48 -0
  98. truequery/standard/ingest/quarantine-invalid-rows-at-intake.yaml +56 -0
  99. truequery/standard/ingest/raw-landing-append-only.yaml +48 -0
  100. truequery/standard/ingest/reconcile-row-counts-per-load.yaml +43 -0
  101. truequery/standard/ingest/record-file-lineage-at-intake.yaml +48 -0
  102. truequery/standard/ingest/reference-sources-not-raw-tables.yaml +42 -0
  103. truequery/standard/ingest/require-completeness-marker.yaml +50 -0
  104. truequery/standard/ingest/scan-pii-at-landing.yaml +45 -0
  105. truequery/standard/ingest/seeds-small-static-only.yaml +43 -0
  106. truequery/standard/ingest/test-intake-against-fixtures.yaml +46 -0
  107. truequery/standard/ingest/watermark-and-late-data-policy.yaml +49 -0
  108. truequery/standard/model/README.md +91 -0
  109. truequery/standard/model/dbt-contract-enforced.yaml +50 -0
  110. truequery/standard/model/delete-handling-incremental.yaml +51 -0
  111. truequery/standard/model/explicit-column-lists.yaml +83 -0
  112. truequery/standard/model/full-refresh-parity.yaml +49 -0
  113. truequery/standard/model/grain-preserved-joins.yaml +52 -0
  114. truequery/standard/model/idempotent-reexec.yaml +48 -0
  115. truequery/standard/model/incremental-lookback-window.yaml +53 -0
  116. truequery/standard/model/incremental-watermark-filter.yaml +48 -0
  117. truequery/standard/model/jinja-macro-determinism.yaml +46 -0
  118. truequery/standard/model/jinja-no-nested-curlies.yaml +43 -0
  119. truequery/standard/model/materialization-choice.yaml +51 -0
  120. truequery/standard/model/merge-source-deduped.yaml +57 -0
  121. truequery/standard/model/microbatch-event-time.yaml +39 -0
  122. truequery/standard/model/null-join-keys-excluded.yaml +45 -0
  123. truequery/standard/model/snapshot-scd2-validity.yaml +45 -0
  124. truequery/standard/model/snapshot-strategy-choice.yaml +41 -0
  125. truequery/standard/model/staging-no-business-logic.yaml +44 -0
  126. truequery/standard/model/surrogate-keys-stable.yaml +37 -0
  127. truequery/standard/model/unique-key-merge-key.yaml +55 -0
  128. truequery/standard/ops/README.md +98 -0
  129. truequery/standard/ops/breaking-change-categorization.yaml +35 -0
  130. truequery/standard/ops/ci-pr-checks-required.yaml +44 -0
  131. truequery/standard/ops/ci-slim-builds-only-changed.yaml +42 -0
  132. truequery/standard/ops/cost-estimate-before-run.yaml +41 -0
  133. truequery/standard/ops/cost-guardrail-confidence-levels.yaml +39 -0
  134. truequery/standard/ops/dev-environment-expiry.yaml +42 -0
  135. truequery/standard/ops/disposable-dev-environments.yaml +42 -0
  136. truequery/standard/ops/drift-detection-history-first.yaml +42 -0
  137. truequery/standard/ops/full-refresh-approval.yaml +76 -0
  138. truequery/standard/ops/model-versioning-breaking-changes.yaml +83 -0
  139. truequery/standard/ops/plan-preview-before-apply.yaml +39 -0
  140. truequery/standard/ops/post-deploy-data-validation.yaml +42 -0
  141. truequery/standard/ops/pre-destructive-snapshot.yaml +35 -0
  142. truequery/standard/ops/prod-credentials-isolation.yaml +96 -0
  143. truequery/standard/ops/run-artifact-retention.yaml +41 -0
  144. truequery/standard/ops/staged-upgrades-with-backup.yaml +39 -0
  145. truequery/standard/orch/README.md +74 -0
  146. truequery/standard/orch/concurrency-pools-and-limits.yaml +63 -0
  147. truequery/standard/orch/dag-ci-parse-and-unit-tests.yaml +70 -0
  148. truequery/standard/orch/data-aware-scheduling-over-polling.yaml +62 -0
  149. truequery/standard/orch/declared-asset-lineage.yaml +66 -0
  150. truequery/standard/orch/deferrable-sensors-with-timeout.yaml +75 -0
  151. truequery/standard/orch/explicit-catchup-policy.yaml +53 -0
  152. truequery/standard/orch/explicit-schedule-timezone.yaml +68 -0
  153. truequery/standard/orch/failure-alert-routing.yaml +100 -0
  154. truequery/standard/orch/idempotent-backfill-windowing.yaml +68 -0
  155. truequery/standard/orch/idempotent-retries-with-limits.yaml +79 -0
  156. truequery/standard/orch/light-dag-parse-no-heavy-top-level.yaml +75 -0
  157. truequery/standard/orch/no-circular-dependencies.yaml +67 -0
  158. truequery/standard/orch/partitioned-assets-windowing.yaml +66 -0
  159. truequery/standard/orch/sla-deadline-alerts.yaml +76 -0
  160. truequery/standard/orch/task-atomicity-no-partial-writes.yaml +62 -0
  161. truequery/standard/orch/task-timeout-execution-limit.yaml +70 -0
  162. truequery/standard/py/README.md +63 -0
  163. truequery/standard/py/airflow-isolate-task-dependencies.yaml +40 -0
  164. truequery/standard/py/dagster-asset-declared-dependencies.yaml +47 -0
  165. truequery/standard/py/dagster-io-manager-not-manual-io.yaml +47 -0
  166. truequery/standard/py/dagster-resources-for-external-clients.yaml +53 -0
  167. truequery/standard/py/dbt-python-model-contract.yaml +48 -0
  168. truequery/standard/py/deterministic-model-outputs.yaml +83 -0
  169. truequery/standard/py/hook-adapter-connection-lifecycle.yaml +59 -0
  170. truequery/standard/py/idempotent-task-logic.yaml +55 -0
  171. truequery/standard/py/no-n-plus-one-queries-in-loops.yaml +46 -0
  172. truequery/standard/py/pandas-filter-before-collect.yaml +44 -0
  173. truequery/standard/py/pandas-no-chained-assignment.yaml +41 -0
  174. truequery/standard/py/pandas-no-row-loop-mutations.yaml +44 -0
  175. truequery/standard/py/pin-pipeline-dependencies.yaml +82 -0
  176. truequery/standard/py/sqlmesh-audit-python-outputs.yaml +49 -0
  177. truequery/standard/py/sqlmesh-python-model-execution-context.yaml +52 -0
  178. truequery/standard/sem/README.md +97 -0
  179. truequery/standard/sem/aggregation-type-matches-additivity.yaml +61 -0
  180. truequery/standard/sem/conversion-metrics-window-bounds.yaml +54 -0
  181. truequery/standard/sem/count-distinct-uses-entity-key.yaml +52 -0
  182. truequery/standard/sem/cumulative-metrics-time-spine.yaml +53 -0
  183. truequery/standard/sem/derived-metrics-compose-from-governed.yaml +52 -0
  184. truequery/standard/sem/description-and-ownership-per-metric.yaml +50 -0
  185. truequery/standard/sem/dimension-naming-conventions.yaml +52 -0
  186. truequery/standard/sem/entity-primary-key-declared.yaml +54 -0
  187. truequery/standard/sem/join-paths-declared-via-entities.yaml +47 -0
  188. truequery/standard/sem/metric-filters-declared-not-hidden.yaml +57 -0
  189. truequery/standard/sem/metric-grain-declaration.yaml +52 -0
  190. truequery/standard/sem/metric-smoke-test-on-change.yaml +43 -0
  191. truequery/standard/sem/no-fanout-double-counting.yaml +52 -0
  192. truequery/standard/sem/no-metric-defined-off-staging.yaml +47 -0
  193. truequery/standard/sem/null-handling-in-aggregations.yaml +57 -0
  194. truequery/standard/sem/percentile-metrics-use-approximate.yaml +51 -0
  195. truequery/standard/sem/ratio-metrics-divide-after-aggregation.yaml +49 -0
  196. truequery/standard/sem/saved-query-canonical-slices.yaml +56 -0
  197. truequery/standard/sem/semantic-model-validates.yaml +50 -0
  198. truequery/standard/sem/single-governed-metric-definition.yaml +68 -0
  199. truequery/standard/sem/time-dimension-standardized.yaml +51 -0
  200. truequery/standard/sem/unit-consistency.yaml +54 -0
  201. truequery/standard/sql/README.md +126 -0
  202. truequery/standard/sql/accidental-cross-join.yaml +58 -0
  203. truequery/standard/sql/average-of-ratios.yaml +63 -0
  204. truequery/standard/sql/between-date-on-timestamp.yaml +74 -0
  205. truequery/standard/sql/case-without-else-in-aggregate.yaml +67 -0
  206. truequery/standard/sql/count-star-after-left-join.yaml +67 -0
  207. truequery/standard/sql/distinct-dedup-bandaid.yaml +59 -0
  208. truequery/standard/sql/explore-limit-is-not-a-sample.yaml +56 -0
  209. truequery/standard/sql/explore-profile-filter-columns.yaml +48 -0
  210. truequery/standard/sql/fanout-join-pre-aggregation.yaml +64 -0
  211. truequery/standard/sql/group-by-bare-column.yaml +75 -0
  212. truequery/standard/sql/insert-explicit-column-list.yaml +60 -0
  213. truequery/standard/sql/integer-division-truncation.yaml +94 -0
  214. truequery/standard/sql/keyset-pagination.yaml +70 -0
  215. truequery/standard/sql/left-join-null-rejecting-where.yaml +58 -0
  216. truequery/standard/sql/limit-requires-order-by.yaml +67 -0
  217. truequery/standard/sql/natural-join-explicit-keys.yaml +56 -0
  218. truequery/standard/sql/negative-filter-drops-nulls.yaml +70 -0
  219. truequery/standard/sql/nondeterministic-merge-keys.yaml +69 -0
  220. truequery/standard/sql/not-in-nullable-subquery.yaml +57 -0
  221. truequery/standard/sql/null-equality-comparison.yaml +65 -0
  222. truequery/standard/sql/random-sampling-no-sort.yaml +48 -0
  223. truequery/standard/sql/row-number-dedupe-tiebreaker.yaml +67 -0
  224. truequery/standard/sql/sargable-filter-bare-column.yaml +53 -0
  225. truequery/standard/sql/set-operation-column-position.yaml +59 -0
  226. truequery/standard/sql/unguarded-update-delete.yaml +63 -0
  227. truequery/standard/sql/union-all-default.yaml +70 -0
  228. truequery/standard/store/README.md +88 -0
  229. truequery/standard/store/cluster-on-frequent-filters-joins.yaml +44 -0
  230. truequery/standard/store/compact-small-files-optimize.yaml +44 -0
  231. truequery/standard/store/declare-not-null-check-constraints.yaml +43 -0
  232. truequery/standard/store/declare-sort-order-for-range-queries.yaml +44 -0
  233. truequery/standard/store/enable-change-feed-for-incremental-consumers.yaml +44 -0
  234. truequery/standard/store/evolve-partitions-by-metadata-not-rewrite.yaml +45 -0
  235. truequery/standard/store/explicit-physical-types-no-implicit-casts.yaml +43 -0
  236. truequery/standard/store/full-refresh-by-replace-not-delete-insert.yaml +44 -0
  237. truequery/standard/store/incremental-watermark-partition-alignment.yaml +40 -0
  238. truequery/standard/store/no-high-cardinality-partition-column.yaml +47 -0
  239. truequery/standard/store/partition-expiration-retention-policy.yaml +43 -0
  240. truequery/standard/store/partition-on-filter-column.yaml +43 -0
  241. truequery/standard/store/partition-predicate-required.yaml +41 -0
  242. truequery/standard/store/prefer-auto-liquid-clustering-over-manual-tune.yaml +37 -0
  243. truequery/standard/store/rewrite-manifests-and-stats-periodically.yaml +37 -0
  244. truequery/standard/store/single-open-format-per-medallion-layer.yaml +37 -0
  245. truequery/standard/store/time-travel-retention-explicit.yaml +46 -0
  246. truequery/standard/store/use-transient-temporary-for-ephemeral.yaml +35 -0
  247. truequery/standard/store/vacuum-expire-snapshots-with-retention-floor.yaml +42 -0
  248. truequery/std_registry.py +3310 -0
  249. truequery-0.1.0.dist-info/METADATA +52 -0
  250. truequery-0.1.0.dist-info/RECORD +254 -0
  251. truequery-0.1.0.dist-info/WHEEL +4 -0
  252. truequery-0.1.0.dist-info/entry_points.txt +3 -0
  253. truequery-0.1.0.dist-info/licenses/LICENSE +201 -0
  254. truequery-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,529 @@
1
+ """Generic SQL analyzer: plain SQL, dbt models, and SQLMesh models.
2
+
3
+ Pipeline: Jinja/SQLMesh-tolerant preprocessing (line-preserving) ->
4
+ per-statement parse isolation -> registry-driven checks -> findings
5
+ with per-mode severity. Anything unparsable becomes a diagnostic, never
6
+ silence. Positions are anchor-located: the pinned sqlglot version
7
+ attaches no node positions, so each rule searches its statement text
8
+ for its own anchor (documented approximation, good to the statement
9
+ and usually the line).
10
+ """
11
+ import re
12
+
13
+ import sqlglot
14
+
15
+ from truequery import std_registry
16
+ from truequery.checks import sql as checks
17
+
18
+ JINJA_PH = "__tq_jinja"
19
+
20
+
21
+ # -- preprocessing (line-preserving) ------------------------------------------
22
+ def _skip_trivia(text, i):
23
+ while i < len(text):
24
+ if text[i].isspace():
25
+ i += 1
26
+ elif text.startswith("--", i):
27
+ end = text.find("\n", i)
28
+ i = len(text) if end == -1 else end + 1
29
+ elif text.startswith("/*", i):
30
+ end = text.find("*/", i + 2)
31
+ i = len(text) if end == -1 else end + 2
32
+ else:
33
+ break
34
+ return i
35
+
36
+
37
+ def strip_model_block(text):
38
+ i = _skip_trivia(text, 0)
39
+ m = re.match(r"(MODEL|AUDIT)\s*\(", text[i:], re.IGNORECASE)
40
+ if not m:
41
+ return text
42
+ depth, i, squote = 0, i + m.start(), False
43
+ while i < len(text):
44
+ ch = text[i]
45
+ if ch == "'" and not squote:
46
+ squote = True
47
+ elif ch == "'" and squote:
48
+ squote = False
49
+ elif not squote:
50
+ if ch == "(":
51
+ depth += 1
52
+ elif ch == ")":
53
+ depth -= 1
54
+ if depth == 0:
55
+ tail = text[i + 1:]
56
+ semi = tail.find(";")
57
+ cut = i + 1 + (semi + 1 if semi != -1 else 0)
58
+ return "".join("\n" if c == "\n" else " " for c in text[:cut]) + text[cut:]
59
+ i += 1
60
+ return text
61
+
62
+
63
+ _MACRO_CALL = re.compile(r"@[A-Za-z_]\w*\(")
64
+
65
+
66
+ def strip_sqlmesh_macros(text):
67
+ """Blank SQLMesh macro calls (@name(...)) to a `1` placeholder,
68
+ keeping newlines. Linear: jumps from one `@` to the next."""
69
+ out, i = [], 0
70
+ while i < len(text):
71
+ at = text.find("@", i)
72
+ if at == -1:
73
+ out.append(text[i:])
74
+ break
75
+ if not _MACRO_CALL.match(text, at):
76
+ out.append(text[i:at + 1])
77
+ i = at + 1
78
+ continue
79
+ out.append(text[i:at])
80
+ i = at
81
+ depth, j, squote = 0, i, False
82
+ while j < len(text):
83
+ ch = text[j]
84
+ if ch == "'" and not squote:
85
+ squote = True
86
+ elif ch == "'" and squote:
87
+ squote = False
88
+ elif not squote:
89
+ if ch == "(":
90
+ depth += 1
91
+ elif ch == ")":
92
+ depth -= 1
93
+ if depth == 0:
94
+ break
95
+ j += 1
96
+ seg = text[i:j + 1]
97
+ out.append("1" + "".join("\n" if c == "\n" else " " for c in seg[1:]))
98
+ i = j + 1
99
+ return "".join(out)
100
+
101
+
102
+ _REF_RE = re.compile(r"\{\{\s*ref\(\s*['\"]([^'\"\n]+)['\"]\s*\)\s*\}\}")
103
+ _SOURCE_RE = re.compile(
104
+ r"\{\{\s*source\(\s*['\"]([^'\"\n]+)['\"]\s*,\s*['\"]([^'\"\n]+)['\"]\s*\)\s*\}\}")
105
+
106
+
107
+ def _resolve_dbt_refs(text):
108
+ """Resolve dbt relation builders to plain names before parsing, so
109
+ table references (and contract matching) see real relations:
110
+ {{ ref('orders') }} -> orders, {{ source('shop','orders') }} ->
111
+ shop.orders. Single-line tags only, so line numbers never shift."""
112
+
113
+ def sub_source(match):
114
+ return f"{match.group(1)}.{match.group(2)}"
115
+
116
+ text = _SOURCE_RE.sub(sub_source, text)
117
+ return _REF_RE.sub(r"\1", text)
118
+
119
+
120
+ _JINJA_CLOSE = {"{{": "}}", "{%": "%}", "{#": "#}"}
121
+ _JINJA_MARKERS = ("JINJA_QUERY_BEGIN;", "JINJA_QUERY_BEGIN", "JINJA_END;",
122
+ "JINJA_END")
123
+
124
+
125
+ def _jinja_spans(text):
126
+ """(start, end, opener) for each Jinja tag. Quotes inside a tag are
127
+ respected, so `post_hook="{{ x }}"` doesn't close the outer tag."""
128
+ spans, i, n = [], 0, len(text)
129
+ while True:
130
+ i = text.find("{", i)
131
+ if i == -1 or i + 1 >= n:
132
+ return spans
133
+ opener = text[i:i + 2]
134
+ if opener not in _JINJA_CLOSE:
135
+ i += 1
136
+ continue
137
+ closer, j, quote = _JINJA_CLOSE[opener], i + 2, None
138
+ while j < n:
139
+ ch = text[j]
140
+ if quote:
141
+ if ch == "\\":
142
+ j += 1
143
+ elif ch == quote:
144
+ quote = None
145
+ elif ch in "'\"" and opener != "{#":
146
+ quote = ch
147
+ elif text.startswith(closer, j):
148
+ j += 2
149
+ break
150
+ j += 1
151
+ spans.append((i, min(j, n), opener))
152
+ i = j
153
+
154
+
155
+ def strip_jinja(text):
156
+ """Remove Jinja (and SQLMesh MODEL blocks / macros), keeping every
157
+ newline so line numbers still match the source. A line holding only
158
+ tags becomes blank; an inline `{{ ... }}` becomes a placeholder
159
+ identifier so the SQL around it still parses."""
160
+ text = strip_model_block(text)
161
+ text = strip_sqlmesh_macros(text)
162
+ text = _resolve_dbt_refs(text)
163
+ spans = _jinja_spans(text)
164
+ in_tag = bytearray(len(text))
165
+ for start, end, _ in spans:
166
+ in_tag[start:end] = b"\x01" * (end - start)
167
+ out = list(text)
168
+ # Lines whose only content (besides `;`) is Jinja are blanked whole.
169
+ pos = 0
170
+ for line in text.split("\n"):
171
+ end = pos + len(line)
172
+ rest = "".join(c for k, c in enumerate(line) if not in_tag[pos + k])
173
+ if line.strip() in _JINJA_MARKERS or (
174
+ rest.strip() in ("", ";") and line.strip()):
175
+ for k in range(pos, end):
176
+ out[k] = " "
177
+ pos = end + 1
178
+ for start, end, opener in spans:
179
+ if all(out[k] in " \n" for k in range(start, end)):
180
+ continue # already blanked with its line
181
+ # An expression tag becomes one placeholder token with no padding,
182
+ # so `is_{{ name }}_model` stays one identifier; statement and
183
+ # comment tags become spaces. Newlines are kept either way.
184
+ fill = "" if opener == "{{" else " "
185
+ for k in range(start, end):
186
+ if out[k] != "\n":
187
+ out[k] = fill
188
+ if opener == "{{":
189
+ out[start] = JINJA_PH
190
+ return "".join(out)
191
+
192
+
193
+ def strip_comments(text, dialect):
194
+ out, i, n = [], 0, len(text)
195
+ squote = False
196
+ while i < n:
197
+ ch = text[i]
198
+ two = text[i:i + 2]
199
+ if squote:
200
+ out.append(ch)
201
+ if ch == "\\" and i + 1 < n:
202
+ out.append(text[i + 1])
203
+ i += 2
204
+ continue
205
+ if ch == "'":
206
+ squote = False
207
+ i += 1
208
+ elif ch == "'":
209
+ squote = True
210
+ out.append(ch)
211
+ i += 1
212
+ elif two == "--":
213
+ while i < n and text[i] != "\n":
214
+ out.append(" ")
215
+ i += 1
216
+ elif ch == "#" and dialect in ("mysql", "bigquery"):
217
+ while i < n and text[i] != "\n":
218
+ out.append(" ")
219
+ i += 1
220
+ elif two == "/*":
221
+ end = text.find("*/", i + 2)
222
+ end = n if end == -1 else end + 2
223
+ for c in text[i:end]:
224
+ out.append("\n" if c == "\n" else " ")
225
+ i = end
226
+ else:
227
+ out.append(ch)
228
+ i += 1
229
+ return "".join(out)
230
+
231
+
232
+ def split_statements(cleaned):
233
+ parts, depth, cur, start, line = [], 0, [], 1, 1
234
+ squote = False
235
+ for ch in cleaned:
236
+ if ch == "'":
237
+ squote = not squote
238
+ if not squote:
239
+ if ch in "([":
240
+ depth += 1
241
+ elif ch in ")]":
242
+ depth -= 1
243
+ cur.append(ch)
244
+ if ch == "\n":
245
+ line += 1
246
+ if ch == ";" and depth == 0 and not squote:
247
+ parts.append(("".join(cur), start))
248
+ cur, start = [], line
249
+ tail = "".join(cur)
250
+ if tail.strip() and tail.strip().strip(";"):
251
+ parts.append((tail, start))
252
+ return parts
253
+
254
+
255
+ ANCHORS = {
256
+ "sql/not-in-nullable-subquery": [r"\bNOT\s+IN\s*\(", r"\bNOT\s*\("],
257
+ "sql/unguarded-update-delete": [r"\bDELETE\b", r"\bUPDATE\b"],
258
+ "sql/accidental-cross-join": [r"\bJOIN\b", ","],
259
+ "sql/left-join-null-rejecting-where": [r"\bWHERE\b"],
260
+ "sql/null-equality-comparison": [r"=\s*NULL", r"<>\s*NULL", r"!=\s*NULL"],
261
+ # Fallbacks for rules whose node is the whole statement.
262
+ "sql/union-all-default": [r"\bUNION\b"],
263
+ "sql/set-operation-column-position": [r"\bUNION\b", r"\bINTERSECT\b",
264
+ r"\bEXCEPT\b"],
265
+ "sql/insert-explicit-column-list": [r"\bINSERT\b"],
266
+ "sql/keyset-pagination": [r"\bOFFSET\b"],
267
+ "sql/limit-requires-order-by": [r"\bLIMIT\b"],
268
+ "sql/group-by-bare-column": [r"\bGROUP\s+BY\b"],
269
+ "sql/row-number-dedupe-tiebreaker": [r"\bROW_NUMBER\s*\("],
270
+ }
271
+
272
+
273
+ def _compact_map(text):
274
+ """Lowercased whitespace-stripped text + original offsets per char."""
275
+ chars, idx = [], []
276
+ for i, ch in enumerate(text):
277
+ if not ch.isspace():
278
+ chars.append(ch.lower())
279
+ idx.append(i)
280
+ return "".join(chars), idx
281
+
282
+
283
+ def _usable_needle(needle, compact):
284
+ # Tiny needles match everywhere; a needle that IS the statement
285
+ # carries no position information. Both defer to anchors.
286
+ if len(needle) < 4 and not (
287
+ len(needle) == 3 and re.search(r"[^a-z0-9]", needle)):
288
+ return False
289
+ if needle and len(needle) > 0.8 * len(compact):
290
+ return False
291
+ return bool(needle)
292
+
293
+
294
+ class Locator:
295
+ def __init__(self, stmt_text, start_line):
296
+ self.text = stmt_text
297
+ self.start_line = start_line
298
+ # Rules append their slug here when a declared-key lookup
299
+ # suppresses a firing; check_text reports them.
300
+ self.suppressed = []
301
+ # Occurrence indexes are keyed per (rule, node text): the nth
302
+ # firing of the same rule on the same node text points at the
303
+ # nth textual occurrence. A per-rule counter misattributes when
304
+ # a clean earlier occurrence exists (e.g. an unjoined count(*)
305
+ # before the firing one).
306
+ self.used = {}
307
+ self._compact, self._index = _compact_map(stmt_text)
308
+
309
+ def locate(self, rule, node=None):
310
+ # Primary: the parser's own offset for the firing node. This
311
+ # points at the node itself even when an identical clean text
312
+ # occurs earlier (e.g. an unjoined count(*) before the firing
313
+ # one) — text search alone cannot tell those apart.
314
+ off = self._locate_meta(node)
315
+ if off is None:
316
+ sig = None
317
+ if node is not None:
318
+ try:
319
+ sig, _ = _compact_map(node.sql())
320
+ except Exception:
321
+ sig = None
322
+ if sig is not None and not _usable_needle(sig, self._compact):
323
+ sig = None
324
+ if sig is not None:
325
+ nth = self.used.get((rule, sig), 0)
326
+ off = self._locate_text(sig, nth)
327
+ self.used[(rule, sig)] = nth + 1
328
+ if off is None:
329
+ nth = self.used.get((rule, None), 0)
330
+ off = self._locate_anchor(rule, nth)
331
+ self.used[(rule, None)] = nth + 1
332
+ pre = self.text[:off]
333
+ return (self.start_line + pre.count("\n"),
334
+ off - (pre.rfind("\n") + 1) + 1)
335
+
336
+ def _locate_meta(self, node):
337
+ # sqlglot tags only some nodes (functions, leaves) with offsets;
338
+ # synthesized parents (NOT, predicates) often carry none. The
339
+ # minimum over the node and its descendants is the construct's
340
+ # own start — always inside the firing node, never at an
341
+ # earlier clean occurrence of similar text.
342
+ if node is None:
343
+ return None
344
+ try:
345
+ whole, _ = _compact_map(node.sql())
346
+ except Exception:
347
+ return None
348
+ if not _usable_needle(whole, self._compact):
349
+ # The node IS the statement (or is unrenderable): its
350
+ # offsets carry no position information. Defer to text.
351
+ return None
352
+ best, end = None, None
353
+ try:
354
+ nodes = [node] + list(node.walk())
355
+ except Exception:
356
+ nodes = [node]
357
+ for cand in nodes:
358
+ cand = cand[0] if isinstance(cand, tuple) else cand
359
+ meta = getattr(cand, "meta", None) or {}
360
+ start = meta.get("start")
361
+ stop = meta.get("end", start)
362
+ if isinstance(start, int) and 0 <= start < len(self.text):
363
+ best = start if best is None else min(best, start)
364
+ if isinstance(stop, int):
365
+ end = stop if end is None else max(end, stop)
366
+ if best is None:
367
+ return None
368
+ # A node spanning the statement (UNION of two branches, a whole
369
+ # DELETE) carries no position information by itself: defer to
370
+ # the text/anchor path, which handles those shapes.
371
+ if end is not None and end - best > 0.8 * len(self.text):
372
+ return None
373
+ return best
374
+
375
+ def _locate_text(self, needle, nth):
376
+ occ, start = [], 0
377
+ while True:
378
+ hit = self._compact.find(needle, start)
379
+ if hit == -1:
380
+ break
381
+ occ.append(self._index[hit])
382
+ start = hit + 1
383
+ if not occ:
384
+ return None
385
+ return occ[nth] if nth < len(occ) else occ[-1]
386
+
387
+ def _locate_anchor(self, rule, nth):
388
+ pats = ANCHORS.get(rule, [])
389
+ cands = []
390
+ for p in pats:
391
+ cands += [m.start() for m in re.finditer(p, self.text, re.IGNORECASE)]
392
+ cands.sort()
393
+ if nth < len(cands):
394
+ return cands[nth]
395
+ return cands[-1] if cands else 0
396
+
397
+
398
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
399
+
400
+
401
+ def _parse_diagnostic(start_line, chunk, exc):
402
+ """One clean agent-facing line + full detail for --json/--verbose.
403
+
404
+ The line names no internals and no statement body; the path is
405
+ attached by the caller (which knows the display path).
406
+ """
407
+ detail = _ANSI.sub("", str(exc)).replace("\n", " ")
408
+ detail = re.sub(r"\s+", " ", detail).strip()
409
+ message = f"line {start_line}: could not parse (not checked)"
410
+ if "`" in chunk:
411
+ message += (" - set `dialect` in the DATA.md tq-config "
412
+ "if identifiers need one")
413
+ return {"message": message, "detail": detail}
414
+
415
+
416
+ def _intdiv_outside_strings(chunk):
417
+ """Locate `//` outside single-quoted strings (comments and Jinja are
418
+ already stripped). `//` is integer division in every dialect that
419
+ accepts it, and a syntax error elsewhere — either way it needs a
420
+ human, so it reports as a finding, not silence."""
421
+ squote, i = False, 0
422
+ while i < len(chunk):
423
+ ch = chunk[i]
424
+ if ch == "'":
425
+ squote = not squote
426
+ elif not squote and chunk[i:i + 2] == "//":
427
+ pre = chunk[:i]
428
+ return (1 + pre.count("\n"), i - (pre.rfind("\n") + 1) + 1)
429
+ i += 1
430
+ return None
431
+
432
+
433
+ def _source_snippet(chunk, start_line, line):
434
+ """The user's own source line (placeholders named), not normalized SQL."""
435
+ rows = chunk.split("\n")
436
+ idx = line - start_line
437
+ text = rows[idx].strip() if 0 <= idx < len(rows) else ""
438
+ return text.replace(JINJA_PH, "<jinja>")[:160]
439
+
440
+
441
+ def _severity(record, setting, mode):
442
+ return setting.get("severity") or record["severity"].get(mode, "warning")
443
+
444
+
445
+ def _finding(slug, severity, mode, path, chunk, start_line, line, col,
446
+ why, fix):
447
+ return {"rule": slug, "severity": severity, "mode": mode,
448
+ "evidence": "file", "file": path, "line": line, "col": col,
449
+ "snippet": _source_snippet(chunk, start_line, line),
450
+ "why": why, "fix": fix}
451
+
452
+
453
+ def _active_checks(registry, settings, tiers, include_disabled):
454
+ """(slug, check, record, setting) for every check that runs."""
455
+ active = []
456
+ for slug, check in checks.CHECKS.items():
457
+ record = registry.RECORDS.get(slug)
458
+ if record is None or not record["implemented"]:
459
+ continue
460
+ setting = (settings or {}).get(slug, {})
461
+ if not include_disabled and not setting.get("enabled",
462
+ record["enabled"]):
463
+ continue
464
+ tier = setting.get("tier", record["tier"])
465
+ if tiers is not None and tier not in tiers:
466
+ continue
467
+ active.append((slug, check, record, setting))
468
+ return active
469
+
470
+
471
+ INTDIV = "sql/integer-division-truncation"
472
+
473
+
474
+ def check_text(text, dialect=None, path="<input>", mode="build", registry=None,
475
+ include_disabled=False, tiers=None, settings=None):
476
+ """Run enabled file-level checks. Returns {path, findings,
477
+ diagnostics, verified, key_lookups}. Findings carry rule slug +
478
+ per-mode severity. ``tiers`` restricts to rule tiers (the hook's
479
+ immediate pass); ``settings`` ({slug: {enabled, tier, severity}}
480
+ from truequery.settings.effective) overrides the registry."""
481
+ from truequery import keylookup # imports project, which imports us
482
+ registry = registry or std_registry
483
+ cleaned = strip_comments(strip_jinja(text), dialect or "generic")
484
+ findings, diagnostics, key_lookups = [], [], []
485
+ chunks = split_statements(cleaned)
486
+ # A file with no statements at all (blank, comments or Jinja only)
487
+ # is verified. Anything else must parse: a typo or prose is "not
488
+ # checked", never "clean".
489
+ active = _active_checks(registry, settings, tiers, include_disabled)
490
+ ctx = checks.CheckContext(dialect=dialect, path=path, mode=mode,
491
+ keys=keylookup.declared_keys_for(path))
492
+ for chunk, start_line in chunks:
493
+ try:
494
+ stmts = sqlglot.parse(chunk, read=dialect or None)
495
+ except Exception as exc:
496
+ # A parse failure is never a silent pass: the chunk was not
497
+ # checked, so the file is unverified even when a `//`
498
+ # finding is salvaged from it.
499
+ intdiv = _intdiv_outside_strings(chunk)
500
+ salvage = [a for a in active if a[0] == INTDIV]
501
+ if intdiv is not None and salvage:
502
+ _, _, record, setting = salvage[0]
503
+ line = start_line + intdiv[0] - 1
504
+ findings.append(_finding(
505
+ INTDIV, _severity(record, setting, mode), mode, path,
506
+ chunk, start_line, line, intdiv[1],
507
+ "integer division truncates (5 // 2 = 2)",
508
+ "use / with a decimal operand if a fraction is meant"))
509
+ rows = chunk.split("\n")
510
+ first = next((i for i, row in enumerate(rows) if row.strip()), 0)
511
+ diagnostics.append(_parse_diagnostic(start_line + first, chunk,
512
+ exc))
513
+ continue
514
+ for stmt in stmts:
515
+ if stmt is None:
516
+ continue
517
+ loc = Locator(chunk, start_line)
518
+ for slug, check, record, setting in active:
519
+ for node, why, fix in check(stmt, loc, ctx):
520
+ line, col = loc.locate(slug, node)
521
+ findings.append(_finding(
522
+ slug, _severity(record, setting, mode), mode, path,
523
+ chunk, start_line, line, col, why, fix))
524
+ for suppressed in loc.suppressed:
525
+ if suppressed not in key_lookups:
526
+ key_lookups.append(suppressed)
527
+ findings.sort(key=lambda f: (f["line"], f["col"], f["rule"]))
528
+ return {"path": path, "findings": findings, "diagnostics": diagnostics,
529
+ "verified": not diagnostics, "key_lookups": key_lookups}
File without changes