feat(learning-harness): absence rows travel with their registration

Eight of the ten vendored access-control rows had no named handler at all,
so every one of them would have scored `missing_context` forever rather
than as a detector miss. After the re-harvest nine are named and the tenth
states its reason.

The handler-context mode now carries the class its framework registers,
multi-line decorators, and commented-out guards -- `#@token_required`
above a ThreatByte handler is the entire bug. Where the registration lives
in another document (connexion's `operationId`, an express router), a
recipe names `context = [...]`, the harvester writes that stanza per side,
and the pair materializes both files at their repository-relative paths so
the entry-point mapper sees the layout the project has. `harvest_recipe`
fetches every blob before the first write and the driver takes `--report`,
so a failed fetch leaves a reviewed excerpt exactly as it was.

Two defects the re-harvest exposed: `.mjs` was not a known suffix, so the
one pair vendored as an ES module had no file target at all; and
`_python_route_access` matched auth words inside decorator prose, reading
`@ns.doc(description='... without proper authorization checks')` as a
guard. Recorded in the hygiene artifact: nine of the ten duplicate groups
sit on both sides of the split, which the repository-level split cannot
see.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-06 12:39:55 +02:00
co-authored by Claude Opus 5
parent 7a33756a7e
commit c2d9c4ad01
48 changed files with 1613 additions and 11776 deletions
+39 -5
View File
@@ -132,6 +132,10 @@ class PairCase:
split_declared: bool = True # False when the catalog row named no split, so the split helper may assign one
fix_date: str = "" # ISO date of the fixing commit; the catalog generator fills it, the split helper orders by it
recipe: tuple[tuple[str, object], ...] = () # pointer pairs carry their harvest recipe instead of excerpts
# (relpath, vuln document, fixed document): files that register the handler from outside its own module — an
# OpenAPI operation, a router table. Materialized beside the excerpt so the mapper can name the handler at all
# (learning-harness Req 10.2).
context_files: tuple[tuple[str, Path, Path], ...] = ()
@dataclass(frozen=True)
@@ -264,6 +268,7 @@ def _load_catalog_file(path: Path) -> tuple[PairCase, ...]:
vuln_file, fixed_file = _resolve(root, str(item["vuln"])), _resolve(root, str(item["fixed"]))
else:
vuln_file, fixed_file, recipe = _pointer_files(item, slice_name, name)
context_files = _context_files(item, root, name)
cases.append(
PairCase(
name=name,
@@ -291,11 +296,30 @@ def _load_catalog_file(path: Path) -> tuple[PairCase, ...]:
reviewer=reviewer,
vendored=vendored,
recipe=recipe,
context_files=context_files,
)
)
return tuple(cases)
def _context_files(item: dict[str, object], root: Path, name: str) -> tuple[tuple[str, Path, Path], ...]:
rows = item.get("context")
if not rows:
return ()
if not isinstance(rows, list):
raise CatalogError(f"pair {name}: context must be a list of tables")
out: list[tuple[str, Path, Path]] = []
for row in rows:
if not isinstance(row, dict) or not {"relpath", "vuln", "fixed"} <= set(row):
raise CatalogError(f"pair {name}: each context row needs relpath, vuln and fixed")
vuln, fixed = _resolve(root, str(row["vuln"])), _resolve(root, str(row["fixed"]))
for path in (vuln, fixed):
if not path.is_file():
raise CatalogError(f"pair {name}: context document {path} is missing; re-harvest the row")
out.append((str(row["relpath"]), vuln, fixed))
return tuple(out)
def pair_cache_dir() -> Path:
"""Cache root for pointer pairs (Req 10.2); outside the repository unless the operator points it elsewhere."""
return Path(os.environ.get(PAIR_CACHE_ENV) or DEFAULT_PAIR_CACHE).expanduser()
@@ -395,8 +419,8 @@ def _review_tier(item: dict[str, object], slice_name: str, name: str) -> tuple[s
def evaluate_pair(case: PairCase, *, hunter: HunterScan | None = None, ruleset: tuple[PatternRule, ...] | None = None) -> PairOutcome:
with tempfile.TemporaryDirectory(prefix="ousast-pair-") as scratch:
vuln_root = _materialize(Path(scratch) / "vuln", case.vuln_file, case.relpath)
fix_root = _materialize(Path(scratch) / "fixed", case.fixed_file, case.relpath)
vuln_root = _materialize_side(Path(scratch) / "vuln", case, side="vuln")
fix_root = _materialize_side(Path(scratch) / "fixed", case, side="fixed")
if hunter is not None:
return _evaluate_hunter_pair(case, vuln_root, fix_root, hunter)
if case.slice in OVERLAY_SLICES:
@@ -609,7 +633,9 @@ def duplicate_groups(cases: Sequence[PairCase]) -> tuple[tuple[str, ...], ...]:
digest = _body_digest(case.vuln_file) if case.vendored else None
if digest is not None:
by_digest.setdefault(digest, []).append(case.name)
return tuple(tuple(sorted(names)) for _, names in sorted(by_digest.items()) if len(names) > 1)
# Ordered by name, not by digest: the report is a committed artifact and a re-harvest that changes one excerpt
# must not reshuffle rows it did not touch.
return tuple(sorted(tuple(sorted(names)) for names in by_digest.values() if len(names) > 1))
def split_by_repository(cases: Sequence[PairCase], *, holdout_fraction: float = 0.5) -> tuple[tuple[PairCase, ...], SplitReport]:
@@ -783,6 +809,14 @@ def _materialize(dest: Path, source: Path, relpath: str) -> Path:
return dest
def _materialize_side(dest: Path, case: PairCase, *, side: str) -> Path:
"""The excerpt plus the documents that register it, at their own repository-relative paths."""
root = _materialize(dest, case.vuln_file if side == "vuln" else case.fixed_file, case.relpath)
for relpath, vuln_doc, fixed_doc in case.context_files:
_materialize(root, vuln_doc if side == "vuln" else fixed_doc, relpath)
return root
# --- scans -----------------------------------------------------------------
@@ -864,8 +898,8 @@ def _obligation_scan(root: Path, targets: Sequence[FileTarget]) -> list[StaticFi
def _inventory_only(case: PairCase, ruleset: tuple[PatternRule, ...] | None = None) -> PairOutcome:
with tempfile.TemporaryDirectory(prefix="ousast-pair-inv-") as scratch:
vuln_root = _materialize(Path(scratch) / "vuln", case.vuln_file, case.relpath)
fix_root = _materialize(Path(scratch) / "fixed", case.fixed_file, case.relpath)
vuln_root = _materialize_side(Path(scratch) / "vuln", case, side="vuln")
fix_root = _materialize_side(Path(scratch) / "fixed", case, side="fixed")
return _evaluate_inventory_pair(case, vuln_root, fix_root, ruleset=ruleset)