3"""Exhaustive census, immutability, and inline-scope rules for checker constants.
5Three fail-closed properties are enforced over every checker module:
71. CENSUS -- every module-level ALL_CAPS binding must be classified, either as
8 a typed scope/control authority (``AUTHORITY_SCHEMAS``) or as an explicit
9 non-authority carrying a category reason (``NON_AUTHORITIES``). A constant
10 in neither registry fails; a registry row whose constant no longer exists
11 fails; a non-authority whose value shape contradicts its category fails.
12 Walrus bindings count: ``_ = (ROOTS := ("evil/",))`` binds ``ROOTS`` at
13 module level just as an assignment statement does.
142. IMMUTABILITY -- a registered authority is bound exactly once, at module
15 top level, by a plain assignment. Reassignment, augmented assignment,
16 conditional or nested binding, subscript/attribute stores, ``del``,
17 ``global`` rebinding, mutator method calls, and mutation reached through
18 ANY alias of the object are rejected, so the authenticated digest always
19 describes the runtime value. Aliasing is resolved semantically rather than
20 by token: an alias of a module attribute (``d = mod.AUTHORITY``), a
21 namespace read (``vars(mod)["AUTHORITY"]``, ``getattr(mod, "AUTHORITY")``,
22 ``mod.__dict__["AUTHORITY"]``), a dynamically imported module
23 (``__import__("mod").AUTHORITY``), an unbound mutator
24 (``dict.update(mod.AUTHORITY, ...)``), ``setattr``/``delattr``, and the
25 in-place ``operator`` functions all reach the same guarded object and are
273. INLINE SCOPE -- path-shaped scope literals may not be smuggled into filter
28 sinks (``startswith``/``endswith`` prefix tuples, or ``Path.parts``
29 membership and set algebra). Both sinks resolve their operand through
30 local names, walrus bindings, aliases, conditional expressions, container
31 constructors, and container algebra, so a literal moved out of the call
32 is still found. Scope data must be a declared module authority.
35from __future__
import annotations
41from collections.abc
import Iterable
42from dataclasses
import dataclass
43from pathlib
import Path
45from suppression_model
import Finding
47MODULE_CONSTANT_RE = re.compile(
r"^_{0,2}[A-Z][A-Z0-9_]*$")
48PATHLIKE_FRAGMENT_RE = re.compile(
r"[A-Za-z0-9_.-]/")
49MUTATOR_METHODS = frozenset(
66OPERATOR_MUTATORS = frozenset(
86ATTRIBUTE_MUTATORS = frozenset({
"delattr",
"setattr"})
87NAMESPACE_CALLS = frozenset({
"globals",
"vars"})
88MODULE_IMPORT_CALLS = frozenset({
"__import__",
"import_module"})
89UNBOUND_MUTATOR_TYPES = frozenset({
"bytearray",
"dict",
"list",
"set"})
90CONTAINER_CALLS = frozenset({
"frozenset",
"list",
"set",
"tuple"})
91PREFIX_SINK_METHODS = frozenset({
"endswith",
"startswith"})
92LITERAL_CONTAINERS = (ast.Tuple, ast.List, ast.Set, ast.Dict)
93SCOPE_NODES = (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Lambda)
94SET_ALGEBRA_OPS = (ast.BitAnd, ast.BitOr, ast.BitXor, ast.Sub)
97def _scope_nodes(scope: ast.AST) -> list[ast.AST]:
98 """Return every node inside one scope, never descending into a nested scope."""
99 nodes: list[ast.AST] = []
100 for child
in ast.iter_child_nodes(scope):
102 if not isinstance(child, SCOPE_NODES):
103 nodes.extend(_scope_nodes(child))
107def _call_name(node: ast.Call) -> str:
108 """Return the simple or attribute name a call invokes, without evaluating it."""
110 if isinstance(func, ast.Name):
112 if isinstance(func, ast.Attribute):
117def _constant_text(node: ast.AST) -> str |
None:
118 """Return the exact string a constant expression names, if it names one."""
119 if isinstance(node, ast.Constant)
and isinstance(node.value, str):
124def _name_bindings(nodes: Iterable[ast.AST]) -> list[tuple[str, ast.AST]]:
125 """Return every simple ``name = expression`` binding among some nodes."""
126 pairs: list[tuple[str, ast.AST]] = []
128 if isinstance(node, ast.Assign):
130 (target.id, node.value)
for target
in node.targets
if isinstance(target, ast.Name)
133 isinstance(node, (ast.NamedExpr, ast.AnnAssign))
134 and isinstance(node.target, ast.Name)
137 pairs.append((node.target.id, node.value))
141def _statement_bindings(node: ast.stmt) -> list[tuple[str, int, ast.AST |
None]]:
142 """Return the ALL_CAPS names one module-level statement binds directly."""
143 bindings: list[tuple[str, int, ast.AST |
None]] = []
144 if isinstance(node, ast.Assign):
145 for target
in node.targets:
146 names: list[ast.Name] = []
147 if isinstance(target, ast.Name):
149 elif isinstance(target, (ast.Tuple, ast.List)):
150 names = [item
for item
in target.elts
if isinstance(item, ast.Name)]
152 (name.id, node.lineno, node.value)
154 if MODULE_CONSTANT_RE.match(name.id)
157 target = getattr(node,
"target",
None)
159 isinstance(node, (ast.AnnAssign, ast.AugAssign))
160 and isinstance(target, ast.Name)
161 and MODULE_CONSTANT_RE.match(target.id)
163 bindings.append((target.id, node.lineno, node.value))
167def _walrus_bindings(node: ast.stmt) -> list[tuple[str, int, ast.AST |
None]]:
168 """Return the ALL_CAPS names one module-level statement binds by walrus.
170 A ``def``/``class``/``lambda`` opens its own namespace, so a walrus inside
171 one binds there, not at module level, and is not a module constant.
173 if isinstance(node, SCOPE_NODES):
176 (child.target.id, child.lineno, child.value)
177 for child
in _scope_nodes(node)
178 if isinstance(child, ast.NamedExpr)
179 and isinstance(child.target, ast.Name)
180 and MODULE_CONSTANT_RE.match(child.target.id)
184def census_bindings(tree: ast.Module) -> list[tuple[str, int, ast.AST |
None]]:
185 """Return every module-level ALL_CAPS binding as ``(name, line, value)``."""
186 bindings: list[tuple[str, int, ast.AST |
None]] = []
187 for node
in tree.body:
188 bindings.extend(_statement_bindings(node))
189 bindings.extend(_walrus_bindings(node))
193def _store_root(node: ast.AST) -> ast.AST:
194 """Return the base expression a subscript/attribute store mutates."""
195 while isinstance(node, (ast.Subscript, ast.Attribute)):
200def _store_chain(node: ast.AST) -> list[ast.AST]:
201 """Return a store target followed by every expression it is an element of."""
203 while isinstance(node, (ast.Subscript, ast.Attribute)):
210 tree: ast.Module, module_authorities: dict[str, frozenset[str]]
211) -> tuple[dict[str, str], dict[str, str]]:
212 """Return authority-import aliases and checker-module aliases in one file."""
213 imported: dict[str, str] = {}
214 module_aliases: dict[str, str] = {}
215 for node
in ast.walk(tree):
216 if isinstance(node, ast.ImportFrom)
and node.module
is not None and node.level == 0:
217 module = node.module.rpartition(
".")[2]
218 names = module_authorities.get(module)
221 for alias
in node.names:
222 if alias.name
in names:
223 imported[alias.asname
or alias.name] = f
"{module}:{alias.name}"
224 elif isinstance(node, ast.Import):
225 for alias
in node.names:
226 module = alias.name.rpartition(
".")[2]
227 if module
in module_authorities:
228 module_aliases[alias.asname
or alias.name] = module
229 return imported, module_aliases
232def _mutation(rel: str, line: int, name: str, how: str) -> Finding:
233 """Build one authority-mutation finding."""
235 "checker-authority-mutation",
236 f
"{name} is mutated after authentication ({how})",
242@dataclass(frozen=True)
244 """One module's guarded names and cross-module authority bindings."""
247 guarded: frozenset[str]
248 module_lookup: dict[str, str]
249 module_authorities: dict[str, frozenset[str]]
251 def module_of(self, node: ast.AST) -> str |
None:
252 """Return the checker module an expression names, without importing it."""
253 if isinstance(node, ast.Name):
254 return self.module_lookup.get(node.id)
255 if isinstance(node, ast.Call)
and _call_name(node)
in MODULE_IMPORT_CALLS
and node.args:
256 text = _constant_text(node.args[0])
257 module = text.rpartition(
".")[2]
if text
is not None else ""
258 return module
if module
in self.module_authorities
else None
261 def namespace_of(self, node: ast.AST) -> str |
None:
262 """Return the module whose writable namespace mapping an expression is."""
263 if isinstance(node, ast.Call)
and _call_name(node)
in NAMESPACE_CALLS:
264 return "" if not node.args
else self.module_of(node.args[0])
265 if isinstance(node, ast.Attribute)
and node.attr ==
"__dict__":
266 return self.module_of(node.value)
269 def holds(self, module: str, name: str) -> bool:
270 """Return whether one module binds a registered authority of that name."""
272 return name
in self.guarded
273 return name
in self.module_authorities.get(module, frozenset())
275 def authority_label(self, node: ast.AST) -> str |
None:
276 """Return the label of the registered authority an expression resolves to."""
277 if isinstance(node, ast.Name)
and node.id
in self.guarded:
279 if isinstance(node, ast.Attribute):
280 module = self.module_of(node.value)
281 if module
is not None and self.holds(module, node.attr):
282 return f
"{module}.{node.attr}"
283 if isinstance(node, ast.Subscript):
284 return self._namespace_item(self.namespace_of(node.value), node.slice)
285 if isinstance(node, ast.Call)
and _call_name(node) ==
"getattr" and len(node.args) > 1:
286 return self._namespace_item(self.module_of(node.args[0]), node.args[1])
289 def _namespace_item(self, module: str |
None, key: ast.AST) -> str |
None:
290 """Return the label of one authority reached through a module namespace."""
291 name = _constant_text(key)
292 if module
is None or name
is None or not self.holds(module, name):
294 return f
"{module}.{name}" if module
else name
297 self, node: ast.Assign | ast.AugAssign | ast.Delete, targets: list[ast.AST], how: str
299 """Reject stores and deletions that reach a guarded object."""
300 findings: list[Finding] = []
301 for target
in targets:
302 if isinstance(target, (ast.Subscript, ast.Attribute)):
303 findings.extend(self._element_findings(node.lineno, target, how))
304 elif isinstance(target, ast.Name)
and isinstance(node, (ast.AugAssign, ast.Delete)):
305 if target.id
in self.guarded:
306 findings.append(_mutation(self.rel, node.lineno, target.id, how))
307 elif isinstance(target, (ast.Tuple, ast.List)):
308 findings.extend(self.store_findings(node, list(target.elts), how))
311 def _element_findings(self, line: int, target: ast.AST, how: str) -> list[Finding]:
312 """Reject one subscript or attribute store reaching a registered authority."""
313 root = _store_root(target)
314 if isinstance(root, ast.Name)
and root.id
in self.guarded:
315 return [_mutation(self.rel, line, root.id, f
"{how} via element store")]
316 for index, element
in enumerate(_store_chain(target)):
317 label = self.authority_label(element)
318 if label
is not None:
319 detail = how
if index == 0
else f
"{how} via element store"
320 return [_mutation(self.rel, line, label, detail)]
323 def call_findings(self, node: ast.Call) -> list[Finding]:
324 """Reject mutator method calls and mutating functions on guarded objects."""
325 return self._method_findings(node) + self._function_findings(node)
327 def _method_findings(self, node: ast.Call) -> list[Finding]:
328 """Reject a mutator method invoked on an authority or a module namespace."""
330 if not isinstance(func, ast.Attribute)
or func.attr
not in MUTATOR_METHODS:
332 how = f
".{func.attr}() call"
333 label = self.authority_label(func.value)
334 if label
is not None:
335 return [_mutation(self.rel, node.lineno, label, how)]
336 if self.namespace_of(func.value)
is not None:
337 return [_mutation(self.rel, node.lineno,
"module namespace", f
"{how} on a namespace")]
338 unbound = isinstance(func.value, ast.Name)
and func.value.id
in UNBOUND_MUTATOR_TYPES
339 label = self.authority_label(node.args[0])
if unbound
and node.args
else None
340 return [_mutation(self.rel, node.lineno, label, f
"unbound {how}")]
if label
else []
342 def _function_findings(self, node: ast.Call) -> list[Finding]:
343 """Reject setattr/delattr and in-place operator functions on an authority."""
344 name = _call_name(node)
345 if not node.args
or name
not in ATTRIBUTE_MUTATORS | OPERATOR_MUTATORS:
348 module = self.module_of(first)
if name
in ATTRIBUTE_MUTATORS
else None
349 if module
is not None:
350 return [_mutation(self.rel, node.lineno, module, f
"{name} on checker module")]
351 label = self.authority_label(first)
352 if label
is not None:
353 return [_mutation(self.rel, node.lineno, label, f
"{name}() call")]
354 if self.namespace_of(first)
is not None:
356 _mutation(self.rel, node.lineno,
"module namespace", f
"{name}() on a namespace")
361def _protected_aliases(
364 module_lookup: dict[str, str],
365 module_authorities: dict[str, frozenset[str]],
367 """Return same-module names bound to a protected authority object."""
368 pairs = _name_bindings(ast.walk(tree))
369 aliases: set[str] = set()
373 scan = _MutationScan(
"", frozenset(protected | aliases), module_lookup, module_authorities)
374 for name, value
in pairs:
375 if name
in protected
or name
in aliases:
377 if scan.authority_label(value)
is not None:
383def _binding_findings(
385 node: ast.Assign | ast.AnnAssign,
386 local_authorities: set[str],
387 top_level: set[ast.stmt],
390 """Reject nested, conditional, and repeated authority bindings."""
391 findings: list[Finding] = []
392 targets = node.targets
if isinstance(node, ast.Assign)
else [node.target]
393 for target
in targets:
394 if not isinstance(target, ast.Name)
or target.id
not in local_authorities:
396 if node
not in top_level:
397 problem = f
"{target.id} is bound inside control flow or a function"
398 elif target.id
in bound:
399 problem = f
"{target.id} is bound more than once at module level"
403 findings.append(Finding(
"checker-authority-rebinding", problem, rel, node.lineno))
407def _dynamic_store_finding(rel: str, node: ast.Subscript) -> Finding |
None:
408 """Reject writes through globals()/vars() namespaces."""
411 isinstance(base, ast.Call)
412 and isinstance(base.func, ast.Name)
413 and base.func.id
in NAMESPACE_CALLS
416 "checker-authority-mutation",
417 "dynamic namespace store cannot be statically authenticated",
424def mutation_findings(
427 local_authorities: set[str],
428 module_authorities: dict[str, frozenset[str]],
430 """Reject every write that changes a registered authority after binding."""
431 imported, module_lookup = _import_bindings(tree, module_authorities)
432 protected = set(local_authorities) | set(imported)
433 aliases = _protected_aliases(tree, protected, module_lookup, module_authorities)
434 scan = _MutationScan(rel, frozenset(protected | aliases), module_lookup, module_authorities)
435 findings: list[Finding] = []
436 top_level = set(tree.body)
437 bound: set[str] = set()
438 for node
in ast.walk(tree):
439 if isinstance(node, (ast.Assign, ast.AnnAssign)):
440 findings.extend(_binding_findings(rel, node, local_authorities, top_level, bound))
441 targets = node.targets
if isinstance(node, ast.Assign)
else [node.target]
442 findings.extend(scan.store_findings(node, list(targets),
"assignment"))
443 elif isinstance(node, ast.AugAssign):
444 findings.extend(scan.store_findings(node, [node.target],
"augmented assignment"))
445 elif isinstance(node, ast.Delete):
446 findings.extend(scan.store_findings(node, list(node.targets),
"del"))
447 elif isinstance(node, ast.Global):
449 _mutation(rel, node.lineno, name,
"global rebinding")
450 for name
in node.names
451 if name
in local_authorities
453 elif isinstance(node, ast.Call):
454 findings.extend(scan.call_findings(node))
455 elif isinstance(node, ast.Subscript)
and isinstance(node.ctx, ast.Store):
456 problem = _dynamic_store_finding(rel, node)
457 if problem
is not None:
458 findings.append(problem)
462def _container_elements(node: ast.AST) -> list[ast.AST] |
None:
463 """Return a literal container's element nodes, or None if it is not one."""
464 if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
465 return list(node.elts)
466 if isinstance(node, ast.Dict):
467 return [key
for key
in node.keys
if key
is not None]
471def _pathlike_container(node: ast.AST) -> bool:
472 """Return whether a literal container carries path-fragment scope strings."""
473 elements = _container_elements(node)
477 isinstance(item, ast.Constant)
478 and isinstance(item.value, str)
479 and PATHLIKE_FRAGMENT_RE.search(item.value)
484def _resolve_containers(
485 node: ast.AST, bindings: dict[str, list[ast.AST]], seen: frozenset[str]
487 """Return every literal container one filter-sink operand can evaluate to."""
488 if _container_elements(node)
is not None:
490 if isinstance(node, ast.Name):
491 if node.id
not in bindings
or node.id
in seen:
493 nested = seen | {node.id}
494 values = bindings[node.id]
495 return [item
for value
in values
for item
in _resolve_containers(value, bindings, nested)]
496 return _resolve_composite(node, bindings, seen)
499def _resolve_composite(
500 node: ast.AST, bindings: dict[str, list[ast.AST]], seen: frozenset[str]
502 """Resolve constructors, walrus bindings, conditionals, and container algebra."""
503 if isinstance(node, ast.NamedExpr):
504 return _resolve_containers(node.value, bindings, seen)
505 if isinstance(node, ast.Call)
and _call_name(node)
in CONTAINER_CALLS
and node.args:
506 return _resolve_containers(node.args[0], bindings, seen)
507 branches: tuple[ast.AST, ...] = ()
508 if isinstance(node, ast.IfExp):
509 branches = (node.body, node.orelse)
510 elif isinstance(node, ast.BinOp)
and isinstance(node.op, (ast.Add, *SET_ALGEBRA_OPS)):
511 branches = (node.left, node.right)
512 return [item
for branch
in branches
for item
in _resolve_containers(branch, bindings, seen)]
515def _scope_bindings(scope: ast.AST, *, module: bool) -> dict[str, list[ast.AST]]:
516 """Return the names one scope binds, minus the censused module constants."""
517 bindings: dict[str, list[ast.AST]] = {}
518 for name, value
in _name_bindings(_scope_nodes(scope)):
519 if module
and MODULE_CONSTANT_RE.match(name):
521 bindings.setdefault(name, []).append(value)
525def _scope_table(tree: ast.Module) -> list[tuple[ast.AST, dict[str, list[ast.AST]]]]:
526 """Return every scope paired with the container bindings visible inside it."""
527 table: list[tuple[ast.AST, dict[str, list[ast.AST]]]] = [
528 (tree, _scope_bindings(tree, module=
True))
531 while index < len(table):
532 scope, visible = table[index]
535 (node, {**visible, **_scope_bindings(node, module=
False)})
536 for node
in _scope_nodes(scope)
537 if isinstance(node, SCOPE_NODES)
542def _reads_parts(node: ast.AST) -> bool:
543 """Return whether an expression reads a path's ``parts`` tuple."""
545 isinstance(child, ast.Attribute)
and child.attr ==
"parts" for child
in ast.walk(node)
549def _comprehension_comparators(
550 node: ast.GeneratorExp | ast.ListComp | ast.SetComp,
551) -> list[tuple[int, ast.AST]]:
552 """Return the membership operands of a comprehension over ``Path.parts``."""
553 iterates_parts = any(
554 isinstance(generator.iter, ast.Attribute)
and generator.iter.attr ==
"parts"
555 for generator
in node.generators
557 element = getattr(node,
"elt",
None)
558 if not iterates_parts
or element
is None:
561 (node.lineno, compare.comparators[0])
562 for compare
in ast.walk(element)
563 if isinstance(compare, ast.Compare)
564 and len(compare.ops) == 1
565 and isinstance(compare.ops[0], (ast.In, ast.NotIn))
569def _membership_comparators(node: ast.AST) -> list[tuple[int, ast.AST]]:
570 """Return the container operands of one ``Path.parts`` scope filter."""
571 if isinstance(node, (ast.GeneratorExp, ast.ListComp, ast.SetComp)):
572 return _comprehension_comparators(node)
573 if isinstance(node, ast.Compare)
and len(node.ops) == 1:
575 isinstance(node.ops[0], (ast.In, ast.NotIn))
576 and isinstance(node.left, ast.Attribute)
577 and node.left.attr ==
"parts"
579 return [(node.lineno, node.comparators[0])]
if over_parts
else []
580 if isinstance(node, ast.BinOp)
and isinstance(node.op, SET_ALGEBRA_OPS):
581 left = _reads_parts(node.left)
582 if left != _reads_parts(node.right):
583 return [(node.lineno, node.right
if left
else node.left)]
587def _prefix_sink_findings(
588 rel: str, nodes: list[ast.AST], bindings: dict[str, list[ast.AST]]
590 """Reject path-shaped scope containers reaching a startswith/endswith filter."""
591 findings: list[Finding] = []
594 isinstance(node, ast.Call)
595 and isinstance(node.func, ast.Attribute)
596 and node.func.attr
in PREFIX_SINK_METHODS
601 containers = _resolve_containers(node.args[0], bindings, frozenset())
602 if any(_pathlike_container(item)
for item
in containers):
605 "inline-scope-literal",
606 f
".{node.func.attr}() path tuple must be a declared authority",
614def _membership_findings(
615 rel: str, nodes: list[ast.AST], bindings: dict[str, list[ast.AST]]
617 """Reject ``Path.parts`` filters resolved against an undeclared literal."""
618 findings: list[Finding] = []
622 "inline-scope-literal",
623 "Path.parts scope filter must use a declared module authority",
627 for line, comparator
in _membership_comparators(node)
628 if _resolve_containers(comparator, bindings, frozenset())
633def _assertion_nodes(tree: ast.Module) -> set[int]:
634 """Collect nodes inside test expectations, which state facts, not scope."""
635 inside: set[int] = set()
636 for node
in ast.walk(tree):
637 is_expectation = isinstance(node, ast.Assert)
or (
638 isinstance(node, ast.Call)
639 and isinstance(node.func, ast.Name)
640 and node.func.id ==
"expect"
643 inside.update(id(child)
for child
in ast.walk(node))
647def sink_findings(rel: str, tree: ast.Module) -> list[Finding]:
648 """Reject scope literals reaching filter sinks through any binding form."""
649 expectations = _assertion_nodes(tree)
650 findings: list[Finding] = []
651 for scope, bindings
in _scope_table(tree):
652 nodes = [node
for node
in _scope_nodes(scope)
if id(node)
not in expectations]
653 findings.extend(_prefix_sink_findings(rel, nodes, bindings))
654 findings.extend(_membership_findings(rel, nodes, bindings))
658CATEGORY_NODE_KINDS = {
659 "exit-code": frozenset({
"Constant"}),
660 "numeric-format": frozenset(
661 {
"Attribute",
"BinOp",
"Call",
"Constant",
"Subscript",
"Tuple",
"UnaryOp"}
663 "derived-runtime": frozenset(
682EMPTY_CONTAINER_CATEGORIES = frozenset({
"derived-runtime"})
683FIXTURE_BASENAME_MARKERS = (
"selftest",
"fixture")
684FIXTURE_OWNER_RELATIVE_PATHS = frozenset(
686 "scripts/checks/hil_convergence_safety_runtime_loader_harness.py",
687 "scripts/checks/hil_convergence_safety_runtime_sources.py",
692def _empty_container(value: ast.AST) -> bool:
693 """Return whether a value is an empty literal container (a runtime cache)."""
694 if isinstance(value, (ast.Tuple, ast.List, ast.Set)):
695 return not value.elts
696 if isinstance(value, ast.Dict):
697 return not value.keys
701def _scope_shaped_literal(value: ast.AST) -> str |
None:
702 """Return a path-prefix string literal hiding inside a non-authority value."""
703 for node
in ast.walk(value):
705 isinstance(node, ast.Constant)
706 and isinstance(node.value, str)
707 and node.value.endswith(
"/")
708 and PATHLIKE_FRAGMENT_RE.search(node.value)
715def shape_problem(rel: str, category: str, value: ast.AST, *, has_selftest: bool) -> str |
None:
716 """Return why a non-authority value contradicts its category, if it does."""
717 if category ==
"selftest-fixture":
718 basename = Path(rel).name
719 marked = rel
in FIXTURE_OWNER_RELATIVE_PATHS
or any(
720 marker
in basename
for marker
in FIXTURE_BASENAME_MARKERS
722 if not marked
and not has_selftest:
723 return "selftest-fixture classification outside a module with a selftest"
725 if category
in EMPTY_CONTAINER_CATEGORIES
and _empty_container(value):
727 allowed = CATEGORY_NODE_KINDS.get(category)
728 if allowed
is not None and type(value).__name__
not in allowed:
729 return f
"{type(value).__name__} value contradicts category {category}"
730 prefix = _scope_shaped_literal(value)
731 if prefix
is not None:
732 return f
"path-prefix literal {prefix!r} is scope-shaped data"
736def classification_digest(
737 authority_schemas: dict[str, object], non_authorities: dict[str, str]
739 """Authenticate the complete constant classification map."""
741 **dict.fromkeys(authority_schemas,
"authority"),
742 **{key: f
"non-authority:{category}" for key, category
in non_authorities.items()},
744 encoded = json.dumps(dict(sorted(payload.items())), sort_keys=
True, separators=(
",",
":"))
745 return hashlib.sha256(encoded.encode()).hexdigest()
748def repository_mutation_findings(
751 module_authorities: dict[str, frozenset[str]],
752 candidate_paths: frozenset[str],
754 """Reject imported-authority mutation from every tracked Python file."""
755 findings: list[Finding] = []
757 if not rel.endswith(
".py")
or rel
in candidate_paths:
760 tree = ast.parse((root / rel).read_text(encoding=
"utf-8"), filename=rel)
761 except (OSError, SyntaxError)
as exc:
762 findings.append(Finding(
"checker-scope-ast", str(exc), rel))
764 findings.extend(mutation_findings(rel, tree, set(), module_authorities))