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.