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.
- truequery/__init__.py +3 -0
- truequery/analyzers/__init__.py +0 -0
- truequery/analyzers/project_checks.py +220 -0
- truequery/analyzers/sql.py +529 -0
- truequery/checks/__init__.py +0 -0
- truequery/checks/sql.py +1278 -0
- truequery/cli.py +710 -0
- truequery/engine.py +346 -0
- truequery/hook.py +860 -0
- truequery/keylookup.py +135 -0
- truequery/models.py +32 -0
- truequery/project.py +561 -0
- truequery/registry_impl.json +127 -0
- truequery/report.py +343 -0
- truequery/schema.py +206 -0
- truequery/settings.py +239 -0
- truequery/standard/consume/README.md +111 -0
- truequery/standard/consume/adhoc-limit-required.yaml +50 -0
- truequery/standard/consume/aggregate-before-export.yaml +39 -0
- truequery/standard/consume/consumer-cost-guard.yaml +44 -0
- truequery/standard/consume/consumer-reads-marts.yaml +38 -0
- truequery/standard/consume/dashboard-query-budget.yaml +40 -0
- truequery/standard/consume/dashboard-refresh-schedule.yaml +47 -0
- truequery/standard/consume/deterministic-export-order.yaml +44 -0
- truequery/standard/consume/explicit-timezone-consumers.yaml +44 -0
- truequery/standard/consume/explore-answer-carries-reproducible-query.yaml +56 -0
- truequery/standard/consume/explore-caveats-and-unchecked-stated.yaml +52 -0
- truequery/standard/consume/explore-day-boundaries-and-partial-periods.yaml +51 -0
- truequery/standard/consume/explore-experiment-readout-follows-plan.yaml +63 -0
- truequery/standard/consume/explore-freshness-and-coverage-checked.yaml +54 -0
- truequery/standard/consume/explore-inspect-distributions-and-examples.yaml +47 -0
- truequery/standard/consume/explore-join-row-count-checks.yaml +53 -0
- truequery/standard/consume/explore-null-aware-negative-filters.yaml +50 -0
- truequery/standard/consume/explore-question-metric-grain-stated.yaml +55 -0
- truequery/standard/consume/explore-rates-not-counts-across-segments.yaml +51 -0
- truequery/standard/consume/explore-reconcile-to-known-total.yaml +51 -0
- truequery/standard/consume/explore-reuse-governed-metric.yaml +52 -0
- truequery/standard/consume/export-row-cap.yaml +45 -0
- truequery/standard/consume/freshness-displayed.yaml +44 -0
- truequery/standard/consume/no-deep-offset.yaml +38 -0
- truequery/standard/consume/parameterized-dashboard-filters.yaml +38 -0
- truequery/standard/consume/reverse-etl-idempotent-keys.yaml +45 -0
- truequery/standard/consume/reverse-etl-pii-consent.yaml +50 -0
- truequery/standard/consume/reverse-etl-schema-contract.yaml +47 -0
- truequery/standard/dq/README.md +117 -0
- truequery/standard/dq/accuracy-proxy-or-unmeasurable.yaml +45 -0
- truequery/standard/dq/audits-blocking-every-run.yaml +47 -0
- truequery/standard/dq/checks-versioned-with-code.yaml +52 -0
- truequery/standard/dq/completeness-declare-missing-sentinels.yaml +44 -0
- truequery/standard/dq/completeness-fill-rate-thresholds.yaml +45 -0
- truequery/standard/dq/completeness-not-null-required-columns.yaml +60 -0
- truequery/standard/dq/completeness-table-not-empty.yaml +48 -0
- truequery/standard/dq/consistency-cross-column-row-rules.yaml +49 -0
- truequery/standard/dq/consistency-no-orphan-keys.yaml +49 -0
- truequery/standard/dq/consistency-paired-aggregate-reconciliation.yaml +48 -0
- truequery/standard/dq/schema-conformance-checked-on-read.yaml +48 -0
- truequery/standard/dq/unenforced-constraints-are-tested.yaml +51 -0
- truequery/standard/dq/uniqueness-composite-grain.yaml +52 -0
- truequery/standard/dq/uniqueness-key-no-duplicates.yaml +53 -0
- truequery/standard/dq/unit-tests-with-fixtures.yaml +52 -0
- truequery/standard/dq/validity-allowed-value-sets.yaml +46 -0
- truequery/standard/dq/validity-enforce-model-contracts.yaml +51 -0
- truequery/standard/dq/validity-range-format-type-rules.yaml +50 -0
- truequery/standard/gov/README.md +94 -0
- truequery/standard/gov/audit-columns-or-snapshots.yaml +45 -0
- truequery/standard/gov/column-grants-for-pii.yaml +41 -0
- truequery/standard/gov/declared-model-owner.yaml +43 -0
- truequery/standard/gov/description-coverage-for-shared-models.yaml +40 -0
- truequery/standard/gov/erasure-path-documented.yaml +43 -0
- truequery/standard/gov/exposures-registered.yaml +73 -0
- truequery/standard/gov/group-private-access.yaml +41 -0
- truequery/standard/gov/least-privilege-grants.yaml +80 -0
- truequery/standard/gov/lineage-from-declared-sources.yaml +41 -0
- truequery/standard/gov/mesh-public-contracts-only.yaml +41 -0
- truequery/standard/gov/no-bypassrls-roles.yaml +39 -0
- truequery/standard/gov/no-public-privileges.yaml +39 -0
- truequery/standard/gov/no-raw-pii-in-seeds.yaml +41 -0
- truequery/standard/gov/pii-classification-declared.yaml +42 -0
- truequery/standard/gov/production-pii-not-in-dev.yaml +43 -0
- truequery/standard/gov/redacting-views-for-pii.yaml +41 -0
- truequery/standard/gov/retention-declared.yaml +42 -0
- truequery/standard/gov/row-policy-for-sensitive-tables.yaml +38 -0
- truequery/standard/ingest/README.md +100 -0
- truequery/standard/ingest/cap-intake-cost-and-volume.yaml +46 -0
- truequery/standard/ingest/cdc-replica-identity-required.yaml +47 -0
- truequery/standard/ingest/checkpoint-streaming-intake.yaml +42 -0
- truequery/standard/ingest/choose-batch-vs-streaming-deliberately.yaml +45 -0
- truequery/standard/ingest/declare-source-freshness.yaml +80 -0
- truequery/standard/ingest/dedupe-on-natural-key-before-append.yaml +45 -0
- truequery/standard/ingest/handle-schema-drift-explicitly.yaml +45 -0
- truequery/standard/ingest/idempotent-batch-loads.yaml +49 -0
- truequery/standard/ingest/isolate-source-failures.yaml +50 -0
- truequery/standard/ingest/landing-rebuildable-from-source.yaml +43 -0
- truequery/standard/ingest/monitor-cdc-lag-and-slots.yaml +47 -0
- truequery/standard/ingest/pin-connector-and-schema-versions.yaml +42 -0
- truequery/standard/ingest/pin-file-format-options.yaml +42 -0
- truequery/standard/ingest/propagate-deletes-explicitly.yaml +48 -0
- truequery/standard/ingest/quarantine-invalid-rows-at-intake.yaml +56 -0
- truequery/standard/ingest/raw-landing-append-only.yaml +48 -0
- truequery/standard/ingest/reconcile-row-counts-per-load.yaml +43 -0
- truequery/standard/ingest/record-file-lineage-at-intake.yaml +48 -0
- truequery/standard/ingest/reference-sources-not-raw-tables.yaml +42 -0
- truequery/standard/ingest/require-completeness-marker.yaml +50 -0
- truequery/standard/ingest/scan-pii-at-landing.yaml +45 -0
- truequery/standard/ingest/seeds-small-static-only.yaml +43 -0
- truequery/standard/ingest/test-intake-against-fixtures.yaml +46 -0
- truequery/standard/ingest/watermark-and-late-data-policy.yaml +49 -0
- truequery/standard/model/README.md +91 -0
- truequery/standard/model/dbt-contract-enforced.yaml +50 -0
- truequery/standard/model/delete-handling-incremental.yaml +51 -0
- truequery/standard/model/explicit-column-lists.yaml +83 -0
- truequery/standard/model/full-refresh-parity.yaml +49 -0
- truequery/standard/model/grain-preserved-joins.yaml +52 -0
- truequery/standard/model/idempotent-reexec.yaml +48 -0
- truequery/standard/model/incremental-lookback-window.yaml +53 -0
- truequery/standard/model/incremental-watermark-filter.yaml +48 -0
- truequery/standard/model/jinja-macro-determinism.yaml +46 -0
- truequery/standard/model/jinja-no-nested-curlies.yaml +43 -0
- truequery/standard/model/materialization-choice.yaml +51 -0
- truequery/standard/model/merge-source-deduped.yaml +57 -0
- truequery/standard/model/microbatch-event-time.yaml +39 -0
- truequery/standard/model/null-join-keys-excluded.yaml +45 -0
- truequery/standard/model/snapshot-scd2-validity.yaml +45 -0
- truequery/standard/model/snapshot-strategy-choice.yaml +41 -0
- truequery/standard/model/staging-no-business-logic.yaml +44 -0
- truequery/standard/model/surrogate-keys-stable.yaml +37 -0
- truequery/standard/model/unique-key-merge-key.yaml +55 -0
- truequery/standard/ops/README.md +98 -0
- truequery/standard/ops/breaking-change-categorization.yaml +35 -0
- truequery/standard/ops/ci-pr-checks-required.yaml +44 -0
- truequery/standard/ops/ci-slim-builds-only-changed.yaml +42 -0
- truequery/standard/ops/cost-estimate-before-run.yaml +41 -0
- truequery/standard/ops/cost-guardrail-confidence-levels.yaml +39 -0
- truequery/standard/ops/dev-environment-expiry.yaml +42 -0
- truequery/standard/ops/disposable-dev-environments.yaml +42 -0
- truequery/standard/ops/drift-detection-history-first.yaml +42 -0
- truequery/standard/ops/full-refresh-approval.yaml +76 -0
- truequery/standard/ops/model-versioning-breaking-changes.yaml +83 -0
- truequery/standard/ops/plan-preview-before-apply.yaml +39 -0
- truequery/standard/ops/post-deploy-data-validation.yaml +42 -0
- truequery/standard/ops/pre-destructive-snapshot.yaml +35 -0
- truequery/standard/ops/prod-credentials-isolation.yaml +96 -0
- truequery/standard/ops/run-artifact-retention.yaml +41 -0
- truequery/standard/ops/staged-upgrades-with-backup.yaml +39 -0
- truequery/standard/orch/README.md +74 -0
- truequery/standard/orch/concurrency-pools-and-limits.yaml +63 -0
- truequery/standard/orch/dag-ci-parse-and-unit-tests.yaml +70 -0
- truequery/standard/orch/data-aware-scheduling-over-polling.yaml +62 -0
- truequery/standard/orch/declared-asset-lineage.yaml +66 -0
- truequery/standard/orch/deferrable-sensors-with-timeout.yaml +75 -0
- truequery/standard/orch/explicit-catchup-policy.yaml +53 -0
- truequery/standard/orch/explicit-schedule-timezone.yaml +68 -0
- truequery/standard/orch/failure-alert-routing.yaml +100 -0
- truequery/standard/orch/idempotent-backfill-windowing.yaml +68 -0
- truequery/standard/orch/idempotent-retries-with-limits.yaml +79 -0
- truequery/standard/orch/light-dag-parse-no-heavy-top-level.yaml +75 -0
- truequery/standard/orch/no-circular-dependencies.yaml +67 -0
- truequery/standard/orch/partitioned-assets-windowing.yaml +66 -0
- truequery/standard/orch/sla-deadline-alerts.yaml +76 -0
- truequery/standard/orch/task-atomicity-no-partial-writes.yaml +62 -0
- truequery/standard/orch/task-timeout-execution-limit.yaml +70 -0
- truequery/standard/py/README.md +63 -0
- truequery/standard/py/airflow-isolate-task-dependencies.yaml +40 -0
- truequery/standard/py/dagster-asset-declared-dependencies.yaml +47 -0
- truequery/standard/py/dagster-io-manager-not-manual-io.yaml +47 -0
- truequery/standard/py/dagster-resources-for-external-clients.yaml +53 -0
- truequery/standard/py/dbt-python-model-contract.yaml +48 -0
- truequery/standard/py/deterministic-model-outputs.yaml +83 -0
- truequery/standard/py/hook-adapter-connection-lifecycle.yaml +59 -0
- truequery/standard/py/idempotent-task-logic.yaml +55 -0
- truequery/standard/py/no-n-plus-one-queries-in-loops.yaml +46 -0
- truequery/standard/py/pandas-filter-before-collect.yaml +44 -0
- truequery/standard/py/pandas-no-chained-assignment.yaml +41 -0
- truequery/standard/py/pandas-no-row-loop-mutations.yaml +44 -0
- truequery/standard/py/pin-pipeline-dependencies.yaml +82 -0
- truequery/standard/py/sqlmesh-audit-python-outputs.yaml +49 -0
- truequery/standard/py/sqlmesh-python-model-execution-context.yaml +52 -0
- truequery/standard/sem/README.md +97 -0
- truequery/standard/sem/aggregation-type-matches-additivity.yaml +61 -0
- truequery/standard/sem/conversion-metrics-window-bounds.yaml +54 -0
- truequery/standard/sem/count-distinct-uses-entity-key.yaml +52 -0
- truequery/standard/sem/cumulative-metrics-time-spine.yaml +53 -0
- truequery/standard/sem/derived-metrics-compose-from-governed.yaml +52 -0
- truequery/standard/sem/description-and-ownership-per-metric.yaml +50 -0
- truequery/standard/sem/dimension-naming-conventions.yaml +52 -0
- truequery/standard/sem/entity-primary-key-declared.yaml +54 -0
- truequery/standard/sem/join-paths-declared-via-entities.yaml +47 -0
- truequery/standard/sem/metric-filters-declared-not-hidden.yaml +57 -0
- truequery/standard/sem/metric-grain-declaration.yaml +52 -0
- truequery/standard/sem/metric-smoke-test-on-change.yaml +43 -0
- truequery/standard/sem/no-fanout-double-counting.yaml +52 -0
- truequery/standard/sem/no-metric-defined-off-staging.yaml +47 -0
- truequery/standard/sem/null-handling-in-aggregations.yaml +57 -0
- truequery/standard/sem/percentile-metrics-use-approximate.yaml +51 -0
- truequery/standard/sem/ratio-metrics-divide-after-aggregation.yaml +49 -0
- truequery/standard/sem/saved-query-canonical-slices.yaml +56 -0
- truequery/standard/sem/semantic-model-validates.yaml +50 -0
- truequery/standard/sem/single-governed-metric-definition.yaml +68 -0
- truequery/standard/sem/time-dimension-standardized.yaml +51 -0
- truequery/standard/sem/unit-consistency.yaml +54 -0
- truequery/standard/sql/README.md +126 -0
- truequery/standard/sql/accidental-cross-join.yaml +58 -0
- truequery/standard/sql/average-of-ratios.yaml +63 -0
- truequery/standard/sql/between-date-on-timestamp.yaml +74 -0
- truequery/standard/sql/case-without-else-in-aggregate.yaml +67 -0
- truequery/standard/sql/count-star-after-left-join.yaml +67 -0
- truequery/standard/sql/distinct-dedup-bandaid.yaml +59 -0
- truequery/standard/sql/explore-limit-is-not-a-sample.yaml +56 -0
- truequery/standard/sql/explore-profile-filter-columns.yaml +48 -0
- truequery/standard/sql/fanout-join-pre-aggregation.yaml +64 -0
- truequery/standard/sql/group-by-bare-column.yaml +75 -0
- truequery/standard/sql/insert-explicit-column-list.yaml +60 -0
- truequery/standard/sql/integer-division-truncation.yaml +94 -0
- truequery/standard/sql/keyset-pagination.yaml +70 -0
- truequery/standard/sql/left-join-null-rejecting-where.yaml +58 -0
- truequery/standard/sql/limit-requires-order-by.yaml +67 -0
- truequery/standard/sql/natural-join-explicit-keys.yaml +56 -0
- truequery/standard/sql/negative-filter-drops-nulls.yaml +70 -0
- truequery/standard/sql/nondeterministic-merge-keys.yaml +69 -0
- truequery/standard/sql/not-in-nullable-subquery.yaml +57 -0
- truequery/standard/sql/null-equality-comparison.yaml +65 -0
- truequery/standard/sql/random-sampling-no-sort.yaml +48 -0
- truequery/standard/sql/row-number-dedupe-tiebreaker.yaml +67 -0
- truequery/standard/sql/sargable-filter-bare-column.yaml +53 -0
- truequery/standard/sql/set-operation-column-position.yaml +59 -0
- truequery/standard/sql/unguarded-update-delete.yaml +63 -0
- truequery/standard/sql/union-all-default.yaml +70 -0
- truequery/standard/store/README.md +88 -0
- truequery/standard/store/cluster-on-frequent-filters-joins.yaml +44 -0
- truequery/standard/store/compact-small-files-optimize.yaml +44 -0
- truequery/standard/store/declare-not-null-check-constraints.yaml +43 -0
- truequery/standard/store/declare-sort-order-for-range-queries.yaml +44 -0
- truequery/standard/store/enable-change-feed-for-incremental-consumers.yaml +44 -0
- truequery/standard/store/evolve-partitions-by-metadata-not-rewrite.yaml +45 -0
- truequery/standard/store/explicit-physical-types-no-implicit-casts.yaml +43 -0
- truequery/standard/store/full-refresh-by-replace-not-delete-insert.yaml +44 -0
- truequery/standard/store/incremental-watermark-partition-alignment.yaml +40 -0
- truequery/standard/store/no-high-cardinality-partition-column.yaml +47 -0
- truequery/standard/store/partition-expiration-retention-policy.yaml +43 -0
- truequery/standard/store/partition-on-filter-column.yaml +43 -0
- truequery/standard/store/partition-predicate-required.yaml +41 -0
- truequery/standard/store/prefer-auto-liquid-clustering-over-manual-tune.yaml +37 -0
- truequery/standard/store/rewrite-manifests-and-stats-periodically.yaml +37 -0
- truequery/standard/store/single-open-format-per-medallion-layer.yaml +37 -0
- truequery/standard/store/time-travel-retention-explicit.yaml +46 -0
- truequery/standard/store/use-transient-temporary-for-ephemeral.yaml +35 -0
- truequery/standard/store/vacuum-expire-snapshots-with-retention-floor.yaml +42 -0
- truequery/std_registry.py +3310 -0
- truequery-0.1.0.dist-info/METADATA +52 -0
- truequery-0.1.0.dist-info/RECORD +254 -0
- truequery-0.1.0.dist-info/WHEEL +4 -0
- truequery-0.1.0.dist-info/entry_points.txt +3 -0
- truequery-0.1.0.dist-info/licenses/LICENSE +201 -0
- truequery-0.1.0.dist-info/licenses/NOTICE +4 -0
truequery/cli.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""truequery: deterministic SQL checks for the agent edit loop. Offline, no LLM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from truequery import engine as E
|
|
12
|
+
from truequery import hook
|
|
13
|
+
from truequery import project as P
|
|
14
|
+
from truequery import report as RP
|
|
15
|
+
from truequery import schema
|
|
16
|
+
from truequery import settings as S
|
|
17
|
+
from truequery import std_registry
|
|
18
|
+
from truequery.engine import ENGINE_VERSION, active_overrides, run
|
|
19
|
+
|
|
20
|
+
EXIT_CLEAN = 0
|
|
21
|
+
EXIT_FINDINGS = 1
|
|
22
|
+
EXIT_ERROR = 2
|
|
23
|
+
EXIT_UNVERIFIED = 3
|
|
24
|
+
|
|
25
|
+
EXIT_HELP = ("Exit codes: 0 = everything checked, no findings at or above "
|
|
26
|
+
"--fail-on (default error); 1 = findings at or above it; "
|
|
27
|
+
"2 = usage or config error; 3 = no such findings, but some "
|
|
28
|
+
"files could not be checked (parse failures, undecodable "
|
|
29
|
+
"text, or files over the size limit, "
|
|
30
|
+
"listed on stderr). --allow-unverified turns 3 into 0.")
|
|
31
|
+
|
|
32
|
+
FAIL_LEVELS = ("error", "warning", "advisory")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dump(args, text: str, json_doc: str, sarif_doc: str) -> None:
|
|
36
|
+
if args.sarif:
|
|
37
|
+
print(sarif_doc)
|
|
38
|
+
elif args.json:
|
|
39
|
+
print(json_doc)
|
|
40
|
+
elif text:
|
|
41
|
+
print(text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cli_layer(args) -> dict | None:
|
|
45
|
+
"""CLI rule flags (the highest-precedence settings layer)."""
|
|
46
|
+
rules: dict[str, dict] = {}
|
|
47
|
+
for item in getattr(args, "rule_tier", None) or []:
|
|
48
|
+
slug, sep, tier = item.partition(":")
|
|
49
|
+
if not sep or tier not in ("immediate", "stop"):
|
|
50
|
+
return None
|
|
51
|
+
rules.setdefault(slug, {})["tier"] = tier
|
|
52
|
+
for slug in getattr(args, "enable_rule", None) or []:
|
|
53
|
+
rules.setdefault(slug, {})["enabled"] = True
|
|
54
|
+
for slug in getattr(args, "disable_rule", None) or []:
|
|
55
|
+
rules.setdefault(slug, {})["enabled"] = False
|
|
56
|
+
return {"rules": rules} if rules else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_rule_flags(parser) -> None:
|
|
60
|
+
parser.add_argument("--rule-tier", action="append",
|
|
61
|
+
metavar="SLUG:TIER",
|
|
62
|
+
help="override a rule's tier for this run "
|
|
63
|
+
"(highest precedence)")
|
|
64
|
+
parser.add_argument("--enable-rule", action="append", metavar="SLUG",
|
|
65
|
+
help="enable a rule for this run")
|
|
66
|
+
parser.add_argument("--disable-rule", action="append", metavar="SLUG",
|
|
67
|
+
help="disable a rule for this run")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _effective_for(args, root):
|
|
71
|
+
"""(Effective, settings map) for CLI verbs; None layer on bad flags."""
|
|
72
|
+
layer = _cli_layer(args)
|
|
73
|
+
if layer is None and (getattr(args, "rule_tier", None)
|
|
74
|
+
or getattr(args, "enable_rule", None)
|
|
75
|
+
or getattr(args, "disable_rule", None)):
|
|
76
|
+
raise ValueError("bad --rule-* flags (want SLUG[:TIER])")
|
|
77
|
+
eff = S.effective(root=root, cli=layer)
|
|
78
|
+
return eff, eff.check_map()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fail_level(args) -> int:
|
|
82
|
+
return RP.SEVERITY_RANK[args.fail_on]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _exit_for(results, args) -> int:
|
|
86
|
+
threshold = _fail_level(args)
|
|
87
|
+
for finding in RP.all_findings(results):
|
|
88
|
+
if RP.SEVERITY_RANK.get(finding.severity, 0) >= threshold:
|
|
89
|
+
return EXIT_FINDINGS
|
|
90
|
+
if not args.allow_unverified and any(not r.verified for r in results):
|
|
91
|
+
return EXIT_UNVERIFIED # a CI gate must not pass on unchecked SQL
|
|
92
|
+
return EXIT_CLEAN
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stderr_diagnostics(results, verbose: bool = False) -> None:
|
|
96
|
+
for diagnostic in RP.all_diagnostics(results):
|
|
97
|
+
print(f"{diagnostic.file}: {diagnostic.message}", file=sys.stderr)
|
|
98
|
+
if verbose and diagnostic.detail:
|
|
99
|
+
print(f" detail: {diagnostic.detail}", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unverified_reason(results) -> str:
|
|
103
|
+
"""Why files went unchecked: parse failures, the size limit, or both."""
|
|
104
|
+
diags = [d for r in results if not r.verified for d in r.diagnostics]
|
|
105
|
+
reasons = []
|
|
106
|
+
if any(d.message != E.TOO_LARGE for d in diags):
|
|
107
|
+
reasons.append("could not parse")
|
|
108
|
+
if any(d.message == E.TOO_LARGE for d in diags):
|
|
109
|
+
reasons.append("over the size limit")
|
|
110
|
+
return " / ".join(reasons) or "could not parse"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _summary_line(results, findings) -> str:
|
|
114
|
+
"""Never print 'clean' unless every file was verified."""
|
|
115
|
+
macros = sum(1 for r in results if r.kind == "macro")
|
|
116
|
+
results = [r for r in results if r.kind != "macro"]
|
|
117
|
+
verified = sum(1 for r in results if r.verified)
|
|
118
|
+
unverified = len(results) - verified
|
|
119
|
+
checked = RP._plural(verified, "file")
|
|
120
|
+
hidden = sum(len(r.suppressed) for r in results)
|
|
121
|
+
ignored = f" ({hidden} hidden by ignores)" if hidden else ""
|
|
122
|
+
reason = _unverified_reason(results)
|
|
123
|
+
skipped = (f" · {RP._plural(macros, 'dbt macro file')} not checked "
|
|
124
|
+
"(macro definitions)") if macros else ""
|
|
125
|
+
if not findings:
|
|
126
|
+
if unverified:
|
|
127
|
+
return (f"no findings in {checked} checked{ignored} · "
|
|
128
|
+
f"{unverified} not checked ({reason}){skipped}")
|
|
129
|
+
if skipped:
|
|
130
|
+
return f"no findings in {checked} checked offline{ignored}{skipped}"
|
|
131
|
+
if hidden:
|
|
132
|
+
return f"no findings in {checked} checked offline{ignored}"
|
|
133
|
+
return f"clean: {checked} checked offline, no findings"
|
|
134
|
+
counts = [RP._plural(sum(1 for f in findings if f.severity == sev), sev)
|
|
135
|
+
for sev in ("error", "warning")]
|
|
136
|
+
advisories = sum(1 for f in findings if f.severity == "advisory")
|
|
137
|
+
if advisories:
|
|
138
|
+
counts.append(RP._plural(advisories, "advisory"))
|
|
139
|
+
line = f"{', '.join(counts)} in {checked} checked offline{ignored}"
|
|
140
|
+
if unverified:
|
|
141
|
+
line += f" ({unverified} not checked: {reason})"
|
|
142
|
+
return line + skipped
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _dialect_arg(value: str) -> str | None:
|
|
146
|
+
try:
|
|
147
|
+
return P.normalize_dialect(value)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise argparse.ArgumentTypeError(str(exc)) from None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_output(parser) -> None:
|
|
153
|
+
group = parser.add_mutually_exclusive_group()
|
|
154
|
+
group.add_argument("--json", action="store_true",
|
|
155
|
+
help="machine-readable report")
|
|
156
|
+
group.add_argument("--sarif", action="store_true",
|
|
157
|
+
help="SARIF 2.1.0 report")
|
|
158
|
+
parser.add_argument("--dialect", default=None, type=_dialect_arg,
|
|
159
|
+
help="sqlglot dialect; else DATA.md tq-config; else auto")
|
|
160
|
+
parser.add_argument("--mode", default=None,
|
|
161
|
+
choices=["explore", "build", "change", "assure"],
|
|
162
|
+
help="override DATA.md mode globs")
|
|
163
|
+
parser.add_argument("--fail-on", default="error", choices=FAIL_LEVELS,
|
|
164
|
+
help="lowest severity that fails (default error)")
|
|
165
|
+
parser.add_argument("--verbose", action="store_true",
|
|
166
|
+
help="print full parse-error detail on stderr")
|
|
167
|
+
parser.add_argument("--allow-unverified", action="store_true",
|
|
168
|
+
help="exit 0 even when some files could not be "
|
|
169
|
+
"checked")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_detect(args: argparse.Namespace) -> int:
|
|
173
|
+
root = os.path.abspath(".")
|
|
174
|
+
try:
|
|
175
|
+
_, settings_map = _effective_for(args, root)
|
|
176
|
+
results = run(args.paths, root, dialect=args.dialect,
|
|
177
|
+
mode=args.mode, settings=settings_map)
|
|
178
|
+
except (P.ContextError, ValueError) as exc:
|
|
179
|
+
print(str(exc), file=sys.stderr)
|
|
180
|
+
return EXIT_ERROR
|
|
181
|
+
modes = [r.mode for r in results]
|
|
182
|
+
findings = RP.all_findings(results)
|
|
183
|
+
if not results:
|
|
184
|
+
note = "truequery detect: no SQL files found"
|
|
185
|
+
if args.sarif:
|
|
186
|
+
print(RP.render_sarif(results, ENGINE_VERSION))
|
|
187
|
+
elif args.json:
|
|
188
|
+
print(RP.render_json(results, ENGINE_VERSION, modes))
|
|
189
|
+
else:
|
|
190
|
+
print(note)
|
|
191
|
+
return EXIT_CLEAN
|
|
192
|
+
_dump(args,
|
|
193
|
+
RP.render_text(results, ENGINE_VERSION)
|
|
194
|
+
or _summary_line(results, findings),
|
|
195
|
+
RP.render_json(results, ENGINE_VERSION, modes),
|
|
196
|
+
RP.render_sarif(results, ENGINE_VERSION))
|
|
197
|
+
_stderr_diagnostics(results, verbose=args.verbose)
|
|
198
|
+
if not (args.json or args.sarif) and findings:
|
|
199
|
+
print(_summary_line(results, findings))
|
|
200
|
+
return _exit_for(results, args)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _review_tables(results, dialect) -> list[str]:
|
|
204
|
+
# Only files the checker read: a skipped file (over the size limit,
|
|
205
|
+
# unparseable) is never opened again for the receipt.
|
|
206
|
+
return P.tables_in_files([r.path for r in results
|
|
207
|
+
if r.kind == "sql" and r.verified], dialect)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_review(args: argparse.Namespace) -> int:
|
|
211
|
+
root = os.path.abspath(".")
|
|
212
|
+
paths = args.paths or [root]
|
|
213
|
+
try:
|
|
214
|
+
eff, settings_map = _effective_for(args, root)
|
|
215
|
+
results = run(paths, root, dialect=args.dialect, mode=args.mode,
|
|
216
|
+
settings=settings_map)
|
|
217
|
+
except (P.ContextError, ValueError) as exc:
|
|
218
|
+
print(str(exc), file=sys.stderr)
|
|
219
|
+
return EXIT_ERROR
|
|
220
|
+
modes = [r.mode for r in results]
|
|
221
|
+
contracts, invalid = P.load_contracts_reporting(root)
|
|
222
|
+
tables = _review_tables(results, args.dialect)
|
|
223
|
+
receipt = RP.build_receipt(results, modes, contracts, tables,
|
|
224
|
+
tier_overrides=active_overrides(
|
|
225
|
+
P.load_config(root)),
|
|
226
|
+
rule_settings=eff.settings_line(),
|
|
227
|
+
invalid_contracts=invalid)
|
|
228
|
+
if args.sarif:
|
|
229
|
+
print(RP.render_sarif(results, ENGINE_VERSION))
|
|
230
|
+
elif args.json:
|
|
231
|
+
print(RP.render_json(results, ENGINE_VERSION, modes,
|
|
232
|
+
receipt=receipt))
|
|
233
|
+
else:
|
|
234
|
+
text = RP.render_text(results, ENGINE_VERSION)
|
|
235
|
+
if text:
|
|
236
|
+
print(text)
|
|
237
|
+
print()
|
|
238
|
+
else:
|
|
239
|
+
print(_summary_line(results, RP.all_findings(results)))
|
|
240
|
+
print()
|
|
241
|
+
print(RP.render_receipt(receipt))
|
|
242
|
+
_stderr_diagnostics(results, verbose=args.verbose)
|
|
243
|
+
return _exit_for(results, args)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
247
|
+
root = os.path.abspath(args.path or ".")
|
|
248
|
+
stack = P.detect_stack(root)
|
|
249
|
+
dialect = P.detect_dialect(root, stack)
|
|
250
|
+
config = P.default_config(stack, dialect)
|
|
251
|
+
wrote, kept = [], []
|
|
252
|
+
data_md = os.path.join(root, "DATA.md")
|
|
253
|
+
if not os.path.exists(data_md) or args.force:
|
|
254
|
+
with open(data_md, "w", encoding="utf-8") as handle:
|
|
255
|
+
handle.write(P.render_data_md(config))
|
|
256
|
+
wrote.append(data_md)
|
|
257
|
+
else:
|
|
258
|
+
kept.append(data_md)
|
|
259
|
+
contracts_dir = os.path.join(root, "contracts")
|
|
260
|
+
os.makedirs(contracts_dir, exist_ok=True)
|
|
261
|
+
# Contracts hold the team's confirmed answers: seeded when missing,
|
|
262
|
+
# never overwritten, not even with --force.
|
|
263
|
+
for path in P.seed_contracts(root):
|
|
264
|
+
wrote.append(path)
|
|
265
|
+
summary = {"stack": stack, "dialect": dialect, "wrote": wrote,
|
|
266
|
+
"kept": kept}
|
|
267
|
+
if args.sarif:
|
|
268
|
+
print(RP.render_sarif([], ENGINE_VERSION))
|
|
269
|
+
elif args.json:
|
|
270
|
+
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
271
|
+
else:
|
|
272
|
+
for path in wrote:
|
|
273
|
+
print(f"wrote {path}")
|
|
274
|
+
for path in kept:
|
|
275
|
+
print(f"kept existing {path} (use --force to overwrite)")
|
|
276
|
+
print(f"init: stack={stack} dialect={dialect or 'auto'} "
|
|
277
|
+
f"({len(wrote)} wrote, {len(kept)} kept)")
|
|
278
|
+
return EXIT_CLEAN
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_context(args: argparse.Namespace) -> int:
|
|
282
|
+
root = os.path.abspath(".")
|
|
283
|
+
config = P.load_config(root)
|
|
284
|
+
stale = config is None
|
|
285
|
+
if stale:
|
|
286
|
+
config = P.default_config(P.detect_stack(root), None)
|
|
287
|
+
dialect = (config or {}).get("dialect")
|
|
288
|
+
rel, target_text = None, ""
|
|
289
|
+
if args.target and os.path.isdir(args.target):
|
|
290
|
+
# A folder: every SQL file in it. One MODE when they agree, else
|
|
291
|
+
# "mixed" with the modes listed.
|
|
292
|
+
rel = P._rel(os.path.abspath(args.target), root)
|
|
293
|
+
files = P.discover([args.target])
|
|
294
|
+
modes = sorted({P.resolve_mode(P._rel(f, root), config, None)
|
|
295
|
+
for f in files}) or [P.resolve_mode("", config, None)]
|
|
296
|
+
mode = modes[0] if len(modes) == 1 else "mixed (" + ", ".join(modes) + ")"
|
|
297
|
+
tables = P.tables_in_files(files, dialect)
|
|
298
|
+
else:
|
|
299
|
+
if args.target:
|
|
300
|
+
rel = P._rel(os.path.abspath(args.target), root)
|
|
301
|
+
try:
|
|
302
|
+
with open(args.target, encoding="utf-8") as handle:
|
|
303
|
+
target_text = handle.read()
|
|
304
|
+
except OSError:
|
|
305
|
+
print(f"truequery: no such file or folder: {args.target}",
|
|
306
|
+
file=sys.stderr)
|
|
307
|
+
return EXIT_ERROR
|
|
308
|
+
mode = P.resolve_mode(rel or "", config, None)
|
|
309
|
+
tables = P.referenced_tables(target_text, dialect)
|
|
310
|
+
contracts = P.load_contracts(root)
|
|
311
|
+
directives = [f"MODE: {mode}", "STANDARD: 0.1",
|
|
312
|
+
"EVIDENCE_AVAILABLE: file"]
|
|
313
|
+
for table in tables:
|
|
314
|
+
if P.match_contract(table, contracts) is None:
|
|
315
|
+
directives.append(f"CONTRACT_MISSING: {table}")
|
|
316
|
+
if stale:
|
|
317
|
+
directives.append("CONTEXT_STALE")
|
|
318
|
+
doc = {"mode": mode, "standard": "0.1",
|
|
319
|
+
"evidence_available": ["file"],
|
|
320
|
+
"config": config, "target": rel,
|
|
321
|
+
"tables_referenced": tables,
|
|
322
|
+
"contracts": {t: contracts[P.match_contract(t, contracts)]
|
|
323
|
+
for t in tables
|
|
324
|
+
if P.match_contract(t, contracts) is not None},
|
|
325
|
+
"directives": directives,
|
|
326
|
+
"stale": stale}
|
|
327
|
+
if args.sarif:
|
|
328
|
+
print(RP.render_sarif([], ENGINE_VERSION))
|
|
329
|
+
elif args.json:
|
|
330
|
+
print(json.dumps(doc, indent=2, sort_keys=True))
|
|
331
|
+
else:
|
|
332
|
+
print(f"# truequery context (project root: {root})")
|
|
333
|
+
print(f"stack={config.get('stack')} "
|
|
334
|
+
f"dialect={config.get('dialect') or 'auto'}")
|
|
335
|
+
if rel:
|
|
336
|
+
print(f"target={rel}")
|
|
337
|
+
for table in tables:
|
|
338
|
+
key = P.match_contract(table, contracts)
|
|
339
|
+
contract = contracts.get(key) if key is not None else None
|
|
340
|
+
if contract is not None:
|
|
341
|
+
print(f"contract {table}: grain={contract['grain']} "
|
|
342
|
+
f"keys={P._format_keys(contract['keys']) or '—'}")
|
|
343
|
+
if not tables:
|
|
344
|
+
print("(target references no tables)")
|
|
345
|
+
for directive in directives:
|
|
346
|
+
print(directive)
|
|
347
|
+
return EXIT_CLEAN
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _rules_set(args: argparse.Namespace, root) -> int:
|
|
351
|
+
"""`tq rules set` — a HUMAN command: writes rule settings to
|
|
352
|
+
the project (DATA.md tq-config) or user config. Agents never run
|
|
353
|
+
this; SKILL.md says so."""
|
|
354
|
+
slug = args.target
|
|
355
|
+
updates = {}
|
|
356
|
+
if args.enabled is not None:
|
|
357
|
+
updates["enabled"] = args.enabled == "on"
|
|
358
|
+
if args.tier is not None:
|
|
359
|
+
updates["tier"] = args.tier
|
|
360
|
+
if args.severity is not None:
|
|
361
|
+
updates["severity"] = args.severity
|
|
362
|
+
problem = S.guard(slug, updates, std_registry.RECORDS)
|
|
363
|
+
if problem:
|
|
364
|
+
print(problem, file=sys.stderr)
|
|
365
|
+
return EXIT_ERROR
|
|
366
|
+
try:
|
|
367
|
+
if args.scope == "user":
|
|
368
|
+
where = S.write_user_rule(slug, updates)
|
|
369
|
+
else:
|
|
370
|
+
data_md = os.path.join(root, "DATA.md")
|
|
371
|
+
try:
|
|
372
|
+
with open(data_md, encoding="utf-8") as handle:
|
|
373
|
+
text = handle.read()
|
|
374
|
+
except OSError:
|
|
375
|
+
print("truequery: no DATA.md here; run `truequery init` first "
|
|
376
|
+
"(or use --scope user)", file=sys.stderr)
|
|
377
|
+
return EXIT_ERROR
|
|
378
|
+
with open(data_md, "w", encoding="utf-8") as handle:
|
|
379
|
+
handle.write(S.data_md_with_rule(text, slug, updates))
|
|
380
|
+
where = data_md
|
|
381
|
+
except ValueError as exc:
|
|
382
|
+
print(f"truequery: {exc}", file=sys.stderr)
|
|
383
|
+
return EXIT_ERROR
|
|
384
|
+
eff = S.effective(root=root).rules[slug]
|
|
385
|
+
state = ("on" if eff["enabled"] else "off") + f" tier={eff['tier']}"
|
|
386
|
+
if eff["severity"]:
|
|
387
|
+
state += f" severity={eff['severity']}"
|
|
388
|
+
print(f"wrote {where}")
|
|
389
|
+
print(f"{slug} is now {state} (effective, from the {eff['source']} "
|
|
390
|
+
"settings)")
|
|
391
|
+
if args.scope == "user" and eff["source"] == "project":
|
|
392
|
+
print("note: DATA.md in this project sets this rule too, and a "
|
|
393
|
+
"project setting overrides your user setting here.")
|
|
394
|
+
return EXIT_CLEAN
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
398
|
+
records = std_registry.RECORDS
|
|
399
|
+
if getattr(args, "slug", None) == "set":
|
|
400
|
+
return _rules_set(args, os.path.abspath("."))
|
|
401
|
+
if args.slug:
|
|
402
|
+
slug = args.slug
|
|
403
|
+
if slug not in records:
|
|
404
|
+
print(f"truequery: unknown rule: {slug}", file=sys.stderr)
|
|
405
|
+
return EXIT_ERROR
|
|
406
|
+
try:
|
|
407
|
+
full = schema.load_all()[slug]
|
|
408
|
+
except (schema.SchemaError, KeyError, OSError) as exc:
|
|
409
|
+
print(f"truequery: cannot load {slug}: {exc}", file=sys.stderr)
|
|
410
|
+
return EXIT_ERROR
|
|
411
|
+
if args.sarif:
|
|
412
|
+
print(RP.render_sarif([], ENGINE_VERSION))
|
|
413
|
+
elif args.json:
|
|
414
|
+
print(json.dumps({
|
|
415
|
+
"rule": full.id, "title": full.title,
|
|
416
|
+
"domain": full.domain, "modes": list(full.modes),
|
|
417
|
+
"enforcement": full.enforcement,
|
|
418
|
+
"evidence": full.evidence,
|
|
419
|
+
"severity": full.severity, "tier": full.tier,
|
|
420
|
+
"implemented": full.implemented,
|
|
421
|
+
"enabled": full.enabled,
|
|
422
|
+
"statement": full.raw.get("statement"),
|
|
423
|
+
"rationale": full.raw.get("rationale"),
|
|
424
|
+
"fix": (full.raw.get("examples") or {}).get("good"),
|
|
425
|
+
"proof": ({
|
|
426
|
+
"setup": full.proof.setup, "bad": full.proof.bad,
|
|
427
|
+
"bad_result": full.proof.bad_result,
|
|
428
|
+
"good": full.proof.good,
|
|
429
|
+
"good_result": full.proof.good_result,
|
|
430
|
+
"note": full.proof.note} if full.proof else None),
|
|
431
|
+
}, indent=2, sort_keys=True))
|
|
432
|
+
else:
|
|
433
|
+
print(f"{full.id} [{full.tier}] "
|
|
434
|
+
f"(implemented={full.implemented} "
|
|
435
|
+
f"enabled={full.enabled})")
|
|
436
|
+
print(f"title: {full.title}")
|
|
437
|
+
print(f"statement: {full.raw.get('statement')}")
|
|
438
|
+
print(f"rationale: {full.raw.get('rationale')}")
|
|
439
|
+
good = (full.raw.get("examples") or {}).get("good")
|
|
440
|
+
if good:
|
|
441
|
+
print(f"fix:\n{good}")
|
|
442
|
+
if full.proof:
|
|
443
|
+
print(f"proof bad: {full.proof.bad} "
|
|
444
|
+
f"-> {full.proof.bad_result}")
|
|
445
|
+
print(f"proof good: {full.proof.good} "
|
|
446
|
+
f"-> {full.proof.good_result}")
|
|
447
|
+
return EXIT_CLEAN
|
|
448
|
+
rows = [(slug, rec) for slug, rec in sorted(records.items())
|
|
449
|
+
if args.all or rec["implemented"]]
|
|
450
|
+
if args.sarif:
|
|
451
|
+
print(RP.render_sarif([], ENGINE_VERSION))
|
|
452
|
+
return EXIT_CLEAN
|
|
453
|
+
try:
|
|
454
|
+
eff, _ = _effective_for(args, os.path.abspath("."))
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
print(f"truequery: {exc}", file=sys.stderr)
|
|
457
|
+
return EXIT_ERROR
|
|
458
|
+
if args.json:
|
|
459
|
+
print(json.dumps(
|
|
460
|
+
[{"rule": slug, "title": rec["title"],
|
|
461
|
+
"implemented": rec["implemented"],
|
|
462
|
+
"enabled": eff.rules[slug]["enabled"],
|
|
463
|
+
"tier": eff.rules[slug]["tier"],
|
|
464
|
+
"severity_override": eff.rules[slug]["severity"],
|
|
465
|
+
"source": eff.rules[slug]["source"],
|
|
466
|
+
"evidence": rec["evidence"],
|
|
467
|
+
"enforcement": rec["enforcement"],
|
|
468
|
+
"severity": rec["severity"]} for slug, rec in rows],
|
|
469
|
+
indent=2, sort_keys=True))
|
|
470
|
+
else:
|
|
471
|
+
for slug, rec in rows:
|
|
472
|
+
state = eff.rules[slug]
|
|
473
|
+
head = f"{slug} [{'on' if state['enabled'] else 'off'} " \
|
|
474
|
+
f"{state['tier']}]"
|
|
475
|
+
detail = (f"(implemented={rec['implemented']}, "
|
|
476
|
+
f"source: {state['source']})")
|
|
477
|
+
if state["severity"]:
|
|
478
|
+
detail = (f"(implemented={rec['implemented']}, "
|
|
479
|
+
f"severity: {state['severity']}, "
|
|
480
|
+
f"source: {state['source']})")
|
|
481
|
+
print(f"{head} {detail} {rec['title']}")
|
|
482
|
+
if not args.all:
|
|
483
|
+
hidden = sum(1 for rec in records.values()
|
|
484
|
+
if not rec["implemented"])
|
|
485
|
+
print(f"({hidden} unimplemented records hidden; "
|
|
486
|
+
f"use --all)")
|
|
487
|
+
return EXIT_CLEAN
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def cmd_hook(args: argparse.Namespace) -> int:
|
|
491
|
+
return hook.main(args)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
495
|
+
return hook.install(args)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
499
|
+
return hook.uninstall(args)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
503
|
+
return hook.status(args)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cmd_ignore_add(args: argparse.Namespace) -> int:
|
|
507
|
+
"""Narrowest-ignore with a reason. Line scope is required: the agent
|
|
508
|
+
may self-serve only one line; broader ignores are the user's call
|
|
509
|
+
(hand-edit .truequery/ignores.json)."""
|
|
510
|
+
if args.rule not in std_registry.RECORDS:
|
|
511
|
+
print(f"truequery: unknown rule: {args.rule}", file=sys.stderr)
|
|
512
|
+
return EXIT_ERROR
|
|
513
|
+
if not args.reason or not args.reason.strip():
|
|
514
|
+
print("truequery: --reason is required (who and what evidence)",
|
|
515
|
+
file=sys.stderr)
|
|
516
|
+
return EXIT_ERROR
|
|
517
|
+
match = re.match(r"^(.*):(\d+)$", args.target)
|
|
518
|
+
if not match:
|
|
519
|
+
print("truequery: target must be path:line — narrowest scope only; "
|
|
520
|
+
"file-level ignores need a human", file=sys.stderr)
|
|
521
|
+
return EXIT_ERROR
|
|
522
|
+
raw_path, line = match.group(1), int(match.group(2))
|
|
523
|
+
root = os.path.abspath(".")
|
|
524
|
+
# Store the path the engine reports: relative to the project, with
|
|
525
|
+
# forward slashes (`./models\\a.sql` -> `models/a.sql`).
|
|
526
|
+
path = P._rel(os.path.abspath(raw_path), root)
|
|
527
|
+
config = P.load_config(root)
|
|
528
|
+
try:
|
|
529
|
+
with open(raw_path if os.path.isabs(raw_path)
|
|
530
|
+
else os.path.join(root, raw_path),
|
|
531
|
+
encoding="utf-8") as handle:
|
|
532
|
+
text = handle.read()
|
|
533
|
+
except OSError:
|
|
534
|
+
print(f"truequery: no such file: {raw_path}", file=sys.stderr)
|
|
535
|
+
return EXIT_ERROR
|
|
536
|
+
spans = E._chunk_hashes(text, (config or {}).get("dialect"))
|
|
537
|
+
digest = E.stmt_hash_at(spans, line)
|
|
538
|
+
if digest is None:
|
|
539
|
+
print(f"truequery: line {line} is not inside a SQL statement",
|
|
540
|
+
file=sys.stderr)
|
|
541
|
+
return EXIT_ERROR
|
|
542
|
+
state_dir = os.path.join(root, ".truequery")
|
|
543
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
544
|
+
ignores_path = os.path.join(state_dir, "ignores.json")
|
|
545
|
+
try:
|
|
546
|
+
with open(ignores_path, encoding="utf-8") as handle:
|
|
547
|
+
data = json.load(handle)
|
|
548
|
+
except (OSError, ValueError):
|
|
549
|
+
data = {"ignores": []}
|
|
550
|
+
if not isinstance(data, dict):
|
|
551
|
+
data = {"ignores": []}
|
|
552
|
+
entries = data.setdefault("ignores", [])
|
|
553
|
+
entry = {"rule": args.rule, "path": path, "line": line,
|
|
554
|
+
"stmt": digest, "offset": E.stmt_offset(spans, line),
|
|
555
|
+
"reason": args.reason.strip()}
|
|
556
|
+
if entry not in entries:
|
|
557
|
+
entries.append(entry)
|
|
558
|
+
with open(ignores_path, "w", encoding="utf-8") as handle:
|
|
559
|
+
json.dump(data, handle, indent=2)
|
|
560
|
+
print(f"ignore added: {args.rule} on {path}:{line} "
|
|
561
|
+
f"(reason recorded)")
|
|
562
|
+
return EXIT_CLEAN
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
566
|
+
parser = argparse.ArgumentParser(
|
|
567
|
+
# Program name follows the invoked entry point (`truequery`
|
|
568
|
+
# or its `tq` alias), never a hardcoded default.
|
|
569
|
+
prog=os.path.basename(sys.argv[0] or "truequery"),
|
|
570
|
+
description="TrueQuery: catch SQL that silently returns the wrong "
|
|
571
|
+
"answer. Offline, deterministic, no LLM.")
|
|
572
|
+
# The plugin's hook shim uses this as a handshake: it runs a
|
|
573
|
+
# `truequery` on PATH only when it prints exactly "truequery <plugin
|
|
574
|
+
# version>", so a stale or foreign binary is never trusted silently.
|
|
575
|
+
parser.add_argument("--version", action="version",
|
|
576
|
+
version=f"truequery {ENGINE_VERSION}")
|
|
577
|
+
sub = parser.add_subparsers(dest="command", required=False)
|
|
578
|
+
|
|
579
|
+
# `check` is what agents tend to guess first; accept it.
|
|
580
|
+
p_detect = sub.add_parser(
|
|
581
|
+
"detect", aliases=["check"],
|
|
582
|
+
help="run enabled checks on files/dirs (zero config)",
|
|
583
|
+
epilog=EXIT_HELP)
|
|
584
|
+
p_detect.add_argument("paths", nargs="+", help=".sql files or dirs")
|
|
585
|
+
_add_output(p_detect)
|
|
586
|
+
_add_rule_flags(p_detect)
|
|
587
|
+
p_detect.set_defaults(func=cmd_detect)
|
|
588
|
+
|
|
589
|
+
p_review = sub.add_parser(
|
|
590
|
+
"review", help="checks plus the receipt (what was/wasn't checked)",
|
|
591
|
+
epilog=EXIT_HELP)
|
|
592
|
+
p_review.add_argument("paths", nargs="*",
|
|
593
|
+
help="files/dirs; default: the project")
|
|
594
|
+
_add_output(p_review)
|
|
595
|
+
_add_rule_flags(p_review)
|
|
596
|
+
p_review.set_defaults(func=cmd_review)
|
|
597
|
+
|
|
598
|
+
p_init = sub.add_parser(
|
|
599
|
+
"init", help="detect stack/dialect; write DATA.md; seed contracts/")
|
|
600
|
+
p_init.add_argument("path", nargs="?", default=".",
|
|
601
|
+
help="project root (default .)")
|
|
602
|
+
p_init.add_argument("--force", action="store_true",
|
|
603
|
+
help="rewrite DATA.md (contracts are never "
|
|
604
|
+
"overwritten)")
|
|
605
|
+
p_init.add_argument("--json", action="store_true")
|
|
606
|
+
p_init.add_argument("--sarif", action="store_true")
|
|
607
|
+
p_init.set_defaults(func=cmd_init)
|
|
608
|
+
|
|
609
|
+
p_context = sub.add_parser(
|
|
610
|
+
"context", help="DATA.md summary + referenced contracts + directives")
|
|
611
|
+
p_context.add_argument("--target", default=None,
|
|
612
|
+
help="only contracts this SQL file (or the SQL "
|
|
613
|
+
"files in this folder) references")
|
|
614
|
+
p_context.add_argument("--json", action="store_true")
|
|
615
|
+
p_context.add_argument("--sarif", action="store_true")
|
|
616
|
+
p_context.set_defaults(func=cmd_context)
|
|
617
|
+
|
|
618
|
+
p_rules = sub.add_parser(
|
|
619
|
+
"rules", help="list rules with effective settings; "
|
|
620
|
+
"'set' adjusts a rule (human only)",
|
|
621
|
+
epilog="truequery rules set <slug> --enabled on|off --tier T "
|
|
622
|
+
"--severity S [--scope project|user] # human-only "
|
|
623
|
+
"command; agents must never change rule settings.")
|
|
624
|
+
p_rules.add_argument("--all", action="store_true",
|
|
625
|
+
help="include unimplemented records")
|
|
626
|
+
p_rules.add_argument("slug", nargs="?",
|
|
627
|
+
help="show one record (statement, fix, proof); "
|
|
628
|
+
"'set' writes rule settings")
|
|
629
|
+
p_rules.add_argument("target", nargs="?",
|
|
630
|
+
help="slug for 'truequery rules set'")
|
|
631
|
+
p_rules.add_argument("--json", action="store_true")
|
|
632
|
+
p_rules.add_argument("--sarif", action="store_true")
|
|
633
|
+
p_rules.add_argument("--enabled", choices=["on", "off"],
|
|
634
|
+
help="(set) enable or disable the rule")
|
|
635
|
+
p_rules.add_argument("--tier", choices=["immediate", "stop"],
|
|
636
|
+
help="(set) per-edit or end-of-turn tier")
|
|
637
|
+
p_rules.add_argument("--severity", choices=list(
|
|
638
|
+
("error", "warning", "advisory")),
|
|
639
|
+
help="(set) severity in every mode")
|
|
640
|
+
p_rules.add_argument("--scope", default="project",
|
|
641
|
+
choices=["project", "user"],
|
|
642
|
+
help="(set) write to DATA.md or user config")
|
|
643
|
+
_add_rule_flags(p_rules)
|
|
644
|
+
p_rules.set_defaults(func=cmd_rules)
|
|
645
|
+
|
|
646
|
+
p_hook = sub.add_parser(
|
|
647
|
+
"hook", help="agent-harness hook entrypoint (event JSON on stdin)")
|
|
648
|
+
p_hook.add_argument("--harness", default="auto",
|
|
649
|
+
choices=["auto", "claude", "codex", "muse"])
|
|
650
|
+
p_hook.add_argument("--event", default="auto",
|
|
651
|
+
choices=["auto", "post", "stop"],
|
|
652
|
+
help="override the payload event (tests)")
|
|
653
|
+
p_hook.add_argument("--log-file", default=None)
|
|
654
|
+
p_hook.set_defaults(func=cmd_hook)
|
|
655
|
+
|
|
656
|
+
p_install = sub.add_parser(
|
|
657
|
+
"install", help="write agent-harness hook configs")
|
|
658
|
+
p_install.add_argument("--harness", default="auto",
|
|
659
|
+
choices=["auto", "claude", "codex", "muse"])
|
|
660
|
+
p_install.add_argument("--scope", default="project",
|
|
661
|
+
choices=["project", "global"])
|
|
662
|
+
p_install.set_defaults(func=cmd_install)
|
|
663
|
+
|
|
664
|
+
p_uninstall = sub.add_parser(
|
|
665
|
+
"uninstall", help="remove tq-installed harness hook configs")
|
|
666
|
+
p_uninstall.add_argument("--harness", default="auto",
|
|
667
|
+
choices=["auto", "claude", "codex", "muse"])
|
|
668
|
+
p_uninstall.add_argument("--scope", default="project",
|
|
669
|
+
choices=["project", "global"])
|
|
670
|
+
p_uninstall.set_defaults(func=cmd_uninstall)
|
|
671
|
+
|
|
672
|
+
p_status = sub.add_parser(
|
|
673
|
+
"status", help="report hook state per harness (never writes)")
|
|
674
|
+
p_status.add_argument("--harness", default="auto",
|
|
675
|
+
choices=["auto", "claude", "codex", "muse"])
|
|
676
|
+
p_status.add_argument("--scope", default="project",
|
|
677
|
+
choices=["project", "global"])
|
|
678
|
+
p_status.set_defaults(func=cmd_status)
|
|
679
|
+
|
|
680
|
+
p_ignore = sub.add_parser(
|
|
681
|
+
"ignore", help="narrowest-ignore with a reason (line scope only)")
|
|
682
|
+
p_ignore_sub = p_ignore.add_subparsers(dest="ignore_command",
|
|
683
|
+
required=True)
|
|
684
|
+
p_add = p_ignore_sub.add_parser("add")
|
|
685
|
+
p_add.add_argument("rule", help="rule slug from tq rules")
|
|
686
|
+
p_add.add_argument("target", help="path:line (line required)")
|
|
687
|
+
p_add.add_argument("--reason", default=None,
|
|
688
|
+
help="who and what evidence (required)")
|
|
689
|
+
p_add.set_defaults(func=cmd_ignore_add)
|
|
690
|
+
return parser
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def main(argv: list[str] | None = None) -> int:
|
|
694
|
+
parser = build_parser()
|
|
695
|
+
args = parser.parse_args(argv)
|
|
696
|
+
if args.command is None:
|
|
697
|
+
parser.print_help()
|
|
698
|
+
print("\nTry: tq detect <file.sql> (start here) | tq init (new project)")
|
|
699
|
+
return EXIT_ERROR
|
|
700
|
+
try:
|
|
701
|
+
return args.func(args)
|
|
702
|
+
except P.ContextError as exc:
|
|
703
|
+
# Unreadable DATA.md / user config: say so, never fall back to
|
|
704
|
+
# defaults silently.
|
|
705
|
+
print(f"truequery: {exc}", file=sys.stderr)
|
|
706
|
+
return EXIT_ERROR
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
if __name__ == "__main__":
|
|
710
|
+
sys.exit(main())
|