Coverage for src/keel/scope.py: 100%
25 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +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
17import fnmatch
18from typing import Any
21def _matches_any(path: str, globs: tuple[str, ...]) -> bool:
22 for g in globs:
23 if fnmatch.fnmatch(path, g):
24 return True
25 return False
27SCHEMA_VERSION = "keel.scope-verify.v1"
30def verify(
31 declared_files: list[str] | None,
32 actual_files: list[str],
33 *,
34 docs_globs: tuple[str, ...] = (),
35 deferrals: tuple[str, ...] = (),
36) -> dict[str, Any]:
37 """Compare ``declared_files`` against ``actual_files`` and return a verdict.
39 * ``declared_files`` is the implementer's recorded scope contract, or
40 ``None`` when no scope was recorded. With ``None`` the result is an
41 advisory pass carrying a ``no-declared-scope`` note — back-compat so
42 existing flows that never recorded a scope are never broken.
43 * Files in ``actual_files`` not present in ``declared_files`` are scope
44 creep, *unless* they match ``docs_globs`` (docs extras are allowed) or the
45 operator has waived scope via a ``scope-waived`` deferral.
47 The returned report lists the in-scope files and the creep files and sets a
48 ``pass``/``fail`` status. ``waived`` and ``advisory`` flags explain a pass
49 that carried creep or had no declared scope.
50 """
51 waived = "scope-waived" in deferrals or "all" in deferrals
52 if declared_files is None:
53 return {
54 "schema_version": SCHEMA_VERSION,
55 "status": "pass",
56 "advisory": True,
57 "waived": waived,
58 "note": "no declared scope recorded",
59 "declared": None,
60 "in_scope": [],
61 "scope_creep": [],
62 "docs_exempt": [],
63 }
64 declared = set(declared_files)
65 in_scope: list[str] = []
66 creep: list[str] = []
67 docs_exempt: list[str] = []
68 for path in actual_files:
69 if path in declared:
70 in_scope.append(path)
71 elif docs_globs and _matches_any(path, docs_globs):
72 docs_exempt.append(path)
73 else:
74 creep.append(path)
75 blocking = bool(creep) and not waived
76 return {
77 "schema_version": SCHEMA_VERSION,
78 "status": "fail" if blocking else "pass",
79 "advisory": False,
80 "waived": waived,
81 "note": (
82 "scope creep waived by operator deferral"
83 if creep and waived
84 else None
85 ),
86 "declared": sorted(declared),
87 "in_scope": in_scope,
88 "scope_creep": creep,
89 "docs_exempt": docs_exempt,
90 }