gravitar 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.
- gravitar/__init__.py +1 -0
- gravitar/__main__.py +4 -0
- gravitar/analysis/__init__.py +1 -0
- gravitar/analysis/confidence.py +90 -0
- gravitar/analysis/demotion.py +95 -0
- gravitar/analysis/live_corroboration.py +166 -0
- gravitar/analysis/sql_patterns.py +141 -0
- gravitar/baseline.py +88 -0
- gravitar/cli.py +619 -0
- gravitar/config.py +440 -0
- gravitar/errors.py +31 -0
- gravitar/feedback/__init__.py +27 -0
- gravitar/feedback/ids.py +63 -0
- gravitar/feedback/recalibrate.py +57 -0
- gravitar/feedback/reliability.py +44 -0
- gravitar/feedback/store.py +77 -0
- gravitar/output/__init__.py +0 -0
- gravitar/output/github.py +129 -0
- gravitar/output/json.py +120 -0
- gravitar/output/sarif.py +255 -0
- gravitar/output/text.py +192 -0
- gravitar/schema/__init__.py +231 -0
- gravitar/schema/compare.py +278 -0
- gravitar/schema/migrations.py +736 -0
- gravitar/schema/model_reader/__init__.py +256 -0
- gravitar/schema/model_reader/classes.py +424 -0
- gravitar/schema/model_reader/columns.py +707 -0
- gravitar/schema/model_reader/consts.py +22 -0
- gravitar/schema/model_reader/files.py +160 -0
- gravitar/schema/model_reader/module_graph.py +148 -0
- gravitar/schema/model_reader/symbols.py +348 -0
- gravitar/schema/models.py +104 -0
- gravitar/schema/parsers/__init__.py +76 -0
- gravitar/schema/parsers/alembic_parser.py +1168 -0
- gravitar/schema/parsers/django_models.py +98 -0
- gravitar/schema/parsers/django_parser.py +276 -0
- gravitar/schema/parsers/django_types.py +79 -0
- gravitar/schema/types.py +436 -0
- gravitar-0.1.0.dist-info/METADATA +279 -0
- gravitar-0.1.0.dist-info/RECORD +43 -0
- gravitar-0.1.0.dist-info/WHEEL +4 -0
- gravitar-0.1.0.dist-info/entry_points.txt +2 -0
- gravitar-0.1.0.dist-info/licenses/LICENSE +21 -0
gravitar/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
gravitar/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Confidence scoring and (Milestone 8) raw-SQL signature classification."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Confidence scoring.
|
|
2
|
+
|
|
3
|
+
Callers (compare.py, migrations.py) don't have a single Alembic op call backing
|
|
4
|
+
every finding — several categories come from a full static schema diff rather
|
|
5
|
+
than one operation. `base_confidence` is therefore keyed by the *resolution
|
|
6
|
+
signal* a finding was built from, not by op type directly: "ast_fully_resolved"
|
|
7
|
+
covers every v1 category built from a complete static comparison, "unknown_type"
|
|
8
|
+
covers a type neither side's introspection could normalize, and
|
|
9
|
+
"raw_sql_unresolved" covers an `op.execute()` call the migration parser can't
|
|
10
|
+
see into.
|
|
11
|
+
|
|
12
|
+
`unresolved_penalty`/`corroboration_bonus` are fixed per-item deductions/bonuses
|
|
13
|
+
so the raw-SQL signature classifier can plug real unresolved AST nodes
|
|
14
|
+
and matched SQL signatures into `score_finding` without this formula changing
|
|
15
|
+
shape.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
from gravitar.schema import Evidence
|
|
23
|
+
|
|
24
|
+
# base_confidence(op_type) — see module docstring for why this is keyed by
|
|
25
|
+
# resolution signal rather than Alembic op type.
|
|
26
|
+
_BASE_CONFIDENCE: dict[str, float] = {
|
|
27
|
+
"ast_fully_resolved": 1.0,
|
|
28
|
+
"unknown_type": 0.5,
|
|
29
|
+
"raw_sql_unresolved": 0.3,
|
|
30
|
+
# Lower than raw_sql_unresolved: the raw-SQL classifier *did* recover the
|
|
31
|
+
# operation's structure (we know exactly what it's trying to do), but the
|
|
32
|
+
# table/column it names isn't present in the schema state being built — a
|
|
33
|
+
# concrete, concerning signal, not mere ambiguity.
|
|
34
|
+
"orphaned_reference": 0.2,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Fixed per-item deduction/bonus.
|
|
38
|
+
UNRESOLVED_NODE_PENALTY = 0.15
|
|
39
|
+
SIGNATURE_MATCH_BONUS = 0.2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def base_confidence(signal: str) -> float:
|
|
43
|
+
"""Fixed lookup table entry for a resolution signal (Section 4)."""
|
|
44
|
+
return _BASE_CONFIDENCE[signal]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unresolved_penalty(_node: object) -> float:
|
|
48
|
+
"""Fixed deduction per AST node the walker could not statically resolve."""
|
|
49
|
+
return UNRESOLVED_NODE_PENALTY
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def corroboration_bonus(_signature: str) -> float:
|
|
53
|
+
"""Fixed addition per raw-SQL pattern the Section 5 classifier matched."""
|
|
54
|
+
return SIGNATURE_MATCH_BONUS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def score_finding(
|
|
58
|
+
signal: str,
|
|
59
|
+
detail: str,
|
|
60
|
+
*,
|
|
61
|
+
unresolved_nodes: Sequence[object] = (),
|
|
62
|
+
matched_signatures: Sequence[str] = (),
|
|
63
|
+
) -> tuple[float, list[Evidence]]:
|
|
64
|
+
"""Compute a finding's confidence score and its evidence trail (Section 4).
|
|
65
|
+
|
|
66
|
+
`evidence[0]` always records the base signal; one entry follows per
|
|
67
|
+
unresolved node (negative weight) and per matched signature (positive
|
|
68
|
+
weight), in that order, so the trail is reproducible from its inputs alone.
|
|
69
|
+
"""
|
|
70
|
+
base = base_confidence(signal)
|
|
71
|
+
evidence: list[Evidence] = [Evidence(signal=signal, weight=base, detail=detail)]
|
|
72
|
+
total = base
|
|
73
|
+
|
|
74
|
+
for node in unresolved_nodes:
|
|
75
|
+
penalty = unresolved_penalty(node)
|
|
76
|
+
total -= penalty
|
|
77
|
+
evidence.append(Evidence(signal="unresolved_node", weight=-penalty, detail=str(node)))
|
|
78
|
+
|
|
79
|
+
for matched_signature in matched_signatures:
|
|
80
|
+
bonus = corroboration_bonus(matched_signature)
|
|
81
|
+
total += bonus
|
|
82
|
+
evidence.append(
|
|
83
|
+
Evidence(signal="sql_pattern_match", weight=bonus, detail=matched_signature)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return clamp(total), evidence
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clamp(value: float) -> float:
|
|
90
|
+
return max(0.0, min(1.0, value))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Demote-only-when-explained. SPEC-16 Amendment v1.1 Decision #2 + R3.
|
|
2
|
+
|
|
3
|
+
A finding whose table (or column) is in-scope for an ``Unresolved`` note gets
|
|
4
|
+
demoted one tier (``error``/``warning`` → ``review``). The note's id goes in
|
|
5
|
+
``explaining_notes`` so the output can show *which* notes caused the demotion
|
|
6
|
+
— T3 requires that demotion never deletes and always shows the reason.
|
|
7
|
+
|
|
8
|
+
Unscoped (global) notes — ``tables=None`` — never demote: they can't claim to
|
|
9
|
+
explain any specific finding, so promoting them to a demotion mechanism would
|
|
10
|
+
mean one unreadable migration anywhere demotes every finding in the whole
|
|
11
|
+
repo. Decision #2 is explicit about this ("global notes lower confidence
|
|
12
|
+
only"). The confidence-lowering happens inside the comparator's existing
|
|
13
|
+
``unresolved_node`` evidence contribution, not here.
|
|
14
|
+
|
|
15
|
+
Column-level scope (``columns=["t.c"]``) is a tighter filter than table
|
|
16
|
+
scope (``tables=["t"]``): a note that names a column only demotes findings
|
|
17
|
+
on that exact column, not every finding on its table. Table-level scope
|
|
18
|
+
demotes any finding on that table.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from gravitar.schema import DriftFinding, Unresolved
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def demote_explained_findings(
|
|
27
|
+
findings: list[DriftFinding], taint: dict[str, list[Unresolved]]
|
|
28
|
+
) -> list[DriftFinding]:
|
|
29
|
+
"""Return a new list of findings with any that are in-scope for a
|
|
30
|
+
taint note demoted a tier (``kind="review"``, ``demoted_from=<orig>``,
|
|
31
|
+
``explaining_notes=[note ids]``).
|
|
32
|
+
|
|
33
|
+
No side effects on the input list; a finding not covered by taint
|
|
34
|
+
passes through unchanged (same dict identity, not a copy — so the
|
|
35
|
+
rest of the pipeline doesn't accidentally break reference equality).
|
|
36
|
+
"""
|
|
37
|
+
out: list[DriftFinding] = []
|
|
38
|
+
for finding in findings:
|
|
39
|
+
explaining = _notes_explaining(finding, taint)
|
|
40
|
+
if not explaining:
|
|
41
|
+
out.append(finding)
|
|
42
|
+
continue
|
|
43
|
+
out.append(_demoted(finding, explaining))
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _notes_explaining(
|
|
48
|
+
finding: DriftFinding, taint: dict[str, list[Unresolved]]
|
|
49
|
+
) -> list[Unresolved]:
|
|
50
|
+
"""Which taint notes explain this finding — the test for demotion."""
|
|
51
|
+
table = finding["table"]
|
|
52
|
+
column = finding["column"]
|
|
53
|
+
if table is None:
|
|
54
|
+
# A finding without a table (broken_chain gap/cycle etc.) is a
|
|
55
|
+
# revision-level claim, not a table-level one — no table-scoped
|
|
56
|
+
# note can explain it. Only global notes could, and Decision #2
|
|
57
|
+
# excludes those from demotion.
|
|
58
|
+
return []
|
|
59
|
+
table_notes = taint.get(table, [])
|
|
60
|
+
if not table_notes:
|
|
61
|
+
return []
|
|
62
|
+
# Narrow further by column if the note names columns explicitly:
|
|
63
|
+
# a note scoped to ``columns=["t.other"]`` doesn't explain a finding
|
|
64
|
+
# on ``t.this``, even though it's tainting the same table.
|
|
65
|
+
out: list[Unresolved] = []
|
|
66
|
+
qualified = f"{table}.{column}" if column else None
|
|
67
|
+
for note in table_notes:
|
|
68
|
+
if note["columns"] is None:
|
|
69
|
+
# Table-level scope — explains any finding on this table.
|
|
70
|
+
out.append(note)
|
|
71
|
+
continue
|
|
72
|
+
# Column-level scope — must mention this exact column.
|
|
73
|
+
if qualified and qualified in note["columns"]:
|
|
74
|
+
out.append(note)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _demoted(finding: DriftFinding, explaining: list[Unresolved]) -> DriftFinding:
|
|
79
|
+
return DriftFinding(
|
|
80
|
+
category=finding["category"],
|
|
81
|
+
severity=finding["severity"],
|
|
82
|
+
table=finding["table"],
|
|
83
|
+
column=finding["column"],
|
|
84
|
+
model_value=finding["model_value"],
|
|
85
|
+
migration_value=finding["migration_value"],
|
|
86
|
+
message=finding["message"],
|
|
87
|
+
suggestion=finding["suggestion"],
|
|
88
|
+
confidence=finding["confidence"],
|
|
89
|
+
source=finding["source"],
|
|
90
|
+
evidence=finding["evidence"],
|
|
91
|
+
live_verified=finding["live_verified"],
|
|
92
|
+
kind="review",
|
|
93
|
+
demoted_from=finding["severity"],
|
|
94
|
+
explaining_notes=[n["id"] for n in explaining],
|
|
95
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Live (read-only) database corroboration.
|
|
2
|
+
|
|
3
|
+
Strictly optional, off by default: nothing in this module runs unless the
|
|
4
|
+
caller (cli.py) passes a `--live-db-url`. This is corroboration, not a data
|
|
5
|
+
source — it only resolves ambiguity in findings the static reconstruction
|
|
6
|
+
already produced, one targeted read-only column lookup per low-confidence
|
|
7
|
+
finding, never a schema dump.
|
|
8
|
+
|
|
9
|
+
Nothing here ever logs or stores the connection URL itself ("never
|
|
10
|
+
persisted... including logs") — failure messages report the exception *type*
|
|
11
|
+
only, never its text, since a driver's error message can itself embed the DSN.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import create_engine, inspect
|
|
20
|
+
from sqlalchemy.engine import Engine
|
|
21
|
+
|
|
22
|
+
from gravitar.analysis.confidence import clamp
|
|
23
|
+
from gravitar.schema import DriftFinding, Evidence
|
|
24
|
+
from gravitar.schema.types import normalize_type
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("gravitar.live_corroboration")
|
|
27
|
+
|
|
28
|
+
# Only findings the static path was already unsure about are worth a live
|
|
29
|
+
# round-trip; anything at or above this is left alone (Section 4).
|
|
30
|
+
LIVE_CORROBORATION_THRESHOLD = 0.7
|
|
31
|
+
LIVE_CORROBORATION_BONUS = 0.3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LiveColumn:
|
|
36
|
+
type: str
|
|
37
|
+
nullable: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def corroboration_bonus_live(confidence: float) -> float:
|
|
41
|
+
return clamp(confidence + LIVE_CORROBORATION_BONUS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def corroborate(findings: list[DriftFinding], live_db_url: str) -> list[DriftFinding]:
|
|
45
|
+
"""Corroborate every finding below LIVE_CORROBORATION_THRESHOLD against a
|
|
46
|
+
live database. Connects once, runs the targeted per-finding lookups, and
|
|
47
|
+
closes before returning (Section 5) — never a persistent pool.
|
|
48
|
+
|
|
49
|
+
Never raises: a connection failure fails soft (Section 5), logging one
|
|
50
|
+
warning (exception type only — see module docstring) and returning
|
|
51
|
+
`findings` completely unchanged, exactly as if this had never been called.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
engine = create_engine(live_db_url)
|
|
55
|
+
with engine.connect():
|
|
56
|
+
pass # fail fast on bad credentials/unreachable host, before touching findings
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.warning("live-db corroboration disabled: could not connect (%s)", type(e).__name__)
|
|
59
|
+
return findings
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return [_corroborate_finding(engine, finding) for finding in findings]
|
|
63
|
+
finally:
|
|
64
|
+
engine.dispose()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _corroborate_finding(engine: Engine, finding: DriftFinding) -> DriftFinding:
|
|
68
|
+
if finding["confidence"] >= LIVE_CORROBORATION_THRESHOLD:
|
|
69
|
+
return finding
|
|
70
|
+
if finding["table"] is None or finding["column"] is None:
|
|
71
|
+
return finding
|
|
72
|
+
|
|
73
|
+
live_column = _introspect_single_column(engine, finding["table"], finding["column"])
|
|
74
|
+
if live_column is None:
|
|
75
|
+
# Section 6: no live value either way (table/column doesn't exist
|
|
76
|
+
# live, or its type can't be normalized) is a neutral result, not
|
|
77
|
+
# evidence for or against — leave the finding exactly as it was.
|
|
78
|
+
return finding
|
|
79
|
+
|
|
80
|
+
return _apply_corroboration(finding, live_column)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _introspect_single_column(engine: Engine, table: str, column: str) -> LiveColumn | None:
|
|
84
|
+
"""One targeted, read-only reflection query for a single named column —
|
|
85
|
+
never a blanket schema sweep (Section 2 Non-goals).
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
inspector = inspect(engine)
|
|
89
|
+
if not inspector.has_table(table):
|
|
90
|
+
return None
|
|
91
|
+
for col in inspector.get_columns(table):
|
|
92
|
+
if col["name"] != column:
|
|
93
|
+
continue
|
|
94
|
+
type_str = normalize_type(col["type"])
|
|
95
|
+
if type_str.startswith("unknown:"):
|
|
96
|
+
# Section 6: a live type Gravitar's own canonical vocabulary
|
|
97
|
+
# can't represent doesn't corroborate anything — don't guess.
|
|
98
|
+
return None
|
|
99
|
+
return LiveColumn(type=type_str, nullable=bool(col["nullable"]))
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug(
|
|
102
|
+
"live-db corroboration: could not introspect %s.%s (%s)",
|
|
103
|
+
table,
|
|
104
|
+
column,
|
|
105
|
+
type(e).__name__,
|
|
106
|
+
)
|
|
107
|
+
return None
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _apply_corroboration(finding: DriftFinding, live_column: LiveColumn) -> DriftFinding:
|
|
112
|
+
if finding["category"] == "orphaned_reference":
|
|
113
|
+
# migration_value is raw SQL text and model_value is always None for
|
|
114
|
+
# this category — neither is a type string to compare against, so
|
|
115
|
+
# the column's mere existence live is itself the corroborating
|
|
116
|
+
# signal (the reference wasn't a typo/dead end after all).
|
|
117
|
+
return _bump(
|
|
118
|
+
finding,
|
|
119
|
+
corroboration_bonus_live(finding["confidence"]),
|
|
120
|
+
LIVE_CORROBORATION_BONUS,
|
|
121
|
+
"live schema confirms the table/column exists",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
matched_side = _matching_side(finding, live_column.type)
|
|
125
|
+
if matched_side is not None:
|
|
126
|
+
return _bump(
|
|
127
|
+
finding,
|
|
128
|
+
corroboration_bonus_live(finding["confidence"]),
|
|
129
|
+
LIVE_CORROBORATION_BONUS,
|
|
130
|
+
f"live schema matches {matched_side}",
|
|
131
|
+
)
|
|
132
|
+
# Live state contradicts both sides — this is now a confirmed,
|
|
133
|
+
# high-confidence finding, not a low-confidence one (Section 4).
|
|
134
|
+
return _bump(finding, 1.0, 1.0, "live schema matches neither migration nor model")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _matching_side(finding: DriftFinding, live_type: str) -> str | None:
|
|
138
|
+
if finding["migration_value"] == live_type:
|
|
139
|
+
return "migration"
|
|
140
|
+
if finding["model_value"] == live_type:
|
|
141
|
+
return "model"
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _bump(finding: DriftFinding, confidence: float, weight: float, detail: str) -> DriftFinding:
|
|
146
|
+
evidence = [
|
|
147
|
+
*finding["evidence"],
|
|
148
|
+
Evidence(signal="live_db_corroboration", weight=weight, detail=detail),
|
|
149
|
+
]
|
|
150
|
+
return DriftFinding(
|
|
151
|
+
category=finding["category"],
|
|
152
|
+
severity=finding["severity"],
|
|
153
|
+
table=finding["table"],
|
|
154
|
+
column=finding["column"],
|
|
155
|
+
model_value=finding["model_value"],
|
|
156
|
+
migration_value=finding["migration_value"],
|
|
157
|
+
message=finding["message"],
|
|
158
|
+
suggestion=finding["suggestion"],
|
|
159
|
+
confidence=confidence,
|
|
160
|
+
source=finding["source"],
|
|
161
|
+
evidence=evidence,
|
|
162
|
+
live_verified=True,
|
|
163
|
+
kind=finding["kind"],
|
|
164
|
+
demoted_from=finding["demoted_from"],
|
|
165
|
+
explaining_notes=finding["explaining_notes"],
|
|
166
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Signature-based raw-SQL classification.
|
|
2
|
+
|
|
3
|
+
Recovers structured schema-operation intent from the string argument of an
|
|
4
|
+
`op.execute("<raw sql>")` call, entirely statically: a lightweight, non-validating
|
|
5
|
+
tokenizer (whitespace/keyword split, not a grammar parser — see Non-goals) followed
|
|
6
|
+
by matching against a fixed library of common schema-mutating SQL shapes. A string
|
|
7
|
+
that doesn't match any shape is not an error: it stays unresolved and the caller
|
|
8
|
+
(migrations.py) keeps scoring it at the "raw_sql_unresolved" base confidence.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
# Matches, in order of preference: a single-quoted string literal (with SQL's ''
|
|
17
|
+
# escape for an embedded quote), a double-quoted identifier, a parenthesis or comma
|
|
18
|
+
# as its own token, a bare identifier/keyword, or — the non-validating fallback —
|
|
19
|
+
# any other single non-space character. Every character in the input is consumed by
|
|
20
|
+
# some alternative, so this never raises and never loops without making progress;
|
|
21
|
+
# SQL it can't make sense of just degrades into tokens that fail every pattern
|
|
22
|
+
# below (Section 6's first failure mode: no-match, never a crash).
|
|
23
|
+
_TOKEN_RE = re.compile(r"""'(?:[^']|'')*'|"[^"]*"|[(),;]|[A-Za-z_][A-Za-z0-9_.]*|\S""", re.VERBOSE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tokenize(sql: str) -> list[str]:
|
|
27
|
+
return _TOKEN_RE.findall(sql)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class SqlMatch:
|
|
32
|
+
pattern_id: str
|
|
33
|
+
table: str
|
|
34
|
+
column: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _keyword(token: str, expected: str) -> bool:
|
|
38
|
+
return token.upper() == expected
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _identifier(token: str) -> str:
|
|
42
|
+
return token.strip("\"'")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _strip_trailing_semicolon(tokens: list[str]) -> list[str]:
|
|
46
|
+
return tokens[:-1] if tokens and tokens[-1] == ";" else tokens
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _match_add_column(tokens: list[str]) -> SqlMatch | None:
|
|
50
|
+
# ALTER TABLE <t> ADD COLUMN <c> <type...>
|
|
51
|
+
if len(tokens) < 6:
|
|
52
|
+
return None
|
|
53
|
+
if not (
|
|
54
|
+
_keyword(tokens[0], "ALTER")
|
|
55
|
+
and _keyword(tokens[1], "TABLE")
|
|
56
|
+
and _keyword(tokens[3], "ADD")
|
|
57
|
+
and _keyword(tokens[4], "COLUMN")
|
|
58
|
+
):
|
|
59
|
+
return None
|
|
60
|
+
return SqlMatch("sql:add_column", table=_identifier(tokens[2]), column=_identifier(tokens[5]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _match_drop_column(tokens: list[str]) -> SqlMatch | None:
|
|
64
|
+
# ALTER TABLE <t> DROP COLUMN <c>
|
|
65
|
+
if len(tokens) != 6:
|
|
66
|
+
return None
|
|
67
|
+
if not (
|
|
68
|
+
_keyword(tokens[0], "ALTER")
|
|
69
|
+
and _keyword(tokens[1], "TABLE")
|
|
70
|
+
and _keyword(tokens[3], "DROP")
|
|
71
|
+
and _keyword(tokens[4], "COLUMN")
|
|
72
|
+
):
|
|
73
|
+
return None
|
|
74
|
+
return SqlMatch("sql:drop_column", table=_identifier(tokens[2]), column=_identifier(tokens[5]))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _match_alter_column_type(tokens: list[str]) -> SqlMatch | None:
|
|
78
|
+
# ALTER TABLE <t> ALTER COLUMN <c> TYPE <type...>
|
|
79
|
+
if len(tokens) < 8:
|
|
80
|
+
return None
|
|
81
|
+
if not (
|
|
82
|
+
_keyword(tokens[0], "ALTER")
|
|
83
|
+
and _keyword(tokens[1], "TABLE")
|
|
84
|
+
and _keyword(tokens[3], "ALTER")
|
|
85
|
+
and _keyword(tokens[4], "COLUMN")
|
|
86
|
+
and _keyword(tokens[6], "TYPE")
|
|
87
|
+
):
|
|
88
|
+
return None
|
|
89
|
+
return SqlMatch(
|
|
90
|
+
"sql:alter_column_type", table=_identifier(tokens[2]), column=_identifier(tokens[5])
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _match_create_index(tokens: list[str]) -> SqlMatch | None:
|
|
95
|
+
# CREATE INDEX <idx> ON <t> ( <cols> )
|
|
96
|
+
if len(tokens) < 7:
|
|
97
|
+
return None
|
|
98
|
+
if not (
|
|
99
|
+
_keyword(tokens[0], "CREATE") and _keyword(tokens[1], "INDEX") and _keyword(tokens[3], "ON")
|
|
100
|
+
):
|
|
101
|
+
return None
|
|
102
|
+
return SqlMatch("sql:create_index", table=_identifier(tokens[4]))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _match_create_unique_index(tokens: list[str]) -> SqlMatch | None:
|
|
106
|
+
# CREATE UNIQUE INDEX <idx> ON <t> ( <cols> )
|
|
107
|
+
if len(tokens) < 8:
|
|
108
|
+
return None
|
|
109
|
+
if not (
|
|
110
|
+
_keyword(tokens[0], "CREATE")
|
|
111
|
+
and _keyword(tokens[1], "UNIQUE")
|
|
112
|
+
and _keyword(tokens[2], "INDEX")
|
|
113
|
+
and _keyword(tokens[4], "ON")
|
|
114
|
+
):
|
|
115
|
+
return None
|
|
116
|
+
return SqlMatch("sql:create_unique_index", table=_identifier(tokens[5]))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Order matters: CREATE UNIQUE INDEX must be tried before CREATE INDEX, since the
|
|
120
|
+
# latter's fixed-position checks would otherwise misread "UNIQUE" as the index name.
|
|
121
|
+
_MATCHERS = [
|
|
122
|
+
_match_add_column,
|
|
123
|
+
_match_drop_column,
|
|
124
|
+
_match_alter_column_type,
|
|
125
|
+
_match_create_unique_index,
|
|
126
|
+
_match_create_index,
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def match(sql: str) -> SqlMatch | None:
|
|
131
|
+
"""Match a raw SQL string against the Section 5 pattern library.
|
|
132
|
+
|
|
133
|
+
Returns None on no match — including for SQL the tokenizer can't make sense
|
|
134
|
+
of — never raises.
|
|
135
|
+
"""
|
|
136
|
+
tokens = _strip_trailing_semicolon(tokenize(sql))
|
|
137
|
+
for matcher in _MATCHERS:
|
|
138
|
+
result = matcher(tokens)
|
|
139
|
+
if result is not None:
|
|
140
|
+
return result
|
|
141
|
+
return None
|
gravitar/baseline.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Baseline/ratchet storage. SPEC-16 Amendment R14 (remainder).
|
|
2
|
+
|
|
3
|
+
A baseline is a snapshot of the finding ids present at a known-good point
|
|
4
|
+
in time. Each subsequent ``gravitar check`` compares against it: findings
|
|
5
|
+
in the baseline are "unchanged" and do not fail CI, findings not in the
|
|
6
|
+
baseline are "new" and do. The pattern lets a drifty project onboard
|
|
7
|
+
Gravitar without turning its CI red on day one — teams accept the
|
|
8
|
+
current state as the floor, then ratchet from there.
|
|
9
|
+
|
|
10
|
+
Stored as JSON at ``<repo>/.gravitar/baseline.json`` by default. Finding
|
|
11
|
+
identity is :func:`gravitar.feedback.ids.finding_id` (stable across
|
|
12
|
+
runs; see Section 3.1), so a baseline survives refactors that don't
|
|
13
|
+
move the drift itself.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
BASELINE_RELATIVE_PATH = Path(".gravitar") / "baseline.json"
|
|
24
|
+
BASELINE_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def baseline_path_for(repo_path: Path) -> Path:
|
|
28
|
+
return repo_path / BASELINE_RELATIVE_PATH
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_baseline(baseline_path: Path) -> set[str] | None:
|
|
32
|
+
"""Read the baseline's finding ids, or None if no baseline exists.
|
|
33
|
+
|
|
34
|
+
Missing file is the common case (no baseline configured) — never an
|
|
35
|
+
error. A malformed baseline is loud: a project should not silently
|
|
36
|
+
behave as if it had no baseline when the file is actually corrupt,
|
|
37
|
+
because baselined findings would suddenly fail CI.
|
|
38
|
+
"""
|
|
39
|
+
if not baseline_path.exists():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(baseline_path.read_text(encoding="utf-8"))
|
|
43
|
+
except json.JSONDecodeError as e:
|
|
44
|
+
raise ValueError(f"{baseline_path}: malformed JSON: {e}") from e
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise ValueError(f"{baseline_path}: expected an object, got {type(data).__name__}")
|
|
47
|
+
finding_ids = data.get("finding_ids")
|
|
48
|
+
if not isinstance(finding_ids, list):
|
|
49
|
+
raise ValueError(f"{baseline_path}: 'finding_ids' must be a list")
|
|
50
|
+
return {str(fid) for fid in finding_ids}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_baseline(baseline_path: Path, finding_ids: list[str]) -> None:
|
|
54
|
+
"""Write the baseline atomically. Creates ``.gravitar/`` on first use.
|
|
55
|
+
|
|
56
|
+
The ids are sorted so repeat runs that produce the same set yield a
|
|
57
|
+
byte-identical file — a `git diff` on the baseline shows only genuine
|
|
58
|
+
drift additions/removals rather than key reorderings.
|
|
59
|
+
"""
|
|
60
|
+
baseline_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
payload: dict[str, Any] = {
|
|
62
|
+
"version": BASELINE_VERSION,
|
|
63
|
+
"created_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
64
|
+
"finding_ids": sorted(set(finding_ids)),
|
|
65
|
+
}
|
|
66
|
+
tmp = baseline_path.with_suffix(baseline_path.suffix + ".tmp")
|
|
67
|
+
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
68
|
+
tmp.replace(baseline_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_baseline(baseline_path: Path) -> bool:
|
|
72
|
+
"""Remove the baseline file. Returns True if a baseline was removed,
|
|
73
|
+
False if there was nothing to remove."""
|
|
74
|
+
if not baseline_path.exists():
|
|
75
|
+
return False
|
|
76
|
+
baseline_path.unlink()
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_baseline_metadata(baseline_path: Path) -> dict[str, Any] | None:
|
|
81
|
+
"""Return the full baseline payload (version, created_at, finding_ids)
|
|
82
|
+
for ``baseline show``. None if no baseline exists."""
|
|
83
|
+
if not baseline_path.exists():
|
|
84
|
+
return None
|
|
85
|
+
data = json.loads(baseline_path.read_text(encoding="utf-8"))
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
raise ValueError(f"{baseline_path}: expected an object")
|
|
88
|
+
return data
|