feat(contributor-scan): give each family its own change context (M1b 12.2)

Access control and configuration could not complete their change context on any
repository, which left 231 of 436 questions unresolved on the measured Node case
and made two of the three shipped families unable to answer.

The gap was larger than a missing query block. The evidence pass dispatches taint
only, because only taint has an evidence vector to rank by, so these families were
never asked for context at all. A new context pass asks each family with the flag
set.

Each query describes its own scope, which the owner research required and which
matters: copying taint's projection would describe a reachability walk these
queries never perform. Dominance exports the file's methods labelled operation,
guard or sibling, because its claim is that a file contradicts itself. Configuration
exports the file's methods labelled setting or sibling, because it asks what a file
declares. Both report an empty scope explicitly rather than implying one.

A family whose context query answers nothing keeps no context and stays unresolved,
so none can complete on another family's context. A control asserts that directly.

Confirmed on a real NodeGoat graph through the production backend: both families
produce a context summary and seven scoped methods where they previously produced
nothing. All seven are siblings for that file, which is the honest answer since it
declares no access-control operation and no matched setting.

1,405 tests passed, nine skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-17 11:19:30 +02:00
co-authored by Claude Opus 5
parent 4ee31142ab
commit a91551e547
6 changed files with 223 additions and 11 deletions
+29 -5
View File
@@ -20,7 +20,8 @@
function: String = "",
file: String = "",
requests: String = "",
requestsFile: String = ""
requestsFile: String = "",
contextEvidence: String = ""
) = {
importCpg(cpgFile)
@@ -33,7 +34,7 @@
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
def rowsFor(settingsS: String, functionS: String, fileS: String): List[ujson.Obj] = {
def rowsFor(settingsS: String, functionS: String, fileS: String, contextS: String = ""): List[ujson.Obj] = {
val names = split(settingsS)
val function = functionS
@@ -63,7 +64,30 @@
)
}
rows
// Change-attribution context for THIS family, describing the scope this query actually used.
// Configuration asks which settings a file declares, so the scope is that file's own methods,
// and the ones holding a matched setting are what the claim is about. It is not taint's
// reachability projection and not dominance's sibling comparison; each family's context has to
// describe its own query or it is describing work nobody did.
def contextRowsFor(): List[ujson.Obj] = {
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 = {
val all = cpg.method.filterNot(_.isExternal).l
val inFile = if (fileS.isEmpty) all else all.filter(_.filename.endsWith(fileS))
if (function.isEmpty) inFile else inFile.filter(_.name == function)
}
val settingIds = calls.map(_.method.id).toSet
val located = scoped.map(m => location(m, if (settingIds.contains(m.id)) "setting" else "sibling"))
val boundaries =
if (scoped.isEmpty) List(ujson.Obj("kind" -> "context_boundary", "reason" -> "context_scope_empty:contributor-scan"))
else Nil
ujson.Obj("kind" -> "context_summary") :: (located ++ boundaries)
}
if (contextS == "true") rows ++ contextRowsFor() else rows
}
println("---OUSAST-CPG-BEGIN---")
@@ -71,7 +95,7 @@
val parsed = ujson.read(requestsJson).obj
val answers = parsed.map { case (id, req) =>
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
id -> ujson.Arr(rowsFor(field("settings"), field("function"), field("file")): _*)
id -> ujson.Arr(rowsFor(field("settings"), field("function"), field("file"), field("contextEvidence")): _*)
}
// A census of the graph, under a key no request id can collide with (ids are numbers). A frontend can
// fail every file and STILL exit 0 with a valid, empty CPG -- `joern-parse` does not even propagate the
@@ -81,7 +105,7 @@
val census = ujson.Arr(ujson.Obj("methods" -> cpg.method.size.toString, "files" -> cpg.file.size.toString, "file_names" -> ujson.Arr.from(cpg.file.name.l)))
println(ujson.write(ujson.Obj.from(answers.toSeq :+ ("__census__" -> census))))
} else {
println(ujson.write(ujson.Arr(rowsFor(settings, function, file): _*)))
println(ujson.write(ujson.Arr(rowsFor(settings, function, file, contextEvidence): _*)))
}
println("---OUSAST-CPG-END---")
}
+36 -5
View File
@@ -43,7 +43,8 @@
operationRequires: String = "",
dischargersByKind: String = "",
requests: String = "",
requestsFile: String = ""
requestsFile: String = "",
contextEvidence: String = ""
) = {
importCpg(cpgFile)
@@ -66,7 +67,8 @@
functionS: String,
fileS: String,
requiresS: String,
byKindS: String
byKindS: String,
contextS: String = ""
): List[ujson.Obj] = {
val opNames = split(operationsS)
@@ -138,6 +140,34 @@
if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
}
// Change-attribution context for THIS family, describing the scope this query actually used.
// It is deliberately not taint's projection: dominance compares sibling operations across a
// file, so the file's own methods are the scope, the methods holding a matched operation are
// what the claim is about, and the methods holding an applicable discharger are what could
// discharge it. Exporting an entry/callee reachability walk here would describe work this
// query never performs.
def contextRowsFor(): List[ujson.Obj] = {
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 =
if (fileS.isEmpty) cpg.method.filterNot(_.isExternal).l
else cpg.method.filterNot(_.isExternal).filter(_.filename.endsWith(fileS)).l
val operationIds = opCalls.map(_.method.id).toSet
val guardIds = scoped.filter(m => m.ast.isCall.code.l.exists(code => mentionsGuard(code))).map(_.id).toSet -- operationIds
val rows = scoped.map { m =>
val kind = if (operationIds.contains(m.id)) "operation" else if (guardIds.contains(m.id)) "guard" else "sibling"
location(m, kind)
}
// An empty scope is reported, never implied: a file the frontend produced no methods for
// cannot support a statement about that file contradicting itself.
val boundaries =
if (scoped.isEmpty) List(ujson.Obj("kind" -> "context_boundary", "reason" -> "context_scope_empty:contributor-scan"))
else Nil
ujson.Obj("kind" -> "context_summary") :: (rows ++ boundaries)
}
val rows = opCalls.map { op =>
val method = op.method
@@ -166,7 +196,7 @@
)
}
rows
if (contextS == "true") rows ++ contextRowsFor() else rows
}
println("---OUSAST-CPG-BEGIN---")
@@ -181,7 +211,8 @@
field("function"),
field("file"),
field("operationRequires"),
field("dischargersByKind")
field("dischargersByKind"),
field("contextEvidence")
): _*
)
}
@@ -193,7 +224,7 @@
val census = ujson.Arr(ujson.Obj("methods" -> cpg.method.size.toString, "files" -> cpg.file.size.toString, "file_names" -> ujson.Arr.from(cpg.file.name.l)))
println(ujson.write(ujson.Obj.from(answers.toSeq :+ ("__census__" -> census))))
} else {
println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function, file, operationRequires, dischargersByKind): _*)))
println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function, file, operationRequires, dischargersByKind, contextEvidence): _*)))
}
println("---OUSAST-CPG-END---")
}
+37
View File
@@ -809,9 +809,46 @@ def _evidence_pass(
for key, languages in languages_by_pair.items():
if len(languages) > 1:
out.pop(key, None)
if context_rows is not None:
_context_pass(cpg, work, context_rows, unit=unit)
return out, len(requests) - len(out)
def _context_pass(
cpg: Any,
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]],
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]],
*,
unit: str,
) -> None:
"""Collect change context for the families the evidence pass does not ask.
The evidence pass dispatches taint only, because only taint has an evidence vector to rank by.
Change context is a different question and every family needs its own: without this, dominance
and configuration questions reported `context_projection_unavailable` on every repository and
could never complete, which left 231 of 436 questions unresolved on the measured Node case.
Each family's own query supplies its own scope. A failure here leaves the family's context
absent, which the caller already treats as unresolved, so it degrades exactly as before.
"""
grouped = _grouped(list(work))
by_rid = {rid: (region, spec) for rid, region, spec in work}
for kind in ("dominance", "config"):
requests = {rid: {**dict(params), "contextEvidence": "true"} for rid, params in grouped.get(kind, {}).items()}
if not requests:
continue
answered = cpg.run_batch(kind, requests)
if answered is None:
continue
answered.pop("__census__", None)
for rid, rows in answered.items():
pair = by_rid.get(rid)
if pair is None or not isinstance(rows, list):
continue
region, spec = pair
context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)]
def order_by_evidence(regions: Sequence[ScanRegion], evidence: Mapping[tuple[str, str, str], Evidence]) -> list[ScanRegion]:
"""Regions by their best pair's (tier, score), then by the static order they arrived in.