dblect 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 (103) hide show
  1. dblect/__init__.py +28 -0
  2. dblect/_version.py +1 -0
  3. dblect/adapters/__init__.py +40 -0
  4. dblect/adapters/builtin/__init__.py +7 -0
  5. dblect/adapters/builtin/bigquery.py +18 -0
  6. dblect/adapters/builtin/duckdb.py +24 -0
  7. dblect/adapters/builtin/postgres.py +17 -0
  8. dblect/adapters/builtin/redshift.py +17 -0
  9. dblect/adapters/builtin/snowflake.py +16 -0
  10. dblect/adapters/model.py +87 -0
  11. dblect/adapters/registry.py +108 -0
  12. dblect/analysis.py +125 -0
  13. dblect/audit/__init__.py +26 -0
  14. dblect/audit/sourcemap.py +177 -0
  15. dblect/audit/suppress.py +273 -0
  16. dblect/audit/walker.py +307 -0
  17. dblect/baseline.py +29 -0
  18. dblect/bootstrap/__init__.py +97 -0
  19. dblect/bootstrap/skill.md +290 -0
  20. dblect/check/__init__.py +59 -0
  21. dblect/check/coverage.py +132 -0
  22. dblect/check/findings.py +135 -0
  23. dblect/check/flags.py +79 -0
  24. dblect/check/incremental.py +122 -0
  25. dblect/check/run.py +713 -0
  26. dblect/check/worlds.py +126 -0
  27. dblect/cli/__init__.py +543 -0
  28. dblect/contracts/__init__.py +59 -0
  29. dblect/contracts/ast.py +213 -0
  30. dblect/contracts/compile.py +374 -0
  31. dblect/contracts/decorator.py +80 -0
  32. dblect/contracts/proxy.py +343 -0
  33. dblect/contracts/stubs.py +91 -0
  34. dblect/demo/__init__.py +20 -0
  35. dblect/demo/enums.py +65 -0
  36. dblect/demo/library.py +21 -0
  37. dblect/execution/__init__.py +5 -0
  38. dblect/execution/incremental.py +168 -0
  39. dblect/execution/project_env.py +69 -0
  40. dblect/execution/run.py +255 -0
  41. dblect/flatten/__init__.py +13 -0
  42. dblect/flatten/detector.py +69 -0
  43. dblect/lineage/__init__.py +41 -0
  44. dblect/lineage/builder.py +1123 -0
  45. dblect/lineage/facts/__init__.py +92 -0
  46. dblect/lineage/facts/grounding.py +190 -0
  47. dblect/lineage/facts/lattice.py +71 -0
  48. dblect/lineage/facts/model.py +170 -0
  49. dblect/lineage/facts/property.py +338 -0
  50. dblect/lineage/facts/registry.py +115 -0
  51. dblect/lineage/graph.py +262 -0
  52. dblect/lineage/predicate.py +521 -0
  53. dblect/lineage/properties/__init__.py +84 -0
  54. dblect/lineage/properties/activation.py +43 -0
  55. dblect/lineage/properties/aggregation_depth.py +71 -0
  56. dblect/lineage/properties/array_nonemptiness.py +207 -0
  57. dblect/lineage/properties/domain_type.py +709 -0
  58. dblect/lineage/properties/functional_dependency.py +540 -0
  59. dblect/lineage/properties/nullability.py +644 -0
  60. dblect/lineage/properties/predicate_flow.py +316 -0
  61. dblect/lineage/properties/uniqueness.py +1252 -0
  62. dblect/lineage/properties/where_provenance.py +57 -0
  63. dblect/lineage/property.py +466 -0
  64. dblect/lineage/semiring.py +110 -0
  65. dblect/loader.py +130 -0
  66. dblect/manifest/__init__.py +42 -0
  67. dblect/manifest/catalog.py +63 -0
  68. dblect/manifest/dag.py +155 -0
  69. dblect/manifest/parse.py +732 -0
  70. dblect/nullability/__init__.py +23 -0
  71. dblect/nullability/detector.py +502 -0
  72. dblect/py.typed +0 -0
  73. dblect/report.py +492 -0
  74. dblect/sarif.py +418 -0
  75. dblect/severity.py +152 -0
  76. dblect/snapshot/__init__.py +18 -0
  77. dblect/snapshot/detector.py +142 -0
  78. dblect/sql/__init__.py +101 -0
  79. dblect/sql/_sqlglot.py +447 -0
  80. dblect/sql/aggregates.py +181 -0
  81. dblect/sql/findings.py +100 -0
  82. dblect/sql/guards.py +261 -0
  83. dblect/sql/parse.py +166 -0
  84. dblect/sql/patterns.py +889 -0
  85. dblect/sql/vocab.py +240 -0
  86. dblect/templating.py +83 -0
  87. dblect/types/__init__.py +106 -0
  88. dblect/types/bridge.py +732 -0
  89. dblect/types/contract.py +325 -0
  90. dblect/types/domain.py +270 -0
  91. dblect/types/enums.py +28 -0
  92. dblect/types/errors.py +15 -0
  93. dblect/types/scalars.py +148 -0
  94. dblect/uniqueness/__init__.py +24 -0
  95. dblect/uniqueness/detector.py +1070 -0
  96. dblect/varinf/__init__.py +52 -0
  97. dblect/varinf/usage.py +224 -0
  98. dblect/varinf/walker.py +343 -0
  99. dblect-0.1.0.dist-info/METADATA +443 -0
  100. dblect-0.1.0.dist-info/RECORD +103 -0
  101. dblect-0.1.0.dist-info/WHEEL +4 -0
  102. dblect-0.1.0.dist-info/entry_points.txt +2 -0
  103. dblect-0.1.0.dist-info/licenses/LICENSE +201 -0
dblect/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """dblect: semantic correctness framework for dbt analytics pipelines.
2
+
3
+ The authored surface a project writes against: domain types and model contracts
4
+ (also under ``dblect.types``), and the contract/proxy layer (``contract``,
5
+ ``models``) for relating columns across a model's rows and across models. More
6
+ surfaces (flags, the CLI) land as they are built.
7
+ """
8
+
9
+ from dblect._version import __version__
10
+ from dblect.contracts import contract, models
11
+ from dblect.types import (
12
+ DomainType,
13
+ Field,
14
+ ForeignKey,
15
+ ModelContract,
16
+ PrimaryKey,
17
+ )
18
+
19
+ __all__ = [
20
+ "DomainType",
21
+ "Field",
22
+ "ForeignKey",
23
+ "ModelContract",
24
+ "PrimaryKey",
25
+ "__version__",
26
+ "contract",
27
+ "models",
28
+ ]
dblect/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,40 @@
1
+ """Target adapters: one :class:`AdapterProfile` per dbt adapter, behind a registry.
2
+
3
+ A dbt project compiles against one adapter (duckdb, snowflake, bigquery, ...), and
4
+ that single choice fixes everything dblect reasons about the target: which sqlglot
5
+ dialect parses its compiled SQL, whether the warehouse enforces PRIMARY KEY /
6
+ UNIQUE and NOT NULL on write, and which incremental strategy runs when a model
7
+ leaves ``incremental_strategy`` unset. :class:`AdapterProfile` gathers those facets
8
+ into one value so a run reads a single coherent target.
9
+
10
+ Adding a warehouse is self-contained: a module that builds an ``AdapterProfile``
11
+ and calls :func:`register`. The built-ins under :mod:`dblect.adapters.builtin`
12
+ are auto-discovered, so this package never enumerates them.
13
+
14
+ An adapter is **validated** when dblect's detectors have been exercised against its
15
+ SQL end-to-end (today, only duckdb). The others carry the runtime semantics dbt's
16
+ adapter docs describe and route through the matching sqlglot dialect once the
17
+ operator opts in via ``--dialect``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dblect.adapters.model import DEDUP_STRATEGIES, AdapterProfile, IncrementalStrategy
23
+ from dblect.adapters.registry import (
24
+ UnvalidatedAdapterError,
25
+ profile_for_adapter,
26
+ register,
27
+ resolve_profile,
28
+ validated_adapters,
29
+ )
30
+
31
+ __all__ = [
32
+ "DEDUP_STRATEGIES",
33
+ "AdapterProfile",
34
+ "IncrementalStrategy",
35
+ "UnvalidatedAdapterError",
36
+ "profile_for_adapter",
37
+ "register",
38
+ "resolve_profile",
39
+ "validated_adapters",
40
+ ]
@@ -0,0 +1,7 @@
1
+ """Built-in adapter profiles, one module per warehouse.
2
+
3
+ Each module builds an :class:`~dblect.adapters.model.AdapterProfile` and calls
4
+ :func:`~dblect.adapters.registry.register`. The registry discovers every module in
5
+ this package automatically, so adding a warehouse is a new file here and nothing
6
+ else.
7
+ """
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dblect.adapters import AdapterProfile, IncrementalStrategy, register
4
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
5
+
6
+ register(
7
+ AdapterProfile(
8
+ adapter_type="bigquery",
9
+ sqlglot_dialect="bigquery",
10
+ validated=True,
11
+ # NOT NULL is enforced (REQUIRED mode). PRIMARY KEY / FOREIGN KEY constraints
12
+ # exist but are advisory (unenforced), so keys are not enforced.
13
+ not_null_enforced=True,
14
+ key_enforced=False,
15
+ default_incremental_strategy=IncrementalStrategy.MERGE,
16
+ non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
17
+ )
18
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dblect.adapters import AdapterProfile, register
4
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
5
+
6
+ # `txid_current()` and `nextval()` arrive as `exp.Anonymous`. `random()`, `uuid()`,
7
+ # `today()` and `gen_random_uuid()` are usually normalised to a typed node (so
8
+ # already caught dialect-neutrally) and are listed for robustness, so a sqlglot
9
+ # version that leaves them anonymous still fires.
10
+ _DUCKDB_NON_DETERMINISTIC_BUILTINS = PORTABLE_NON_DETERMINISTIC_BUILTINS | frozenset(
11
+ {"random", "uuid", "txid_current", "nextval", "today", "gen_random_uuid"}
12
+ )
13
+
14
+ register(
15
+ AdapterProfile(
16
+ adapter_type="duckdb",
17
+ sqlglot_dialect="duckdb",
18
+ validated=True,
19
+ not_null_enforced=True,
20
+ key_enforced=True,
21
+ default_incremental_strategy=None, # left unset pending validation
22
+ non_deterministic_builtins=_DUCKDB_NON_DETERMINISTIC_BUILTINS,
23
+ )
24
+ )
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dblect.adapters import AdapterProfile, IncrementalStrategy, register
4
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
5
+
6
+ register(
7
+ AdapterProfile(
8
+ adapter_type="postgres",
9
+ sqlglot_dialect="postgres",
10
+ validated=False,
11
+ not_null_enforced=True,
12
+ key_enforced=True,
13
+ # dbt-postgres defaults to delete+insert (not merge) once a unique_key is set
14
+ default_incremental_strategy=IncrementalStrategy.DELETE_INSERT,
15
+ non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
16
+ )
17
+ )
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dblect.adapters import AdapterProfile, IncrementalStrategy, register
4
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
5
+
6
+ register(
7
+ AdapterProfile(
8
+ adapter_type="redshift",
9
+ sqlglot_dialect="redshift",
10
+ validated=False,
11
+ not_null_enforced=True,
12
+ key_enforced=False,
13
+ # dbt-redshift, like Postgres, defaults to delete+insert (not merge) with a unique_key
14
+ default_incremental_strategy=IncrementalStrategy.DELETE_INSERT,
15
+ non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
16
+ )
17
+ )
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dblect.adapters import AdapterProfile, IncrementalStrategy, register
4
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
5
+
6
+ register(
7
+ AdapterProfile(
8
+ adapter_type="snowflake",
9
+ sqlglot_dialect="snowflake",
10
+ validated=False,
11
+ not_null_enforced=True,
12
+ key_enforced=False,
13
+ default_incremental_strategy=IncrementalStrategy.MERGE,
14
+ non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
15
+ )
16
+ )
@@ -0,0 +1,87 @@
1
+ """The value types describing one target adapter: :class:`AdapterProfile` and the
2
+ incremental strategies it can default to.
3
+
4
+ These carry no per-adapter data themselves; the concrete profiles live in
5
+ self-contained modules under :mod:`dblect.adapters.builtin` and register
6
+ themselves with the registry, so adding a warehouse never edits this file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+
15
+ class IncrementalStrategy(StrEnum):
16
+ """The incremental materialization strategies dbt ships.
17
+
18
+ A custom strategy (a project-defined macro) is outside this closed set;
19
+ :meth:`parse` maps an unrecognized value to ``None`` so a caller reads it as
20
+ "no known dedup guarantee" rather than guessing.
21
+ """
22
+
23
+ APPEND = "append"
24
+ MERGE = "merge"
25
+ DELETE_INSERT = "delete+insert"
26
+ INSERT_OVERWRITE = "insert_overwrite"
27
+ MICROBATCH = "microbatch"
28
+
29
+ @classmethod
30
+ def parse(cls, raw: str | None) -> IncrementalStrategy | None:
31
+ """The strategy a config string names, or ``None`` when unset or custom."""
32
+ if raw is None:
33
+ return None
34
+ try:
35
+ return cls(raw.strip().lower())
36
+ except ValueError:
37
+ return None
38
+
39
+
40
+ # Strategies whose write deduplicates on ``unique_key``: ``merge`` updates the
41
+ # matching row and ``delete+insert`` removes then reinserts, so each key value
42
+ # lands once. ``append`` inserts unconditionally and ``insert_overwrite`` replaces
43
+ # whole partitions, so neither enforces the key.
44
+ DEDUP_STRATEGIES: frozenset[IncrementalStrategy] = frozenset(
45
+ {IncrementalStrategy.MERGE, IncrementalStrategy.DELETE_INSERT}
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class AdapterProfile:
51
+ """Everything dblect's analysis needs to know about one target adapter.
52
+
53
+ ``adapter_type`` is the effective target's dbt name (the manifest's, or the one
54
+ an override selects); ``sqlglot_dialect`` parses its compiled SQL. The two
55
+ enforcement flags are descriptive provenance, read by the unenforced-constraint
56
+ findings and never by fact resolution. ``default_incremental_strategy`` is the
57
+ strategy in force when a model leaves ``incremental_strategy`` unset, or
58
+ ``None`` where dblect does not know the adapter's default to deduplicate.
59
+ ``non_deterministic_builtins`` is the complete name set (the portable baseline
60
+ plus this adapter's own builtins) the non-determinism detector matches anonymous
61
+ function calls against; a consumer reads it whole and unions nothing.
62
+ ``duplicate_safe_aggregate_builtins`` is the same shape for the duplicate-sensitivity
63
+ predicate: names of UDF aggregates this warehouse exposes (parsed as anonymous
64
+ calls) whose result a duplicated row does not change, so a fan-out into them is
65
+ harmless. It defaults to empty, since most duplicate-safe aggregates already carry a
66
+ dedicated sqlglot type; the few that arrive anonymous are declared here.
67
+ """
68
+
69
+ adapter_type: str
70
+ sqlglot_dialect: str
71
+ validated: bool
72
+ not_null_enforced: bool
73
+ key_enforced: bool
74
+ default_incremental_strategy: IncrementalStrategy | None
75
+ non_deterministic_builtins: frozenset[str]
76
+ duplicate_safe_aggregate_builtins: frozenset[str] = frozenset()
77
+
78
+ def effective_strategy(self, declared: str | None) -> IncrementalStrategy | None:
79
+ """The strategy in force for a model: the declared one when set (``None`` if
80
+ that is a custom strategy dblect does not recognize, so no default is
81
+ assumed for a model that did choose a strategy), else this adapter's
82
+ default. The default branch is meaningful only when a ``unique_key`` is
83
+ present, which the config discoverer checks before consulting it.
84
+ """
85
+ if declared is not None:
86
+ return IncrementalStrategy.parse(declared)
87
+ return self.default_incremental_strategy
@@ -0,0 +1,108 @@
1
+ """The adapter registry: registration, auto-discovery, and resolution.
2
+
3
+ Built-in profiles live one per module under :mod:`dblect.adapters.builtin` and are
4
+ auto-discovered on first lookup, so nothing here enumerates them. See the package
5
+ docstring for how the pieces fit together.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import pkgutil
12
+
13
+ from dblect.adapters.model import AdapterProfile
14
+ from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
15
+
16
+ _PROFILES: dict[str, AdapterProfile] = {}
17
+ _loaded = False
18
+
19
+
20
+ def register(profile: AdapterProfile) -> AdapterProfile:
21
+ """Register ``profile`` under its (case-folded) adapter name and return it, so a
22
+ module can both register and keep a reference: ``SNOWFLAKE = register(...)``.
23
+
24
+ A later registration of the same name wins, which lets a host refine or replace
25
+ a built-in profile without editing it.
26
+ """
27
+ _PROFILES[profile.adapter_type.strip().lower()] = profile
28
+ return profile
29
+
30
+
31
+ def _ensure_loaded() -> None:
32
+ """Import the built-in adapter modules once, so their ``register`` calls run.
33
+
34
+ Discovery walks the :mod:`dblect.adapters.builtin` package rather than naming
35
+ its modules, so a new built-in is picked up without being listed here. The flag
36
+ is set before importing so a module that triggers a lookup during its own
37
+ import does not recurse.
38
+ """
39
+ global _loaded
40
+ if _loaded:
41
+ return
42
+ _loaded = True
43
+ from dblect.adapters import builtin
44
+
45
+ for info in pkgutil.iter_modules(builtin.__path__, builtin.__name__ + "."):
46
+ importlib.import_module(info.name)
47
+
48
+
49
+ def _conservative(adapter_type: str, *, sqlglot_dialect: str | None = None) -> AdapterProfile:
50
+ """The profile for an adapter dblect has no specific knowledge of: NOT NULL
51
+ enforced (true on essentially every warehouse), PRIMARY KEY / UNIQUE advisory,
52
+ no known dedup default (so an unset incremental strategy claims no key), and
53
+ only the portable non-determinism baseline."""
54
+ return AdapterProfile(
55
+ adapter_type=adapter_type,
56
+ sqlglot_dialect=sqlglot_dialect if sqlglot_dialect is not None else adapter_type,
57
+ validated=False,
58
+ not_null_enforced=True,
59
+ key_enforced=False,
60
+ default_incremental_strategy=None,
61
+ non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
62
+ )
63
+
64
+
65
+ def profile_for_adapter(adapter_type: str) -> AdapterProfile:
66
+ """The capability profile for a dbt adapter by name.
67
+
68
+ An adapter no module has registered gets a conservative profile, never an
69
+ error: this is the semantics lookup, distinct from the parsing-validation gate
70
+ in :func:`resolve_profile`.
71
+ """
72
+ _ensure_loaded()
73
+ return _PROFILES.get(adapter_type.strip().lower()) or _conservative(adapter_type)
74
+
75
+
76
+ def validated_adapters() -> frozenset[str]:
77
+ """The names of registered adapters dblect has validated end-to-end."""
78
+ _ensure_loaded()
79
+ return frozenset(name for name, profile in _PROFILES.items() if profile.validated)
80
+
81
+
82
+ class UnvalidatedAdapterError(ValueError):
83
+ """The manifest's adapter is not in dblect's validated set and no ``--dialect``
84
+ override is in effect. Carries the adapter name so the CLI can build an
85
+ actionable message."""
86
+
87
+ def __init__(self, adapter_type: str) -> None:
88
+ super().__init__(
89
+ f"adapter `{adapter_type}` is not in dblect's validated set "
90
+ f"({sorted(validated_adapters())})"
91
+ )
92
+ self.adapter_type = adapter_type
93
+
94
+
95
+ def resolve_profile(*, adapter_type: str, explicit_dialect: str | None) -> AdapterProfile:
96
+ """The single target profile for a run, or raise :class:`UnvalidatedAdapterError`.
97
+
98
+ An ``explicit_dialect`` override names the target wholesale (its grammar and its
99
+ runtime semantics together), so the two cannot drift apart; passing it is the
100
+ operator's acknowledgment of a best-effort, possibly unvalidated
101
+ interpretation. Without an override the manifest's adapter must be validated.
102
+ """
103
+ if explicit_dialect is not None:
104
+ return profile_for_adapter(explicit_dialect)
105
+ profile = profile_for_adapter(adapter_type)
106
+ if not profile.validated:
107
+ raise UnvalidatedAdapterError(adapter_type)
108
+ return profile
dblect/analysis.py ADDED
@@ -0,0 +1,125 @@
1
+ """One analysis door: run every detector family over a manifest, return every
2
+ finding under one sealed type.
3
+
4
+ Two families surface findings today. :func:`~dblect.check.run.run_check` reports
5
+ the declaration-level ones (contract resolution, domain-type contradictions across
6
+ the DAG, not-well-typed aggregations), located by model, column, and contract.
7
+ :func:`~dblect.audit.run_audit` reports the SQL-structural ones (join fan-out,
8
+ window order, the nullability hazards, the rest), located by a span in one compiled
9
+ statement. The two stay distinct in representation and altitude; issue #107 weighs
10
+ whether to merge the representations as well.
11
+
12
+ What this module removes is the obligation to *know* there are two families. Asking
13
+ for "the findings of a manifest" should not require remembering to call both
14
+ producers and merge them: a consumer that threads findings (the incremental-worlds
15
+ cross-world diff, and the world axes still to come) calls :func:`analyze` once and
16
+ gets them all. Adding a third family is a change here, not in every consumer, and
17
+ :data:`AnalysisFinding` is sealed so a ``match`` over it with ``assert_never`` turns
18
+ a forgotten family into a type error rather than a silent coverage gap. That gap is
19
+ not hypothetical: the incremental check first threaded only the declaration-level
20
+ family, so the structural detectors (the very hazard it exists to catch) were absent
21
+ until the omission was noticed by hand.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Hashable
27
+ from dataclasses import dataclass
28
+ from typing import assert_never
29
+
30
+ from dblect.adapters import AdapterProfile
31
+ from dblect.audit import AuditReport, LocatedFinding, run_audit
32
+ from dblect.check.findings import CheckFinding, CheckReport
33
+ from dblect.check.run import build_check_graphs, run_check
34
+ from dblect.manifest import Manifest
35
+ from dblect.sql import parse_manifest_models
36
+ from dblect.types import ContractRegistry
37
+
38
+ # The sealed set of findings any analysis surfaces: one member per detector family.
39
+ # A ``match`` over this union closed by ``assert_never`` is exhaustiveness-checked,
40
+ # so adding a family without handling it everywhere is a type error, not a quiet
41
+ # blind spot.
42
+ AnalysisFinding = CheckFinding | LocatedFinding
43
+
44
+ # A finding's identity across two compilations of the same project: enough to say
45
+ # "the same issue in both worlds" while ignoring what drifts between compiled SQLs.
46
+ FindingIdentity = tuple[Hashable, ...]
47
+
48
+
49
+ def cross_world_identity(finding: AnalysisFinding) -> FindingIdentity:
50
+ """The stable cross-world identity of ``finding``: where it lands and what it is,
51
+ without the message or line span that differ between two compilations. A
52
+ declaration-level finding keys on kind/model/column/contract; a structural one on
53
+ kind/model and the rendered offending snippet. A snippet present in one world only
54
+ (a steady-state-only join) has no match in the other, so it surfaces as varying.
55
+ """
56
+ match finding:
57
+ case CheckFinding():
58
+ return (
59
+ "check",
60
+ finding.kind,
61
+ finding.model_unique_id,
62
+ finding.column,
63
+ finding.contract,
64
+ )
65
+ case LocatedFinding():
66
+ inner = finding.finding
67
+ return ("audit", inner.kind, finding.model_unique_id, inner.sql_snippet)
68
+ assert_never(finding)
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class AnalysisReport:
73
+ """Every finding for one manifest, from every detector family, plus each family's
74
+ own report so a caller that needs the family-specific extras (coverage blocks,
75
+ suppressed directives) still has them. ``findings`` is the merged, sealed view the
76
+ finding-threading consumers read; the rest is there when an altitude matters."""
77
+
78
+ findings: tuple[AnalysisFinding, ...]
79
+ check: CheckReport
80
+ audit: AuditReport
81
+
82
+
83
+ def analyze(
84
+ manifest: Manifest,
85
+ profile: AdapterProfile,
86
+ *,
87
+ registry: ContractRegistry | None = None,
88
+ resolution_floor: float | None = None,
89
+ ) -> AnalysisReport:
90
+ """Run every detector family over ``manifest`` and return every finding.
91
+
92
+ ``profile`` is the resolved target whose dialect parses every model, and
93
+ ``registry`` the contracts to resolve (defaulting to the active one), the same
94
+ inputs :func:`~dblect.check.run.run_check` takes. ``resolution_floor`` is forwarded
95
+ to it unchanged. The merged ``findings`` carry both families so a consumer never
96
+ has to enumerate the families itself.
97
+
98
+ The two families share one parse and one lineage-graph build (the run's dominant cost),
99
+ threaded into both. The shared build stamps the parsed trees in place and ``propagate``
100
+ never mutates them, so both families read the same resolution they would have built
101
+ themselves. ``run_check`` takes ``graphs`` rather than ``registry`` here, since ``graphs``
102
+ already carries the resolved contracts.
103
+
104
+ The build's resolved ``determines`` facts are also threaded into the structural audit so
105
+ join-fanout grounds key coverage through functional dependencies (a declared ``wiki_id
106
+ determines wiki_name`` lets a join on the determinant cover a key carrying the dependent).
107
+ The declaration family already reads these off the shared build; this hands the same facts
108
+ to the structural one.
109
+ """
110
+ parsed, trees = parse_manifest_models(manifest, dialect=profile.sqlglot_dialect)
111
+ graphs = build_check_graphs(manifest, profile, registry=registry, trees=trees)
112
+ check = run_check(manifest, profile, resolution_floor=resolution_floor, graphs=graphs)
113
+ audit = run_audit(
114
+ manifest,
115
+ profile,
116
+ parsed=parsed,
117
+ column_graph=graphs.column_build.graph,
118
+ relation_graph=graphs.relation_build.graph,
119
+ fd_facts=graphs.resolved.fd_facts,
120
+ )
121
+ return AnalysisReport(
122
+ findings=(*check.findings, *audit.findings),
123
+ check=check,
124
+ audit=audit,
125
+ )
@@ -0,0 +1,26 @@
1
+ """Audit pipeline: static detectors, replay-determinism, heuristic invariants."""
2
+
3
+ from dblect.audit.sourcemap import SourceSpan, SpanBasis
4
+ from dblect.audit.suppress import SuppressionDirective
5
+ from dblect.audit.walker import (
6
+ DEFAULT_DETECTORS,
7
+ AuditReport,
8
+ Detector,
9
+ LocatedFinding,
10
+ SkippedModel,
11
+ SuppressedFinding,
12
+ run_audit,
13
+ )
14
+
15
+ __all__ = [
16
+ "DEFAULT_DETECTORS",
17
+ "AuditReport",
18
+ "Detector",
19
+ "LocatedFinding",
20
+ "SkippedModel",
21
+ "SourceSpan",
22
+ "SpanBasis",
23
+ "SuppressedFinding",
24
+ "SuppressionDirective",
25
+ "run_audit",
26
+ ]