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

1"""Branch-scope verification — declared files vs. the observed PR diff. 

2 

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. 

8 

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""" 

14 

15from __future__ import annotations 

16 

17import fnmatch 

18from typing import Any 

19 

20 

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 

26 

27SCHEMA_VERSION = "keel.scope-verify.v1" 

28 

29 

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. 

38 

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. 

46 

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 }