Contracts
baseline-identity-contract.md
Contract: baseline source/parse-mode identity + lifecycle + #2874 read seam
Traces: FR-008, FR-009, FR-010, FR-012, NFR-005, C-005 Homes: baseline.py (BaselineTestResult, capture), pre_review_gate.py:851-909 (head compare), tasks_move_task.py:1282-1303 (#2874 kind-aware read)
The identity field
# round-trip: skip: illustrative field add — executable round-trip in tests/review/test_baseline.py
@dataclass(frozen=True)
class BaselineTestResult:
... # existing fields, baseline.py:66-75
source_identity: str = "unknown" # NEW: "<SourceClass>/<parse_mode>"
| Aspect | Value |
|---|---|
| Format | "<SourceClassName>/<parse_mode>" |
parse_mode ∈ | junit_xml, text, none, unknown |
| Producer | scope_source_identity(scope_source, raw) (new, scope_source.py) — ONE helper, BOTH sides |
to_dict | add "source_identity": self.source_identity (baseline.py:77-89) |
from_dict | source_identity=data.get("source_identity", "unknown") (baseline.py:91-105) |
Parse-mode derivation (mirrors parse_results, scope_source.py:518-540):
Condition on raw | parse_mode |
|---|---|
raw.output_artifact_path exists | junit_xml |
no artifact, FAIL-lines present in stdout/stderr | text |
| no artifact, non-zero exit, nothing parseable | none |
Backward-compat obligation (US1 AS4 / FR-009)
# round-trip: skip: illustrative degradation table, not a fixture
legacy_artifact_without_field:
from_dict: source_identity -> "unknown" # no KeyError
head_diff_behaviour: UNVERIFIED_BASELINE # never SOURCE_MISMATCH, never a crash
sentinel_baseline (failed == -1):
unchanged: diff_baseline returns everything-new # baseline.py:561-562, untouched
Parse-mode matching at diff time (FR-009)
Runs in the injected-ScopeSource head path ONLY (_evaluate_via_scope_source, pre_review_gate.py:851-909) — NEVER the FR-004 shared-override tier (evaluate_with_scope(scope_source=None), pre_review_gate.py:952-985; no injected source, no recorded identity).
# round-trip: skip: illustrative diff-time gate — executable coverage in tests/review/test_pre_review_gate_engine.py
head_identity = scope_source_identity(scope_source, raw) # after the head run
if baseline is not None and baseline.source_identity != "unknown" and head_identity != baseline.source_identity:
return GateVerdict(outcome=GateOutcome.SOURCE_MISMATCH, scope=scope,
reason=f"baseline captured under {baseline.source_identity}; "
f"head ran under {head_identity} — failure identities are not comparable")
failures = scope_source.parse_results(raw)
return _classify_current_failures(failures, scope=scope, baseline=baseline)
| baseline state | head identity | verdict |
|---|---|---|
None | any | UNVERIFIED_BASELINE (existing, _classify_current_failures :785-791) |
source_identity == "unknown" | any | UNVERIFIED_BASELINE (legacy degrade) |
| known, equal | equal | normal NO_NEW_FAILURES / NEW_FAILURES diff |
| known, differs | differs | SOURCE_MISMATCH (warn, fail-open) |
Artifact lifecycle (FR-008 / B1) — read/relocate BEFORE teardown
# round-trip: skip: illustrative before/after — executable coverage in tests/review/test_baseline.py (FR-010 parity)
# _capture_baseline_via_scope_source, baseline.py:491-536
with _baseline_worktree(repo_root, base_branch) as tmp_worktree:
if tmp_worktree is None:
return _make_sentinel(...)
raw = _run_command_for_baseline(command, cwd=tmp_worktree)
failures = tuple(scope_source.parse_results(raw)) # MOVED INSIDE the with (was :522, post-teardown)
identity = scope_source_identity(scope_source, raw) # recorded at the same point
# ... build BaselineTestResult(..., source_identity=identity, failures=failures)
Invariant. For DeclaredCommandScopeSource writing a worktree-relative --junitxml, the artifact is parsed while the worktree still exists → baseline identities share the head run's namespace. For GateCoverageScopeSource (absolute tempfile JUnit, scope_source.py:429-433) behaviour is unchanged.
FR-010 parity test matrix
Baseline and head MUST land in the same failure-identity namespace across all three:
| Source | Artifact | Zero-new-failure change → must NOT be NEW_FAILURES |
|---|---|---|
GateCoverageScopeSource | absolute JUnit tempfile | ✓ |
DeclaredCommandScopeSource | worktree-relative JUnit (--junitxml) | ✓ (the B1 case) |
DeclaredCommandScopeSource | FAIL-text convention | ✓ |
Plus a parity assertion: baseline source_identity == head identity in each case (NFR-005).
#2874 kind-aware read seam (FR-009 / D-6)
The diff-time load MUST consume the already-merged seam, not reconstruct feature_dir:
# round-trip: skip: illustrative — merged code at tasks_move_task.py:1296-1303
baseline_read_dir = _resolve_workflow_read_dir( # workflow.py:573-587
repo_root=st.main_repo_root, mission_slug=st.mission_slug,
kind=MissionArtifactKind.WORK_PACKAGE_TASK, # C-008, coord-topology safe
)
return BaselineTestResult.load(baseline_read_dir / "tasks" / wp_slug / "baseline-tests.json")
The new source_identity rides on the loaded object — the read site needs no change. Reconstructing a feature_dir here is a regression (reopens the coord-husk bug #2874 closed).
Anti-narrowing guard (FR-012 / C-005)
A focused test asserts the baseline command is run WITHOUT head's per-file scope.test_targets appended. Command authority is unified; scope legitimately differs (baseline broad, head narrowed). The head path appends targets (pre_review_gate.py:889 — [command, scope.test_targets]) but the baseline path (_run_command_for_baseline, baseline.py:454-488) runs the bare command — the guard pins that a future refactor cannot silently narrow the baseline. </content>
gate-outcome-contract.md
Contract: GateOutcome.SOURCE_MISMATCH — warn-shaped, fail-open by construction
Traces: FR-011, SC-004, C-003 (no new hard-stop) Homes: pre_review_gate.py:748-756 (member), verdict_aggregation.py:58-138 (allowlists), tasks_move_task.py:1156-1184 (console ladder)
The member
# round-trip: skip: illustrative enum add — executable coverage in tests/review/test_pre_review_gate_engine.py
class GateOutcome(StrEnum):
NO_COVERAGE = "no_coverage"
NO_NEW_FAILURES = "no_new_failures"
NEW_FAILURES = "new_failures"
UNVERIFIED_BASELINE = "unverified_baseline"
TIMED_OUT = "timed_out"
CANCELLED = "cancelled"
SOURCE_MISMATCH = "source_mismatch" # NEW — warn-shaped, distinct reason
| Property | Value |
|---|---|
| Verdict shape | warn: transition_applied=True, run_state=COMPLETED, distinct reason |
| Emitted by | _evaluate_via_scope_source (pre_review_gate.py:851-909), at diff time, when head identity ≠ a known baseline.source_identity |
| NOT | NO_COVERAGE (empty-scope/no-config) and NOT NEW_FAILURES (block) — never overload either |
reason | names both identities, e.g. "baseline captured under GateCoverageScopeSource/junit_xml; head ran under DeclaredCommandScopeSource/text — failure identities are not comparable" |
Fail-open-by-allowlist proof obligation
Both hard-stop paths are member-explicit allowlists, so a new member fails open automatically. The mission MUST assert this with a test and MUST NOT edit the filters (FR-011).
| Path | Site | Filter | SOURCE_MISMATCH present? |
|---|---|---|---|
| Terminal (hard-stop) | verdict_aggregation.py:58-60 (_TERMINAL_OUTCOMES), consumed :99-104 | frozenset({TIMED_OUT, CANCELLED}) | no → never terminal |
| Block (hard-stop) | verdict_aggregation.py:138, predicate _should_block :107-111 | v.outcome is GateOutcome.NEW_FAILURES | no → never blocks |
| Warn / proceed | verdict_aggregation.py:148-154 | fall-through default | yes → WARN_PROCEED |
Assertion (SC-004): feed aggregate_verdicts([GateVerdict(outcome=SOURCE_MISMATCH, …)], block_enabled=True, force=False) → decision == WARN_PROCEED, should_exit is False, transition_applied is True. Do the same via a full for_review transition and assert the move completes (no Exit(1)), a SOURCE_MISMATCH console warn is shown, and it is neither a silent pass nor a NO_COVERAGE.
Console-ladder invariant (the ONE live edit)
_mt_pre_review_gate_console_warning (tasks_move_task.py:1156-1184) today ends in an unconditional fall-through (:1184 — return "[dim]…no new failures[/dim]"). Any non-handled member renders as a clean pass. Rewrite:
# round-trip: skip: illustrative ladder shape — executable coverage in tests/.../test_tasks_move_task*.py
if outcome is GateOutcome.NEW_FAILURES:
... # unchanged (:1166-1173)
if outcome in (GateOutcome.NO_COVERAGE, GateOutcome.UNVERIFIED_BASELINE):
... # unchanged (:1174-1181)
if outcome in (GateOutcome.TIMED_OUT, GateOutcome.CANCELLED):
... # unchanged (:1182-1183)
if outcome is GateOutcome.SOURCE_MISMATCH: # NEW explicit branch
return f"[yellow]Pre-review regression gate: source/parse-mode mismatch — {verdict.reason}[/yellow]"
if outcome is GateOutcome.NO_NEW_FAILURES: # was the implicit fall-through
return "[dim]Pre-review regression gate: no new failures[/dim]"
return f"[dim]Pre-review regression gate: {outcome.value}[/dim]" # DEFENSIVE else — never a green pass
Invariant: after this change no GateOutcome member renders as "no new failures" unless it is NO_NEW_FAILURES. A test enumerates every member through the ladder and asserts none but NO_NEW_FAILURES yields the clean-pass string; the defensive else renders outcome.value.
What this contract does NOT change
member-explicitness IS the fail-open proof.
</content>
_TERMINAL_OUTCOMESand theNEW_FAILURESblock filter — left exactly as-is (C-003); theiraggregate_verdictsprecedence /AggregateDecision(verdict_aggregation.py:63-154).GateVerdictdataclass (pre_review_gate.py:759-768).
scope-source-contract.md
Contract: ScopeSource predicates, factory port, and breakdown mixin
Traces: FR-003, FR-005, FR-006, FR-007, NFR-005 Home: src/specify_cli/review/scope_source.py · Consumers: pre_review_gate.py, tasks_move_task.py, baseline.py
This contract replaces the single welded isinstance(scope_source, ScopeBreakdownSource) decision (two sites: pre_review_gate.py:881 and :1013) with two independently-evaluable predicates, hoists the source factory to one shared home, and makes file_to_scope a default projection over scope_breakdown via an ABC/mixin.
Two independent predicates (FR-005)
Each predicate reads a different signal, so a source may satisfy one without the other.
| Predicate | Signature | Backing signal | GateCoverageScopeSource | DeclaredCommandScopeSource | Synthetic split |
|---|---|---|---|---|---|
exposes_scope_breakdown | (source: ScopeSource) -> bool | isinstance(source, ScopeBreakdownSource) (has scope_breakdown) | True | False | may be True while policy False |
empty_scope_is_coverage_gap | (source: ScopeSource) -> bool | getattr(source, "treats_empty_scope_as_coverage_gap", False) (ClassVar marker) | True | False | may be True while capability False |
Invariant (weld removed, not renamed). The two predicates MUST NOT share a signal. A test constructs a synthetic source that satisfies exactly one (US3 AS3) and asserts each predicate returns its declared value independently.
Consumer rewrite:
# round-trip: skip: illustrative call-site rewrite — executable behaviour in tests/review/test_pre_review_gate_engine.py
# pre_review_gate.py:881 (policy site — empty derived scope ⇒ NO_COVERAGE)
if empty_scope_is_coverage_gap(scope_source) and scope.is_empty:
return GateVerdict(outcome=GateOutcome.NO_COVERAGE, scope=scope, reason=scope.describe_empty_reason())
# pre_review_gate.py:1013 (capability site — build the full breakdown metadata)
if exposes_scope_breakdown(scope_source):
return _scope_result_from_breakdown(scope_source, changed_files)
Behaviour preservation (FR-005/FR-007). For the two shipped sources the verdict is byte-identical to the pre-split isinstance behaviour: GateCoverageScopeSource → both True; DeclaredCommandScopeSource → both False.
file_to_scope as a default projection (FR-006)
An ABC/mixin (proposed ScopeBreakdownMixin) — NOT a Protocol default, which never reaches a structural implementer:
# round-trip: skip: illustrative mixin shape — executable behaviour in tests/review/test_scope_source.py
class ScopeBreakdownMixin(abc.ABC):
treats_empty_scope_as_coverage_gap: ClassVar[bool] = True
@abc.abstractmethod
def scope_breakdown(self, path: str) -> FileScopeBreakdown: ...
def file_to_scope(self, path: str) -> tuple[str, ...]:
return self.scope_breakdown(path).test_targets
| Class | Inherits mixin | Defines scope_breakdown | Defines file_to_scope | treats_empty_scope_as_coverage_gap |
|---|---|---|---|---|
GateCoverageScopeSource | yes | yes | inherited (drops hand-written :355-362) | True |
DeclaredCommandScopeSource | no (structural ScopeSource) | no | () always (:508-516) | False (default) |
Obligation. GateCoverageScopeSource still satisfies both ScopeSource and ScopeBreakdownSource structurally; DeclaredCommandScopeSource still satisfies ScopeSource structurally with no scope_breakdown. @runtime_checkable isinstance checks are unchanged.
Shared factory port (FR-003 / NFR-005)
# round-trip: skip: illustrative factory signature — executable behaviour in tests/review/test_scope_source_factory.py
def resolve_scope_source(
repo_root: Path,
*,
filter_groups_override: Mapping[str, tuple[str, ...]] | None = None,
composite_routing_override: Mapping[str, _CompositeRoute] | None = None,
) -> ScopeSource:
"""The single authority both baseline capture and the head hook resolve through."""
return GateCoverageScopeSource(
repo_root=repo_root,
filter_groups_override=filter_groups_override,
composite_routing_override=composite_routing_override,
)
| Caller | Home | Override args | Result |
|---|---|---|---|
_mt_resolve_scope_source (head hook) | tasks_move_task.py:1250 | _pre_review_gate_filter_groups() / _pre_review_gate_composite_routing() (both None in prod) | wrapper threads the test seams; no import back into tasks_move_task |
implement_capture_baseline (baseline) | workflow_executor.py:1153 | none (prod → None) | capture_baseline(..., scope_source=resolve_scope_source(main_repo_root)) |
No-import-cycle obligation (FR-003). resolve_scope_source lives in scope_source.py, already imported by both baseline.py:30 and pre_review_gate.py:65-71; it MUST NOT import tasks_move_task. The seams are passed as parameters, never imported into the factory.
Equivalence obligation (NFR-005). For the same repo_root and config, the source resolved at implement-time (baseline) and at for_review (head) is equivalent = equal test_command() output AND equal parse-mode/identity (scope_source_identity, see baseline-identity-contract.md). A test pins this so the baseline↔head split cannot re-open.
What this contract does NOT change
file_to_scope's provenance on GateCoverageScopeSource moves to the mixin.
untouched (C-002).
</content>
- The
ScopeSource/ScopeBreakdownSourceport shapes (scope_source.py:85-165) — only - The private census derivation inside
scope_source.py(:195-411) — it is the live derivation, RawRunResult/FileScopeBreakdowndataclasses (:66-83,:126-145).