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.
Files changed (43) hide show
  1. gravitar/__init__.py +1 -0
  2. gravitar/__main__.py +4 -0
  3. gravitar/analysis/__init__.py +1 -0
  4. gravitar/analysis/confidence.py +90 -0
  5. gravitar/analysis/demotion.py +95 -0
  6. gravitar/analysis/live_corroboration.py +166 -0
  7. gravitar/analysis/sql_patterns.py +141 -0
  8. gravitar/baseline.py +88 -0
  9. gravitar/cli.py +619 -0
  10. gravitar/config.py +440 -0
  11. gravitar/errors.py +31 -0
  12. gravitar/feedback/__init__.py +27 -0
  13. gravitar/feedback/ids.py +63 -0
  14. gravitar/feedback/recalibrate.py +57 -0
  15. gravitar/feedback/reliability.py +44 -0
  16. gravitar/feedback/store.py +77 -0
  17. gravitar/output/__init__.py +0 -0
  18. gravitar/output/github.py +129 -0
  19. gravitar/output/json.py +120 -0
  20. gravitar/output/sarif.py +255 -0
  21. gravitar/output/text.py +192 -0
  22. gravitar/schema/__init__.py +231 -0
  23. gravitar/schema/compare.py +278 -0
  24. gravitar/schema/migrations.py +736 -0
  25. gravitar/schema/model_reader/__init__.py +256 -0
  26. gravitar/schema/model_reader/classes.py +424 -0
  27. gravitar/schema/model_reader/columns.py +707 -0
  28. gravitar/schema/model_reader/consts.py +22 -0
  29. gravitar/schema/model_reader/files.py +160 -0
  30. gravitar/schema/model_reader/module_graph.py +148 -0
  31. gravitar/schema/model_reader/symbols.py +348 -0
  32. gravitar/schema/models.py +104 -0
  33. gravitar/schema/parsers/__init__.py +76 -0
  34. gravitar/schema/parsers/alembic_parser.py +1168 -0
  35. gravitar/schema/parsers/django_models.py +98 -0
  36. gravitar/schema/parsers/django_parser.py +276 -0
  37. gravitar/schema/parsers/django_types.py +79 -0
  38. gravitar/schema/types.py +436 -0
  39. gravitar-0.1.0.dist-info/METADATA +279 -0
  40. gravitar-0.1.0.dist-info/RECORD +43 -0
  41. gravitar-0.1.0.dist-info/WHEEL +4 -0
  42. gravitar-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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,4 @@
1
+ from gravitar.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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