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