diff --git a/docs/features/impact-analysis.md b/docs/features/impact-analysis.md index f235b0f..d5b26ec 100644 --- a/docs/features/impact-analysis.md +++ b/docs/features/impact-analysis.md @@ -127,6 +127,15 @@ for the exact rule and the honesty invariants. additive: it never posts a comment and never changes the exit code — gating stays with `fail-on`. +!!! warning "Tests attached to skipped models do not run" + The selector selects **models**, and dbt only runs the tests attached to the models it + selects. A test that lives on a *skipped* model but references a *rebuilt* one — most + commonly a `relationships` test on a skipped child pointing at a rebuilt parent — will + **not** run in a selective CI build, even though the rebuilt side may have just broken it. + If such cross-model tests matter to your gate, widen the selection (e.g. + `dbt build --select "$SELECTOR" --indirect-selection=buildable`, or append `+1` to the + selector), or run a full `dbt test` on a schedule to catch what the selective build skipped. + ## Use Cases **Before modifying a column:** diff --git a/parrant/artifacts/manifest.py b/parrant/artifacts/manifest.py index 07e1f38..59f164a 100644 --- a/parrant/artifacts/manifest.py +++ b/parrant/artifacts/manifest.py @@ -49,7 +49,7 @@ def __init__(self, manifest_path: str | None = None): def load(self) -> None: if not self.manifest_path or not self.manifest_path.exists(): raise FileNotFoundError(f"Manifest file not found: {self.manifest_path}") - with open(self.manifest_path, "r") as f: + with open(self.manifest_path) as f: self.manifest = json.load(f) def get_adapter(self) -> str | None: @@ -80,6 +80,55 @@ def get_model_dependencies(self) -> dict[str, set[str]]: dependencies[model_id] = depends_on return dependencies + def get_macro_dependents(self) -> dict[str, set[str]]: + """Map each macro *file* to the model-like nodes whose compiled SQL it can affect. + + Keys are the macros' normalized ``original_file_path`` (e.g. ``macros/money.sql``); + values are lowercased names of nodes (models/snapshots/seeds) that depend on a macro + in that file — directly (``node.depends_on.macros``) or transitively (a macro that a + used macro itself calls, via the ``macros[*].depends_on.macros`` graph). Feeds the + ``--scope-git`` fail-safe: a changed macro file scopes to exactly these dependents. + """ + macros = self.manifest.get("macros", {}) + macro_calls: dict[str, list[str]] = { + macro_id: list(macro.get("depends_on", {}).get("macros", [])) + for macro_id, macro in macros.items() + } + + # For each macro id, the closure of macros it (transitively) calls, itself included. + # Iterative BFS with a visited set so macro-call cycles terminate. + def _closure(macro_id: str) -> set[str]: + seen: set[str] = set() + stack = [macro_id] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + stack.extend(macro_calls.get(current, [])) + return seen + + def _norm(path: str) -> str: + return re.sub(r"^\./", "", path.strip()).lstrip("/") + + dependents: dict[str, set[str]] = { + _norm(macro.get("original_file_path") or ""): set() + for macro in macros.values() + if macro.get("original_file_path") + } + for node in self.manifest.get("nodes", {}).values(): + if node.get("resource_type") not in ("model", "snapshot", "seed"): + continue + node_name = (node.get("name") or "").lower() + if not node_name: + continue + for macro_id in node.get("depends_on", {}).get("macros", []): + for reached in _closure(macro_id): + path = (macros.get(reached) or {}).get("original_file_path") + if path: + dependents[_norm(path)].add(node_name) + return dependents + def get_model_upstream(self) -> dict[str, set[str]]: """Get upstream dependencies for each model.""" upstream: dict[str, set[str]] = {} @@ -109,7 +158,10 @@ def get_model_upstream(self) -> dict[str, set[str]]: # Fallback to source name if identifier not found source_name = parts[-1].lower() upstream[model_name].add(source_name) - elif parts[0] == "snapshot": + elif parts[0] in ("snapshot", "seed"): + # Seeds are upstream nodes like any other: without this edge a model + # ref()ing a seed records no dependency, so the seed's consumers are + # invisible to reachability and to the rebuild selection. dep_name = parts[-1].lower() upstream[model_name].add(dep_name) diff --git a/parrant/artifacts/registry.py b/parrant/artifacts/registry.py index 09b4f2a..d46c960 100644 --- a/parrant/artifacts/registry.py +++ b/parrant/artifacts/registry.py @@ -1,3 +1,5 @@ +import hashlib +import json import logging from dataclasses import dataclass, field from typing import Any @@ -10,6 +12,8 @@ ) from parrant.artifacts.manifest import ManifestReader from parrant.models.schema import ( + ArtifactStamp, + ArtifactStamps, Column, ColumnLineage, Coverage, @@ -97,6 +101,8 @@ def __init__( # attached to it AND relationships tests referencing it. Column-level recovery can # miss a model's tested columns, but a wholly-removed model breaks all of its tests. self._model_tests: dict[str, list[TestNode]] = {} + # Lazily-built macro file -> dependent model-like nodes (see get_macro_dependents). + self._macro_dependents: dict[str, set[str]] | None = None @property def is_loaded(self) -> bool: @@ -344,6 +350,19 @@ def get_column_tests(self, model: str, column: str) -> list[TestNode]: """ return list(self._column_tests.get((model.lower(), column.lower()), [])) + def get_macro_dependents(self) -> dict[str, set[str]]: + """Macro file path -> model-like nodes whose compiled SQL that file can affect. + + Capability method consumed by the ``--scope-git`` fail-safe (looked up via + ``getattr``, not part of the provider protocols): a changed macro file scopes to + exactly its (transitive) dependents instead of disabling scoping. Delegates to the + manifest's macro graph; cached after the first call (the manifest is immutable + post-load). + """ + if self._macro_dependents is None: + self._macro_dependents = self._manifest_reader.get_macro_dependents() + return self._macro_dependents + def get_tests_referencing(self, model: str, column: str) -> list[TestNode]: """Return relationships tests whose *referenced* (parent) side is ``model.column``. @@ -761,6 +780,39 @@ def get_dialect(self) -> str | None: """ return self._dialect + def get_artifact_stamps(self) -> ArtifactStamps: + """Identity stamps (``generated_at`` / ``invocation_id``) of the loaded artifacts. + + Read from each artifact's ``metadata`` block; a missing block yields empty stamps + (no identity claim). Consumed by the two-manifest diff to detect a stale or shared + catalog and stamp the report ``structural_diff: degraded`` instead of staying silent. + """ + catalog_meta = (self._catalog_reader.catalog or {}).get("metadata") or {} + manifest_meta = (self._manifest_reader.manifest or {}).get("metadata") or {} + return ArtifactStamps( + catalog=ArtifactStamp( + generated_at=catalog_meta.get("generated_at"), + invocation_id=catalog_meta.get("invocation_id"), + ), + manifest=ArtifactStamp( + generated_at=manifest_meta.get("generated_at"), + invocation_id=manifest_meta.get("invocation_id"), + ), + ) + + def get_catalog_fingerprint(self) -> str | None: + """Content fingerprint (sha256 over canonical JSON) of the loaded ``catalog.json``. + + Byte-formatting-insensitive: two catalogs with the same CONTENT (the real signal — + e.g. one prod catalog mounted on both diff sides) fingerprint equal even if + re-serialized. ``None`` when no catalog content is loaded (no identity claim). + """ + content = self._catalog_reader.catalog + if not content: + return None + canonical = json.dumps(content, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + def get_model(self, model_name: str) -> Model: """Get a specific model by name.""" if not self.is_loaded: @@ -949,7 +1001,7 @@ def _find_compiled_sql(self, model_name: str) -> str | None: compiled_path = self._manifest_reader.get_model_path(model_name) if compiled_path: try: - with open(compiled_path, "r") as f: + with open(compiled_path) as f: compiled_sql = f.read() model.compiled_sql = compiled_sql return compiled_sql diff --git a/parrant/cli/main.py b/parrant/cli/main.py index 7b96fc0..00b8df6 100644 --- a/parrant/cli/main.py +++ b/parrant/cli/main.py @@ -12,7 +12,7 @@ OverrideResolution, build_changeset_report, build_git_changeset, - git_changed_models, + resolve_git_scope, scope_changes_to_models, ) from parrant.lineage.display import DotDisplay, JsonDisplay, TextDisplay @@ -27,6 +27,7 @@ decide_verdict, ineffective_overrides, ) +from parrant.models.schema import GitScopeStatus logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") @@ -587,6 +588,9 @@ def impact( base_service: LineageService | None = None changes: list[ColumnChange] + # Populated only under --scope-git: whether the git intersection was applied or + # disabled fail-safe (unmappable logic-bearing files). Rendered as report["scope_git"]. + scope_status: GitScopeStatus | None = None # Whether structural checks (added/removed/type_changed) could run. They need a # real catalog on both sides; the two-manifest path decides this from the builder # below. The git-diff fallback is a separate, self-evident coarse mode, so it is @@ -630,10 +634,31 @@ def impact( if scope_git: # Intersect the precise two-manifest changeset with the models the # branch actually touched, so a stale base artifact can't leak - # already-merged changes into the report. - scoped_models = git_changed_models(head_service.registry, scope_git) - changes = scope_changes_to_models(changes, scoped_models) - source = f"two-manifest scoped to git-diff ({scope_git})" + # already-merged changes into the report. FAIL-SAFE: when the diff + # contains a logic-bearing file that cannot be pinned to models (a + # macro, an unmapped Python model, dbt_project.yml vars, ...), the + # narrowing is unprovable — keep the FULL changeset and surface the + # reason, instead of silently dropping real changes into a SAFE. + scope = resolve_git_scope(head_service.registry, scope_git) + if scope.unmappable: + reason = "disabled — unmappable changed files: [{}]".format( + ", ".join(scope.unmappable) + ) + click.echo(f"Warning: --scope-git {reason}", err=True) + source = "two-manifest (git scoping disabled: unmappable changed files)" + scope_status = GitScopeStatus( + base=scope_git, + applied=False, + changed_models=sorted(scope.models), + unmappable_files=list(scope.unmappable), + reason=reason, + ) + else: + changes = scope_changes_to_models(changes, scope.models) + source = f"two-manifest scoped to git-diff ({scope_git})" + scope_status = GitScopeStatus( + base=scope_git, applied=True, changed_models=sorted(scope.models) + ) elif git_base: override_collector = OverrideResolution() changes = build_git_changeset( @@ -680,6 +705,8 @@ def impact( report = build_changeset_report(source, changes, aggregated) report["coverage"] = head_service.get_coverage().model_dump() report["structural_checks_available"] = structural_checks_available + if scope_status is not None: + report["scope_git"] = scope_status.model_dump() # Provable breaks + the SAFE/REVIEW/BLOCK ruling. Base registry (when present) is the # reliable source of the tests that existed before the change. @@ -913,7 +940,7 @@ def policy_test( if baseline_path: try: - with open(baseline_path, "r", encoding="utf-8") as handle: + with open(baseline_path, encoding="utf-8") as handle: baseline = BacktestReport.model_validate_json(handle.read()) except Exception as exc: raise click.ClickException( diff --git a/parrant/lineage/backtest.py b/parrant/lineage/backtest.py index f7cd2c8..bf92575 100644 --- a/parrant/lineage/backtest.py +++ b/parrant/lineage/backtest.py @@ -21,6 +21,7 @@ import json import os +import statistics import subprocess import sys from collections import defaultdict @@ -40,9 +41,11 @@ BacktestPointResult, BacktestReport, BacktestRuleStat, + BacktestSelectionStats, GateDecision, Policy, PolicyVerdict, + ResolutionReasonCount, SemanticChangeKind, ) @@ -148,6 +151,7 @@ def _replay_point( ) for hit in verdict.hits ] + widened, skippable_count, forced, reasons = _selection_facts(aggregated) point = BacktestPointResult( ref=ref, source=source, @@ -158,10 +162,84 @@ def _replay_point( blast_radius=blast_radius, fired=fired, sample_reach=sample_reach, + selection_widened=widened, + skippable_models_count=skippable_count, + rebuild_forced_by_nonresolution=forced, + resolution_reasons=reasons, ) return point, verdict +def _selection_facts( + aggregated: Any, +) -> tuple[bool | None, int | None, int | None, dict[str, int]]: + """Extract the per-point selection/resolution facts for the widen-rate stats. + + Pure observation of the already-computed ``selection`` / ``resolution_summary`` blocks — + nothing here influences selection or gating. Returns all-``None``/empty when the impact + carried no selection block (e.g. a stub service without a registry), so such points are + honestly excluded from the aggregate rather than counted as "not widened". + """ + if not isinstance(aggregated, dict): + return None, None, None, {} + selection = aggregated.get("selection") + if not isinstance(selection, dict): + return None, None, None, {} + widened = bool(selection.get("widened_to_all_reachable", False)) + skippable_count = len(selection.get("skippable_models") or []) + + forced: int | None = None + reasons: dict[str, int] = {} + summary = aggregated.get("resolution_summary") + if isinstance(summary, dict): + forced = int(summary.get("rebuild_forced_by_nonresolution", 0)) + for entry in summary.get("top_reasons") or []: + if isinstance(entry, dict) and entry.get("reason") is not None: + reasons[str(entry["reason"])] = int(entry.get("count", 0)) + return widened, skippable_count, forced, reasons + + +# Cap the aggregated top-reasons list so a reason-rich range stays a readable breakdown. +_TOP_REASONS_CAP = 10 + + +def _aggregate_selection_stats( + points: list[BacktestPointResult], +) -> BacktestSelectionStats | None: + """Roll the per-point selection facts up into the range-wide widen-rate statistics. + + Only points that actually carried a selection block participate (``selection_widened is + not None``); a range with none returns ``None`` so the report omits the surface instead of + fabricating a 0% widen rate. Median is over ``skippable_models_count``; reasons are summed + across points and ranked by frequency (ties broken alphabetically for determinism). + """ + with_selection = [p for p in points if p.selection_widened is not None] + if not with_selection: + return None + + widened_points = sum(1 for p in with_selection if p.selection_widened) + skippable_counts = [p.skippable_models_count or 0 for p in with_selection] + forced_total = sum(p.rebuild_forced_by_nonresolution or 0 for p in with_selection) + + reason_totals: dict[str, int] = defaultdict(int) + for point in with_selection: + for reason, count in point.resolution_reasons.items(): + reason_totals[reason] += count + top_reasons = [ + ResolutionReasonCount(reason=reason, count=count) + for reason, count in sorted(reason_totals.items(), key=lambda kv: (-kv[1], kv[0])) + ][:_TOP_REASONS_CAP] + + return BacktestSelectionStats( + points_with_selection=len(with_selection), + widened_points=widened_points, + widen_rate_pct=round(100.0 * widened_points / len(with_selection), 1), + median_skippable_models=float(statistics.median(skippable_counts)), + rebuild_forced_by_nonresolution_total=forced_total, + top_reasons=top_reasons, + ) + + def _aggregate_rule_stats( points_verdicts: list[tuple[BacktestPointResult, PolicyVerdict]], policy: Policy, @@ -231,7 +309,7 @@ def _load_changesets_dir(path: str) -> list[tuple[str, list[ColumnChange]]]: if not name.endswith(".json"): continue full = os.path.join(path, name) - with open(full, "r", encoding="utf-8") as handle: + with open(full, encoding="utf-8") as handle: data = json.load(handle) entries = _extract_change_entries(data) out.append((name, changes_from_dicts(entries))) @@ -462,6 +540,7 @@ def _assemble_report( fidelity_note=fidelity_note, warnings=warnings, baseline_delta=baseline_delta, + selection_stats=_aggregate_selection_stats(points), ) diff --git a/parrant/lineage/changeset.py b/parrant/lineage/changeset.py index 3b5e7c0..9017e92 100644 --- a/parrant/lineage/changeset.py +++ b/parrant/lineage/changeset.py @@ -15,6 +15,7 @@ import re import subprocess from dataclasses import dataclass, field +from datetime import datetime from enum import Enum from typing import Any @@ -22,15 +23,17 @@ from parrant.lineage.semantic_diff import ( _UNPARSEABLE_PREFIX, canonical_key, + comment_free_token_signature, compare_expressions, ) from parrant.models.schema import ( + LogicDiffStatus, OverrideDirective, OverrideVerb, SemanticChangeKind, + StructuralDiffStatus, ) from parrant.parser.sql_parser import parse_override_directives -from parrant.parser.sql_parser_utils import strip_sql_comments logger = logging.getLogger(__name__) @@ -87,6 +90,11 @@ class ColumnChange: # equality/hashing (``compare=False``) so it never perturbs the sort key or dedup, and so a # frozen ``ColumnChange`` stays hashable even though ``OverrideDirective`` (pydantic) is not. override: OverrideDirective | None = field(default=None, compare=False) + # Set to ``"indeterminate"`` when the model-level logic diff was IMPOSSIBLE (compiled SQL + # unavailable on a side for a model node): "no logic change" was unprovable, so the change + # is emitted fail-safe rather than silently dropped. ``None`` for every ordinary change. + # Consumed by the service to degrade confidence and widen the rebuild selection. + logic_diff_status: LogicDiffStatus | None = None def to_dict(self) -> dict[str, object]: payload: dict[str, object] = { @@ -104,6 +112,10 @@ def to_dict(self) -> dict[str, object]: "base": self.base_expression, "head": self.head_expression, } + # Only attach ``logic_diff_status`` when set (the indeterminate missing-SQL case), so + # ordinary changes keep their existing JSON shape byte-for-byte. + if self.logic_diff_status is not None: + payload["logic_diff_status"] = self.logic_diff_status # Only attach ``override`` when one is present, so JSON stays byte-stable when absent. # Flows into service.by_change automatically (by_change spreads change.to_dict()). if self.override is not None: @@ -117,18 +129,6 @@ def to_dict(self) -> dict[str, object]: return payload -def _normalize_sql(sql: str | None) -> str | None: - """Normalize compiled SQL so cosmetic reformatting isn't read as a logic change. - - ``strip_sql_comments`` already removes comments and collapses whitespace runs, - which is exactly the noise we want to ignore when deciding whether the logic - that produces a model actually changed. - """ - if sql is None: - return None - return strip_sql_comments(sql) - - def _registry_dialect(registry: object) -> str | None: """Best-effort SQL dialect from a registry, ``None`` when it exposes no getter. @@ -343,13 +343,21 @@ def record(change: ColumnChange) -> None: ) ) + # Logic diff: three-state, never silently safe. ``indeterminate`` (a MODEL whose + # compiled SQL is unavailable on a side) emits fail-safe changes below — treating + # it as "no change" would hide the edit AND leave the model out of the rebuild. + logic_status = self._logic_diff_status(model_name, head_model) + if logic_status == "indeterminate": + for change in self._indeterminate_logic_changes(model_name, head_model): + record(change) + # Logic change: the model's compiled SQL differs. Rather than flag EVERY output # column — which floods the downstream blast radius with unrelated pass-throughs # (editing one column must not implicate every other column of the model) — diff each output # column's derivation between base and head and flag ONLY the columns that actually # changed. Falls back to flagging all columns when neither side exposes per-column # lineage (nothing to diff precisely). - if self._logic_changed(model_name): + if logic_status == "changed": classified = self._logic_changed_columns(base_model, head_model) if not classified: # The compiled SQL provably changed, yet no output column's per-column @@ -419,12 +427,61 @@ def structural_diff_available(self) -> bool: def _side_has_catalog(registry: LineageProvider) -> bool: return any(registry.is_catalog_backed(name) for name in registry.get_models()) - def _logic_changed(self, model_name: str) -> bool: - base_sql = _normalize_sql(self._safe_compiled_sql(self.base, model_name)) - head_sql = _normalize_sql(self._safe_compiled_sql(self.head, model_name)) + def _logic_diff_status(self, model_name: str, head_model: Any) -> LogicDiffStatus: + """Three-state logic diff for a node present on both sides — never silently safe. + + ``"changed"`` / ``"unchanged"`` when both sides expose compiled SQL to compare. + When either side's compiled SQL is unavailable the answer depends on the node kind: + sources / seeds / snapshots legitimately have no logic to diff (``"unchanged"``), + but a MODEL without compiled SQL cannot be proven unchanged — that is + ``"indeterminate"``, and the caller emits fail-safe changes for it. The resource + type is read defensively (``getattr``) so lightweight stubs without one keep the + historical no-op behaviour; every real registry model carries it. + """ + base_sql = self._safe_compiled_sql(self.base, model_name) + head_sql = self._safe_compiled_sql(self.head, model_name) if not base_sql or not head_sql: - return False - return base_sql != head_sql + if getattr(head_model, "resource_type", None) == "model": + return "indeterminate" + return "unchanged" + if base_sql == head_sql: + return "unchanged" + # Gate on the string-literal-safe token signature, NOT a regex comment strip: the + # tokenizer keeps string/quoted-identifier CONTENTS intact, so a `--` or `/*` inside a + # quoted string is data — a regex strip would swallow the real tokens around it and + # silently report "no logic change" (a false SAFE). Equal signatures prove the edit is + # comment/whitespace-only; anything else — including a side we cannot even tokenize — + # is treated as changed (fail-safe; the per-column diff classifies it precisely). + base_sig = comment_free_token_signature(base_sql, self._dialect) + head_sig = comment_free_token_signature(head_sql, self._dialect) + if base_sig is not None and head_sig is not None: + return "changed" if base_sig != head_sig else "unchanged" + return "changed" + + def _indeterminate_logic_changes(self, model_name: str, head_model: Any) -> list[ColumnChange]: + """Fail-safe changes for a model whose logic diff was impossible (missing SQL). + + Every head output column is flagged ``LOGIC_CHANGED`` / ``INDETERMINATE`` with the + ``logic_diff_status`` marker so the service degrades confidence and widens the rebuild + selection. A column-less model (no catalog entry AND no SQL to parse) still emits one + ``*`` sentinel change — an unprovable model must never vanish from the report. + """ + reason = ( + f"compiled SQL for model '{model_name}' is unavailable on at least one side — " + "the logic diff is impossible, so 'no logic change' cannot be proven (fail-safe)" + ) + columns = sorted(head_model.columns) or ["*"] + return [ + ColumnChange( + model_name, + column, + ChangeKind.LOGIC_CHANGED, + semantic=SemanticChangeKind.INDETERMINATE, + reason=reason, + logic_diff_status="indeterminate", + ) + for column in columns + ] def _logic_changed_columns(self, base_model, head_model) -> dict[str, _ColumnDiff]: """Which output columns changed derivation, each with a semantic classification. @@ -596,6 +653,90 @@ def _safe_compiled_sql(registry: LineageProvider, model_name: str) -> str | None return None +def _provider_call(provider: object, method: str) -> Any: + """Call a zero-arg accessor on a provider defensively (``None`` when absent/failing). + + Mirrors :func:`_registry_dialect`: a real ``ModelRegistry`` answers, a lightweight test + stub without the accessor simply makes no claim rather than raising. + """ + getter = getattr(provider, method, None) + if not callable(getter): + return None + try: + return getter() + except Exception: + return None + + +def _parse_artifact_timestamp(raw: str | None) -> datetime | None: + """Parse a dbt ``generated_at`` ISO timestamp, ``None`` when absent/unparseable.""" + if not raw or not isinstance(raw, str): + return None + try: + # dbt stamps UTC with a trailing ``Z``, which py3.10's fromisoformat rejects. + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + + +def detect_structural_degradation( + base: LineageProvider, head: LineageProvider +) -> StructuralDiffStatus | None: + """Detect a base/head catalog pair that structurally CANNOT show column changes. + + Two real-deployment failure modes make the structural (added/removed/type_changed) diff + silently blind while ``structural_diff_available()`` still reports True: + + * **Identical catalogs** — one prod ``catalog.json`` mounted on both sides. Diffing a + catalog against itself can never surface a removed or retyped column, so + ``provable_break_count`` is stuck at 0 and ``--fail-on tests`` can never fire. + * **Stale head catalog** — the head ``catalog.json`` predates the head manifest (and comes + from a different dbt invocation): its column truth describes an older build. + + Returns a ``degraded`` stamp with a human-readable reason, or ``None`` when neither + condition is provable (providers without artifact identity — e.g. stubs — make no claim). + Advisory only: consumed by the report/JSON/markdown surfaces, never by exit codes. + """ + base_fingerprint = _provider_call(base, "get_catalog_fingerprint") + head_fingerprint = _provider_call(head, "get_catalog_fingerprint") + if base_fingerprint is not None and base_fingerprint == head_fingerprint: + return StructuralDiffStatus( + status="degraded", + reason=( + "base and head catalog.json are identical — one catalog backs both sides, so " + "removed/type_changed columns can never be detected" + ), + ) + + stamps = _provider_call(head, "get_artifact_stamps") + if stamps is not None: + catalog_stamp = stamps.catalog + manifest_stamp = stamps.manifest + same_invocation = ( + catalog_stamp.invocation_id is not None + and catalog_stamp.invocation_id == manifest_stamp.invocation_id + ) + if not same_invocation: + catalog_ts = _parse_artifact_timestamp(catalog_stamp.generated_at) + manifest_ts = _parse_artifact_timestamp(manifest_stamp.generated_at) + try: + stale = ( + catalog_ts is not None and manifest_ts is not None and catalog_ts < manifest_ts + ) + except TypeError: # mixed aware/naive timestamps — no provable claim + stale = False + if stale: + return StructuralDiffStatus( + status="degraded", + reason=( + f"head catalog.json (generated_at {catalog_stamp.generated_at}) predates " + f"the head manifest ({manifest_stamp.generated_at}) — its column truth " + "is stale, so removed/type_changed detection is unreliable" + ), + ) + return None + + def _path_to_model_map(head: LineageProvider) -> dict[str, str]: """Map each model's ``resource_path`` (dbt ``original_file_path``) to its name.""" mapping: dict[str, str] = {} @@ -763,10 +904,132 @@ def scope_changes_to_models(changes: list[ColumnChange], models: set[str]) -> li return [change for change in changes if change.model in models] +# Changed files that can influence models' compiled SQL. A change to one of these that +# cannot be pinned to specific models makes git scoping unsound (see resolve_git_scope): +# *.sql — a model (mappable) OR a macro/generic test that can rewrite ANY model's SQL +# *.py — a Python model (mappable when in the manifest; otherwise unknown logic) +# *.csv — a seed: column changes ripple into downstream models without any .sql edit +_LOGIC_BEARING_SUFFIXES = (".sql", ".py", ".csv") +# Project-level files whose edits (vars, packages, profiles/targets) can change the +# compiled SQL of arbitrarily many models and are never mappable to one model. +_LOGIC_BEARING_BASENAMES = frozenset( + { + "dbt_project.yml", + "dbt_project.yaml", + "packages.yml", + "packages.yaml", + "package-lock.yml", + "dependencies.yml", + "profiles.yml", + "profiles.yaml", + } +) + + +@dataclass +class GitScopeResolution: + """How the git diff resolved for ``--scope-git``. + + ``models`` is the union of models the diff could be pinned to (model files by + ``resource_path``, macros by their dependent models when the backend can map them). + ``unmappable`` lists logic-bearing changed files that could NOT be pinned to models; + when non-empty, narrowing the changeset to ``models`` is unsound and the caller must + disable scoping for the run (fail-safe) rather than silently drop changes. + Docs/property-only files (schema.yml, *.md, ...) influence neither compiled SQL nor + the catalog, so they are ignored and never disable scoping. + """ + + models: set[str] = field(default_factory=set) + unmappable: list[str] = field(default_factory=list) + + +def _is_logic_bearing(path: str) -> bool: + """Whether a changed file can influence some model's compiled SQL / columns.""" + basename = path.rsplit("/", 1)[-1].lower() + return basename in _LOGIC_BEARING_BASENAMES or basename.endswith(_LOGIC_BEARING_SUFFIXES) + + +def _macro_dependents_or_empty(head: LineageProvider) -> dict[str, set[str]]: + """Macro-file -> dependent-model map, when the backend can provide it. + + Capability lookup (not part of the :class:`LineageProvider` protocol): backends that + cannot map macros return ``{}``, which downgrades macro changes to *unmappable* — the + fail-safe floor — instead of crashing or, worse, silently narrowing. + """ + getter = getattr(head, "get_macro_dependents", None) + if not callable(getter): + return {} + try: + dependents = getter() + except Exception: # pragma: no cover - honest degrade to the fail-safe floor + logger.warning("get_macro_dependents failed; macro changes treated as unmappable") + return {} + return dependents if isinstance(dependents, dict) else {} + + +def resolve_git_scope( + head: LineageProvider, + git_base: str, + repo_dir: str | None = None, + git_head: str = "HEAD", +) -> GitScopeResolution: + """Classify the FULL git diff (not just ``*.sql``) for ``--scope-git``. + + Unlike :func:`git_changed_models` (which silently ignores files it cannot map — fine + for the coarse ``--git-base`` fallback where unmapped files simply add no changes), + scoping *subtracts* from a correct two-manifest changeset, so it must prove that every + logic-bearing changed file was accounted for. Each changed file is either: + + * mapped to a model via its ``resource_path`` (``.sql`` and ``.py`` models, seeds), or + * mapped to its dependent models via the manifest macro graph (when available), or + * logic-bearing but unmappable -> recorded in ``unmappable`` (disables scoping), or + * non-logic (schema.yml/docs/README/...) -> ignored. + """ + path_to_model = _path_to_model_map(head) + macro_dependents = _macro_dependents_or_empty(head) + models: set[str] = set() + unmappable: list[str] = [] + for changed_file in _git_changed_files(git_base, repo_dir, git_head): + path = _norm_path(changed_file) + model = path_to_model.get(path) + if model: + models.add(model) + continue + if path in macro_dependents: + # Ceiling: the macro is known, so scope to every model it (transitively) + # feeds instead of disabling scoping outright. + models.update(macro_dependents[path]) + continue + if _is_logic_bearing(path): + unmappable.append(path) + return GitScopeResolution(models=models, unmappable=unmappable) + + def _norm_path(path: str) -> str: return re.sub(r"^\./", "", path.strip()).lstrip("/") +def _git_changed_files(git_base: str, repo_dir: str | None, git_head: str = "HEAD") -> list[str]: + """Every changed file between ``git_base`` and ``git_head`` (no pathspec filter). + + :func:`resolve_git_scope` needs the complete diff — macros, Python models, + ``dbt_project.yml`` — not only the ``*.sql`` view that :func:`_git_changed_sql_files` + serves to the coarse git fallback and the backtest. + """ + try: + result = subprocess.run( + ["git", "diff", "--name-only", f"{git_base}...{git_head}"], + cwd=repo_dir, + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + raise RuntimeError(f"Failed to compute git diff against '{git_base}': {exc}") from exc + + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + def _git_changed_sql_files( git_base: str, repo_dir: str | None, git_head: str = "HEAD" ) -> list[str]: @@ -784,6 +1047,16 @@ def _git_changed_sql_files( return [line.strip() for line in result.stdout.splitlines() if line.strip()] +def _parrant_version() -> str | None: + """The installed parrant version, or ``None`` when metadata is unavailable.""" + try: + from importlib.metadata import version + + return version("parrant") + except Exception: + return None + + def build_changeset_report( source: str, changes: list[ColumnChange], @@ -794,18 +1067,22 @@ def build_changeset_report( The impact keys (``summary``, ``affected_models``, ``affected_columns``, ``affected_exposures``) are a superset of the single-column ``impact`` block, so existing consumers keep working; ``changeset`` and ``by_change`` are added. + ``report_version`` / ``parrant_version`` identify the report shape and producer so a + machine consumer can pin what it parses. """ by_kind: dict[str, int] = {} for change in changes: by_kind[change.kind.value] = by_kind.get(change.kind.value, 0) + 1 report: dict[str, object] = { + "report_version": 1, + "parrant_version": _parrant_version(), "changeset": { "source": source, "total_changes": len(changes), "by_kind": by_kind, "changes": [change.to_dict() for change in changes], - } + }, } report.update(aggregated) return report diff --git a/parrant/lineage/display/backtest.py b/parrant/lineage/display/backtest.py index 09c7ed1..19246ae 100644 --- a/parrant/lineage/display/backtest.py +++ b/parrant/lineage/display/backtest.py @@ -11,7 +11,7 @@ from __future__ import annotations -from parrant.models.schema import BacktestReport, BacktestRuleStat +from parrant.models.schema import BacktestReport, BacktestRuleStat, BacktestSelectionStats def _rule_flag(stat: BacktestRuleStat) -> str: @@ -34,6 +34,28 @@ def _totals_line(report: BacktestReport) -> str: ) +def _selection_lines(stats: BacktestSelectionStats | None) -> list[str]: + """The selection widen-rate block (both renderers), or ``[]`` when no point carried one. + + Measurement surface only: how often the rebuild selection widened to every reachable model, + the median skippable count, and the ranked resolution reasons forcing the widening. + """ + if stats is None: + return [] + lines = [ + ( + f"Selection: widened to all-reachable in {stats.widened_points}/" + f"{stats.points_with_selection} point(s) ({stats.widen_rate_pct}%); " + f"median skippable models {stats.median_skippable_models}; " + f"rebuilds forced by non-resolution: {stats.rebuild_forced_by_nonresolution_total}" + ) + ] + if stats.top_reasons: + ranked = ", ".join(f"{entry.reason} ({entry.count})" for entry in stats.top_reasons) + lines.append(f"Top widening/forcing reasons: {ranked}") + return lines + + def render_backtest_table(report: BacktestReport) -> str: """A fixed-width text table for terminals — the per-rule aggregate + totals + fidelity note.""" lines: list[str] = [] @@ -42,6 +64,7 @@ def render_backtest_table(report: BacktestReport) -> str: lines.append(f"Range: {report.base}..{report.head}") lines.append("") lines.append(_totals_line(report)) + lines.extend(_selection_lines(report.selection_stats)) lines.append("") if not report.rule_stats: @@ -80,6 +103,8 @@ def render_backtest_markdown(report: BacktestReport) -> str: if report.base or report.head: lines.append(f"- **Range:** `{report.base}..{report.head}`") lines.append(f"- {_totals_line(report)}") + for selection_line in _selection_lines(report.selection_stats): + lines.append(f"- {selection_line}") lines.append("") lines.append( diff --git a/parrant/lineage/display/markdown.py b/parrant/lineage/display/markdown.py index 0db980c..548014f 100644 --- a/parrant/lineage/display/markdown.py +++ b/parrant/lineage/display/markdown.py @@ -53,6 +53,22 @@ def _structural_checks_skipped(report: dict[str, Any]) -> bool: return not report.get("structural_checks_available", True) +def _structural_diff_degraded_note(report: dict[str, Any]) -> str | None: + """The honesty line for a degraded structural diff (identical/stale catalogs), or None. + + A degraded stamp means removed/type_changed columns may be INVISIBLE in this report even + though structural checks nominally ran — a quiet run must not read as proof. + """ + block = report.get("structural_diff") + if not isinstance(block, dict) or block.get("status") != "degraded": + return None + reason = block.get("reason") or "base and head catalogs are not independent" + return ( + f"⚠️ Structural diff degraded — {reason}. " + "Removed/retyped columns may be missing from this report." + ) + + def _plural(n: int, word: str) -> str: return f"{n} {word}" + ("" if n == 1 else "s") @@ -423,6 +439,44 @@ def _render_policy_section(verdict: dict[str, Any]) -> list[str]: "", ] + out += _render_unproven_conditions(verdict) + + return out + + +def _render_unproven_conditions(verdict: dict[str, Any]) -> list[str]: + """Suppressed-unknown telemetry (additive section, issue #124): warn-rule conditions that a + ``fail_closed`` knob would have fired were the rule blocking. An all-warn pilot policy must + never read clean while block mode would fail closed here — so the count line is loud and the + per-rule list is folded (visibility without warning-flood).""" + unproven = verdict.get("unproven") or [] + if not unproven: + return [] + out: list[str] = [ + "> ⚠️ **" + + _plural(len(unproven), "unproven warn-rule condition") + + " (unknown metadata under `fail_closed`)** — suppressed, not fired; block mode " + "would fail closed here.", + "", + f"
Unproven conditions ({len(unproven)})", + "", + ] + for record in unproven: + rule_id = record.get("rule_id", "?") + model = record.get("change_model") + column = record.get("change_column") + if model and column: + subject = f"`{model}.{column}`" + elif model: + subject = f"`{model}`" + else: + subject = "_changeset_" + cause = record.get("unknown_cause") + cause_txt = "evaluation error" if cause == "error" else "meta missing" + leaves = record.get("unknown_leaves") or [] + leaves_txt = " — unknown: " + ", ".join(f"`{leaf}`" for leaf in leaves) if leaves else "" + out.append(f"- **{rule_id}** on {subject} ({cause_txt}){leaves_txt}") + out += ["", "
", ""] return out @@ -454,6 +508,9 @@ def render_changeset_markdown(report: dict[str, Any], explain: bool = False) -> ) if _structural_checks_skipped(report): out += ["", "" + _STRUCTURAL_SKIP_NOTE + ""] + degraded_note = _structural_diff_degraded_note(report) + if degraded_note: + out += ["", "" + degraded_note + ""] out.append("") out.append(_CREDIT_LINE) out.append("") @@ -701,6 +758,9 @@ def _fmt(exp: dict[str, Any]) -> str: footer: list[str] = [] if _structural_checks_skipped(report): footer.append(_STRUCTURAL_SKIP_NOTE) + degraded_note = _structural_diff_degraded_note(report) + if degraded_note: + footer.append(degraded_note) # Break detection is a lower bound: tests it couldn't attribute to a column are never # checked, so a clean (SAFE/REVIEW) ruling is not proof no test breaks. unattributable = (report.get("verdict_coverage") or {}).get("unattributable_tests", 0) diff --git a/parrant/lineage/policy.py b/parrant/lineage/policy.py index ed64d61..cecf4a7 100644 --- a/parrant/lineage/policy.py +++ b/parrant/lineage/policy.py @@ -25,8 +25,11 @@ genuine type error is never masked by a fail-open missing-meta default. A predicate that stays UNKNOWN at the rule level is resolved by the matching fail-safe policy: a *blocking* rule under ``fail_closed`` fires (bias toward safety); a non-blocking rule does not (never manufacture a -spurious warning). ``fail_open`` never fires on UNKNOWN; ``skip`` drops the rule for that -subject (recorded in ``skipped_missing_meta``). +spurious warning) — but the suppression is SURFACED as ``PolicyVerdict.unproven`` telemetry so +an all-warn pilot policy never understates what block mode will do (issue #124); the opt-in +``defaults.warn_rules_fire_on_unknown`` knob makes the non-blocking rule fire too, at its own +severity. ``fail_open`` never fires on UNKNOWN; ``skip`` drops the rule for that subject +(recorded in ``skipped_missing_meta``). """ from __future__ import annotations @@ -61,6 +64,7 @@ RuleHit, SemanticChangeKind, StructuralCondition, + UnprovenPolicyHit, ) @@ -151,7 +155,7 @@ def load_policy(path: str | None) -> Policy | None: if resolved is None: return None try: - with open(resolved, "r", encoding="utf-8") as handle: + with open(resolved, encoding="utf-8") as handle: raw = yaml.safe_load(handle) except OSError as exc: raise PolicyConfigError(f"could not read policy file '{resolved}': {exc}") from exc @@ -779,11 +783,19 @@ class _Trace: def __init__(self) -> None: self.matched_reach: list[str] = [] self.saw_unresolved_reach: bool = False + # Compact ``axis.key`` labels of the leaf conditions that resolved UNKNOWN — carried + # into the suppressed-unknown (``unproven``) telemetry so a report can say WHICH + # condition could not be proven, not just that one exists. + self.unknown_leaves: list[str] = [] def add(self, name: str) -> None: if name not in self.matched_reach: self.matched_reach.append(name) + def note_unknown_leaf(self, label: str) -> None: + if label not in self.unknown_leaves: + self.unknown_leaves.append(label) + class PolicyEngine: """Evaluate a Policy against a changeset and its impact/meta context. @@ -822,6 +834,7 @@ def evaluate(self, changes: list[ColumnChange]) -> PolicyVerdict: build_set: set[str] = set() test_set: set[str] = set() notifications: list[Notification] = [] + unproven: list[UnprovenPolicyHit] = [] skipped = 0 unresolved_reach = 0 @@ -844,6 +857,27 @@ def evaluate(self, changes: list[ColumnChange]) -> PolicyVerdict: skipped += 1 continue if not resolved: + # Suppressed-unknown telemetry (issue #124): a fail_closed knob WOULD have + # fired this rule were it blocking, but the rule is non-blocking (and the + # warn_rules_fire_on_unknown knob is off), so it was suppressed. Record the + # suppression so an all-warn pilot policy never silently understates what + # block mode will do. Report-only: no hit, no decision, no exit-code change. + if ( + _is_unknown(result) + and not _is_blocking(rule) + and self._unknown_policy(result, rule) is MissingMetaPolicy.FAIL_CLOSED + ): + unproven.append( + UnprovenPolicyHit( + rule_id=rule.id, + change_model=subject.model if subject else None, + change_column=subject.column if subject else None, + unknown_cause=( + "error" if result is Tri.UNKNOWN_ERROR else "missing" + ), + unknown_leaves=list(trace.unknown_leaves), + ) + ) continue hit, b_add, t_add, notes = self._apply_actions(rule, subject, trace) @@ -878,6 +912,8 @@ def evaluate(self, changes: list[ColumnChange]) -> PolicyVerdict: fired_rules=len(hits), unresolved_reach_count=unresolved_reach, skipped_missing_meta=skipped, + unproven=unproven, + unproven_count=len(unproven), ) # -- built-in semantic-severity knobs ------------------------------------ @@ -931,7 +967,10 @@ def _resolve(self, result: Tri, rule: Rule) -> bool | None: * ``UNKNOWN_ERROR`` (operator/type mismatch) -> ``on_error``. Each falls back rule -> policy default. The chosen policy then resolves the leaf: * fail_closed: a *blocking* rule fires (bias toward safety); a non-blocking rule does - not (never manufacture a spurious warning) — the asymmetry from. + not (never manufacture a spurious warning) — the asymmetry from — UNLESS the + policy-level ``defaults.warn_rules_fire_on_unknown`` knob is on, in which case the + non-blocking rule fires too, at its own declared severity (issue #124). A suppressed + non-blocking firing (knob off) is surfaced as ``PolicyVerdict.unproven`` telemetry. * fail_open: never fires on UNKNOWN. * skip: returns None (drop the rule for this subject; counted for honesty). """ @@ -939,16 +978,19 @@ def _resolve(self, result: Tri, rule: Rule) -> bool | None: return True if result is Tri.FALSE: return False - if result is Tri.UNKNOWN_ERROR: - policy = rule.on_error or self._policy.defaults.on_error - else: # UNKNOWN_MISSING - policy = rule.on_missing_meta or self._policy.defaults.on_missing_meta + policy = self._unknown_policy(result, rule) if policy is MissingMetaPolicy.SKIP: return None if policy is MissingMetaPolicy.FAIL_OPEN: return False - # fail_closed: blocking rules fire on UNKNOWN, non-blocking rules do not. - return _is_blocking(rule) + # fail_closed: blocking rules fire on UNKNOWN; non-blocking rules only under the knob. + return _is_blocking(rule) or self._policy.defaults.warn_rules_fire_on_unknown + + def _unknown_policy(self, result: Tri, rule: Rule) -> MissingMetaPolicy: + """The fail-safe knob that governs this UNKNOWN, per its cause (rule -> policy default).""" + if result is Tri.UNKNOWN_ERROR: + return rule.on_error or self._policy.defaults.on_error + return rule.on_missing_meta or self._policy.defaults.on_missing_meta # -- predicate evaluation (change scope) --------------------------------- @@ -959,19 +1001,33 @@ def _eval_predicate(self, predicate: Predicate, subject: ColumnChange, trace: _T return _or([self._eval_predicate(p, subject, trace) for p in predicate.any_]) if predicate.not_ is not None: return _not(self._eval_predicate(predicate.not_, subject, trace)) + # A leaf: evaluate it and, when it resolves UNKNOWN, note its compact ``axis.key`` label + # on the trace so the suppressed-unknown telemetry can name WHICH condition was unproven. + result: Tri + label: str if predicate.change is not None: - return self._eval_change(predicate.change, subject) - if predicate.meta is not None: - return self._eval_meta_subject(predicate.meta, subject) - if predicate.inferred_meta is not None: - return self._eval_inferred_subject(predicate.inferred_meta, subject) - if predicate.config is not None: - return self._eval_config_subject(predicate.config, subject) - if predicate.reach is not None: - return self._eval_reach(predicate.reach, subject, trace) - if predicate.structural is not None: - return self._eval_structural(predicate.structural, subject, trace) - return Tri.UNKNOWN_ERROR # unreachable: the schema enforces exactly-one + result = self._eval_change(predicate.change, subject) + label = f"change.{predicate.change.field}" + elif predicate.meta is not None: + result = self._eval_meta_subject(predicate.meta, subject) + label = f"meta.{predicate.meta.key}" + elif predicate.inferred_meta is not None: + result = self._eval_inferred_subject(predicate.inferred_meta, subject) + label = f"inferred_meta.{predicate.inferred_meta.key}" + elif predicate.config is not None: + result = self._eval_config_subject(predicate.config, subject) + label = f"config.{predicate.config.key}" + elif predicate.reach is not None: + result = self._eval_reach(predicate.reach, subject, trace) + label = f"reach.{predicate.reach.kind.value}" + elif predicate.structural is not None: + result = self._eval_structural(predicate.structural, subject, trace) + label = f"structural.{predicate.structural.fact}" + else: + return Tri.UNKNOWN_ERROR # unreachable: the schema enforces exactly-one + if _is_unknown(result): + trace.note_unknown_leaf(label) + return result def _eval_change(self, cond: ChangeCondition, subject: ColumnChange) -> Tri: # NOTE: ``change.semantic`` (and ``change.kind``) are always PRESENT lookups, so a diff --git a/parrant/lineage/service.py b/parrant/lineage/service.py index 6b2fc38..4dff707 100644 --- a/parrant/lineage/service.py +++ b/parrant/lineage/service.py @@ -193,10 +193,11 @@ def build_selection( could not fully resolve (``partial_edges`` — columns present but a phantom/unresolvable source edge), OR deliberately does not column-analyze (``opaque`` — unparseable SQL such as a semantic view) — the COMPLETE machine lists — is rebuilt; - - if confidence is not ``full`` OR any display list was truncated, ``skippable_models`` is - empty and ``rebuild_models`` widens to the whole reachable universe. A reachable - marker-carrying model drives ``confidence.level`` to ``partial``, so it triggers this - widen automatically — the safe over-build. + - if confidence is not ``full``, any display list was truncated, OR any change is + UNRESOLVED (its impact fan-out raised, so its downstream reach is unknown), + ``skippable_models`` is empty and ``rebuild_models`` widens to the whole reachable + universe. A reachable marker-carrying model drives ``confidence.level`` to ``partial``, + so it triggers this widen automatically — the safe over-build. ``skippable_models`` is the reachable complement and is non-empty only at full confidence with nothing truncated. All emitted lists are sorted for determinism. @@ -237,8 +238,14 @@ def build_selection( (set(changed_models) & universe) | (breaking_reached & universe) | (unanalyzable & universe) ) + # A change parrant could not resolve (impact fan-out raised) carries an UNKNOWN reach: its + # downstream cone contributed nothing to ``breaking_reached``, so leaving the fold as-is + # would park that cone in ``skippable_models`` — an unprovable state classifying models + # safe. Any unresolved change therefore forces the widen (fail-closed). + has_unresolved = any(entry.get("resolved", True) is False for entry in by_change) + level = confidence["level"] - widened = level != "full" or truncated + widened = level != "full" or truncated or has_unresolved if widened: rebuild = set(universe) skippable: list[str] = [] @@ -554,7 +561,12 @@ def _partition_reachable(self, reachable: set[str]) -> _ReachPartition: opaque=opaque, ) - def _impact_confidence(self, reachable: set[str], resolved_models: int) -> dict[str, Any]: + def _impact_confidence( + self, + reachable: set[str], + resolved_models: int, + indeterminate_logic_models: list[str] | None = None, + ) -> dict[str, Any]: """Confidence block: "full" when every reachable model was analyzable, else "partial". The honest signal is the *coverage gap* — reachable downstream models we could @@ -576,6 +588,11 @@ def _impact_confidence(self, reachable: set[str], resolved_models: int) -> dict[ partial_edges = partition.partial_edges opaque = partition.opaque + # Changed models whose model-level logic diff was impossible (missing compiled SQL on a + # side): "no logic change" was unprovable, so confidence must degrade — an unprovable + # state may never classify anything downstream as safe to skip. + indeterminate_logic = sorted(set(indeterminate_logic_models or [])) + unanalyzable_reachable = parse_failed | no_column_info # Degrade to ``partial`` when a reachable model either has no columns to inspect, carries # an unresolved-edge marker, OR is opaque (unparseable SQL we chose not to column-analyze, @@ -586,7 +603,12 @@ def _impact_confidence(self, reachable: set[str], resolved_models: int) -> dict[ # through it, so we widen the rebuild rather than prove anything downstream skippable. The # widen at ``build_selection`` fires on ``partial``. level: Literal["full", "partial"] = ( - "full" if not unanalyzable_reachable and not partial_edges and not opaque else "partial" + "full" + if not unanalyzable_reachable + and not partial_edges + and not opaque + and not indeterminate_logic + else "partial" ) # Machine surface carries the COMPLETE name lists (no cap) so a fail-closed # consumer can never miss a model we couldn't analyze/resolve; the display layer caps. @@ -602,6 +624,8 @@ def _impact_confidence(self, reachable: set[str], resolved_models: int) -> dict[ partial_edges_models=sorted(partial_edges), opaque=len(opaque), opaque_models=sorted(opaque), + indeterminate_logic=len(indeterminate_logic), + indeterminate_logic_models=indeterminate_logic, no_column_info_truncated=False, parse_failed_truncated=False, opaque_truncated=False, @@ -1194,7 +1218,7 @@ def get_changeset_impact( """ # Deferred import: changeset depends on the registry, not the service, so # importing here keeps module load order simple and avoids any cycle. - from parrant.lineage.changeset import ChangeKind + from parrant.lineage.changeset import ChangeKind, detect_structural_degradation affected_models: dict[str, dict[str, Any]] = {} affected_columns: dict[tuple[str, str], dict[str, Any]] = {} @@ -1318,7 +1342,21 @@ def get_changeset_impact( reachable: set[str] = set() for change in changes: reachable |= self._dag_reachable_models(change.model) - confidence = self._impact_confidence(reachable, len(affected_models)) + # Models whose model-level logic diff was impossible (missing compiled SQL on a + # side): the marker rides on the fail-safe changes the builder emitted. Their + # unprovability degrades confidence to ``partial``, which widens the selection. + indeterminate_logic_models = sorted( + { + change.model.lower() + for change in changes + if getattr(change, "logic_diff_status", None) == "indeterminate" + } + ) + confidence = self._impact_confidence( + reachable, + len(affected_models), + indeterminate_logic_models=indeterminate_logic_models, + ) # Policy-free rebuild selection: the edited models plus everything reached by a # non-additive change plus everything unanalyzable. Lowercase the edited names so # the universe partitions cleanly against the (lowercased) reachable set. @@ -1332,7 +1370,7 @@ def get_changeset_impact( self.registry, partition, set(selection["rebuild_models"]) ) - return { + result: dict[str, Any] = { "summary": { "affected_models": len(affected_models), "affected_columns": len(deduped_columns), @@ -1352,3 +1390,16 @@ def get_changeset_impact( "resolution": resolution, "resolution_summary": resolution_summary, } + + # Honesty stamp: identical or stale base/head catalogs make the structural + # (added/removed/type_changed) diff structurally blind while it still nominally "ran". + # Stamp the report so a quiet run reads as degraded, not as proof. Advisory only — + # exit codes and verdicts are untouched; the key is absent when nothing is provable. + if base_service is not None and getattr(self, "registry", None) is not None: + base_registry = getattr(base_service, "registry", None) + if base_registry is not None: + degradation = detect_structural_degradation(base_registry, self.registry) + if degradation is not None: + result["structural_diff"] = degradation.model_dump() + result["summary"]["structural_diff"] = degradation.status + return result diff --git a/parrant/models/schema.py b/parrant/models/schema.py index 1a59107..2c0295d 100644 --- a/parrant/models/schema.py +++ b/parrant/models/schema.py @@ -73,6 +73,45 @@ def is_breaking(self) -> bool: return self is not SemanticChangeKind.EQUIVALENT +class ArtifactStamp(BaseModel): + """Identity stamp of one loaded dbt artifact, from its ``metadata`` block. + + ``generated_at`` / ``invocation_id`` are what dbt writes on every run; either may be + missing on hand-crafted or truncated artifacts, in which case no identity claim is made. + """ + + generated_at: str | None = None + invocation_id: str | None = None + + +class ArtifactStamps(BaseModel): + """The catalog + manifest identity stamps of one side of a two-manifest diff.""" + + catalog: ArtifactStamp = Field(default_factory=ArtifactStamp) + manifest: ArtifactStamp = Field(default_factory=ArtifactStamp) + + +class StructuralDiffStatus(BaseModel): + """Honesty stamp for the structural (added/removed/type_changed) column diff. + + Emitted ONLY when degraded — e.g. one prod ``catalog.json`` mounted on both sides + (identical catalogs can never show a removed/retyped column) or a head catalog that + predates the head manifest (stale column truth). Purely advisory: it changes no exit + code and no verdict, it makes the blind spot visible in the report instead of letting + ``provable_break_count == 0`` read as proof. + """ + + status: Literal["degraded"] + reason: str + + +# Outcome of the model-level compiled-SQL diff. ``indeterminate`` means the diff was +# IMPOSSIBLE (compiled SQL unavailable on a side for a model node): "no logic change" is +# unprovable, so the changeset emits fail-safe changes carrying this marker instead of +# silently reporting safe. Rides on ``ColumnChange.logic_diff_status`` / the JSON change dicts. +LogicDiffStatus = Literal["changed", "unchanged", "indeterminate"] + + class SemanticDiff(BaseModel): """Result of comparing two SQL expressions for semantic equality. @@ -317,6 +356,13 @@ class ImpactConfidence(BaseModel): # COMPLETE, uncapped machine list — len(opaque_models) == opaque always in machine output. opaque: int = 0 opaque_models: list[str] = Field(default_factory=list) + # Changed models whose model-level logic diff was IMPOSSIBLE (compiled SQL unavailable on a + # side — see ``LogicDiffStatus``). "No logic change" was unprovable for them, so their + # presence drops ``level`` to ``partial`` (which widens the rebuild selection): nothing + # downstream of an un-diffable model may be proven safe to skip. COMPLETE, uncapped machine + # list — len(indeterminate_logic_models) == indeterminate_logic always in machine output. + indeterminate_logic: int = 0 + indeterminate_logic_models: list[str] = Field(default_factory=list) # Display-only truncation signals: False in machine output (lists are complete), # set True only by a display layer when it elided names from the rendered list. no_column_info_truncated: bool = False @@ -838,6 +884,13 @@ class PolicyDefaults(BaseModel): on_error: MissingMetaPolicy = MissingMetaPolicy.FAIL_CLOSED on_meaning_changed: GateDecision | None = None on_indeterminate: GateDecision | None = None + # Opt-in (default OFF, byte-identical behavior when unset): makes ``fail_closed`` apply to + # NON-BLOCKING rules too. A warn rule whose predicate stays UNKNOWN under a fail_closed knob + # then FIRES at its declared severity (a normal warn hit marked ``fired_on_unknown``) instead + # of being suppressed. It never escalates severity — a warn rule can only ever contribute + # WARN — so ``--fail-on policy`` exit behavior is untouched. With the knob off, the + # suppression is still surfaced as ``PolicyVerdict.unproven`` telemetry (issue #124). + warn_rules_fire_on_unknown: bool = False class Policy(BaseModel): @@ -885,6 +938,27 @@ class RuleHit(BaseModel): unknown_cause: Literal["missing", "error"] | None = None +class UnprovenPolicyHit(BaseModel): + """A suppressed-unknown firing: a NON-BLOCKING rule whose predicate stayed UNKNOWN under a + ``fail_closed`` knob (issue #124). + + Under fail_closed only a *blocking* rule fires on UNKNOWN; a warn rule is suppressed so the + engine never manufactures a spurious warning. But silence would make an all-warn pilot policy + systematically understate what block mode will do — so each suppression is recorded here: + the rule, the subject change, the UNKNOWN cause (missing meta vs evaluation error), and the + leaf condition(s) that resolved UNKNOWN. Report-only telemetry: it contributes nothing to + the gate decision, hits, or exit code. + """ + + rule_id: str + change_model: str | None = None + change_column: str | None = None + unknown_cause: Literal["missing", "error"] + # The leaf conditions that resolved UNKNOWN while this rule was evaluated, as compact + # ``axis.key`` labels (e.g. ``meta.pii``, ``reach.exposure``) — the "what to tag" pointer. + unknown_leaves: list[str] = Field(default_factory=list) + + class PolicyVerdict(BaseModel): """The engine's output: a gate decision + accumulated build/test sets + notifications.""" @@ -898,6 +972,11 @@ class PolicyVerdict(BaseModel): # Honesty counters for fail-safe explainability (additive; see policy.py §7). unresolved_reach_count: int = 0 skipped_missing_meta: int = 0 + # Suppressed-unknown telemetry (additive; issue #124): warn-rule conditions that a + # fail_closed knob WOULD have fired were the rule blocking. ``unproven_count`` mirrors + # ``len(unproven)`` for one-glance consumers of the JSON verdict. + unproven: list[UnprovenPolicyHit] = Field(default_factory=list) + unproven_count: int = 0 def blocks(self) -> bool: """True when the gate decision is ``BLOCK``.""" @@ -991,6 +1070,40 @@ class BacktestPointResult(BaseModel): blast_radius: int = 0 fired: list[BacktestFiredHit] = Field(default_factory=list) sample_reach: list[str] = Field(default_factory=list) + # --- selection measurement (pure observation of the per-point Selection block) --- + # ``None`` for points replayed without a selection (a failed replay, or a service that + # produced no selection block); such points are excluded from the aggregate stats. + selection_widened: bool | None = None # Selection.widened_to_all_reachable + skippable_models_count: int | None = None # len(Selection.skippable_models) + # ResolutionSummary.rebuild_forced_by_nonresolution for this point (rebuilds forced + # because parrant could not resolve the model, not by a proven reaching change). + rebuild_forced_by_nonresolution: int | None = None + # ResolutionSummary.top_reasons flattened to {reason -> count} for cross-point aggregation. + resolution_reasons: dict[str, int] = Field(default_factory=dict) + + +class BacktestSelectionStats(BaseModel): + """Aggregate selection widen-rate statistics across the replayed range (measurement only). + + Answers "how often does the rebuild selection give up and widen to every reachable model, + and why?" over the whole backtest — pure aggregation of the per-point ``Selection`` / + ``ResolutionSummary`` facts; it changes no selection or gating behavior. + """ + + # Points that carried a selection block (the denominator of every rate below). + points_with_selection: int = 0 + # Points where ``widened_to_all_reachable`` was true — the safe over-build fired. + widened_points: int = 0 + # ``widened_points / points_with_selection`` as a percentage (1 decimal; 0.0 when empty). + widen_rate_pct: float = 0.0 + # Median ``len(skippable_models)`` across points with a selection (0.0 when empty). + median_skippable_models: float = 0.0 + # Sum of per-point ``rebuild_forced_by_nonresolution`` — total rebuilds forced because a + # model could not be resolved rather than by a proven reaching change. + rebuild_forced_by_nonresolution_total: int = 0 + # Cross-point roll-up of the per-point resolution reasons, ranked by frequency: the + # widening/forcing-reason backlog (which resolution gaps drive the widen rate). + top_reasons: list[ResolutionReasonCount] = Field(default_factory=list) class BacktestReport(BaseModel): @@ -1015,6 +1128,9 @@ class BacktestReport(BaseModel): fidelity_note: str = "" warnings: list[str] = Field(default_factory=list) baseline_delta: dict[str, Any] | None = None + # Aggregate selection widen-rate statistics (measurement only). ``None`` when no replayed + # point carried a selection block, so an absent surface is never mistaken for a zero rate. + selection_stats: BacktestSelectionStats | None = None # =========================================================================== @@ -1089,3 +1205,22 @@ def grants_present(self) -> bool: """True when at least one model declares a ``config.grants.select`` — the signal that the config-axis (PII over-grant) template is worth offering.""" return self.models_with_grants > 0 + + +class GitScopeStatus(BaseModel): + """Outcome of ``--scope-git``: was the git intersection applied, or disabled fail-safe? + + Scoping intersects the two-manifest changeset with the models the branch touched, + mapped from the git diff. That mapping is only sound when EVERY logic-bearing changed + file resolves to specific models. A file that can rewrite compiled SQL project-wide + but cannot be pinned to models (an unmapped macro/``.sql``, a ``.py`` model absent + from the manifest, ``dbt_project.yml`` vars, ``packages.yml``, an unmapped seed + ``.csv``) makes narrowing unprovable — scoping is then DISABLED for the run and the + full changeset kept (fail-safe), with the reason surfaced here and in the report. + """ + + base: str + applied: bool + changed_models: list[str] = Field(default_factory=list) + unmappable_files: list[str] = Field(default_factory=list) + reason: str | None = None diff --git a/tests/e2e/test_impact_policy_gate.py b/tests/e2e/test_impact_policy_gate.py index 6588b63..de70379 100644 --- a/tests/e2e/test_impact_policy_gate.py +++ b/tests/e2e/test_impact_policy_gate.py @@ -131,3 +131,129 @@ def test_fail_on_policy_no_change_exits_zero(dbt_artifacts): ] ) assert result.returncode == 0, f"stdout={result.stdout}\nstderr={result.stderr}" + + +_WARN_PII_POLICY = str(REPO_ROOT / "tests" / "resources" / "policies" / "warn_pii_fail_closed.yml") +_WARN_PII_KNOB_POLICY = str( + REPO_ROOT / "tests" / "resources" / "policies" / "warn_pii_fire_on_unknown.yml" +) + + +def test_warn_policy_unknown_exit_code_invariant(dbt_artifacts, mutated_base): + """Issue #124 exit-code invariance: a warn-only fail_closed policy whose rule stays UNKNOWN + exits 0 under `--fail-on policy` — exactly as before the unproven telemetry (a warn-only + policy can never block; the new surface is report-only).""" + result = _run( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + mutated_base["manifest"], + "--base-catalog", + mutated_base["catalog"], + "--ci", + "--fail-on", + "policy", + "--policy", + _WARN_PII_POLICY, + ] + ) + assert result.returncode == 0, f"stdout={result.stdout}\nstderr={result.stderr}" + + +def test_warn_policy_unknown_surfaces_unproven_in_report(dbt_artifacts, mutated_base): + """The suppressed warn-rule UNKNOWN is no longer silent: the Markdown report (the same body + posted as the sticky PR comment) carries the honest unproven line, and the JSON verdict + carries the machine-readable record.""" + md_result = _run( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + mutated_base["manifest"], + "--base-catalog", + mutated_base["catalog"], + "--policy", + _WARN_PII_POLICY, + ] + ) + assert md_result.returncode == 0, f"stdout={md_result.stdout}\nstderr={md_result.stderr}" + md = md_result.stdout + assert "unproven warn-rule condition" in md + assert "warn-pii-guard" in md + # The verdict itself stays ALLOW: nothing fired, nothing blocked. + assert "Policy verdict — ALLOW" in md + + json_result = _run( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + mutated_base["manifest"], + "--base-catalog", + mutated_base["catalog"], + "--policy", + _WARN_PII_POLICY, + "--format", + "json", + ] + ) + assert json_result.returncode == 0, f"stderr={json_result.stderr}" + verdict = json.loads(json_result.stdout)["policy_verdict"] + assert verdict["decision"] == "allow" + assert verdict["unproven_count"] >= 1 + assert any(rec["rule_id"] == "warn-pii-guard" for rec in verdict["unproven"]) + + +def test_warn_policy_knob_on_fires_warn_and_still_exits_zero(dbt_artifacts, mutated_base): + """With `warn_rules_fire_on_unknown: true` the warn rule FIRES on the UNKNOWN leaf as a + normal warn hit marked fired_on_unknown — and the gate still exits 0 under + `--fail-on policy` (warn is not block).""" + json_result = _run( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + mutated_base["manifest"], + "--base-catalog", + mutated_base["catalog"], + "--policy", + _WARN_PII_KNOB_POLICY, + "--format", + "json", + ] + ) + assert json_result.returncode == 0, f"stderr={json_result.stderr}" + verdict = json.loads(json_result.stdout)["policy_verdict"] + assert verdict["decision"] == "warn" + hit = next(h for h in verdict["hits"] if h["rule_id"] == "warn-pii-guard") + assert hit["fired_on_unknown"] is True + assert hit["unknown_cause"] == "missing" + assert verdict["unproven_count"] == 0 + + gate_result = _run( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + mutated_base["manifest"], + "--base-catalog", + mutated_base["catalog"], + "--ci", + "--fail-on", + "policy", + "--policy", + _WARN_PII_KNOB_POLICY, + ] + ) + assert gate_result.returncode == 0, f"stdout={gate_result.stdout}\nstderr={gate_result.stderr}" diff --git a/tests/integration/test_coverage_confidence_integration.py b/tests/integration/test_coverage_confidence_integration.py index df66a9c..0742be5 100644 --- a/tests/integration/test_coverage_confidence_integration.py +++ b/tests/integration/test_coverage_confidence_integration.py @@ -78,6 +78,8 @@ def test_json_impact_carries_confidence_block(dbt_artifacts): "opaque", "opaque_models", "opaque_truncated", + "indeterminate_logic", + "indeterminate_logic_models", "level", } # Everything reachable is in the catalog and parsed, so confidence is full even diff --git a/tests/integration/test_impact_diff_integration.py b/tests/integration/test_impact_diff_integration.py index 77f209f..1d853f5 100644 --- a/tests/integration/test_impact_diff_integration.py +++ b/tests/integration/test_impact_diff_integration.py @@ -165,6 +165,126 @@ def test_impact_two_manifest_json_report(dbt_artifacts, base_artifacts): assert "by_change" in payload +@pytest.fixture +def missing_head_sql_artifacts(dbt_artifacts, tmp_path): + """A HEAD whose stg_accounts has NO compiled SQL available (embedded or on disk). + + Written into a bare temp dir so the ``target/compiled/**`` on-disk recovery cannot + find it either — the model's logic diff is genuinely impossible on the head side. + """ + catalog = copy.deepcopy(_load(dbt_artifacts["catalog_path"])) + manifest = copy.deepcopy(_load(dbt_artifacts["manifest_path"])) + node = manifest["nodes"]["model.test_project.stg_accounts"] + node.pop("compiled_code", None) + node.pop("compiled_path", None) + head_catalog = tmp_path / "head_catalog.json" + head_manifest = tmp_path / "head_manifest.json" + head_catalog.write_text(json.dumps(catalog)) + head_manifest.write_text(json.dumps(manifest)) + return {"catalog": str(head_catalog), "manifest": str(head_manifest)} + + +def test_impact_missing_head_sql_is_indeterminate_widens_and_degrades( + dbt_artifacts, missing_head_sql_artifacts +): + """A model with compiled SQL on base but none on head must never read as silent-safe. + + The unprovable logic diff must (a) surface as explicit indeterminate changes in the + JSON report, (b) degrade the impact confidence, and (c) widen the rebuild selection. + """ + result = _run_impact( + [ + "--manifest", + missing_head_sql_artifacts["manifest"], + "--catalog", + missing_head_sql_artifacts["catalog"], + "--base-manifest", + str(dbt_artifacts["manifest_path"]), + "--base-catalog", + str(dbt_artifacts["catalog_path"]), + "--format", + "json", + ] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + + # (a) explicit indeterminate changes for the SQL-less model in the JSON report. + indeterminate = [ + c + for c in payload["changeset"]["changes"] + if c["model"] == "stg_accounts" and c.get("logic_diff_status") == "indeterminate" + ] + assert indeterminate, payload["changeset"]["changes"] + assert all(c["kind"] == "logic_changed" for c in indeterminate) + assert all(c["semantic"] == "indeterminate" for c in indeterminate) + + # (b) confidence is degraded and names the model. + confidence = payload["confidence"] + assert confidence["level"] == "partial" + assert "stg_accounts" in confidence["indeterminate_logic_models"] + assert confidence["indeterminate_logic"] >= 1 + + # (c) the rebuild selection widens: nothing downstream may be skipped. + selection = payload["selection"] + assert selection["widened_to_all_reachable"] is True + assert selection["skippable_models"] == [] + assert "stg_accounts" in selection["rebuild_models"] + + +def test_impact_identical_catalogs_stamp_structural_diff_degraded(dbt_artifacts, logic_change_base): + """ONE catalog mounted on both sides => removed/type_changed can never surface. + + ``logic_change_base`` copies the head catalog unchanged, reproducing the real deployment + where a single prod catalog backs both sides. The report must say so explicitly. + """ + args = [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + logic_change_base["manifest"], + "--base-catalog", + logic_change_base["catalog"], + ] + result = _run_impact([*args, "--format", "json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + + stamp = payload.get("structural_diff") + assert stamp is not None, "identical base/head catalogs must stamp the report as degraded" + assert stamp["status"] == "degraded" + assert stamp["reason"] + assert payload["summary"]["structural_diff"] == "degraded" + + # The markdown / PR-comment surface carries a visible honesty line too. + markdown = _run_impact(args) + assert markdown.exit_code == 0, markdown.output + assert "Structural diff degraded" in markdown.output + + +def test_impact_distinct_catalogs_carry_no_degraded_stamp(dbt_artifacts, base_artifacts): + result = _run_impact( + [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + base_artifacts["manifest"], + "--base-catalog", + base_artifacts["catalog"], + "--format", + "json", + ] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert "structural_diff" not in payload + assert "structural_diff" not in payload["summary"] + + def test_impact_two_manifest_markdown_default(dbt_artifacts, base_artifacts): result = _run_impact( [ @@ -256,34 +376,106 @@ def test_scope_git_requires_base_manifest(dbt_artifacts): assert "scope-git" in result.output -def test_scope_git_filters_to_changed_models(dbt_artifacts, base_artifacts, monkeypatch): - # The two-manifest diff detects a change on stg_accounts, but we scope to a - # git diff that touched no matching model -> the changeset is emptied. +def _scope_git_args(dbt_artifacts, base_artifacts, *extra): + return [ + "--manifest", + str(dbt_artifacts["manifest_path"]), + "--catalog", + str(dbt_artifacts["catalog_path"]), + "--base-manifest", + base_artifacts["manifest"], + "--base-catalog", + base_artifacts["catalog"], + "--scope-git", + "origin/main", + *extra, + ] + + +def _json_report(result): + """Parse the JSON report from CliRunner output (stderr warnings may precede it).""" + output = result.output + return json.loads(output[output.index("{") :]) + + +def _patch_git_diff(monkeypatch, files): from parrant.lineage import changeset monkeypatch.setattr( changeset, - "_git_changed_sql_files", - lambda ref, repo_dir=None, git_head="HEAD": ["macros/only.sql"], - ) - result = _run_impact( - [ - "--manifest", - str(dbt_artifacts["manifest_path"]), - "--catalog", - str(dbt_artifacts["catalog_path"]), - "--base-manifest", - base_artifacts["manifest"], - "--base-catalog", - base_artifacts["catalog"], - "--scope-git", - "origin/main", - ] + "_git_changed_files", + lambda ref, repo_dir=None, git_head="HEAD": files, ) + + +def test_scope_git_filters_to_changed_models(dbt_artifacts, base_artifacts, monkeypatch): + # The two-manifest diff detects a change on stg_accounts, but the git diff only + # touched a DIFFERENT mapped model -> the changeset is legitimately emptied. + _patch_git_diff(monkeypatch, ["models/marts/transactions.sql"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts)) assert result.exit_code == 0, result.output assert "No column changes detected" in result.output +def test_scope_git_keeps_changes_on_touched_model(dbt_artifacts, base_artifacts, monkeypatch): + # Regression: an ordinary model-.sql diff still scopes exactly as before. + _patch_git_diff(monkeypatch, ["models/staging/stg_accounts.sql"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts, "--format", "json")) + assert result.exit_code == 0, result.output + report = _json_report(result) + changed_models = {c["model"] for c in report["changeset"]["changes"]} + assert changed_models == {"stg_accounts"} + assert report["scope_git"]["applied"] is True + assert report["scope_git"]["reason"] is None + + +def test_scope_git_macro_only_diff_disables_scoping(dbt_artifacts, base_artifacts, monkeypatch): + # FAIL-SAFE: a macro can rewrite the compiled SQL of any model, so a macro-only + # diff must NOT empty the changeset into a silent SAFE. Scoping is disabled and + # the reason is visible in the JSON report. + _patch_git_diff(monkeypatch, ["macros/only.sql"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts, "--format", "json")) + assert result.exit_code == 0, result.output + report = _json_report(result) + assert report["changeset"]["total_changes"] > 0 + assert report["scope_git"]["applied"] is False + assert report["scope_git"]["reason"].startswith("disabled — unmappable changed files:") + assert "macros/only.sql" in report["scope_git"]["reason"] + assert "macros/only.sql" in report["scope_git"]["unmappable_files"] + + +def test_scope_git_dbt_project_yml_disables_scoping(dbt_artifacts, base_artifacts, monkeypatch): + _patch_git_diff(monkeypatch, ["dbt_project.yml"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts, "--format", "json")) + assert result.exit_code == 0, result.output + report = _json_report(result) + assert report["changeset"]["total_changes"] > 0 + assert report["scope_git"]["applied"] is False + assert "dbt_project.yml" in report["scope_git"]["reason"] + + +def test_scope_git_python_model_disables_scoping(dbt_artifacts, base_artifacts, monkeypatch): + # The bundled project has no Python models, so this .py cannot be mapped. + _patch_git_diff(monkeypatch, ["models/marts/py_scores.py"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts, "--format", "json")) + assert result.exit_code == 0, result.output + report = _json_report(result) + assert report["changeset"]["total_changes"] > 0 + assert report["scope_git"]["applied"] is False + assert "models/marts/py_scores.py" in report["scope_git"]["reason"] + + +def test_scope_git_docs_only_diff_stays_scoped(dbt_artifacts, base_artifacts, monkeypatch): + # schema.yml / docs edits cannot alter compiled SQL: scoping stays on and + # legitimately narrows the (stale-base) changeset to nothing. + _patch_git_diff(monkeypatch, ["models/staging/models.yml", "README.md"]) + result = _run_impact(_scope_git_args(dbt_artifacts, base_artifacts, "--format", "json")) + assert result.exit_code == 0, result.output + report = _json_report(result) + assert report["changeset"]["total_changes"] == 0 + assert report["scope_git"]["applied"] is True + + def test_ci_no_change_passes_gate(dbt_artifacts, no_gh_env): result = _run_impact( [ diff --git a/tests/resources/policies/warn_pii_fail_closed.yml b/tests/resources/policies/warn_pii_fail_closed.yml new file mode 100644 index 0000000..440e102 --- /dev/null +++ b/tests/resources/policies/warn_pii_fail_closed.yml @@ -0,0 +1,20 @@ +# Test fixture — the issue #124 repro: a WARN-only pilot of the naive PII guard under +# `on_missing_meta: fail_closed`. +# +# On a column with no `pii` meta key, `eq` resolves to UNKNOWN_MISSING. The rule is +# non-blocking, so under fail_closed it is SUPPRESSED (no spurious warn) — but the suppression +# must surface as an *unproven* record on the verdict, so an all-warn rollout policy never +# understates what block mode will do. Exit codes are unchanged: a warn-only policy never blocks. +version: 1 +defaults: + on_missing_meta: fail_closed +rules: + - id: warn-pii-guard + description: > + Advisory PII guard — warn-only pilot of `meta.pii eq true` under fail_closed. UNKNOWN on + any untagged column; suppressed but surfaced as an unproven condition (issue #124). + scope: change + predicate: + meta: { key: pii, op: eq, value: true } + action: + - type: warn diff --git a/tests/resources/policies/warn_pii_fire_on_unknown.yml b/tests/resources/policies/warn_pii_fire_on_unknown.yml new file mode 100644 index 0000000..17a4799 --- /dev/null +++ b/tests/resources/policies/warn_pii_fire_on_unknown.yml @@ -0,0 +1,18 @@ +# Test fixture — the issue #124 opt-in knob: `warn_rules_fire_on_unknown: true` makes +# fail_closed apply to non-blocking rules too. The same warn-only PII guard now FIRES on an +# UNKNOWN leaf, surfacing as a normal warn hit marked `fired_on_unknown` (never a block, so +# `--fail-on policy` exit behavior is untouched). +version: 1 +defaults: + on_missing_meta: fail_closed + warn_rules_fire_on_unknown: true +rules: + - id: warn-pii-guard + description: > + Advisory PII guard — warn-only `meta.pii eq true` under fail_closed WITH the + warn-on-unknown knob: fires (as warn) on any untagged column instead of staying silent. + scope: change + predicate: + meta: { key: pii, op: eq, value: true } + action: + - type: warn diff --git a/tests/unit/artifacts/test_manifest.py b/tests/unit/artifacts/test_manifest.py index e0e6b2b..1f68dd5 100644 --- a/tests/unit/artifacts/test_manifest.py +++ b/tests/unit/artifacts/test_manifest.py @@ -279,6 +279,44 @@ def test_source_dependencies_without_identifier(tmp_path): assert "raw_customers" in upstream["customers"] +def test_seed_dependencies(tmp_path): + """A model ref()ing a seed must record the upstream edge (and the reverse downstream one). + + Without the ``seed`` dep prefix the edge silently vanished: a seed change's consumers were + invisible to reachability and therefore to the rebuild selection. + """ + manifest_data = { + "nodes": { + "model.jaffle_shop.customers": { + "name": "customers", + "resource_type": "model", + "depends_on": {"nodes": ["seed.jaffle_shop.country_codes"]}, + }, + "seed.jaffle_shop.country_codes": { + "name": "country_codes", + "resource_type": "seed", + "depends_on": {"nodes": []}, + }, + }, + "sources": {}, + } + + manifest_path = tmp_path / "manifest.json" + with open(manifest_path, "w") as f: + json.dump(manifest_data, f) + + reader = ManifestReader(manifest_path) + reader.load() + + upstream = reader.get_model_upstream() + assert "customers" in upstream + assert "country_codes" in upstream["customers"] + + downstream = reader.get_model_downstream() + assert "country_codes" in downstream + assert "customers" in downstream["country_codes"] + + @pytest.fixture def manifest_with_uppercase_names(tmp_path: Path) -> Path: """Create a manifest with uppercase model names and SQL references.""" @@ -358,3 +396,78 @@ def test_manifest_normalizes_exposure_dependencies(tmp_path: Path) -> None: assert "dashboard" in exposure_deps assert "customers" in exposure_deps["dashboard"] assert "CUSTOMERS" not in exposure_deps["dashboard"] + + +# --- macro -> dependent-model mapping (feeds the --scope-git macro ceiling) ---- + + +def test_macro_dependents_direct_and_transitive(): + """A node depending on macro A, where A calls macro B, depends on BOTH files.""" + reader = ManifestReader("some/path") + reader.manifest = { + "nodes": { + "model.pkg.orders": { + "name": "Orders", + "resource_type": "model", + "depends_on": {"nodes": [], "macros": ["macro.pkg.money"]}, + }, + "model.pkg.customers": { + "name": "customers", + "resource_type": "model", + "depends_on": {"nodes": [], "macros": []}, + }, + "test.pkg.some_test": { + "name": "some_test", + "resource_type": "test", + "depends_on": {"nodes": [], "macros": ["macro.pkg.rounding"]}, + }, + }, + "macros": { + "macro.pkg.money": { + "original_file_path": "macros/money.sql", + "depends_on": {"macros": ["macro.pkg.rounding"]}, + }, + "macro.pkg.rounding": { + "original_file_path": "./macros/rounding.sql", + "depends_on": {"macros": []}, + }, + }, + } + dependents = reader.get_macro_dependents() + # Direct use of money -> orders; rounding is called BY money, so a change to + # rounding.sql also reaches orders (transitive). Paths are normalized (no ./). + assert dependents["macros/money.sql"] == {"orders"} + assert dependents["macros/rounding.sql"] == {"orders"} + # The test node is not a model-like node: it never appears as a dependent. + assert "customers" not in dependents["macros/money.sql"] + + +def test_macro_dependents_cycle_safe_and_empty(): + reader = ManifestReader("some/path") + reader.manifest = { + "nodes": { + "model.pkg.m": { + "name": "m", + "resource_type": "model", + "depends_on": {"nodes": [], "macros": ["macro.pkg.a"]}, + }, + }, + "macros": { + "macro.pkg.a": { + "original_file_path": "macros/a.sql", + "depends_on": {"macros": ["macro.pkg.b"]}, + }, + "macro.pkg.b": { + "original_file_path": "macros/b.sql", + # Cycle back to a: closure must terminate. + "depends_on": {"macros": ["macro.pkg.a"]}, + }, + }, + } + dependents = reader.get_macro_dependents() + assert dependents["macros/a.sql"] == {"m"} + assert dependents["macros/b.sql"] == {"m"} + + empty = ManifestReader("some/path") + empty.manifest = {"nodes": {}} + assert empty.get_macro_dependents() == {} diff --git a/tests/unit/lineage/test_backtest.py b/tests/unit/lineage/test_backtest.py index 2fac45a..5eb7aa4 100644 --- a/tests/unit/lineage/test_backtest.py +++ b/tests/unit/lineage/test_backtest.py @@ -15,6 +15,8 @@ from parrant.lineage.backtest import ( _aggregate_rule_stats, + _aggregate_selection_stats, + _selection_facts, backtest_exit_code, ) from parrant.lineage.changeset import ( @@ -285,3 +287,105 @@ def test_fired_hit_shape_is_typed(): hit = BacktestFiredHit(rule_id="r", decision=GateDecision.BLOCK, fired_on_unknown=True) dumped = hit.model_dump(mode="json") assert dumped == {"rule_id": "r", "decision": "block", "fired_on_unknown": True} + + +# --- selection widen-rate statistics (measurement only) ----------------------- + + +def _sel_point( + ref, + widened=None, + skippable=None, + forced=None, + reasons=None, +): + return BacktestPointResult( + ref=ref, + source="s", + total_changes=1, + decision="allow", + selection_widened=widened, + skippable_models_count=skippable, + rebuild_forced_by_nonresolution=forced, + resolution_reasons=reasons or {}, + ) + + +def test_selection_stats_none_when_no_point_carried_a_selection(): + points = [_sel_point("a"), _sel_point("b")] + assert _aggregate_selection_stats(points) is None + + +def test_selection_stats_widen_rate_median_and_forced_total(): + points = [ + _sel_point("a", widened=True, skippable=0, forced=2, reasons={"missing_catalog": 2}), + _sel_point("b", widened=False, skippable=5, forced=0), + _sel_point("c", widened=False, skippable=1, forced=1, reasons={"python_model": 1}), + # A point without a selection block (failed replay) must be EXCLUDED, not read as 0%. + _sel_point("d"), + ] + stats = _aggregate_selection_stats(points) + + assert stats is not None + assert stats.points_with_selection == 3 + assert stats.widened_points == 1 + assert stats.widen_rate_pct == 33.3 # 1/3, one decimal + assert stats.median_skippable_models == 1.0 # median of [0, 5, 1] + assert stats.rebuild_forced_by_nonresolution_total == 3 + + +def test_selection_stats_reasons_summed_and_ranked(): + points = [ + _sel_point( + "a", widened=True, skippable=0, reasons={"missing_catalog": 2, "star_off_cte": 1} + ), + _sel_point( + "b", widened=True, skippable=0, reasons={"missing_catalog": 1, "python_model": 3} + ), + ] + stats = _aggregate_selection_stats(points) + + assert stats is not None + ranked = [(entry.reason, entry.count) for entry in stats.top_reasons] + # Summed across points, ranked by count desc then reason asc (determinism). + assert ranked == [("missing_catalog", 3), ("python_model", 3), ("star_off_cte", 1)] + + +def test_selection_stats_all_widened_is_100_pct(): + points = [ + _sel_point("a", widened=True, skippable=0), + _sel_point("b", widened=True, skippable=0), + ] + stats = _aggregate_selection_stats(points) + assert stats is not None + assert stats.widen_rate_pct == 100.0 + assert stats.median_skippable_models == 0.0 + + +def test_selection_facts_read_from_aggregated_impact(): + aggregated = { + "selection": { + "widened_to_all_reachable": True, + "skippable_models": ["a", "b"], + }, + "resolution_summary": { + "rebuild_forced_by_nonresolution": 4, + "top_reasons": [ + {"reason": "missing_catalog", "count": 3}, + {"reason": "python_model", "count": 1}, + ], + }, + } + widened, skippable, forced, reasons = _selection_facts(aggregated) + assert widened is True + assert skippable == 2 + assert forced == 4 + assert reasons == {"missing_catalog": 3, "python_model": 1} + + +def test_selection_facts_absent_selection_yields_none(): + # A stub service without a registry emits selection=None -> the point must carry no + # selection facts (excluded from stats), never a fabricated "not widened". + assert _selection_facts({"selection": None}) == (None, None, None, {}) + assert _selection_facts({}) == (None, None, None, {}) + assert _selection_facts("not-a-dict") == (None, None, None, {}) diff --git a/tests/unit/lineage/test_backtest_display.py b/tests/unit/lineage/test_backtest_display.py index f25ec7a..56d4623 100644 --- a/tests/unit/lineage/test_backtest_display.py +++ b/tests/unit/lineage/test_backtest_display.py @@ -91,3 +91,42 @@ def test_partly_fail_safe_rule_flagged_distinctly(): ] out = render_backtest_table(_report(stats)) assert "partly fail-safe" in out + + +def _selection_stats(): + from parrant.models.schema import BacktestSelectionStats, ResolutionReasonCount + + return BacktestSelectionStats( + points_with_selection=4, + widened_points=3, + widen_rate_pct=75.0, + median_skippable_models=2.0, + rebuild_forced_by_nonresolution_total=6, + top_reasons=[ + ResolutionReasonCount(reason="missing_catalog", count=4), + ResolutionReasonCount(reason="python_model", count=2), + ], + ) + + +def test_table_renders_selection_widen_stats(): + out = render_backtest_table(_report(_stats(), selection_stats=_selection_stats())) + assert "widened to all-reachable in 3/4 point(s) (75.0%)" in out + assert "median skippable models 2.0" in out + assert "rebuilds forced by non-resolution: 6" in out + assert "missing_catalog (4)" in out + assert "python_model (2)" in out + + +def test_markdown_renders_selection_widen_stats(): + out = render_backtest_markdown(_report(_stats(), selection_stats=_selection_stats())) + assert "widened to all-reachable in 3/4 point(s) (75.0%)" in out + assert "Top widening/forcing reasons: missing_catalog (4), python_model (2)" in out + + +def test_absent_selection_stats_render_nothing(): + # No point carried a selection block -> the surface is OMITTED, never a fake 0% rate. + table = render_backtest_table(_report(_stats())) + md = render_backtest_markdown(_report(_stats())) + assert "widened to all-reachable" not in table + assert "widened to all-reachable" not in md diff --git a/tests/unit/lineage/test_changeset.py b/tests/unit/lineage/test_changeset.py index eb62279..ada2a0e 100644 --- a/tests/unit/lineage/test_changeset.py +++ b/tests/unit/lineage/test_changeset.py @@ -15,11 +15,12 @@ ColumnChange, build_changeset_report, build_git_changeset, + detect_structural_degradation, scope_changes_to_models, ) from parrant.lineage.display.markdown import render_changeset_markdown from parrant.lineage.service import LineageService -from parrant.models.schema import SemanticChangeKind +from parrant.models.schema import ArtifactStamp, ArtifactStamps, SemanticChangeKind # --- stubs ----------------------------------------------------------------- @@ -32,6 +33,9 @@ class _Col: @dataclass class _Model: columns: dict[str, _Col] + # Optional so existing stubs stay minimal; the missing-compiled-SQL indeterminate + # pathway only applies to real model nodes, which always carry a resource_type. + resource_type: str | None = None @dataclass @@ -180,6 +184,108 @@ def test_builder_ignores_cosmetic_sql_changes(): assert ChangesetBuilder(base, head).build() == [] +def test_builder_detects_change_next_to_dash_comment_inside_string_literal(): + # The logic gate must be string-literal-aware: a `--` INSIDE a quoted string is data, + # not a comment. A naive regex comment-strip removes everything after it — including + # the real `* 1` -> `* 100` change on the same line — and silently reports SAFE. + base = _FakeRegistry( + {"m": _Model({"env": _Col("text"), "amt": _Col("int")})}, + compiled={"m": "select 'ref -- prod' as env, amount * 1 as amt from t"}, + ) + head = _FakeRegistry( + {"m": _Model({"env": _Col("text"), "amt": _Col("int")})}, + compiled={"m": "select 'ref -- prod' as env, amount * 100 as amt from t"}, + ) + changes = ChangesetBuilder(base, head).build() + assert changes, "a real change hidden behind a '--' inside a string literal must be detected" + assert {c.kind for c in changes} == {ChangeKind.LOGIC_CHANGED} + + +def test_builder_detects_change_between_block_comment_markers_inside_string_literals(): + # Same soundness hole with `/* */`: markers living INSIDE two string literals make a + # naive regex strip swallow every real token between them (here `1 as n` -> `2 as n`). + base = _FakeRegistry( + {"m": _Model({"a": _Col("text"), "n": _Col("int"), "b": _Col("text")})}, + compiled={"m": "select 'open /*' as a, 1 as n, '*/ close' as b from t"}, + ) + head = _FakeRegistry( + {"m": _Model({"a": _Col("text"), "n": _Col("int"), "b": _Col("text")})}, + compiled={"m": "select 'open /*' as a, 2 as n, '*/ close' as b from t"}, + ) + changes = ChangesetBuilder(base, head).build() + assert changes, "a real change between '/*' and '*/' string contents must be detected" + assert {c.kind for c in changes} == {ChangeKind.LOGIC_CHANGED} + + +def test_builder_comment_only_edit_is_not_a_logic_change(): + base = _FakeRegistry( + {"m": _Model({"a": _Col("int")})}, + compiled={"m": "select 1 as a -- old note\n/* block v1 */ from t"}, + ) + head = _FakeRegistry( + {"m": _Model({"a": _Col("int")})}, + compiled={"m": "select 1 as a -- new note\n/* block v2 */ from t"}, + ) + assert ChangesetBuilder(base, head).build() == [] + + +def test_builder_whitespace_only_edit_is_not_a_logic_change(): + base = _FakeRegistry( + {"m": _Model({"a": _Col("int")})}, + compiled={"m": "select 1 as a from t"}, + ) + head = _FakeRegistry( + {"m": _Model({"a": _Col("int")})}, + compiled={"m": "select 1 as a\n\tfrom t"}, + ) + assert ChangesetBuilder(base, head).build() == [] + + +def test_builder_missing_head_sql_on_model_is_indeterminate_not_silent_safe(): + # A MODEL whose compiled SQL is unavailable on the head side cannot be proven + # unchanged. Silently returning "no logic change" is a false SAFE: the change is + # invisible AND the model would not even rebuild itself. It must surface as an + # explicit indeterminate logic change on every head column. + base = _FakeRegistry( + {"m": _Model({"a": _Col("int"), "b": _Col("text")}, resource_type="model")}, + compiled={"m": "select 1 as a, 'x' as b"}, + ) + head = _FakeRegistry( + {"m": _Model({"a": _Col("int"), "b": _Col("text")}, resource_type="model")}, + compiled={}, # head compiled SQL unavailable + ) + changes = ChangesetBuilder(base, head).build() + assert {c.column for c in changes} == {"a", "b"} + for change in changes: + assert change.kind == ChangeKind.LOGIC_CHANGED + assert change.semantic == SemanticChangeKind.INDETERMINATE + assert change.logic_diff_status == "indeterminate" + assert change.reason + + +def test_builder_missing_base_sql_on_model_is_indeterminate_not_silent_safe(): + base = _FakeRegistry( + {"m": _Model({"a": _Col("int")}, resource_type="model")}, + compiled={}, # base compiled SQL unavailable + ) + head = _FakeRegistry( + {"m": _Model({"a": _Col("int")}, resource_type="model")}, + compiled={"m": "select 1 as a"}, + ) + changes = ChangesetBuilder(base, head).build() + assert [c.logic_diff_status for c in changes] == ["indeterminate"] + assert changes[0].to_dict()["logic_diff_status"] == "indeterminate" + + +def test_builder_missing_sql_on_non_model_nodes_stays_silent(): + # Sources / seeds / snapshots legitimately have no compiled logic to diff — missing + # SQL there is NOT indeterminate. + for resource_type in ("source", "seed", "snapshot"): + base = _FakeRegistry({"n": _Model({"a": _Col("int")}, resource_type=resource_type)}) + head = _FakeRegistry({"n": _Model({"a": _Col("int")}, resource_type=resource_type)}) + assert ChangesetBuilder(base, head).build() == [], resource_type + + def test_builder_logic_change_is_per_column_when_lineage_is_available(): # base: a, b, c are all plain pass-throughs of an upstream column. base = _FakeRegistry( @@ -307,6 +413,88 @@ def test_structural_diff_available_requires_catalog_on_both_sides(): assert ChangesetBuilder(no_catalog, head).structural_diff_available() is False +class _StampedRegistry(_FakeRegistry): + """A registry stub that also exposes artifact identity (fingerprint + stamps).""" + + def __init__( + self, + fingerprint: str | None = None, + catalog_generated_at: str | None = None, + manifest_generated_at: str | None = None, + catalog_invocation_id: str | None = None, + manifest_invocation_id: str | None = None, + ): + super().__init__({"m": _Model({"a": _Col("int")})}) + self._fingerprint = fingerprint + self._stamps = ArtifactStamps( + catalog=ArtifactStamp( + generated_at=catalog_generated_at, invocation_id=catalog_invocation_id + ), + manifest=ArtifactStamp( + generated_at=manifest_generated_at, invocation_id=manifest_invocation_id + ), + ) + + def get_catalog_fingerprint(self) -> str | None: + return self._fingerprint + + def get_artifact_stamps(self) -> ArtifactStamps: + return self._stamps + + +def test_detect_structural_degradation_on_identical_catalogs(): + # ONE prod catalog mounted on both sides: removed/type_changed can never surface, so the + # report must carry an explicit degraded stamp instead of a confident silence. + base = _StampedRegistry(fingerprint="abc") + head = _StampedRegistry(fingerprint="abc") + status = detect_structural_degradation(base, head) + assert status is not None + assert status.status == "degraded" + assert "identical" in status.reason + + +def test_detect_structural_degradation_none_on_distinct_catalogs(): + base = _StampedRegistry( + fingerprint="abc", + catalog_generated_at="2026-01-01T00:00:10Z", + manifest_generated_at="2026-01-01T00:00:00Z", + catalog_invocation_id="inv-1", + manifest_invocation_id="inv-1", + ) + head = _StampedRegistry( + fingerprint="def", + catalog_generated_at="2026-01-02T00:00:10Z", + manifest_generated_at="2026-01-02T00:00:00Z", + catalog_invocation_id="inv-2", + manifest_invocation_id="inv-2", + ) + assert detect_structural_degradation(base, head) is None + + +def test_detect_structural_degradation_on_stale_head_catalog(): + # The head catalog predates the head manifest (and comes from a different dbt + # invocation): its column truth is stale, so structural checks are degraded. + base = _StampedRegistry(fingerprint="abc") + head = _StampedRegistry( + fingerprint="def", + catalog_generated_at="2026-01-01T00:00:00Z", + manifest_generated_at="2026-01-05T00:00:00Z", + catalog_invocation_id="inv-old", + manifest_invocation_id="inv-new", + ) + status = detect_structural_degradation(base, head) + assert status is not None + assert status.status == "degraded" + assert "predates" in status.reason + + +def test_detect_structural_degradation_silent_on_stub_registries(): + # Providers that expose no artifact identity (lightweight stubs) make no claim. + base = _FakeRegistry({"m": _Model({"a": _Col("int")})}) + head = _FakeRegistry({"m": _Model({"a": _Col("int")})}) + assert detect_structural_degradation(base, head) is None + + # --- AST semantic-diff integration (Package B) ----------------------------- @@ -999,6 +1187,140 @@ def test_scope_changes_empty_when_no_overlap(): assert scope_changes_to_models(changes, {"orders"}) == [] +# --- git scope fail-safe ----------------------------------------------------- +# +# --scope-git must never silently narrow the changeset when the branch touched a +# file that can influence compiled SQL but cannot be mapped to a specific model +# (macros, Python models, dbt_project.yml, ...). resolve_git_scope classifies the +# FULL git diff and reports such files as `unmappable`, which the CLI uses to +# disable scoping for the run (fail-safe: keep the full two-manifest changeset). + + +def _patch_changed_files(monkeypatch, files): + from parrant.lineage import changeset + + monkeypatch.setattr( + changeset, + "_git_changed_files", + lambda ref, repo_dir=None, git_head="HEAD": files, + ) + + +def test_resolve_git_scope_model_sql_maps(monkeypatch): + from parrant.lineage import changeset + + _patch_changed_files(monkeypatch, ["models/orders.sql"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.models == {"orders"} + assert scope.unmappable == [] + + +def test_resolve_git_scope_macro_sql_is_unmappable(monkeypatch): + from parrant.lineage import changeset + + _patch_changed_files(monkeypatch, ["macros/helper.sql", "models/orders.sql"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + # The macro can rewrite any model's compiled SQL: scoping must not be trusted. + assert scope.unmappable == ["macros/helper.sql"] + assert scope.models == {"orders"} + + +def test_resolve_git_scope_dbt_project_yml_is_unmappable(monkeypatch): + from parrant.lineage import changeset + + _patch_changed_files(monkeypatch, ["dbt_project.yml"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.unmappable == ["dbt_project.yml"] + + +def test_resolve_git_scope_packages_yml_is_unmappable(monkeypatch): + from parrant.lineage import changeset + + _patch_changed_files(monkeypatch, ["packages.yml"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.unmappable == ["packages.yml"] + + +def test_resolve_git_scope_unmapped_python_file_is_unmappable(monkeypatch): + from parrant.lineage import changeset + + _patch_changed_files(monkeypatch, ["models/new_py_model.py"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.unmappable == ["models/new_py_model.py"] + + +def test_resolve_git_scope_mapped_python_model_scopes(monkeypatch): + from parrant.lineage import changeset + + registry = _PathRegistry( + { + "orders": _PathModel({"id": _Col("int")}, "models/orders.sql"), + "py_features": _PathModel({"id": _Col("int")}, "models/py_features.py"), + } + ) + _patch_changed_files(monkeypatch, ["models/py_features.py"]) + scope = changeset.resolve_git_scope(registry, "origin/main") + assert scope.models == {"py_features"} + assert scope.unmappable == [] + + +def test_resolve_git_scope_docs_only_diff_is_ignorable(monkeypatch): + from parrant.lineage import changeset + + # Property/docs files do not alter compiled SQL: scoping stays trustworthy + # (and legitimately narrows the changeset to nothing). + _patch_changed_files( + monkeypatch, + ["models/staging/models.yml", "models/docs.md", "README.md"], + ) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.models == set() + assert scope.unmappable == [] + + +def test_resolve_git_scope_seed_csv_is_unmappable_when_unmapped(monkeypatch): + from parrant.lineage import changeset + + # A seed column change can remove/retype downstream columns without any .sql + # edit, so an unmapped .csv must disable scoping too. + _patch_changed_files(monkeypatch, ["seeds/country_codes.csv"]) + scope = changeset.resolve_git_scope(_registry_with_paths(), "origin/main") + assert scope.unmappable == ["seeds/country_codes.csv"] + + +def test_resolve_git_scope_macro_maps_to_dependents_when_available(monkeypatch): + from parrant.lineage import changeset + + class _MacroRegistry(_PathRegistry): + def get_macro_dependents(self): + return {"macros/helper.sql": {"orders", "customers"}} + + registry = _MacroRegistry( + { + "orders": _PathModel({"id": _Col("int")}, "models/orders.sql"), + "customers": _PathModel({"id": _Col("int")}, "models/customers.sql"), + } + ) + _patch_changed_files(monkeypatch, ["macros/helper.sql"]) + scope = changeset.resolve_git_scope(registry, "origin/main") + # The macro is mapped to its dependent models: scoping stays on, widened to them. + assert scope.models == {"orders", "customers"} + assert scope.unmappable == [] + + +def test_resolve_git_scope_unknown_macro_still_unmappable(monkeypatch): + from parrant.lineage import changeset + + class _MacroRegistry(_PathRegistry): + def get_macro_dependents(self): + return {"macros/helper.sql": {"orders"}} + + registry = _MacroRegistry({"orders": _PathModel({"id": _Col("int")}, "models/orders.sql")}) + _patch_changed_files(monkeypatch, ["macros/not_in_manifest.sql"]) + scope = changeset.resolve_git_scope(registry, "origin/main") + assert scope.unmappable == ["macros/not_in_manifest.sql"] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/unit/lineage/test_policy_engine.py b/tests/unit/lineage/test_policy_engine.py index 81f9816..7e6d2d4 100644 --- a/tests/unit/lineage/test_policy_engine.py +++ b/tests/unit/lineage/test_policy_engine.py @@ -1302,3 +1302,169 @@ def test_ineffective_policy_overrides_surfaces_allow_change_on_break(): ineff = ineffective_policy_overrides(verdict, [change], breaks=[_break()]) assert len(ineff) == 1 assert "allow-break" in ineff[0]["hint"] + + +# --- suppressed-unknown telemetry + warn-on-unknown knob (issue #124) --------- + + +def _warn_pii_policy(defaults=None): + """A warn-only `meta.pii eq true` rule — UNKNOWN on any untagged column (the #124 repro).""" + doc = { + "version": 1, + "defaults": defaults or {"on_missing_meta": "fail_closed"}, + "rules": [ + { + "id": "warn-pii", + "predicate": {"meta": {"key": "pii", "op": "eq", "value": True}}, + "action": [{"type": "warn"}], + } + ], + } + return parse_policy(doc) + + +def test_warn_rule_unknown_under_fail_closed_surfaces_unproven(): + """Issue #124: a warn rule whose predicate stays UNKNOWN under fail_closed is suppressed + (no spurious warn) but the suppression MUST surface as an unproven record — otherwise an + all-warn pilot policy systematically understates what block mode will do.""" + registry = FakeRegistry(model_meta={"m": {}}) # no `pii` meta anywhere + impact = _impact(_resolved("m", "c")) + verdict = _engine(_warn_pii_policy(), registry, impact).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + # decision surface unchanged: still ALLOW, still zero fired rules. + assert verdict.decision is GateDecision.ALLOW + assert verdict.fired_rules == 0 + assert verdict.hits == [] + # ...but the suppressed firing is now visible. + assert verdict.unproven_count == 1 + (record,) = verdict.unproven + assert record.rule_id == "warn-pii" + assert record.change_model == "m" + assert record.change_column == "c" + assert record.unknown_cause == "missing" + assert "meta.pii" in record.unknown_leaves + + +def test_unproven_error_cause_from_type_mismatch(): + """A warn rule suppressed by an UNKNOWN_ERROR under `on_error: fail_closed` records the + error cause, mirroring the missing/error split of the fired_on_unknown marker.""" + policy = parse_policy( + { + "version": 1, + "defaults": {"on_missing_meta": "fail_open", "on_error": "fail_closed"}, + "rules": [ + { + "id": "roles-subset", + "predicate": {"meta": {"key": "roles", "op": "subset_of", "value": ["A"]}}, + "action": [{"type": "warn"}], + } + ], + } + ) + registry = FakeRegistry(model_meta={"m": {"roles": "ADMIN"}}) # scalar -> type mismatch + verdict = _engine(policy, registry, _impact(_resolved("m", "c"))).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + assert verdict.decision is GateDecision.ALLOW + assert verdict.unproven_count == 1 + assert verdict.unproven[0].unknown_cause == "error" + assert "meta.roles" in verdict.unproven[0].unknown_leaves + + +def test_unproven_not_recorded_under_fail_open_or_skip(): + """Only a suppressed *fail_closed* firing is unproven: fail_open / skip resolve the UNKNOWN + by design and keep their existing surfaces (nothing / skipped_missing_meta).""" + registry = FakeRegistry(model_meta={"m": {}}) + impact = _impact(_resolved("m", "c")) + change = _change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED) + + open_verdict = _engine( + _warn_pii_policy({"on_missing_meta": "fail_open"}), registry, impact + ).evaluate([change]) + assert open_verdict.unproven_count == 0 + assert open_verdict.unproven == [] + + skip_verdict = _engine( + _warn_pii_policy({"on_missing_meta": "skip"}), registry, impact + ).evaluate([change]) + assert skip_verdict.unproven_count == 0 + assert skip_verdict.skipped_missing_meta == 1 + + +def test_unproven_not_recorded_for_blocking_rule(): + """A blocking rule under fail_closed FIRES on UNKNOWN (existing behavior) — it is a hit + marked fired_on_unknown, never an unproven record (no double-count).""" + verdict = _fire(_one_meta_rule("pii", "eq", True), {}) + assert verdict.blocks() + assert verdict.unproven_count == 0 + assert verdict.unproven == [] + + +def test_unproven_not_recorded_on_proven_false(): + """A predicate that resolves to a proven FALSE is not unproven — nothing was suppressed.""" + registry = FakeRegistry(column_meta={("m", "c"): {"pii": False}}) + verdict = _engine(_warn_pii_policy(), registry, _impact(_resolved("m", "c"))).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + assert verdict.unproven_count == 0 + assert verdict.unproven == [] + + +def test_warn_rules_fire_on_unknown_knob_fires_warn_hit(): + """The opt-in knob (defaults.warn_rules_fire_on_unknown) makes fail_closed apply to + non-blocking rules too: the warn rule fires on UNKNOWN as a normal warn hit marked + fired_on_unknown, and nothing lands in unproven.""" + policy = _warn_pii_policy( + {"on_missing_meta": "fail_closed", "warn_rules_fire_on_unknown": True} + ) + registry = FakeRegistry(model_meta={"m": {}}) + verdict = _engine(policy, registry, _impact(_resolved("m", "c"))).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + assert verdict.decision is GateDecision.WARN + assert verdict.fired_rules == 1 + (hit,) = verdict.hits + assert hit.rule_id == "warn-pii" + assert hit.decision is GateDecision.WARN + assert hit.fired_on_unknown is True + assert hit.unknown_cause == "missing" + assert verdict.unproven_count == 0 + assert verdict.unproven == [] + + +def test_warn_rules_fire_on_unknown_knob_never_escalates_to_block(): + """The knob only fires the rule at its declared severity — the gate decision can become + WARN, never BLOCK, so `--fail-on policy` exit behavior is untouched.""" + policy = _warn_pii_policy( + {"on_missing_meta": "fail_closed", "warn_rules_fire_on_unknown": True} + ) + registry = FakeRegistry(model_meta={"m": {}}) + verdict = _engine(policy, registry, _impact(_resolved("m", "c"))).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + assert not verdict.blocks() + + +def test_knob_off_verdict_identical_except_unproven(): + """Default-off regression lock: apart from the additive unproven telemetry, the verdict is + identical to the pre-#124 one (decision, hits, counters).""" + registry = FakeRegistry(model_meta={"m": {}}) + impact = _impact(_resolved("m", "c")) + verdict = _engine(_warn_pii_policy(), registry, impact).evaluate( + [_change("m", "c", semantic=SemanticChangeKind.MEANING_CHANGED)] + ) + dumped = verdict.model_dump(mode="json") + dumped.pop("unproven", None) + dumped.pop("unproven_count", None) + assert dumped == { + "decision": "allow", + "hits": [], + "build_set": [], + "test_set": [], + "notifications": [], + "evaluated_rules": 1, + "fired_rules": 0, + "unresolved_reach_count": 0, + "skipped_missing_meta": 0, + } diff --git a/tests/unit/lineage/test_policy_markdown.py b/tests/unit/lineage/test_policy_markdown.py index f6415f8..eb1e290 100644 --- a/tests/unit/lineage/test_policy_markdown.py +++ b/tests/unit/lineage/test_policy_markdown.py @@ -194,3 +194,65 @@ def test_why_this_verdict_heading_present(): verdict = {"decision": "block", "hits": [_hit()]} md = render_changeset_markdown(_report_with_policy(verdict)) assert "Why this verdict" in md + + +def test_unproven_conditions_render_honest_line_with_folded_list(): + """Issue #124 telemetry: suppressed warn-rule UNKNOWNs under fail_closed render an honest + count line plus the folded per-rule list, so an all-warn pilot report never reads clean + while block mode would fail closed.""" + verdict = { + "decision": "allow", + "hits": [], + "unproven_count": 2, + "unproven": [ + { + "rule_id": "warn-pii", + "change_model": "stg_accounts", + "change_column": "ssn", + "unknown_cause": "missing", + "unknown_leaves": ["meta.pii"], + }, + { + "rule_id": "roles-subset", + "change_model": "stg_accounts", + "change_column": "roles", + "unknown_cause": "error", + "unknown_leaves": ["meta.roles"], + }, + ], + } + md = render_changeset_markdown(_report_with_policy(verdict)) + assert "2 unproven warn-rule conditions (unknown metadata under `fail_closed`)" in md + # The list is folded but present, identifying rule, subject, leaf, and cause. + assert "
Unproven conditions (2)" in md + assert "warn-pii" in md + assert "`stg_accounts.ssn`" in md + assert "meta.pii" in md + assert "meta missing" in md + assert "evaluation error" in md + + +def test_unproven_singular_line(): + verdict = { + "decision": "allow", + "hits": [], + "unproven_count": 1, + "unproven": [ + { + "rule_id": "warn-pii", + "change_model": "m", + "change_column": "c", + "unknown_cause": "missing", + "unknown_leaves": ["meta.pii"], + } + ], + } + md = render_changeset_markdown(_report_with_policy(verdict)) + assert "1 unproven warn-rule condition (unknown metadata under `fail_closed`)" in md + + +def test_no_unproven_renders_no_unproven_section(): + verdict = {"decision": "allow", "hits": [], "unproven_count": 0, "unproven": []} + md = render_changeset_markdown(_report_with_policy(verdict)) + assert "unproven warn-rule condition" not in md + assert "Unproven conditions" not in md diff --git a/tests/unit/lineage/test_selection.py b/tests/unit/lineage/test_selection.py index 319bf6e..a999976 100644 --- a/tests/unit/lineage/test_selection.py +++ b/tests/unit/lineage/test_selection.py @@ -209,6 +209,24 @@ def test_unresolved_change_still_rebuilds_its_own_model() -> None: _assert_partition(selection, changed | reachable) +def test_unresolved_change_widens_the_selection() -> None: + # A change parrant could not analyze (impact resolution raised) has an UNKNOWN reach: its + # downstream cone is invisible to the fold, so nothing in the reachable universe may be + # proven safe to skip. The selection must widen, exactly like non-full confidence. + changed = {"orphan"} + reachable = {"downstream_a", "downstream_b"} + by_change = [ + _change(kind="removed", semantic=None, resolved=False), + _change(kind="added", semantic=None, reached=["downstream_a"]), + ] + selection = build_selection(reachable, changed, by_change, _confidence()) + + assert selection["widened_to_all_reachable"] is True + assert selection["skippable_models"] == [] + assert set(selection["rebuild_models"]) == changed | reachable + _assert_partition(selection, changed | reachable) + + def test_selection_is_wired_into_get_changeset_impact() -> None: # End-to-end through the service: a real logic change on mart.id surfaces a selection block # with the edited model in the rebuild set.