feat(pre-push-safety-net): connect change context to ranked questions

This commit is contained in:
Marius Ciepluch
2026-09-13 10:41:32 +02:00
parent 546db24b8e
commit ebad83009e
11 changed files with 1570 additions and 17 deletions
+49 -3
View File
@@ -95,7 +95,8 @@
evidenceS: String,
fileS: String,
depthS: String,
boundedS: String
boundedS: String,
contextS: String = ""
): List[ujson.Obj] = {
// Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer
@@ -692,7 +693,51 @@
"cleansedOnCall" -> sink.argument.ast.l.exists(node => sanitizesHere(node))
)
}
return summary :: perSink
// Export locations already used by this query, for change attribution only. No new
// source/sink/guard inference and no change to the evidence vector or its weights.
val contextRows = if (contextS != "true") Nil else {
def location(m: io.shiftleft.codepropertygraph.generated.nodes.Method, kind: String): ujson.Obj =
ujson.Obj("kind" -> "context_method", "path" -> m.filename, "function" -> m.name,
"startLine" -> m.lineNumber.getOrElse(-1), "endLine" -> m.lineNumberEnd.getOrElse(-1),
"relationship" -> kind)
val scoped = cpg.method.filterNot(_.isExternal).filter(m => inScope(m)).l
val methods = scoped.map(m => location(m, if (labeledMethods.exists(_.id == m.id)) "entry" else "callee"))
// Field carrying is deliberately same-file in the existing abstraction. Retain
// its file context conservatively, never claim every method carries the value.
val fieldFiles = if (fieldCarriedKind.isEmpty) Set.empty[String] else
cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(_.method.filename).toSet
val fields = fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(m => location(m, "field")))
val callbacks = if (hookApply.isEmpty) Nil else cpg.call.filter(c => hookApply.contains(c.name))
.filter(c => inScope(c.method)).l.flatMap { call =>
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
hookTable.getOrElse(hook, Nil).flatMap(name => cpg.method.nameExact(name).filterNot(_.isExternal).l)
}.distinct
val hooks = callbacks.map(m => location(m, "hook"))
// Export sanitizer locations as guard context, not proof of discharge. Removed
// guards are retained through base spans and unchanged-line correspondence.
val guards = scoped.filter(_.ast.l.exists(sanitizesHere)).map(m => location(m, "guard"))
// Reachability remains bounded. External/dynamic calls and calls outside the
// current reachability set cannot establish a complete negative for a change.
// A modeled argument does not summarize its consumer: unknown(req.body),
// unknown(eval(...)) and unknown("escape") all retain an external boundary.
// Only the directly invoked fact-named operation can use this exemption.
def directlyModeled(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean =
(sinkNames ++ sanitizerNames ++ sourcePatterns).exists { raw =>
val name = raw.stripSuffix("(")
c.name == name || c.code.startsWith(name + "(")
}
val missing = scoped.exists(m => m.ast.isCall.l.exists { c =>
!c.name.startsWith("<operator>") && !directlyModeled(c) && {
val destinations = c.callee.l
destinations.isEmpty || destinations.exists(d => d.isExternal || !inScope(d))
}
})
val boundaries = if (scoped.isEmpty) List(ujson.Obj("kind" -> "context_boundary",
"reason" -> "context_scope_empty:contributor-scan")) else if (missing) List(ujson.Obj("kind" -> "context_boundary",
"reason" -> "dynamic_external_or_depth_context_unresolved:contributor-scan")) else Nil
ujson.Obj("kind" -> "context_summary") :: (methods ++ fields ++ hooks ++ guards ++ boundaries)
}
return summary :: (perSink ++ contextRows)
}
val rows = sinkCalls.l.flatMap { sink =>
@@ -797,7 +842,8 @@
evidence,
field("file"),
field("callDepth"),
field("boundedSinks")
field("boundedSinks"),
field("contextEvidence")
): _*
)
}
+81 -1
View File
@@ -18,9 +18,12 @@ Two restraints are deliberate:
from __future__ import annotations
from collections.abc import Sequence
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from .contracts import AffectedRelationship, ChangeContext, ExecutionBudget, QuestionIdentity
from .specs import config_specs, dominance_specs, taint_specs
# From `mapping.AccessLevel`. A publicly reachable handler at a trust boundary is where a missing check costs
@@ -174,3 +177,80 @@ def _families(language: str, *, has_handler: bool) -> tuple[str, ...]:
if has_handler:
families |= set(dominance_specs(language=language))
return tuple(sorted(families))
def affected_context(
context: ChangeContext,
questions: Sequence[QuestionIdentity],
rows_by_question: Mapping[QuestionIdentity, Sequence[Mapping[str, object]]],
root: Path,
execution_budget: ExecutionBudget | None = None,
) -> tuple[ChangeContext, set[QuestionIdentity]]:
"""Attach existing graph locations to changes; never select or reprioritize a question.
Base spans are file-level hints unless an unchanged line anchors them in the head.
A method relationship is context, not proof of taint, guard removal or novelty.
"""
relationships = list(context.relationships)
gaps = list(context.unresolved_boundaries)
affected_gaps: set[QuestionIdentity] = set()
changed = {context.decode_path(p) for p in context.changed_paths}
if context.deleted_paths:
gaps.append("deleted_dependency_projection_unavailable:contributor-scan")
if changed.intersection(context.decode_path(p) for p in context.declaration_paths):
gaps.append("configuration_dependency_projection_unavailable:contributor-scan")
if context.base_revision is None:
gaps.append("comparison_base_unavailable")
inherited_gap = bool(gaps)
renamed = {context.decode_path(r.base_path): context.decode_path(r.head_path) for r in context.renames}
for identity in questions:
if execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic:
gaps.append("change_context_deadline_exhausted")
affected_gaps.update(questions)
break
rows = rows_by_question.get(identity, ())
local_gaps = []
if not any(r.get("kind") == "context_summary" for r in rows):
local_gaps.append("context_projection_unavailable:contributor-scan")
elif not any(r.get("kind") == "context_method" for r in rows):
local_gaps.append("context_scope_empty:contributor-scan")
for row in rows:
if row.get("kind") == "context_boundary":
local_gaps.append(str(row.get("reason", "context_unresolved")))
if row.get("kind") != "context_method":
continue
path = Path(str(row.get("path", "")))
try:
absolute = path if path.is_absolute() else root / path
path_text = absolute.resolve().relative_to(root.resolve()).as_posix()
if not absolute.is_file():
raise ValueError("missing context source")
start, end = int(str(row.get("startLine", 0))), int(str(row.get("endLine", 0)))
if start < 1 or end < start:
raise ValueError("missing method extent")
except (ValueError, TypeError, OSError):
local_gaps.append("context_location_unavailable")
continue
evidence = []
for span in context.spans:
old_path = context.decode_path(span.path)
match_path = renamed.get(old_path, old_path) if span.side == "base" else old_path
if match_path != path_text:
continue
if span.side == "base" or (span.start_line <= end and span.end_line >= start):
evidence.append(f"{span.side}_span:{old_path}:{span.start_line}-{span.end_line}")
for anchor in context.line_correspondences:
if context.decode_path(anchor.head_path) == path_text and start <= anchor.head_start_line <= end:
evidence.append(f"unchanged_line:{anchor.head_start_line};lexical_only")
if not evidence or path_text not in changed | set(renamed.values()):
continue
function = str(row.get("function", "")) or None
source = QuestionIdentity(identity.unit, identity.language, path_text, function, identity.family)
relation = AffectedRelationship(source, identity, str(row.get("relationship", "method")), tuple(evidence))
if relation not in relationships:
relationships.append(relation)
if local_gaps or inherited_gap:
affected_gaps.add(identity)
gaps.extend(reason + ":" + identity.question_id for reason in local_gaps)
return replace(context, relationships=tuple(relationships), unresolved_boundaries=tuple(dict.fromkeys(gaps))), affected_gaps
+88 -8
View File
@@ -30,13 +30,22 @@ from typing import Any
from ..cpg.backend import CpgResult
from .candidates import enumerate_candidates
from .config_value import request_params as config_params
from .contracts import DeferredQuestion, ExecutionBudget, FamilyCoverage, QuestionIdentity, QuestionOutcome, RankedQuestion, ScopeDecision
from .contracts import (
ChangeContext,
DeferredQuestion,
ExecutionBudget,
FamilyCoverage,
QuestionIdentity,
QuestionOutcome,
RankedQuestion,
ScopeDecision,
)
from .dominance import request_params as dominance_params
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
from .ladder import Rung
from .layout import with_layout
from .pipeline import ModelFinding, scan_region
from .regions import MODULE_SCOPE, ScanRegion
from .regions import MODULE_SCOPE, ScanRegion, affected_context
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
from .taint import request_params as taint_params
from .taxonomy import load_families
@@ -117,6 +126,7 @@ class ModelScanResult:
# could have been found was skipped. Reported so the request count is legible.
requests_pruned: int = 0
change_context: ChangeContext | None = None
scope: ScopeDecision | None = None
question_outcomes: tuple[QuestionOutcome, ...] = ()
family_coverage: tuple[FamilyCoverage, ...] = ()
@@ -139,6 +149,7 @@ def scan_repository(
ranking_mode: str | None = None,
unit: str = "repository",
population_complete: bool = False,
change_context: ChangeContext | None = None,
) -> ModelScanResult:
"""Run with an optional transaction deadline; graph ownership ends on every exit."""
if ranking_mode not in (None, "static", "evidence"):
@@ -164,6 +175,7 @@ def scan_repository(
ranking_mode=mode,
unit=unit,
population_complete=population_complete,
change_context=change_context,
execution_budget=execution_budget,
owned=owned,
)
@@ -197,6 +209,7 @@ def _scan_repository_impl(
ranking_mode: str,
unit: str,
population_complete: bool,
change_context: ChangeContext | None,
) -> ModelScanResult:
"""Arbitrate a repository's regions under one CPG and one budget."""
limits = budget if budget is not None else ScanBudget()
@@ -213,8 +226,11 @@ def _scan_repository_impl(
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
build_seconds = round(time.monotonic() - build_started, 2)
if cpg is None:
scope, _ = _scope_work(regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed")
scope, _ = _scope_work(
regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed", change_context=change_context
)
return ModelScanResult(
change_context=change_context,
scope=scope,
family_coverage=_family_coverage(scope, ()),
by_rung=_empty_tally(),
@@ -273,6 +289,7 @@ def _scan_repository_impl(
tiers: list[tuple[str, str, str, int]] = []
per_kind: dict[str, float] = {}
evidence_by_pair: dict[tuple[str, str, str], Evidence] = {}
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] = {}
if _within_deadline(execution_budget) and limits.tiering and callable(getattr(cpg, "run_batch", None)):
evidence_candidates = (
ordered_regions[: (limits.evidence_regions or len(ordered_regions))]
@@ -280,7 +297,14 @@ def _scan_repository_impl(
else ordered_regions[: limits.max_regions]
)
tier_started = time.monotonic()
evidence_by_pair, failed = _evidence_pass(cpg, _collect(evidence_candidates), hooks, degradations=degradations)
evidence_by_pair, failed = _evidence_pass(
cpg,
_collect(evidence_candidates),
hooks,
degradations=degradations,
context_rows=context_rows if change_context is not None else None,
unit=unit,
)
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
if failed:
degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": failed})
@@ -288,6 +312,12 @@ def _scan_repository_impl(
if limits.order_by_evidence and evidence_by_pair:
ordered_regions = order_by_evidence(ordered_regions, evidence_by_pair)
context_gaps: set[QuestionIdentity] = set()
if change_context is not None:
change_context, context_gaps = affected_context(
change_context, [_identity(unit, r, f) for r in ordered_regions for f in r.families], context_rows, root, execution_budget
)
# Authoritative selection is made once, from the actual final ranker order.
# Stable question IDs are the IDs sent to every execution request below.
scope_limits = (
@@ -295,7 +325,16 @@ def _scan_repository_impl(
if any(d.get("reason") in {"files_unparsed", "cpg_empty", "cpg_sharded"} for d in degradations)
else limits
)
scope, work = _scope_work(ordered_regions, evidence_by_pair, scope_limits, unit, ranking_mode, population_complete)
scope, work = _scope_work(
ordered_regions,
evidence_by_pair,
scope_limits,
unit,
ranking_mode,
population_complete,
change_context=change_context,
context_gaps=context_gaps,
)
outcomes: dict[str, QuestionOutcome] = {}
# Phase 2: ONE invocation per query kind. JVM startup dominates a repository scan -- a call per region
@@ -429,6 +468,8 @@ def _scan_repository_impl(
"deadline_exhausted" if not _within_deadline(execution_budget) else "query_unanswered",
_rows_json(rows_by_id[rid]) if answered else None,
)
if question.identity in context_gaps and outcomes[rid].status == "completed":
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="change_context_incomplete")
if graph_gaps and outcomes[rid].status == "completed":
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete")
scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps))))
@@ -457,6 +498,7 @@ def _scan_repository_impl(
scope=scope,
question_outcomes=ordered_outcomes,
family_coverage=_family_coverage(scope, ordered_outcomes),
change_context=change_context,
)
@@ -477,6 +519,8 @@ def _scope_work(
population_complete: bool,
*,
failure: str | None = None,
change_context: ChangeContext | None = None,
context_gaps: set[QuestionIdentity] | None = None,
) -> tuple[ScopeDecision, list[tuple[str, ScanRegion, ArbiterSpec]]]:
"""Consume the ranker's final order once; the returned work IS selected scope."""
selected: list[RankedQuestion] = []
@@ -485,6 +529,8 @@ def _scope_work(
boundaries: list[str] = [] if population_complete else ["population_not_asserted_complete"]
if failure:
boundaries.append(failure)
if change_context is not None:
boundaries.extend(change_context.unresolved_boundaries)
for index, region in enumerate(regions):
for family in region.families:
identity = _identity(unit, region, family)
@@ -498,6 +544,25 @@ def _scope_work(
boundaries.append("evidence_unknown:" + identity.question_id)
else:
facts += ("taint_evidence_not_applicable",)
if change_context is not None:
facts += (
"change_sites="
+ _rows_json(
{
"base_revision": change_context.base_revision,
"head_revision": change_context.head_revision,
"changed_paths": change_context.changed_paths,
"deleted_paths": change_context.deleted_paths,
"declaration_paths": change_context.declaration_paths,
"path_encoding": change_context.path_encoding,
}
),
)
facts += tuple(
"affected_relationship=" + _rows_json(r.to_payload()) for r in change_context.relationships if r.target == identity
)
if identity in (context_gaps or set()):
facts += ("change_context_incomplete",)
reason = (
"unsupported_family"
if spec is None
@@ -506,7 +571,11 @@ def _scope_work(
"region_budget"
if index >= limits.max_regions
else "tier_zero"
if limits.prune_tier0 and isinstance(spec, TaintSpec) and vector is not None and vector.tier == TIER_EXCLUDE
if limits.prune_tier0
and identity not in (context_gaps or set())
and isinstance(spec, TaintSpec)
and vector is not None
and vector.tier == TIER_EXCLUDE
else None
)
)
@@ -568,7 +637,13 @@ def _collect(regions: Sequence[ScanRegion]) -> list[tuple[str, ScanRegion, Arbit
def _evidence_pass(
cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str, *, degradations: list[Mapping[str, object]] | None = None
cpg: Any,
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]],
hooks: str,
*,
degradations: list[Mapping[str, object]] | None = None,
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None,
unit: str = "repository",
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
@@ -578,7 +653,10 @@ def _evidence_pass(
grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {})
if not grouped_taint:
return {}, 0
requests = {rid: {**dict(params), "evidenceOnly": "true"} for rid, params in grouped_taint.items()}
requests = {
rid: {**dict(params), "evidenceOnly": "true", **({"contextEvidence": "true"} if context_rows is not None else {})}
for rid, params in grouped_taint.items()
}
answered = cpg.run_batch("taint", requests)
if answered is None:
return {}, len(requests)
@@ -601,6 +679,8 @@ def _evidence_pass(
if pair is None or rid not in requests or not isinstance(rows, list):
continue
region, spec = pair
if context_rows is not None:
context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)]
evidence = evidence_from_rows(
rows,
entry=_is_entry_point(region),