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.
- dblect/__init__.py +28 -0
- dblect/_version.py +1 -0
- dblect/adapters/__init__.py +40 -0
- dblect/adapters/builtin/__init__.py +7 -0
- dblect/adapters/builtin/bigquery.py +18 -0
- dblect/adapters/builtin/duckdb.py +24 -0
- dblect/adapters/builtin/postgres.py +17 -0
- dblect/adapters/builtin/redshift.py +17 -0
- dblect/adapters/builtin/snowflake.py +16 -0
- dblect/adapters/model.py +87 -0
- dblect/adapters/registry.py +108 -0
- dblect/analysis.py +125 -0
- dblect/audit/__init__.py +26 -0
- dblect/audit/sourcemap.py +177 -0
- dblect/audit/suppress.py +273 -0
- dblect/audit/walker.py +307 -0
- dblect/baseline.py +29 -0
- dblect/bootstrap/__init__.py +97 -0
- dblect/bootstrap/skill.md +290 -0
- dblect/check/__init__.py +59 -0
- dblect/check/coverage.py +132 -0
- dblect/check/findings.py +135 -0
- dblect/check/flags.py +79 -0
- dblect/check/incremental.py +122 -0
- dblect/check/run.py +713 -0
- dblect/check/worlds.py +126 -0
- dblect/cli/__init__.py +543 -0
- dblect/contracts/__init__.py +59 -0
- dblect/contracts/ast.py +213 -0
- dblect/contracts/compile.py +374 -0
- dblect/contracts/decorator.py +80 -0
- dblect/contracts/proxy.py +343 -0
- dblect/contracts/stubs.py +91 -0
- dblect/demo/__init__.py +20 -0
- dblect/demo/enums.py +65 -0
- dblect/demo/library.py +21 -0
- dblect/execution/__init__.py +5 -0
- dblect/execution/incremental.py +168 -0
- dblect/execution/project_env.py +69 -0
- dblect/execution/run.py +255 -0
- dblect/flatten/__init__.py +13 -0
- dblect/flatten/detector.py +69 -0
- dblect/lineage/__init__.py +41 -0
- dblect/lineage/builder.py +1123 -0
- dblect/lineage/facts/__init__.py +92 -0
- dblect/lineage/facts/grounding.py +190 -0
- dblect/lineage/facts/lattice.py +71 -0
- dblect/lineage/facts/model.py +170 -0
- dblect/lineage/facts/property.py +338 -0
- dblect/lineage/facts/registry.py +115 -0
- dblect/lineage/graph.py +262 -0
- dblect/lineage/predicate.py +521 -0
- dblect/lineage/properties/__init__.py +84 -0
- dblect/lineage/properties/activation.py +43 -0
- dblect/lineage/properties/aggregation_depth.py +71 -0
- dblect/lineage/properties/array_nonemptiness.py +207 -0
- dblect/lineage/properties/domain_type.py +709 -0
- dblect/lineage/properties/functional_dependency.py +540 -0
- dblect/lineage/properties/nullability.py +644 -0
- dblect/lineage/properties/predicate_flow.py +316 -0
- dblect/lineage/properties/uniqueness.py +1252 -0
- dblect/lineage/properties/where_provenance.py +57 -0
- dblect/lineage/property.py +466 -0
- dblect/lineage/semiring.py +110 -0
- dblect/loader.py +130 -0
- dblect/manifest/__init__.py +42 -0
- dblect/manifest/catalog.py +63 -0
- dblect/manifest/dag.py +155 -0
- dblect/manifest/parse.py +732 -0
- dblect/nullability/__init__.py +23 -0
- dblect/nullability/detector.py +502 -0
- dblect/py.typed +0 -0
- dblect/report.py +492 -0
- dblect/sarif.py +418 -0
- dblect/severity.py +152 -0
- dblect/snapshot/__init__.py +18 -0
- dblect/snapshot/detector.py +142 -0
- dblect/sql/__init__.py +101 -0
- dblect/sql/_sqlglot.py +447 -0
- dblect/sql/aggregates.py +181 -0
- dblect/sql/findings.py +100 -0
- dblect/sql/guards.py +261 -0
- dblect/sql/parse.py +166 -0
- dblect/sql/patterns.py +889 -0
- dblect/sql/vocab.py +240 -0
- dblect/templating.py +83 -0
- dblect/types/__init__.py +106 -0
- dblect/types/bridge.py +732 -0
- dblect/types/contract.py +325 -0
- dblect/types/domain.py +270 -0
- dblect/types/enums.py +28 -0
- dblect/types/errors.py +15 -0
- dblect/types/scalars.py +148 -0
- dblect/uniqueness/__init__.py +24 -0
- dblect/uniqueness/detector.py +1070 -0
- dblect/varinf/__init__.py +52 -0
- dblect/varinf/usage.py +224 -0
- dblect/varinf/walker.py +343 -0
- dblect-0.1.0.dist-info/METADATA +443 -0
- dblect-0.1.0.dist-info/RECORD +103 -0
- dblect-0.1.0.dist-info/WHEEL +4 -0
- dblect-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|
dblect/audit/suppress.py
ADDED
|
@@ -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
|