Coverage for src/keel/scope.py: 100%
20 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""Branch-scope verification — declared files vs. the observed PR diff.
3keel's adapter prose calls the implementer's declared-files-vs-actual-diff
4comparison "the primary defence against branch contamination". This module makes
5that defence enforceable: given the implementer's *declared* file set, the live
6PR diff's changed files, and the project's docs-gate globs, it computes which
7diff files fall outside the declared scope ("scope creep") and returns a verdict.
9Pure and deterministic: every input is data and there is no I/O, so the verdict
10is a function of its arguments alone (the cli loads the ledger record and the
11live diff). Docs-path matching reuses the same glob matcher as risk
12classification so docs-only extras are exempt rather than flagged.
13"""
15from __future__ import annotations
17from typing import Any
19from .classify import _matches_any
21SCHEMA_VERSION = "keel.scope-verify.v1"
24def verify(
25 declared_files: list[str] | None,
26 actual_files: list[str],
27 *,
28 docs_globs: tuple[str, ...] = (),
29 deferrals: tuple[str, ...] = (),
30) -> dict[str, Any]:
31 """Compare ``declared_files`` against ``actual_files`` and return a verdict.
33 * ``declared_files`` is the implementer's recorded scope contract, or
34 ``None`` when no scope was recorded. With ``None`` the result is an
35 advisory pass carrying a ``no-declared-scope`` note — back-compat so
36 existing flows that never recorded a scope are never broken.
37 * Files in ``actual_files`` not present in ``declared_files`` are scope
38 creep, *unless* they match ``docs_globs`` (docs extras are allowed) or the
39 operator has waived scope via a ``scope-waived`` deferral.
41 The returned report lists the in-scope files and the creep files and sets a
42 ``pass``/``fail`` status. ``waived`` and ``advisory`` flags explain a pass
43 that carried creep or had no declared scope.
44 """
45 waived = "scope-waived" in deferrals or "all" in deferrals
46 if declared_files is None:
47 return {
48 "schema_version": SCHEMA_VERSION,
49 "status": "pass",
50 "advisory": True,
51 "waived": waived,
52 "note": "no declared scope recorded",
53 "declared": None,
54 "in_scope": [],
55 "scope_creep": [],
56 "docs_exempt": [],
57 }
58 declared = set(declared_files)
59 in_scope: list[str] = []
60 creep: list[str] = []
61 docs_exempt: list[str] = []
62 for path in actual_files:
63 if path in declared:
64 in_scope.append(path)
65 elif docs_globs and _matches_any(path, docs_globs):
66 docs_exempt.append(path)
67 else:
68 creep.append(path)
69 blocking = bool(creep) and not waived
70 return {
71 "schema_version": SCHEMA_VERSION,
72 "status": "fail" if blocking else "pass",
73 "advisory": False,
74 "waived": waived,
75 "note": (
76 "scope creep waived by operator deferral"
77 if creep and waived
78 else None
79 ),
80 "declared": sorted(declared),
81 "in_scope": in_scope,
82 "scope_creep": creep,
83 "docs_exempt": docs_exempt,
84 }