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
@@ -0,0 +1,177 @@
1
+ """Back-map a compiled-SQL line span onto the on-disk source template.
2
+
3
+ Findings carry line numbers from the compiled SQL the parser saw. For a model
4
+ written without Jinja the compiled and source lines coincide; for a macro- or
5
+ ref-heavy model dbt's expansion pushes them apart, so a finding's span points at
6
+ the wrong place in the ``.sql`` the developer wrote.
7
+
8
+ The alignment is deliberately conservative. It anchors verbatim-passthrough lines
9
+ (whitespace collapsed, so a re-indent still matches). A line rewritten or emitted
10
+ by compilation does not match, so for those we reach for a second anchor: the
11
+ source line of the ``{{ ... }}`` call that emitted it, found in the raw template's
12
+ Jinja structure. When the gap between two verbatim anchors holds exactly one call
13
+ site the emitted span anchors there (``MACRO_CALL``); SQLFluff reaches the same
14
+ position from an instrumented render, and this reconstructs it from artifacts.
15
+ When no single source line can be found (several calls in the gap, or a fully
16
+ generated model) the span stays compiled-relative: a wrong source line reads as a
17
+ bug in the tool, so we keep the honest compiled line instead of guessing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from difflib import SequenceMatcher
25
+ from enum import Enum
26
+
27
+ from jinja2 import TemplateError, nodes
28
+
29
+ from dblect.templating import shared_environment
30
+
31
+ _WHITESPACE = re.compile(r"\s+")
32
+
33
+
34
+ class SpanBasis(Enum):
35
+ """Which text a span's line numbers index.
36
+
37
+ ``SOURCE`` indexes the on-disk template at the construct itself. ``MACRO_CALL``
38
+ indexes it at the ``{{ ... }}`` call site that emitted the construct, which lives
39
+ in generated SQL with no source line of its own. ``COMPILED`` indexes the compiled
40
+ SQL the parser saw, the fallback when no source line was found.
41
+ """
42
+
43
+ SOURCE = "source"
44
+ MACRO_CALL = "macro_call"
45
+ COMPILED = "compiled"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class SourceSpan:
50
+ """A 1-indexed line span and the text its line numbers index."""
51
+
52
+ line_start: int
53
+ line_end: int
54
+ basis: SpanBasis
55
+
56
+ @classmethod
57
+ def compiled(cls, line_start: int, line_end: int) -> SourceSpan:
58
+ return cls(line_start, line_end, SpanBasis.COMPILED)
59
+
60
+
61
+ def _normalize(line: str) -> str:
62
+ """Collapse whitespace and trim so a re-indented passthrough line still matches its
63
+ source. A blank line normalizes to ``""`` and never anchors."""
64
+ return _WHITESPACE.sub(" ", line.strip())
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class LineMap:
69
+ """Aligns compiled line indices to source line indices.
70
+
71
+ ``_to_source[i]`` is the 0-indexed source line compiled line ``i`` maps to verbatim,
72
+ or ``-1`` for none. ``_call_lines`` are the 1-indexed source lines holding a
73
+ ``{{ ... }}`` construct, the candidate anchors for macro-emitted spans. ``_source_len``
74
+ bounds the file so a call site with no verbatim anchor after it still has a gap.
75
+ """
76
+
77
+ _to_source: tuple[int, ...]
78
+ _call_lines: tuple[int, ...]
79
+ _source_len: int
80
+
81
+ def map_span(self, line_start: int, line_end: int) -> SourceSpan:
82
+ """Map a 1-indexed compiled span to a source span. ``line_start == 0`` is the
83
+ detector's "no line" sentinel and has no source position."""
84
+ if line_start <= 0 or line_end < line_start:
85
+ return SourceSpan.compiled(line_start, line_end)
86
+ start = self._lookup(line_start)
87
+ if start is not None and self._anchors_verbatim(line_start, line_end, start):
88
+ return SourceSpan(start, start + (line_end - line_start), SpanBasis.SOURCE)
89
+ return self._macro_call_anchor(line_start, line_end)
90
+
91
+ def _anchors_verbatim(self, line_start: int, line_end: int, start: int) -> bool:
92
+ # Every line must map to the consecutive source run from `start`. Anchoring only
93
+ # the endpoints would let a span whose interior a macro emitted claim source lines
94
+ # the construct never occupied, so a gap or a jump declines to SOURCE.
95
+ expected = start
96
+ for compiled_line in range(line_start, line_end + 1):
97
+ if self._lookup(compiled_line) != expected:
98
+ return False
99
+ expected += 1
100
+ return True
101
+
102
+ def _macro_call_anchor(self, line_start: int, line_end: int) -> SourceSpan:
103
+ # The emitted lines sit between two verbatim anchors; the source lines between
104
+ # those anchors are where the call that emitted them lives. One call site there is
105
+ # unambiguous; none or several, we cannot tell which to blame, so keep the line.
106
+ before = self._source_before(line_start)
107
+ after = self._source_after(line_end)
108
+ candidates = [c for c in self._call_lines if before < c < after]
109
+ if len(candidates) == 1:
110
+ return SourceSpan(candidates[0], candidates[0], SpanBasis.MACRO_CALL)
111
+ return SourceSpan.compiled(line_start, line_end)
112
+
113
+ def _source_before(self, line_start: int) -> int:
114
+ """Source line of the nearest verbatim anchor before ``line_start``, else ``0``."""
115
+ for compiled_line in range(line_start - 1, 0, -1):
116
+ source = self._lookup(compiled_line)
117
+ if source is not None:
118
+ return source
119
+ return 0
120
+
121
+ def _source_after(self, line_end: int) -> int:
122
+ """Source line of the nearest verbatim anchor after ``line_end``, else one past
123
+ the file."""
124
+ for compiled_line in range(line_end + 1, len(self._to_source) + 1):
125
+ source = self._lookup(compiled_line)
126
+ if source is not None:
127
+ return source
128
+ return self._source_len + 1
129
+
130
+ def _lookup(self, one_indexed: int) -> int | None:
131
+ idx = one_indexed - 1
132
+ if 0 <= idx < len(self._to_source):
133
+ mapped = self._to_source[idx]
134
+ if mapped >= 0:
135
+ return mapped + 1
136
+ return None
137
+
138
+
139
+ def build_line_map(compiled: str | None, raw: str | None) -> LineMap:
140
+ """Align ``compiled`` SQL to its ``raw`` template line-for-line.
141
+
142
+ With either side absent there is nothing to anchor against, so the map is empty.
143
+ ``autojunk`` is off so a line repeated across the model (common in generated SQL)
144
+ still anchors on its longest matching run rather than being dropped as filler.
145
+ """
146
+ if not compiled or not raw:
147
+ return LineMap((), (), 0)
148
+ compiled_lines = compiled.splitlines()
149
+ raw_lines = raw.splitlines()
150
+ compiled_norm = [_normalize(line) for line in compiled_lines]
151
+ raw_norm = [_normalize(line) for line in raw_lines]
152
+ to_source = [-1] * len(compiled_lines)
153
+ matcher = SequenceMatcher(a=compiled_norm, b=raw_norm, autojunk=False)
154
+ for compiled_i, raw_i, length in matcher.get_matching_blocks():
155
+ for offset in range(length):
156
+ # A blank line matching a blank line is a coincidence, not an alignment.
157
+ if compiled_norm[compiled_i + offset]:
158
+ to_source[compiled_i + offset] = raw_i + offset
159
+ return LineMap(tuple(to_source), _templated_call_lines(raw), len(raw_lines))
160
+
161
+
162
+ def _templated_call_lines(raw: str) -> tuple[int, ...]:
163
+ """The 1-indexed source lines of ``raw`` that hold a ``{{ ... }}`` construct, read
164
+ from the parsed Jinja tree (no render). An unparseable template yields none."""
165
+ try:
166
+ template = shared_environment().parse(raw)
167
+ except TemplateError:
168
+ return ()
169
+ # Jinja coalesces a run of output into one `Output` node whose `lineno` is the start
170
+ # of the run, so the per-call line lives on each non-literal child, not the Output.
171
+ lines = {
172
+ child.lineno
173
+ for output in template.find_all(nodes.Output)
174
+ for child in output.nodes
175
+ if child.lineno and not isinstance(child, nodes.TemplateData)
176
+ }
177
+ return tuple(sorted(lines))
@@ -0,0 +1,273 @@
1
+ """Parse and apply SQLFluff-compatible ``-- noqa`` suppression comments in model SQL.
2
+
3
+ The user can mute a detector finding by placing a SQL comment either on the line
4
+ containing the offending expression or on the line immediately above it. The syntax
5
+ is the one SQLFluff (and dbt Fusion's ``dbt lint``) already speak, so a single
6
+ comment can address both a lint rule and a dblect finding::
7
+
8
+ -- noqa
9
+ select b.k, sum(amount) from a left join b on a.k = b.k group by b.k
10
+
11
+ -- only silence one dblect detector:
12
+ select b.k, sum(amount) from a left join b on a.k = b.k group by b.k -- noqa: DBLECT_NULL_GROUP_AFTER_OUTER_JOIN
13
+
14
+ -- one directive, two audiences (the lint rule is dbt lint's, the DBLECT_ code is ours):
15
+ select b.k, sum(amount) from a left join b on a.k = b.k group by b.k -- noqa: RF01, DBLECT_JOIN_FANOUT
16
+
17
+ Two rules govern the codes after the colon:
18
+
19
+ * A bare ``-- noqa`` (no codes) silences every dblect finding on the line.
20
+ * ``-- noqa: <codes>`` silences only the dblect findings whose code is named.
21
+ A dblect code is ``DBLECT_`` plus the finding kind's value uppercased
22
+ (:func:`dblect.sql.suppression_code`). Codes that do not start with ``DBLECT_``
23
+ are real lint rule codes (``RF01`` and friends): dbt lint owns them, so we ignore
24
+ them. A directive naming only foreign codes silences nothing of ours.
25
+
26
+ There is no reason slot: SQLFluff noqa has none, and dropping it keeps our directives
27
+ interchangeable with the linter's. Every suppression is still logged in the report's
28
+ suppressed section, so a silenced finding is never invisible in review.
29
+
30
+ Directives are read from both of a model's texts (:class:`FramedDirectives`): the source
31
+ frame from ``raw_code`` and the compiled frame from the rendered SQL. A finding is matched
32
+ in each frame against the coordinate that indexes it. Its back-mapped ``located_span`` is
33
+ matched against the source frame when the back-map placed it on a real source line, and
34
+ its ``compiled_span`` is matched against the compiled frame. A macro-emitted construct
35
+ occupies both: the ``{{ ... }}`` call line in the template and the emitted line in the
36
+ compiled SQL. So a ``-- noqa`` on the call line silences it from the source frame, and one
37
+ written in the macro body, which renders next to the construct in every expansion, silences
38
+ it from the compiled frame. That compiled-frame path lets a single comment in a shared
39
+ macro speak for every model the macro expands into, the case a source-only scan leaves
40
+ unsuppressable. Matching each frame against its own coordinate keeps a source directive
41
+ from silencing a compiled-relative finding by line-number coincidence.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ from collections.abc import Iterable
48
+ from dataclasses import dataclass
49
+ from functools import cache
50
+ from typing import TYPE_CHECKING, Protocol, TypeAlias, TypeVar
51
+
52
+ from dblect.audit.sourcemap import SourceSpan, SpanBasis
53
+ from dblect.sql import FindingKind, suppression_code
54
+
55
+ if TYPE_CHECKING:
56
+ from dblect.check.findings import CheckFindingKind
57
+ from dblect.manifest import Node
58
+
59
+ # A directive can name a kind from either detector family: a structural
60
+ # ``FindingKind`` or a declaration-level ``CheckFindingKind``. The same
61
+ # ``-- noqa`` syntax and scanner serve both, so one comment style acknowledges
62
+ # any finding the analysis emits. ``CheckFindingKind`` is referenced only under
63
+ # ``TYPE_CHECKING`` so this audit-side scanner does not pull the declaration-check
64
+ # package in at import time, which would close a cycle.
65
+ SuppressibleKind: TypeAlias = FindingKind | CheckFindingKind
66
+
67
+
68
+ class Suppressible(Protocol):
69
+ """What a directive needs to decide whether it silences a finding: the kind it carries
70
+ and the two coordinates it can occupy. Both finding families satisfy this, so matching
71
+ is written once over the protocol rather than per family.
72
+
73
+ ``located_span`` is the back-mapped span the report shows; ``compiled_span`` is the raw
74
+ compiled coordinate the parser observed. They differ only for a macro-emitted construct,
75
+ where ``located_span`` names the ``{{ ... }}`` call site in the template and
76
+ ``compiled_span`` names the emitted line in the compiled SQL. Matching consults each
77
+ frame against the coordinate that indexes it (:func:`apply`), so a ``-- noqa`` on the
78
+ call line and one in the macro body both reach the finding, each in its own text."""
79
+
80
+ @property
81
+ def kind(self) -> SuppressibleKind: ...
82
+ @property
83
+ def located_span(self) -> SourceSpan: ...
84
+ @property
85
+ def compiled_span(self) -> SourceSpan: ...
86
+
87
+
88
+ _F = TypeVar("_F", bound=Suppressible)
89
+
90
+ # Match ``noqa`` only when it stands alone: followed by ``:``, whitespace, or
91
+ # end-of-token. The ``(?![\w-])`` is load-bearing: it stops ``noqa-file`` and
92
+ # ``noqa-fixture`` (SQLFluff's file-level directive, and dblect's retired one) from
93
+ # being misread as a bare ``noqa`` that silences everything. That misread is the
94
+ # exact collision this rewrite exists to avoid.
95
+ _NOQA = re.compile(r"--\s*noqa(?![\w-])\s*(?::\s*(?P<codes>[^\n]*))?", re.IGNORECASE)
96
+
97
+
98
+ def _comment_of(line: str) -> str | None:
99
+ """The text from `line`'s first ``--`` comment marker, or ``None`` when the line
100
+ has no comment outside a string literal.
101
+
102
+ A ``--`` inside a ``'...'`` or ``"..."`` literal is data, not a comment, so it must
103
+ not start the directive scan (a projection like ``select '-- noqa' as label`` would
104
+ otherwise read as a bare suppression). Doubled quotes (SQL's in-literal escape)
105
+ toggle the state twice, leaving it correct.
106
+ """
107
+ quote: str | None = None
108
+ for i, ch in enumerate(line):
109
+ if quote is not None:
110
+ if ch == quote:
111
+ quote = None
112
+ elif ch in ("'", '"'):
113
+ quote = ch
114
+ elif ch == "-" and line[i + 1 : i + 2] == "-":
115
+ return line[i:]
116
+ return None
117
+
118
+
119
+ @cache
120
+ def _kind_by_code() -> dict[str, SuppressibleKind]:
121
+ """The kind a ``DBLECT_`` code names, across both families. Built on first use
122
+ rather than at import so the structural-audit module and the declaration-check
123
+ module can both reach this scanner without a load-order cycle; the families' codes
124
+ are disjoint, so the two maps merge without collision."""
125
+ from dblect.check.findings import CheckFindingKind
126
+
127
+ merged: dict[str, SuppressibleKind] = {suppression_code(k): k for k in FindingKind}
128
+ merged.update({suppression_code(k): k for k in CheckFindingKind})
129
+ return merged
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class SuppressionDirective:
134
+ """One ``-- noqa`` comment parsed out of model SQL.
135
+
136
+ ``kinds`` is ``None`` for a bare ``-- noqa`` (silences every kind on the line).
137
+ Otherwise it is the set of dblect kinds the directive's codes mapped to, which may
138
+ be empty when every code was foreign (a ``-- noqa: RF01`` that names no dblect
139
+ code silences nothing of ours)."""
140
+
141
+ line: int
142
+ kinds: frozenset[SuppressibleKind] | None
143
+
144
+
145
+ def parse_directives(sql: str) -> tuple[SuppressionDirective, ...]:
146
+ """Pull every ``-- noqa`` directive out of `sql`. Lines are 1-indexed.
147
+
148
+ A line with no codes after ``noqa`` (or only whitespace) yields a bare directive
149
+ (``kinds is None``). A line with codes splits them on ``,``, uppercases each, maps
150
+ it through the ``DBLECT_`` code table, and keeps the kinds that resolved; foreign
151
+ codes are dropped, so the resulting ``kinds`` frozenset may be empty.
152
+ """
153
+ directives: list[SuppressionDirective] = []
154
+ for line_idx, line_text in enumerate(sql.splitlines(), start=1):
155
+ comment = _comment_of(line_text)
156
+ if comment is None:
157
+ continue
158
+ m = _NOQA.search(comment)
159
+ if m is None:
160
+ continue
161
+ raw_codes = (m.group("codes") or "").strip()
162
+ if not raw_codes:
163
+ directives.append(SuppressionDirective(line=line_idx, kinds=None))
164
+ continue
165
+ table = _kind_by_code()
166
+ mapped = (table.get(code.strip().upper()) for code in raw_codes.split(","))
167
+ kinds: frozenset[SuppressibleKind] = frozenset(k for k in mapped if k is not None)
168
+ directives.append(SuppressionDirective(line=line_idx, kinds=kinds))
169
+ return tuple(directives)
170
+
171
+
172
+ def _admits(directive: SuppressionDirective, span: SourceSpan, kind: SuppressibleKind) -> bool:
173
+ """True if `directive`, read in `span`'s coordinate frame, silences a `kind` finding
174
+ occupying `span`.
175
+
176
+ A directive applies when it sits on the line immediately above the span or anywhere
177
+ within it. A bare directive (``kinds is None``) silences every kind; a coded directive
178
+ silences only the kinds it names (and a kind from one family never matches a finding
179
+ from the other, since the codes are distinct). A span with no line range
180
+ (``line_start == 0``) is never matched: a directive can't responsibly silence what it
181
+ can't locate.
182
+ """
183
+ if span.line_start == 0:
184
+ return False
185
+ if not (span.line_start - 1 <= directive.line <= span.line_end):
186
+ return False
187
+ if directive.kinds is None:
188
+ return True
189
+ return kind in directive.kinds
190
+
191
+
192
+ @dataclass(frozen=True, slots=True)
193
+ class FramedDirectives:
194
+ """The ``-- noqa`` directives a model offers, split by the text they were read from.
195
+
196
+ ``source`` directives come from ``raw_code`` (the developer's template); ``compiled``
197
+ directives come from the rendered SQL. :func:`apply` matches a finding against each
198
+ frame using the coordinate that indexes it, so the two never cross."""
199
+
200
+ source: tuple[SuppressionDirective, ...]
201
+ compiled: tuple[SuppressionDirective, ...]
202
+
203
+ @classmethod
204
+ def parse(cls, *, raw: str | None, compiled: str | None) -> FramedDirectives:
205
+ """Parse both frames from a model's two texts. Either text absent yields an empty
206
+ frame, so a model with no template (or no compiled SQL) simply offers fewer
207
+ directives rather than failing."""
208
+ return cls(parse_directives(raw or ""), parse_directives(compiled or ""))
209
+
210
+ @classmethod
211
+ def for_node(cls, node: Node) -> FramedDirectives:
212
+ """Both frames for a manifest node: the source frame from its template and the
213
+ compiled frame from the SQL the analysis layer parses. The single place the
214
+ node-field-to-frame binding lives, shared by the structural and declaration runs."""
215
+ return cls.parse(raw=node.raw_code, compiled=node.analysis_sql)
216
+
217
+
218
+ def format_directive_location(*, in_compiled: bool, line: int) -> str:
219
+ """Render where a directive sat for a human-facing surface. A compiled-frame match (a
220
+ macro body's ``-- noqa``) is tagged ``compiled`` so its line is read in compiled space
221
+ rather than mistaken for a source line. Shared by the text report and the SARIF log so
222
+ the two label a suppression the same way."""
223
+ return f"compiled L{line}" if in_compiled else f"L{line}"
224
+
225
+
226
+ def apply(
227
+ findings: Iterable[_F],
228
+ directives: FramedDirectives,
229
+ ) -> tuple[tuple[_F, ...], tuple[tuple[_F, SuppressionDirective, bool], ...]]:
230
+ """Partition `findings` into (active, suppressed). Each suppressed entry is the finding,
231
+ the directive that silenced it, and whether that directive was read in the compiled
232
+ frame. Generic over the finding family: it works the same for a structural ``Finding``
233
+ and a declaration-level ``CheckFinding``, since both expose the protocol the match reads.
234
+
235
+ A finding is matched in each frame against the coordinate that indexes it: the source
236
+ frame against its ``located_span`` when the back-map anchored it to a real source line
237
+ (``SOURCE`` or ``MACRO_CALL``), and the compiled frame against its ``compiled_span``
238
+ whenever that span is a genuine compiled coordinate (``MACRO_CALL`` or ``COMPILED``). A
239
+ macro-emitted construct occupies both, so a call-line ``-- noqa`` and a macro-body one
240
+ each reach it. The source frame is preferred when both match, since it names the line the
241
+ developer wrote. A purely source-anchored finding is never matched against the compiled
242
+ frame (its compiled coordinate is the same text), and a compiled-relative one is never
243
+ matched against the source frame, so a source directive cannot silence it by coincidence.
244
+ """
245
+ active: list[_F] = []
246
+ suppressed: list[tuple[_F, SuppressionDirective, bool]] = []
247
+ for f in findings:
248
+ match = _suppressing_directive(f, directives)
249
+ if match is None:
250
+ active.append(f)
251
+ else:
252
+ suppressed.append((f, match[0], match[1]))
253
+ return tuple(active), tuple(suppressed)
254
+
255
+
256
+ def _suppressing_directive(
257
+ finding: Suppressible, directives: FramedDirectives
258
+ ) -> tuple[SuppressionDirective, bool] | None:
259
+ """The directive that silences `finding`, paired with whether it came from the compiled
260
+ frame, or ``None`` if none does. Source frame first (the line the developer wrote)."""
261
+ located = finding.located_span
262
+ if located.basis is not SpanBasis.COMPILED:
263
+ source = next((d for d in directives.source if _admits(d, located, finding.kind)), None)
264
+ if source is not None:
265
+ return source, False
266
+ if located.basis is not SpanBasis.SOURCE:
267
+ compiled_span = finding.compiled_span
268
+ compiled = next(
269
+ (d for d in directives.compiled if _admits(d, compiled_span, finding.kind)), None
270
+ )
271
+ if compiled is not None:
272
+ return compiled, True
273
+ return None