feat(prover): access-control prover, delivery A: path records, Stapler and Jetty entries, Java operation rows, the prover and its scoring arm

Codex delivery under spec access-control-prover. `semantic/obligations/paths.py` builds entry-to-function path
records from the cached caller index; `mapping.py` names Stapler web methods and Jetty handlers as entries;
`check_obligations` gains the opt-in premise `assume_entries_protected` with the label `unguarded_path`;
`hunt/prover.py` proves a change with no model call and states its coverage; `benchmarks/valid/prover_arm.py`
scores it with the valid-benchmark metrics. Two fixes after the first smoke run: a product file git holds empty
is read, not unread (four changes failed on `__init__.py` and empty assets), and absent-input reasons are
counted by name.

Smoke on the 23 fix changes of the training pool: 7 pushes caught (30 percent, Wilson 16 to 51), 28 of 93
locations, precision 7 of 8 flagged, no instrument failure; three changes over the 120 second bound. Twelve of
the 17 Jenkins fixes reach the fixed method but check no operation because the method is the operation;
the spec now carries handler-named operations for delivery B.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-14 16:50:04 +02:00
co-authored by Claude Fable 5.1
parent e514551cb6
commit beb272a5ec
15 changed files with 1352 additions and 17 deletions
+267
View File
@@ -0,0 +1,267 @@
"""Deterministic access-control verdicts from recognised entries and bounded paths."""
from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from .. import mapping, source
from ..preprocess import FileTarget, count_loc, detect_language, detect_tags
from ..semantic.facts import load_facts
from ..semantic.ir import FileIR, FunctionIR, trailing_name
from ..semantic.obligations.check import _on_caller_hop, _relevant, check_obligations
from ..semantic.obligations.dominance import OrderDominance
from ..semantic.obligations.facts import load_obligation_facts
from ..semantic.obligations.operations import Operation, find_operations, valid_witness
from ..semantic.obligations.paths import compose_paths
from ..semantic.obligations.policy import DEFAULT_POLICY_RELPATH, load_declared_policy
from .producers import Context, _safe_path
@dataclass(frozen=True)
class Coverage:
files_by_language: dict[str, int]
frameworks_recognised: dict[str, int]
entry_points: int
reached: int
unreached: int
operations_checked: int
operations_discharged: int
unchecked_by_reason: dict[str, int]
@dataclass(frozen=True)
class ProverResult:
findings: tuple[dict[str, Any], ...]
coverage: Coverage
degradations: tuple[dict[str, Any], ...]
def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = None, *, max_depth: int = 3) -> ProverResult:
"""Check touched head functions and their direct operation-bearing callees, with no model."""
if max_depth < 0:
raise ValueError("invalid path depth")
root = Path(root)
ctx = ctx if ctx is not None else Context(root, change)
ctx.verify_head()
facts, flow = load_obligation_facts(), load_facts()
reasons: Counter[str] = Counter()
languages: Counter[str] = Counter()
frameworks: Counter[str] = Counter()
degradations: list[dict[str, Any]] = []
touched: set[tuple[str, str]] = set()
functions: dict[tuple[str, str], FunctionIR] = {}
operations: dict[tuple[str, str], tuple[Operation, ...]] = {}
irs: dict[str, tuple[FileIR, str]] = {}
failed: set[str] = set()
readable: list[str] = []
targets: list[FileTarget] = []
# Read each product file once through the shared cache. Build mapping targets from that text.
for path in ctx.product_files:
try:
text = ctx.read(path)
language = source.canonical_language(detect_language(root / path))
languages[language] += 1
targets.append(FileTarget(path, str(root / path), language, count_loc(text), detect_tags(path, language, text), False, [], []))
readable.append(path)
except Exception as error:
failed.add(path)
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)})
if "entry_points" not in ctx.__dict__:
# External manifest enforcement is supplied by delivery B; this delivery uses source entries.
mapped: list[mapping.EntryPointRecord] = []
for target in targets:
try:
mapped.extend(mapping.analyze_entry_points(root, [target], texts=ctx.texts, include_manifests=False))
except Exception as error:
failed.add(target.path)
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1
degradations.append({"path": target.path, "reason": str(getattr(error, "reason", None) or type(error).__name__)})
ctx.__dict__["entry_points"] = mapped
entries = [entry for entry in ctx.entry_points if entry.function_name and entry.defines_handler and entry.line is not None]
recognised = {entry.path for entry in entries}
for path in recognised:
evidence = " ".join(item for entry in entries if entry.path == path for item in entry.access_evidence)
framework = (
"stapler"
if "Stapler" in evidence
else "jetty"
if "Jetty" in evidence
else "servlet"
if "Servlet" in evidence
else "jaxrs"
if "JAX-RS" in evidence
else source.canonical_language(detect_language(root / path))
)
frameworks[framework] += 1
def read_ir(path: str) -> FileIR | None:
if path in failed:
return None
if path in irs:
return irs[path][0]
try:
ir, text = ctx.ir(path), ctx.read(path)
if not ir.parse_ok:
reasons["language_unsupported" if ir.reason == "language_unsupported" else "parse_failed"] += 1
failed.add(path)
return None
if not facts.for_language(ir.language).operations:
reasons["no_facts"] += 1
failed.add(path)
return None
irs[path] = ir, text
ctx.spans.setdefault(path, source.ranges_from_ir(ir, text))
found = find_operations(ir, facts.for_language(ir.language), text=text)
for fn in ir.functions:
if fn.name != "<module>":
functions[path, fn.name] = fn
operations[path, fn.name] = tuple(op for op in found if op.function == fn.name)
return ir
except Exception as error:
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1
failed.add(path)
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)})
return None
for file in change.get("files", ()):
path = str(file["path"])
_safe_path(root, path)
reason = source.generated_reason(root, path)
if reason:
reasons[reason] += 1
continue
ir = read_ir(path)
if ir is None:
continue
selected = [
fn
for fn in ir.functions
if fn.name != "<module>" and any(fn.start_line <= end and start <= fn.end_line for start, end in file.get("head_lines", ()))
]
touched.update((path, fn.name) for fn in selected)
if file.get("head_lines") and not selected:
reasons["function_unavailable"] += 1
# The existing name-based index supplies call sites; retain file-qualified enclosing functions.
def callers(path: str, function: str) -> list[tuple[str, int]]:
del path
out = []
for caller_path, line in source.callers_of(root, function, files=readable, texts=ctx.texts, index=ctx.call_index):
ir = read_ir(caller_path)
if ir is None:
continue
spans = [fn for fn in ir.functions if fn.name != "<module>" and fn.start_line <= line <= fn.end_line]
if spans:
fn = min(spans, key=lambda item: item.end_line - item.start_line)
out.append((f"{caller_path}::{fn.name}", line))
return out
target_functions = set(touched)
call_names = {trailing_name(call.name) for key in touched for call in functions[key].calls}
if call_names:
for path in readable:
# Ranges are cached and avoid parsing unrelated files just to look for direct callees.
try:
if any(span.name in call_names for span in ctx.ranges(path)):
read_ir(path)
except Exception as error:
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)})
target_functions.update(key for key, ops in operations.items() if key[1] in call_names and ops)
paths, unreached = compose_paths(
callers=callers, entries={f"{e.path}::{e.function_name}" for e in entries}, targets=sorted(target_functions), max_depth=max_depth
)
path_nodes = {node for record in paths for node, _ in record.hops}
for node in path_nodes:
read_ir(node.rpartition("::")[0])
for node in unreached:
path = node.rpartition("::")[0]
reasons["unreached" if path in recognised else "framework_unrecognised"] += 1
entry_index = {f"{e.path}::{e.function_name}": e for e in entries}
policy = load_declared_policy(_safe_path(root, DEFAULT_POLICY_RELPATH.as_posix()))
dominance = OrderDominance(ctx.texts)
findings: dict[str, dict[str, Any]] = {}
checked: set[Operation] = set()
undischarged: set[Operation] = set()
for record in paths:
needed_files = {path for path, _ in touched} | {node.rpartition("::")[0] for node, _ in record.hops}
path_irs = {path: irs[path] for path in sorted(needed_files) if path in irs}
if len(path_irs) != len(needed_files):
continue
result = check_obligations(
irs=path_irs,
entries=entries,
facts=facts,
flow_facts=flow,
policy=policy,
paths=[record],
dominance=dominance,
store_shapes=(),
min_siblings=3,
extra_witnesses=(),
assume_entries_protected=True,
)
degradations.extend(result.degradations)
target_path, _, target_name = record.hops[-1][0].rpartition("::")
for op in operations.get((target_path, target_name), ()):
checked.add(op)
# Silence from a public premise is not evidence that a check dominated the operation.
if not any(
w.kind in op.requires
and valid_witness(w)
and dominance.dominates(w, op, record)
and (_relevant(w, op) or _on_caller_hop(w, record, op, dominance))
for w in result.discharges
):
undischarged.add(op)
for finding in result.findings:
op = finding.operation
identity = f"obligation:{op.kind}:{op.path}:{op.line}:{finding.missing}"
op_fact = next(fact for fact in facts.operations if fact.id == op.fact_id)
fn = functions[op.path, op.function]
calls = sorted(
{
trailing_name(call.name)
for call in fn.calls
if call.line == op.line and any(call.name == name or call.name.endswith("." + name) for name in op_fact.calls)
}
)
entry = asdict(entry_index[record.entry])
hops = [list(hop) for hop in record.hops]
findings.setdefault(
identity,
dict(
path=op.path,
line=op.line,
family="access_control",
function=op.function,
evidence=[
*finding.evidence,
f"hops: {' -> '.join(node for node, _ in record.hops)}",
f"operation {op.fact_id} ({op.kind}) at {op.path}:{op.line}; missing {finding.missing}",
],
id=identity,
entry=entry,
hops=hops,
operation=dict(id=op.fact_id, kind=op.kind, calls=calls, line=op.line),
missing=finding.missing,
label=finding.label,
),
)
coverage = Coverage(
dict(languages),
dict(frameworks),
len(entries),
len({record.hops[-1][0] for record in paths} & {f"{path}::{name}" for path, name in touched}),
len(set(unreached) & {f"{path}::{name}" for path, name in touched}),
len(checked),
len(checked - undischarged),
dict(reasons),
)
return ProverResult(tuple(findings.values()), coverage, tuple(degradations))
+108 -3
View File
@@ -13,6 +13,7 @@ from .preprocess import FileTarget
MappingKind = Literal["semgrep", "codeql", "differential", "sharp_edge"]
AccessLevel = Literal[
"public",
"unknown",
"authenticated",
"role-restricted",
"contract-only/callback",
@@ -95,14 +96,17 @@ def attach_static_hints(targets: list[FileTarget], hints: list[StaticHint]) -> l
return [replace(target, static_hints=hints_by_path.get(target.path, target.static_hints)) for target in targets]
def analyze_entry_points(root: Path, targets: list[FileTarget]) -> list[EntryPointRecord]:
def analyze_entry_points(
root: Path, targets: list[FileTarget], *, texts: Mapping[str, str] | None = None, include_manifests: bool = True
) -> list[EntryPointRecord]:
records: list[EntryPointRecord] = []
for target in targets:
path = root / target.path
text = path.read_text(errors="ignore")
text = texts[target.path] if texts is not None else path.read_text(errors="ignore")
records.extend(_language_entry_points(target, text))
records.extend(_tag_entry_points(target))
records.extend(_route_manifest_entry_points(root, targets))
if include_manifests:
records.extend(_route_manifest_entry_points(root, targets))
return sorted(records, key=lambda item: (item.path, item.line is None, item.line or 0, item.name))
@@ -171,6 +175,8 @@ def write_entry_points(entry_points: list[EntryPointRecord], path: Path) -> None
def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
if target.language == "java":
return _stapler_entries(target, text) + _jetty_entries(target, text)
if target.language == "python":
return _python_entry_points(target, text)
if target.language in {"javascript", "typescript"}:
@@ -184,6 +190,105 @@ def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointReco
return []
def _java_methods(target: FileTarget, text: str) -> list[tuple[object, str, str]]:
"""Use the CST to keep class declarations, parameters and method bodies separate."""
from .semantic.extra import grammar_for
grammar = grammar_for("java")
if grammar is None:
return []
from tree_sitter import Parser
data = text.encode("utf-8")
tree = Parser(grammar).parse(data)
if tree.root_node.has_error:
return []
methods: list[tuple[object, str, str]] = []
def walk(node: Any, owner: str = "") -> None:
if node.type in {"class_declaration", "interface_declaration"}:
body = node.child_by_field_name("body")
owner = data[node.start_byte : body.start_byte if body else node.end_byte].decode("utf-8")
if node.type == "method_declaration":
body = node.child_by_field_name("body")
if body is not None:
header = data[node.start_byte : body.start_byte].decode("utf-8")
methods.append((node, owner, header))
return
for child in node.named_children:
walk(child, owner)
walk(tree.root_node)
return methods
def _stapler_entries(target: FileTarget, text: str) -> list[EntryPointRecord]:
exposed = re.compile(
r"\b(?:extends|implements)\b[^\{]*\b(?:Descriptor|Action|RootAction|Plugin|UnprotectedRootAction|AbstractDescribableImpl)\b"
)
parameters = re.compile(r"\b(?:StaplerRequest2?|StaplerResponse2?)\b|@(?:QueryParameter|AncestorInPath)\b")
records = []
for raw_node, owner, header in _java_methods(target, text):
node = cast(Any, raw_node)
name_node = node.child_by_field_name("name")
name = name_node.text.decode("utf-8")
params = node.child_by_field_name("parameters").text.decode("utf-8")
class_match = exposed.search(owner)
param_match = parameters.search(params)
if not re.search(r"\bpublic\b", header) or not re.match(r"^do[A-Z]", name) or not (class_match or param_match):
continue
public = bool(re.search(r"\bimplements\b[^\{]*\bUnprotectedRootAction\b", owner))
evidence = ["Stapler public do method"]
if class_match:
evidence.append(class_match.group())
if param_match:
evidence.append("Stapler parameter: " + param_match.group())
records.append(
_entry(
target,
node.start_point[0] + 1,
node.end_point[0] + 1,
name,
name,
"route",
"public" if public else "unknown",
"http_request",
evidence,
[],
access_declared=public,
)
)
return records
def _jetty_entries(target: FileTarget, text: str) -> list[EntryPointRecord]:
records = []
for raw_node, owner, header in _java_methods(target, text):
node = cast(Any, raw_node)
name = node.child_by_field_name("name").text.decode("utf-8")
evidence = []
if re.search(r"\bextends\s+(?:[\w.]+\.)?AbstractHandler\b", owner) and name == "handle":
evidence.append("Jetty AbstractHandler.handle")
if re.search(r"\bextends\s+(?:[\w.]+\.)?(?:HttpServlet|GenericServlet|Servlet)\b", owner) and name in {
"service",
"doGet",
"doPost",
"doPut",
"doDelete",
"doPatch",
"doHead",
"doOptions",
}:
evidence.append("Servlet request method: " + name)
if re.search(r"@(?:[\w.]+\.)?(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Path)\b", header):
evidence.append("JAX-RS request annotation")
if evidence and not re.search(r"\bprivate\b", header):
records.append(
_entry(target, node.start_point[0] + 1, node.end_point[0] + 1, name, name, "route", "unknown", "http_request", evidence, [])
)
return records
# WordPress registers handlers rather than decorating them, and the hook name states the access level:
# `wp_ajax_nopriv_*` is admin-ajax for logged-OUT users and is therefore declared public, while `wp_ajax_*`
# fires only for logged-in ones. That is a DECLARED access level, not a guess from an absent decorator, which
@@ -1,3 +1,13 @@
# Delivery A additionally checked actual calls in Git HEAD objects (2026-10-14):
# p4-plugin: CredentialsHelper.lookupCredentialsInItem, P4PasswordImpl and P4TicketImpl
# call ConnectionFactory.testConnection, ConnectionFactory calls connect, ReviewNotifier
# calls openConnection; zephyr-enterprise-test-management-plugin: ZephyrInstance calls
# getCredentialsFromId and UserService.verifyCredentials; azure-keyvault-plugin:
# AzureKeyVaultGlobalConfiguration.doTestConnection calls listPropertiesOfSecrets;
# rocketchatnotifier-plugin: RocketChatNotifier.doTestConnection calls publish.
# Each listed clone is under /home/mc/ousast-results/valid/train/hist/jenkinsci__*.
# No network documentation lookup was performed for this delivery. The existing
# documented checkAnyPermission row below is retained; it was not found in these HEADs.
# Closed authorization facts for Jenkins/Stapler, Spring Security and Servlet code.
# Checked Git HEAD source objects in /home/mc/ousast-results/valid/train/hist/:
# jenkinsci__analysis-core-plugin: DefaultGraphConfigurationView.checkPermission,
@@ -19,7 +29,7 @@ language = "java"
[[operation]]
id = "jenkins-credentials-read"
kind = "protected_read"
calls = ["lookupCredentials", "findCredentialById"]
calls = ["lookupCredentials", "lookupCredentialsInItem", "findCredentialById", "getCredentialsFromId"]
requires = ["path_guard", "ownership_check", "identity_constraint"]
sensitivity = "high"
@@ -46,6 +56,13 @@ calls = ["executeQuery", "execute", "prepareCall"]
requires = ["path_guard", "ownership_check", "identity_constraint"]
sensitivity = "high"
[[operation]]
id = "jenkins-outbound-connection"
kind = "privileged_action"
calls = ["testConnection", "verifyCredentials", "listPropertiesOfSecrets", "openConnection", "connect", "publish"]
requires = ["path_guard", "ownership_check"]
sensitivity = "high"
[[discharger]]
id = "jenkins-permission-guard"
kind = "path_guard"
@@ -4,7 +4,8 @@ An obligated operation is reported when it is reached (a path record ends at it,
an entry point) and none of the dischargers it accepts dominates it. The label says why the obligation exists: a declared
policy clause (``declared_policy_violation``), the practice of sibling handlers (``consistency_violation``), or, in the
degraded mode without path records, the operation fact alone (``function_local``). With path records and neither a clause
nor a sibling norm, nothing justifies the obligation and nothing is reported (design gating 3). Findings are ``suspicion``;
nor a sibling norm, nothing is reported unless the caller opts into the recognised-entry premise (``unguarded_path``).
Findings are ``suspicion``;
the obligation, the missing discharger, the evidence and the known fix travel as data.
"""
@@ -12,7 +13,7 @@ from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from ...findings import StaticFinding
from ..facts import SemanticFacts
@@ -23,7 +24,7 @@ from .operations import Discharge, Operation, find_discharges, find_operations,
from .policy import DeclaredPolicy
from .siblings import Anomaly, SiblingSet, consistency_anomalies, sibling_sets
LABELS = ("declared_policy_violation", "consistency_violation", "function_local")
LABELS = ("declared_policy_violation", "consistency_violation", "function_local", "unguarded_path")
_ROUTE_LITERAL = re.compile(r"@\w[\w.]*\.(?:route|get|post|put|delete|patch|api_route|websocket)\(\s*['\"]([^'\"]+)['\"]")
@@ -64,6 +65,7 @@ def check_obligations(
store_shapes: Sequence[object],
min_siblings: int,
extra_witnesses: Sequence[Discharge] = (),
assume_entries_protected: bool = False,
) -> ObligationResult:
operations: list[Operation] = []
witnesses: list[Discharge] = list(extra_witnesses)
@@ -85,6 +87,7 @@ def check_obligations(
anomalies, under = consistency_anomalies(sets, min_siblings=min_siblings)
anomaly_index: dict[tuple[str, str], Anomaly] = {(a.handler, a.missing): a for a in anomalies}
entry_handlers = {f"{getattr(e, 'path', '')}::{getattr(e, 'function_name', '')}" for e in entries}
entry_index = {f"{getattr(e, 'path', '')}::{getattr(e, 'function_name', '')}": e for e in entries}
reached = _reached(operations, paths, entry_handlers)
if not paths and operations:
for language in sorted({irs[op.path][0].language for op in operations if op.path in irs}):
@@ -103,9 +106,18 @@ def check_obligations(
degradations.append({"stage": "obligations", "reason": "obligations_store_row_invalid", "records": list(invalid_rows)})
for operation, path_record in reached:
handler = f"{operation.path}::{operation.function}"
hops = getattr(path_record, "hops", ())
first = hops[0][0] if hops and isinstance(hops[0], (tuple, list)) else str(hops[0]) if hops else ""
entry = entry_index.get(str(first))
route = _route_path(operation, texts.get(operation.path, ""))
if assume_entries_protected and entry is not None and route is None:
route = str(getattr(entry, "name", "")) or None
declared_access = policy.route_access(route) if policy is not None and route else None
same = [w for w in witnesses if _relevant(w, operation)]
same = [
w
for w in witnesses
if _relevant(w, operation) or (assume_entries_protected and _on_caller_hop(w, path_record, operation, dominance))
]
dominated_kinds = {w.kind for w in same if valid_witness(w) and dominance.dominates(w, operation, path_record)}
# (a) the operation's own obligation: discharged when any accepted discharger dominates. A declared public route
# waives the path guard here too (Req 4.4), so the operation falls through to the next kind it accepts.
@@ -116,7 +128,17 @@ def check_obligations(
required[0],
)
finding = _finding(
operation, missing, same, anomaly_index.get((handler, missing)), policy, route, declared_access, store_shapes, paths, facts
operation,
missing,
same,
anomaly_index.get((handler, missing)),
policy,
route,
declared_access,
store_shapes,
paths,
facts,
entry if assume_entries_protected else None,
)
if finding is not None:
findings.append(finding)
@@ -127,7 +149,17 @@ def check_obligations(
if kind == "path_guard" and declared_access == "public":
continue # declared public: the guard obligation is waived, the identity one is not
finding = _finding(
operation, kind, same, anomaly_index[(handler, kind)], policy, route, declared_access, store_shapes, paths, facts
operation,
kind,
same,
anomaly_index[(handler, kind)],
policy,
route,
declared_access,
store_shapes,
paths,
facts,
entry if assume_entries_protected else None,
)
if finding is not None:
findings.append(finding)
@@ -152,6 +184,8 @@ def findings_to_static(result: ObligationResult) -> list[StaticFinding]:
if finding.known_fix:
tags.append(f"mechanism:{finding.known_fix}")
why = "; ".join(finding.evidence) if finding.evidence else "the operation fact alone (function-local mode)"
if finding.label == "unguarded_path":
why = "unguarded path from a recognised protected entry; " + why
resource = op.resource or "a resource"
provenance = f" (a {finding.missing} is present but bound from {finding.provenance})" if finding.provenance else ""
statics.append(
@@ -190,6 +224,7 @@ def _finding(
store_shapes: Sequence[object],
paths: Sequence[object],
facts: ObligationFacts,
assumed_entry: object | None = None,
) -> ObligationFinding | None:
wrong = next((w for w in same if w.kind == missing and not valid_witness(w)), None)
declared = policy.resources.get(operation.resource or "") if policy is not None else None
@@ -211,6 +246,16 @@ def _finding(
elif anomaly is not None:
label = "consistency_violation"
evidence = anomaly.siblings + anomaly.evidence[len(anomaly.siblings) :]
elif (
paths and assumed_entry is not None and getattr(assumed_entry, "access_level", "public") != "public" and declared_access != "public"
):
label = "unguarded_path"
evidence = (
f"entry {getattr(assumed_entry, 'kind', '')}: {getattr(assumed_entry, 'path', '')}::"
f"{getattr(assumed_entry, 'function_name', '')}",
*(str(item) for item in getattr(assumed_entry, "access_evidence", ())),
"no declared policy clause and no sibling norm; the prover assumes a recognised entry is protected",
)
elif paths:
return None # path-aware mode: without a clause or a sibling norm, nothing justifies the obligation (gating 3)
else:
@@ -245,6 +290,20 @@ def _reached(operations: Sequence[Operation], paths: Sequence[object], entry_han
return reached
def _on_caller_hop(witness: Discharge, path_record: object | None, operation: Operation, dominance: Dominance) -> bool:
"""Use the same dominance contract at the caller's outgoing call in opt-in mode."""
for node, call_line in getattr(path_record, "hops", ())[:-1]:
if node != f"{witness.path}::{witness.function}":
continue
if witness.scope in {"router", "decorator"}:
return True
if witness.kind not in {"path_guard", "ownership_check"} or call_line is None:
return False
caller_operation = replace(operation, path=witness.path, function=witness.function, line=call_line)
return dominance.dominates(witness, caller_operation, None)
return False
def _relevant(witness: Discharge, operation: Operation) -> bool:
return witness.scope == "hop" or (witness.path, witness.function) == (operation.path, operation.function)
@@ -0,0 +1,53 @@
"""Compose bounded caller chains whose first hop is a recognised entry."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class PathRecord:
hops: tuple[tuple[str, int | None], ...]
entry: str
complete: bool
def compose_paths(
*,
callers: Callable[[str, str], Sequence[tuple[str, int]]],
entries: set[str],
targets: Sequence[tuple[str, str]],
max_depth: int = 3,
fan_out: int = 25,
) -> tuple[list[PathRecord], list[str]]:
"""Walk at most max_depth caller edges. The adapter resolves sites to path::function nodes.
Each caller hop holds the line of its outgoing call; the final target has no outgoing call.
Stop at the first entry, and never revisit a node within a chain.
"""
if max_depth < 0 or fan_out < 1:
raise ValueError("invalid path bounds")
found: dict[tuple[tuple[str, int | None], ...], PathRecord] = {}
unreached: list[str] = []
for path, function in dict.fromkeys(targets):
target = f"{path}::{function}"
reached = False
pending: list[tuple[tuple[str, int | None], ...]] = [((target, None),)]
while pending:
hops = pending.pop()
node = hops[0][0]
if node in entries:
found.setdefault(hops, PathRecord(hops, node, True))
reached = True
continue
if len(hops) - 1 >= max_depth:
continue
node_path, _, node_function = node.rpartition("::")
seen = {hop[0] for hop in hops}
for caller, line in list(dict.fromkeys(callers(node_path, node_function)))[:fan_out]:
if caller not in seen:
pending.append(((caller, line), *hops))
if not reached:
unreached.append(target)
return list(found.values()), unreached