Merge upstream/main into feat/r-language-support

Upstream took SCHEMA_BUMP 133 to 137, so the R provider bump moves to 138.
The pin test and the taken-versions list are updated to match. The import-target
benchmark keeps both the R and Rust arms in the heap-budgeted and context
language lists, and the tree-sitter readiness tests keep the R grammar checks
against the reworked report (R is now a peer-patch grammar and the held-grammar
counts changed).
This commit is contained in:
Mick Cooney 2026-10-11 08:50:04 +01:00
commit 0d4e2b36ca
400 changed files with 1421926 additions and 1352385 deletions

56
.github/actions/ec2-runner/action.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Dedicated evolution runner power
description: Use the protected environment's existing OIDC role to start or stop its EC2 runner.
inputs:
operation:
description: start or stop
required: true
role-arn:
description: Existing scoped AWS OIDC role
required: true
instance-id:
description: Existing dedicated EC2 instance
required: true
region:
description: Instance region
required: true
stop-schedule:
description: Existing EventBridge weekly stop in DAY HH:MM UTC format
required: true
outputs:
cleanup_required:
description: The instance was claimed and must be stopped, including on startup failure.
value: ${{ steps.power.outputs.cleanup_required }}
stop_deadline_epoch:
description: Earliest configured stop or 24-hour cap, as Unix seconds.
value: ${{ steps.power.outputs.stop_deadline_epoch }}
runs:
using: composite
steps:
- name: Check startup configuration and stop window
if: inputs.operation == 'start'
shell: bash
env:
GITNEXUS_EVOLUTION_AWS_ROLE_ARN: ${{ inputs.role-arn }}
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID: ${{ inputs.instance-id }}
GITNEXUS_EVOLUTION_AWS_REGION: ${{ inputs.region }}
GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC: ${{ inputs.stop-schedule }}
run: python3 .github/scripts/evolution-runner.py check
- name: Assume the existing protected AWS role
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ inputs.role-arn }}
aws-region: ${{ inputs.region }}
role-duration-seconds: 1800
mask-aws-account-id: true
retry-max-attempts: 3
unset-current-credentials: true
- name: Change power and verify the resulting state
id: power
shell: bash
env:
GITNEXUS_EVOLUTION_AWS_ROLE_ARN: ${{ inputs.role-arn }}
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID: ${{ inputs.instance-id }}
GITNEXUS_EVOLUTION_AWS_REGION: ${{ inputs.region }}
GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC: ${{ inputs.stop-schedule }}
# The expression emits only literal command names, never supplied text.
run: python3 .github/scripts/evolution-runner.py ${{ inputs.operation == 'start' && 'start' || 'stop' }}

View file

@ -0,0 +1,42 @@
name: Require paired agent release evidence
description: Verify fresh agent evidence for the checked out commit, measured by main's current evaluator
runs:
using: composite
steps:
- name: Setup locked evaluation validator
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: '0.11.23'
python-version: '3.13'
# Publishing must not restore caches populated by other runs.
enable-cache: false
# Release evaluation always measures with main's harness. Validate with that
# evaluator, not the release commit's older eval/ tree; the validator then
# rejects evidence whose harness differs from it in any evaluation input.
- name: Load main's current release evaluator
id: evaluator
shell: bash
run: |
set -euo pipefail
evaluator="$RUNNER_TEMP/release-evaluator"
rm -rf "$evaluator"
mkdir -p "$evaluator"
timeout 300 git fetch --no-tags --depth=1 origin refs/heads/main
sha="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
git archive "$sha" eval | tar -x -C "$evaluator"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Require paired evidence for this exact commit
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVALUATOR_SHA: ${{ steps.evaluator.outputs.sha }}
run: |
set -euo pipefail
runtime_sha="$(git rev-parse HEAD)"
cd "$RUNNER_TEMP/release-evaluator/eval"
# Locked base dependencies only: no dev extras or project build in this privileged job.
uv sync --locked --no-install-project
uv run --no-sync python -m workflow_bench.release_evidence \
--repo "$GITHUB_REPOSITORY" --runtime-sha "$runtime_sha" \
--evaluator-sha "$EVALUATOR_SHA" --out "$RUNNER_TEMP/release-evidence"

View file

@ -52,11 +52,11 @@ updates:
- ci
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
# releases that let us drop a pack-time peer patch ASAP. Grammars
# are grouped so lockstep bumps produce a single PR. The tree-sitter
# RUNTIME is pinned — upgrade deliberately via the drift check workflow.
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for
# the upgrade readiness tracker.
# RUNTIME is pinned — upgrade deliberately. See
# .github/scripts/check-tree-sitter-upgrade-readiness.py for the
# compatibility-workarounds tracker.
- package-ecosystem: npm
directory: /gitnexus
schedule:

View file

@ -1,13 +1,16 @@
#!/usr/bin/env python3
"""Monitor tree-sitter 0.25 upgrade readiness — two things Dependabot can't see:
"""Track the compatibility workarounds GitNexus carries for its bundled tree-sitter
runtime — things Dependabot can't see:
1. Peer-dep compatibility: when every grammar's *latest npm release* accepts
tree-sitter@0.25.0 (so we can upgrade without --legacy-peer-deps).
2. Vendored upstream drift: whether a vendored grammar's upstream parser.c moved.
1. Peer-dep patches: several grammars' npm peer ranges reject the bundled
runtime, so prepare-tree-sitter-bundle.cjs patches their manifests at pack
time (AUDITED_PEERS). The report flags when npm latest (or upstream main)
accepts the runtime, so a patch can be dropped by bumping the grammar.
2. Intentional pins and vendored holds, plus vendored upstream drift.
Invoked daily from tree-sitter-upgrade-readiness.yml; runs locally too. Outputs
Markdown to stdout; exit 1 when blockers remain (the workflow upserts a tracking
issue). stdlib-only — runs on any vanilla runner.
Markdown to stdout; exit 1 while any workaround remains (the workflow upserts a
tracking issue). stdlib-only — runs on any vanilla runner.
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py [--offline | --assert-current]
"""
@ -31,10 +34,12 @@ GITNEXUS_DIR = REPO_ROOT / "gitnexus"
# vendored ABIs are still read from the repo. The read-path mirror of --assert-current.
OFFLINE = os.environ.get("GITNEXUS_TS_READINESS_OFFLINE", "") not in ("", "0", "false")
# ── Upgrade target ──────────────────────────────────────────────────────
# The runtime version we want to upgrade TO. Update this when the goal
# changes (e.g. once 0.25 lands and we target 0.26).
TARGET_RUNTIME = "0.25.0"
# ── Target runtime ──────────────────────────────────────────────────────
# The exact runtime GitNexus bundles, read from the package.json pin so the report
# can never go stale against an upgrade (a hardcoded target did after 0.25 landed).
TARGET_RUNTIME = json.loads((GITNEXUS_DIR / "package.json").read_text())["dependencies"][
"tree-sitter"
]
TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2])
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current
@ -172,6 +177,20 @@ def read_pinned_grammar_versions() -> dict[str, str]:
return pinned
def read_patched_peers() -> list[tuple[str, str, str]]:
"""Return the pack-time peer patches as (name, version, original peer range).
Parsed from AUDITED_PEERS in gitnexus/scripts/prepare-tree-sitter-bundle.cjs,
the single source of truth for which bundled manifests get the runtime added
to their tree-sitter peer range.
"""
script = GITNEXUS_DIR / "scripts" / "prepare-tree-sitter-bundle.cjs"
return re.findall(
r"\['(tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
script.read_text(encoding="utf-8"),
)
def npm_view_json(pkg: str) -> dict | None:
"""Fetch package metadata from the npm registry via HTTPS.
@ -624,13 +643,13 @@ def _render_vendored_section(
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
if v["upstream_abi"] <= target_abi_range[1]:
lines.append(
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
f" - **Action:** regenerate `parser.c` from upstream "
f"`{v['upstream_sha']}` (its ABI loads on tree-sitter@{TARGET_RUNTIME})."
)
else:
lines.append(
f" - **Action:** wait for a runtime supporting ABI "
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
f"{v['upstream_abi']}; tree-sitter@{TARGET_RUNTIME} only "
f"goes up to ABI {target_abi_range[1]}."
)
blockers[f"vendored-{v['name']}-abi"] = (
@ -651,7 +670,7 @@ def main() -> int:
# Label for npm/upstream values we couldn't determine: in --offline mode the
# fetch was deliberately skipped (not "failed"), so say so honestly.
miss_label = "offline" if OFFLINE else "fetch failed"
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
lines.append(md_h("Tree-sitter compatibility workarounds", 1))
lines.append("")
if OFFLINE:
lines.append(
@ -667,8 +686,7 @@ def main() -> int:
pinned_versions = read_pinned_grammar_versions()
lines.append(
f"`tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}–{current_abi_range[1]}) "
f"→ target `tree-sitter@{TARGET_RUNTIME}` "
f"Bundled runtime `tree-sitter@{TARGET_RUNTIME}` "
f"(ABI {target_abi_range[0]}–{target_abi_range[1]})."
)
lines.append("")
@ -678,7 +696,7 @@ def main() -> int:
# workflow's row-diff change-detection keeps working).
grammar_rows: list[dict] = []
raw_matrix: list[str] = [
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
"| Grammar | Pinned | npm latest | Peer dep | Peer accepts runtime | ABI | Upstream ABI | Status |",
"|---|---|---|---|---|---|---|---|",
]
@ -714,8 +732,8 @@ def main() -> int:
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
f"{target_abi_range[0]}..{target_abi_range[1]}"
)
# A held vendored grammar (e.g. tree-sitter-c, #1242/#858) is frozen below
# a runtime upgrade: in-range ABI or not, keep it a blocker until the hold
# A held vendored grammar (e.g. tree-sitter-swift) is a workaround we carry:
# in-range ABI or not, keep it a blocker until the hold
# (from the manifest) is lifted — same treatment as npm INTENTIONAL_PINS.
if hold:
v["target_compat"] = False
@ -806,19 +824,15 @@ def main() -> int:
# (entry removed from INTENTIONAL_PINS), then reclassified next run.
status = "Intentionally pinned"
blockers[name] = (
f"`{name}` intentionally pinned at `{pinned_spec}` "
f"({INTENTIONAL_PINS[name]}) — pin must be lifted "
f"before the {TARGET_RUNTIME} runtime upgrade"
f"`{name}` intentionally pinned at `{pinned_spec}` ({INTENTIONAL_PINS[name]})"
)
elif target_compat:
status = "Ready"
elif upstream_abi and upstream_abi >= 15:
status = "Unreleased (ABI 15 on main)"
upstream_progress = f"ABI 15 on `{upstream_repo}@{upstream_branch}` not yet published"
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
else:
status = "Blocking"
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
status = "Peer patch required"
blockers[name] = (
f"`{name}@{npm_version}`: peer `{peer_display}` rejects {TARGET_RUNTIME}"
)
# Also check upstream package.json for relaxed peer dep — beats
# the ABI-15 hint when both are true.
@ -878,11 +892,14 @@ def main() -> int:
vendored_ready = sum(1 for v in vendored_grammars if v["target_compat"])
if not blockers:
verdict = "**Ready** — all grammars are 0.25-compatible. The runtime upgrade can proceed."
verdict = (
f"**Clean** — every grammar runs on tree-sitter@{TARGET_RUNTIME} "
"without a workaround."
)
else:
moved = "no" if not by_bucket["waiting"] else f"yes — {len(by_bucket['waiting'])} grammars have unreleased fixes on main"
verdict = (
f"**Blocked** — {len(blockers)} grammars are not yet 0.25-compatible. "
f"**Workarounds** — {len(blockers)} grammars still need one. "
f"Upstream movement: {moved}."
)
@ -892,11 +909,11 @@ def main() -> int:
lines.append(f"- {ready_count}/{npm_count} npm-installed grammars already accept tree-sitter@{TARGET_RUNTIME}")
if vendored_count:
lines.append(
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the target runtime range"
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the runtime range"
)
lines.append(f"- {len(by_bucket['intentional'])} intentionally pinned (see below)")
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
lines.append(f"- {len(by_bucket['blocked'])} need a peer patch with no upstream fix (not even on main)")
if by_bucket['fetch_failed']:
why = "checks skipped in offline mode" if OFFLINE else "npm registry unreachable"
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked ({why})")
@ -913,7 +930,8 @@ def main() -> int:
lines.append(
"These pins lag npm latest and the latest version's peer dep already "
"accepts our current `tree-sitter@" + current_runtime + ".x` runtime. "
"Bumping is independent of the 0.25 upgrade and should be a quick PR."
"When bumping, remove the old version's `AUDITED_PEERS` entry in "
"`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` (prepack fails until you do)."
)
lines.append("")
for r in sorted(bump_now, key=lambda r: r["name"]):
@ -937,8 +955,9 @@ def main() -> int:
lines.append(md_h("Disposition", 2))
_emit_bucket(
"Ready for 0.25",
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
"Peer accepts runtime",
f"These grammars' npm-latest peer dep already accepts tree-sitter@{TARGET_RUNTIME}. "
"No workaround needed at npm latest.",
by_bucket["ready"],
lambda r: (
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version_label']}`"
@ -964,7 +983,7 @@ def main() -> int:
_emit_bucket(
"Waiting on upstream npm release",
"Fixes are merged on the upstream main branch but not yet published to npm. "
"We can move forward as soon as upstream cuts a release.",
"The peer patch can be dropped as soon as upstream cuts a release.",
by_bucket["waiting"],
lambda r: (
f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`. "
@ -973,9 +992,9 @@ def main() -> int:
)
_emit_bucket(
"Blocked on upstream",
"Peer dep is too tight on both the latest npm release and on upstream main. "
"These need an upstream issue/PR before we can proceed.",
"Peer patch required",
"Peer dep rejects the runtime on both the latest npm release and upstream main. "
"The pack-time peer patch stays until upstream relaxes it.",
by_bucket["blocked"],
lambda r: f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`",
)
@ -992,6 +1011,35 @@ def main() -> int:
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
)
# ── Pack-time peer patches ──────────────────────────────────────
patched = read_patched_peers()
if patched:
rows_by_name = {r["name"]: r for r in grammar_rows}
patch_status = {
"ready": "can drop: npm latest accepts the runtime",
"intentional": "required while intentionally pinned",
"waiting": "drop once upstream publishes its relaxed peer",
"blocked": "required: no upstream fix yet",
"fetch_failed": f"unknown ({miss_label})",
}
lines.append(md_h(f"Pack-time peer patches ({len(patched)})", 2))
lines.append(
"`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` adds "
f"`{TARGET_RUNTIME}` to these bundled manifests' tree-sitter peer range. "
"Removing an entry requires bumping (or dropping) the package that brings it in."
)
lines.append("")
for name, version, peer in sorted(patched):
row = rows_by_name.get(name)
if row is None or pinned_versions.get(name) != version:
status = "transitive: dropped when the grammar that depends on it is bumped"
elif row["bump_now"]:
status = f"can drop: bump to `{row['npm_version_label']}`"
else:
status = patch_status[row["bucket"]]
lines.append(f"- `{name}@{version}` (peer `{peer}`) — {status}")
lines.append("")
# ── Vendored parsers ────────────────────────────────────────────
lines.extend(_render_vendored_section(vendored_grammars, target_abi_range, blockers))

View file

@ -19,6 +19,8 @@ Rules:
This is checked by substring containment rather than prefix match because
the group value is a conditional expression that resolves to a `CI-…` or
`docker-build-push-…` literal at runtime.
The two dedicated EC2 workflows instead share the exact resource lock
`gitnexus-evolution-runner`, including their hosted cleanup jobs.
We deliberately do not use a YAML library — keeps the script dependency-free
on any vanilla runner. `on:` block parsing is line-based and handles both the
@ -33,6 +35,7 @@ import sys
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-")
EC2_WORKFLOWS = {"gitnexus-skill-evolution.yml", "release-evaluation.yml"}
def is_reusable(lines: list[str]) -> bool:
@ -151,7 +154,8 @@ def check(workflows_dir: pathlib.Path) -> int:
fail = 1
continue
if not any(token in group for token in REQUIRED_TOKENS):
shared_ec2_lock = path.name in EC2_WORKFLOWS and group == "gitnexus-evolution-runner"
if not shared_ec2_lock and not any(token in group for token in REQUIRED_TOKENS):
print(
f"::error file={path}::concurrency.group `{group}` must "
f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} "

View file

@ -0,0 +1,86 @@
"""Bound this run's native probe or paid job pickup to five minutes."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
def wait_for_runner(*, timeout: float = 300, job_name: str | None = None) -> None:
"""Require probe success, or bound pickup of the named paid job."""
repository = os.environ.get("GITHUB_REPOSITORY", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
if repository != "abhigyanpatwari/GitNexus" or not re.fullmatch(r"\d+", run_id):
raise ValueError(
"runner pickup requires this repository's current workflow run"
)
until = time.monotonic() + timeout
paid = job_name is not None
job_name = job_name or "Verify the runner service is online"
# Never start a request with no time left, nor let one outlive the deadline.
while (remaining := until - time.monotonic()) > 0:
result = subprocess.run(
[
"gh",
"api",
f"repos/{repository}/actions/runs/{run_id}/jobs?per_page=100",
],
capture_output=True,
text=True,
timeout=min(30, remaining),
)
if result.returncode:
raise RuntimeError(
"cannot verify runner pickup through the current run's jobs API"
)
jobs = json.loads(result.stdout).get("jobs", [])
probes = [
job for job in jobs if job.get("name", "").split(" / ")[-1] == job_name
]
if len(probes) > 1:
raise RuntimeError("current run has multiple runner pickup probes")
if paid and probes:
if probes[0].get("status") == "in_progress":
return
if probes[0].get("status") == "completed":
if probes[0].get("conclusion") in ("skipped", "cancelled"):
raise RuntimeError("paid job did not run")
return
if probes and probes[0].get("status") == "completed":
if probes[0].get("conclusion") != "success":
raise RuntimeError("runner pickup probe failed")
return
time.sleep(min(10, max(0, until - time.monotonic())))
raise RuntimeError(
"runner service did not complete its pickup probe before the deadline"
)
if __name__ == "__main__":
try:
args = sys.argv[1:]
if len(args) == 2 and args[0] == "--job-name" and args[1].strip():
wait_for_runner(job_name=args[1])
elif args == []:
wait_for_runner()
else:
raise ValueError("expected no arguments or --job-name NAME")
print("The dedicated runner picked up this run's requested job.")
except (
ValueError,
RuntimeError,
subprocess.TimeoutExpired,
OSError,
json.JSONDecodeError,
) as exc:
message = (
str(exc)
if isinstance(exc, (ValueError, RuntimeError))
else "runner readiness command failed"
)
print(f"::error::{message}")
raise SystemExit(1) from None

240
.github/scripts/evolution-runner.py vendored Normal file
View file

@ -0,0 +1,240 @@
"""Start and stop the existing dedicated EC2 runner without provisioning resources.
Call only from trusted hosted jobs under the shared evolution/release concurrency
group. The instance is dedicated to that group, so an already-running instance is
also claimed for cleanup. GitHub runner pickup is checked separately.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
PREFIX = "GITNEXUS_EVOLUTION_"
DAYS = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")
STOP_PATTERN = re.compile(r"(MON|TUE|WED|THU|FRI|SAT|SUN) ([01]\d|2[0-3]):([0-5]\d)")
START_TIMEOUT = 600
STOP_TIMEOUT = 600
EVIDENCE_RESERVE = 90 * 60
def configuration() -> dict[str, str]:
values = {}
for name in ("AWS_REGION", "AWS_ROLE_ARN", "EC2_INSTANCE_ID", "STOP_SCHEDULE_UTC"):
value = os.environ.get(PREFIX + name, "")
if not value:
raise ValueError(f"configure protected environment setting {PREFIX + name}")
values[name] = value
if not re.fullmatch(r"[a-z]{2}(?:-[a-z]+)+-\d", values["AWS_REGION"]):
raise ValueError("invalid AWS region")
if not re.fullmatch(r"arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role/[A-Za-z0-9_+=,.@/-]+", values["AWS_ROLE_ARN"]):
raise ValueError("invalid AWS role ARN")
if not re.fullmatch(r"i-(?:[0-9a-f]{8}|[0-9a-f]{17})", values["EC2_INSTANCE_ID"]):
raise ValueError("invalid EC2 instance ID")
if STOP_PATTERN.fullmatch(values["STOP_SCHEDULE_UTC"]) is None:
raise ValueError("STOP_SCHEDULE_UTC must name the actual weekly stop as DAY HH:MM in UTC")
return values
def stop_deadline(schedule: str, *, now: datetime | None = None) -> int:
"""Reserve startup/upload time and respect both the weekly stop and a 24h cap."""
match = STOP_PATTERN.fullmatch(schedule)
if match is None:
raise ValueError("STOP_SCHEDULE_UTC must name the actual weekly stop as DAY HH:MM in UTC")
now = now or datetime.now(UTC)
if now.tzinfo is None:
raise ValueError("stop deadline requires an explicit timezone")
now = now.astimezone(UTC)
scheduled = now.replace(hour=int(match[2]), minute=int(match[3]), second=0, microsecond=0)
scheduled += timedelta(days=(DAYS.index(match[1]) - now.weekday()) % 7)
# EventBridge may fire anywhere within its scheduled minute. Starting during
# that minute must not be interpreted as having a fresh seven-day window.
if scheduled <= now < scheduled + timedelta(minutes=1):
raise ValueError("the configured EventBridge stop is due now")
if scheduled < now:
scheduled += timedelta(days=7)
deadline = min(scheduled, now + timedelta(hours=24))
if (deadline - now).total_seconds() < START_TIMEOUT + EVIDENCE_RESERVE:
raise ValueError("insufficient time before the configured stop and evidence-upload reserve")
return int(deadline.timestamp())
class CommandError(RuntimeError):
"""The AWS CLI call itself failed, timed out, or returned unusable output."""
def aws(values: dict[str, str], operation: str, *extra: str, until: float | None = None) -> dict:
"""Keep each command within the remaining budget and hide AWS diagnostics."""
timeout = 30 if until is None else min(30, until - time.monotonic())
if timeout <= 0:
raise CommandError("EC2 command deadline expired")
try:
result = subprocess.run(
[
"aws",
"ec2",
operation,
"--instance-ids",
values["EC2_INSTANCE_ID"],
"--region",
values["AWS_REGION"],
"--output",
"json",
"--no-cli-pager",
*extra,
],
capture_output=True,
text=True,
timeout=timeout,
)
except (subprocess.TimeoutExpired, OSError):
# TimeoutExpired includes the full command; never expose private IDs.
raise CommandError(f"EC2 {operation} command failed or timed out") from None
if result.returncode:
raise CommandError(f"EC2 {operation} failed; verify the protected role's scoped permissions")
try:
response = json.loads(result.stdout)
except (json.JSONDecodeError, TypeError):
raise CommandError(f"EC2 {operation} returned an invalid response") from None
if not isinstance(response, dict):
raise CommandError(f"EC2 {operation} returned an invalid response")
return response
def instance_state(values: dict[str, str], *, until: float | None = None) -> str:
response = aws(values, "describe-instances", until=until)
try:
instances = [instance for reservation in response["Reservations"] for instance in reservation["Instances"]]
if len(instances) != 1 or instances[0].get("InstanceId") != values["EC2_INSTANCE_ID"]:
raise ValueError
state = instances[0].get("State", {}).get("Name")
except (KeyError, TypeError, AttributeError, ValueError):
raise RuntimeError("EC2 did not return exactly the configured instance") from None
if state not in ("stopped", "stopping", "pending", "running"):
raise RuntimeError("EC2 instance state does not permit this lifecycle operation")
return state
def healthy(values: dict[str, str], *, until: float | None = None) -> bool:
response = aws(values, "describe-instance-status", "--include-all-instances", until=until)
statuses = response.get("InstanceStatuses", [])
if statuses == []:
return False # Status checks can lag behind the running state.
if (
not isinstance(statuses, list)
or len(statuses) != 1
or not isinstance(statuses[0], dict)
or statuses[0].get("InstanceId") != values["EC2_INSTANCE_ID"]
):
raise RuntimeError("EC2 status did not return exactly the configured instance")
return all(
isinstance(statuses[0].get(field), dict) and statuses[0][field].get("Status") == "ok"
for field in ("SystemStatus", "InstanceStatus")
)
def output(name: str, value: str) -> None:
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as handle:
handle.write(f"{name}={value}\n")
def pause(until: float) -> None:
time.sleep(min(10, max(0, until - time.monotonic())))
def stop(values: dict[str, str], *, timeout: float = STOP_TIMEOUT) -> None:
"""Gracefully stop and observe stopped; an accepted API call is insufficient."""
until = time.monotonic() + timeout
failure: CommandError | None = None
while time.monotonic() < until:
try:
state = instance_state(values, until=until)
except CommandError as error:
# A failed or timed-out describe is unknown state, not proof of
# either outcome. Keep observing within the same stop budget; an
# unusable state or identity mismatch still fails at once.
failure = error
pause(until)
continue
failure = None
if state == "stopped":
print("Dedicated EC2 instance confirmed stopped.")
return
if state == "running":
try:
aws(values, "stop-instances", until=until)
except RuntimeError:
# A timeout can lose an accepted response. Observe state again;
# retry only while still running, within the same stop budget.
pass
# StopInstances is invalid for pending instances. Wait for running; for
# stopping instances, wait for the real terminal stopped state.
pause(until)
if failure is not None:
raise RuntimeError(f"EC2 shutdown deadline expired after: {failure}; use the external stop watchdog")
raise RuntimeError("EC2 shutdown deadline expired; use the external stop watchdog")
def start(values: dict[str, str], *, timeout: float = START_TIMEOUT, cleanup_timeout: float = STOP_TIMEOUT) -> None:
deadline_epoch = stop_deadline(values["STOP_SCHEDULE_UTC"])
until = time.monotonic() + timeout
claimed = False
requested = False
try:
while time.monotonic() < until:
state = instance_state(values, until=until)
if state != "stopping" and not claimed:
# Persist cleanup ownership BEFORE StartInstances: an accepted
# call can time out or the hosted process can be interrupted.
output("cleanup_required", "true")
claimed = True
if state == "stopped":
if requested:
raise RuntimeError("EC2 stopped again during bootstrap")
requested = True
aws(values, "start-instances", until=until)
elif state == "running" and healthy(values, until=until):
output("stop_deadline_epoch", str(deadline_epoch))
print("Dedicated EC2 instance is running with healthy system and instance checks.")
return
pause(until)
raise RuntimeError("EC2 startup/health deadline expired")
except BaseException:
if claimed:
try:
stop(values, timeout=cleanup_timeout)
except (RuntimeError, OSError):
print(
"::error::EC2 cleanup also failed; hosted cleanup must retry and the external stop watchdog remains required"
)
raise
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("check", "start", "stop"))
args = parser.parse_args()
values = configuration()
if args.operation == "check":
stop_deadline(values["STOP_SCHEDULE_UTC"])
print("Dedicated runner configuration and stop window validated.")
elif args.operation == "start":
start(values)
else:
# Shutdown must still run after the startup window or stop deadline.
stop(values)
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError, OSError, KeyError) as exc:
message = str(exc) if isinstance(exc, (ValueError, RuntimeError)) else "runner lifecycle command failed"
print(f"::error::{message}")
raise SystemExit(1) from None

View file

@ -48,7 +48,7 @@ _ROW_DIFF_RE = re.compile(r"\| `((?:@[^/`]+/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?
_ISSUE_READY_RE = re.compile(
r"- (\d+)/(\d+) npm-installed grammars already accept tree-sitter@"
)
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Blocked\*\* — (\d+) grammars? ")
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Workarounds\*\* — (\d+) grammars? ")
def _physical_vendor_grammars() -> set[str]:
@ -120,8 +120,9 @@ class ManifestClassification(TestCase):
self.assertNotIn("tree-sitter-cpp", readiness.VENDORED_NAMES)
self.assertNotIn("tree-sitter-go", readiness.VENDORED_NAMES)
def test_c_carries_a_hold_cpp_does_not(self):
self.assertTrue(readiness.VENDORED["tree-sitter-c"]["hold"])
def test_swift_carries_a_hold_c_does_not(self):
self.assertTrue(readiness.VENDORED["tree-sitter-swift"]["hold"])
self.assertFalse(readiness.VENDORED["tree-sitter-c"]["hold"])
self.assertNotIn("tree-sitter-c", readiness.INTENTIONAL_PINS)
# cpp stays an npm intentional pin.
self.assertIn("tree-sitter-cpp", readiness.INTENTIONAL_PINS)
@ -274,21 +275,23 @@ class ScopedGrammarName(TestCase):
row = next(l for l in report.splitlines() if l.startswith(f"| `{_R_GRAMMAR}` |"))
cells = [c.strip() for c in row.strip().strip("|").split("|")]
self.assertEqual(cells[1], "^1.1.2") # Pinned
self.assertEqual(cells[-1], "Blocking")
self.assertEqual(cells[-1], "Peer patch required")
self.assertEqual(code, 1)
self.assertIn(f"`{_R_GRAMMAR}@1.1.2` — peer `^0.21.0`", report) # Blocked bucket
# On latest already: no bump candidate (pre-fix the lost pin made it one).
self.assertNotIn("What you can do today", report)
self.assertNotIn("bump candidate", report)
# On latest already: not a bump candidate (pre-fix the lost pin made it
# one). Other grammars are mocked at 9.9.9 and so do appear in that list;
# only R's own line is asserted absent.
self.assertNotIn(f"- `{_R_GRAMMAR}`: ", report)
self.assertNotIn(f"`{_R_GRAMMAR}` — pinned", report)
def test_row_diff_regex_captures_scoped_and_unscoped_names(self):
rows = (
"| `@eagleoutice/tree-sitter-r` | ^1.1.2 | 1.1.2 | ^0.21.0 | **No** | 14 | 14 | Blocking |\n"
"| `@eagleoutice/tree-sitter-r` | ^1.1.2 | 1.1.2 | ^0.21.0 | **No** | 14 | 14 | Peer patch required |\n"
"| `tree-sitter-go` | ^0.23.0 | 0.25.0 | ^0.25.0 | Yes | 14 | 14 | Ready |\n"
)
self.assertEqual(
dict(_ROW_DIFF_RE.findall(rows)),
{"@eagleoutice/tree-sitter-r": "Blocking", "tree-sitter-go": "Ready"},
{"@eagleoutice/tree-sitter-r": "Peer patch required", "tree-sitter-go": "Ready"},
)
def test_row_diff_regex_mirror_matches_the_workflow(self):
@ -366,9 +369,7 @@ class ReportRendering(TestCase):
cls.rows = dict(_ROW_DIFF_RE.findall(cls.report))
def test_no_bare_question_mark_anywhere(self):
# The only legitimate '?' is the "Satisfies 0.25?" column header.
sanitized = self.report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized, "report still contains a bare '?' placeholder")
self.assertNotIn("?", self.report, "report still contains a bare '?' placeholder")
def test_malformed_npm_version_renders_unknown_in_prose_not_bare_question(self):
# A successful (200) npm /latest response that omits `version` leaves
@ -392,8 +393,7 @@ class ReportRendering(TestCase):
contextlib.redirect_stdout(buf):
readiness.main()
report = buf.getvalue()
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized)
self.assertNotIn("?", report)
# The Ready bucket prose line for go shows the labeled 'unknown', not '?'.
self.assertRegex(report, r"`tree-sitter-go`.*npm latest `unknown`")
@ -427,14 +427,14 @@ class ReportRendering(TestCase):
self.assertIn(_R_GRAMMAR, _render_report.last_npm_calls)
self.assertIn(_R_GRAMMAR, self.rows)
def test_held_c_renders_held_and_keeps_exit_nonzero(self):
def test_held_swift_renders_held_and_keeps_exit_nonzero(self):
# Status is the last matrix cell (the row-diff regex captures the whole
# tail, not just status, so read the cell directly).
cells = [c.strip() for c in self._matrix_row("tree-sitter-c").strip().strip("|").split("|")]
cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")]
self.assertEqual(cells[-1], "Vendored — held")
self.assertIn("**Held:**", self.report)
# With every npm grammar mocked to "Ready", the ONLY remaining blocker is
# the held c — so a non-zero exit proves the hold is treated as a blocker.
# the held Swift — so a non-zero exit proves the hold is treated as a blocker.
self.assertEqual(self.code, 1)
def test_upstream_abi_miss_uses_labeled_sentinel(self):
@ -450,9 +450,9 @@ class ReportRendering(TestCase):
self.assertEqual(len(self.rows), len(readiness.GRAMMARS))
for name in readiness.VENDORED_NAMES:
self.assertIn(name, self.rows)
# group 2 is the Status cell — held c renders exactly "Vendored — held",
# group 2 is the Status cell — held Swift renders exactly "Vendored — held",
# and no captured status contains a pipe (proves cell-scoped capture).
self.assertEqual(self.rows["tree-sitter-c"], "Vendored — held")
self.assertEqual(self.rows["tree-sitter-swift"], "Vendored — held")
for status in self.rows.values():
self.assertNotIn("|", status)
@ -464,16 +464,30 @@ class ReportRendering(TestCase):
# Counts are derived from _render_report()'s mock corpus (all npm peer
# deps mocked permissive): of the 11 npm-installed grammars, 10 render
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
# not counted ready. The 4 blockers are that same pinned tree-sitter-cpp
# plus three held vendored grammars: ABI-held tree-sitter-c (#1242/#858),
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
# interface` support — ABI 14 is in range, but a hold counts as a blocker
# until it is lifted), and tree-sitter-objc. If a grammar is added/removed
# or a pin/hold changes,
# update _render_report()'s mock AND these expected counts together; a
# mismatch here means the report prose drifted, not the regex.
# not counted ready. The remaining workarounds are that intentional pin
# and Swift's declaration-recovery hold.
self.assertEqual(ready.groups(), ("10", "11"))
self.assertEqual(blockers.group(1), "4")
self.assertEqual(blockers.group(1), "2")
def test_lists_every_pack_time_peer_patch_with_its_drop_condition(self):
patched = readiness.read_patched_peers()
# Guards the AUDITED_PEERS regex: the bundle script patches 8 manifests today.
self.assertGreaterEqual(len(patched), 8)
self.assertIn(f"## Pack-time peer patches ({len(patched)})", self.report)
for name, version, _ in patched:
self.assertIn(f"- `{name}@{version}`", self.report)
line = lambda pkg: next(l for l in self.report.splitlines() if l.startswith(f"- `{pkg}`"))
# npm is mocked to a permissive 9.9.9, so a direct pin can be bumped away...
self.assertIn("can drop: bump to `9.9.9`", line("tree-sitter-java@0.23.5"))
# ...but not an intentional pin, nor a nested copy owned by another grammar.
self.assertIn("required while intentionally pinned", line("tree-sitter-cpp@0.23.4"))
self.assertIn("transitive", line("tree-sitter-javascript@0.23.1"))
self.assertIn("transitive", line("tree-sitter-c@0.23.6"))
def test_target_runtime_is_the_bundled_pin(self):
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
self.assertEqual(readiness.TARGET_RUNTIME, pkg["dependencies"]["tree-sitter"])
self.assertIn(f"Bundled runtime `tree-sitter@{readiness.TARGET_RUNTIME}`", self.report)
def _matrix_row(self, name: str) -> str:
for line in self.report.splitlines():
@ -518,8 +532,7 @@ class OfflineMode(TestCase):
def test_offline_report_has_no_bare_question_mark(self):
report, _ = self._render_offline()
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized)
self.assertNotIn("?", report)
class VendoredAbiBranches(TestCase):

View file

@ -9,22 +9,16 @@
* validates the prebuilds — so even an imperfect re-vendor can never silently
* ship: its PR's CI goes red.
*
* ABI awareness is load-bearing. Every grammar is pinned to tree-sitter@0.21.1
* (LANGUAGE_VERSION 13–14, the #1922 gate). Most upstream grammar releases target
* a newer tree-sitter, so a blind "bump to latest" would pull an ABI-incompatible
* parser and open doomed PRs. This monitor fetches the candidate source, reads its
* parser.c `#define LANGUAGE_VERSION`, and only re-vendors when it is 13 or 14;
* incompatible updates are reported (and surfaced as a workflow notice), not
* applied.
* The runtime is tree-sitter@0.25.1 (LANGUAGE_VERSION 13–15). Read the
* candidate's generated ABI before updating; incompatible candidates are
* reported rather than applied. Semantic compatibility is verified by PR CI.
*
* Usage:
* node update-vendored-grammars.mjs # detect only → JSON report on stdout
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
*
* tree-sitter-c and tree-sitter-objc are MONITORED but report-only (`hold`): c is ABI-pinned at 0.21.4
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
* available c update is detected + reported but never auto-applied — even if it is
* ABI-13/14. A maintainer re-vendors it deliberately.
* Policy holds are recorded in the shared manifest. Swift is held at the
* newest tested release that preserves declaration ownership during recovery.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
@ -36,12 +30,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor');
const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION range
const COMPATIBLE_ABI = new Set([13, 14, 15]); // tree-sitter@0.25.1 LANGUAGE_VERSION range
// Source-of-origin per grammar. npm grammars resolve `latest` via the registry;
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
// reason makes a grammar report-only: updates are detected + surfaced but never
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
// auto-applied (for example, when newer upstream error recovery regresses ownership).
//
// The vendored set lives in .github/vendored-grammars.json — the SHARED source of
// truth this monitor and .github/scripts/check-tree-sitter-upgrade-readiness.py both
@ -230,7 +224,7 @@ function detect(deps = {}) {
abiCompatible: abi == null ? null : COMPATIBLE_ABI.has(abi),
hold: g.hold || null,
// Auto-appliable only when there's an update, the ABI is known-compatible,
// AND the grammar is not on a policy hold (c).
// AND the grammar is not on a policy hold (e.g. swift).
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
});
}
@ -285,8 +279,8 @@ function apply(key, opts = {}) {
const abi = readAbiFn(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi))
throw new ApplyExit(
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.25.1 ` +
`compatible (need 13/14/15); refusing to re-vendor. Handle manually.`,
3,
);
@ -299,7 +293,8 @@ function apply(key, opts = {}) {
const dest = path.join(VENDOR, g.name);
// The source-build inputs + runtime entrypoints that change between versions.
// binding.gyp / README / LICENSE / prebuilds are intentionally NOT touched.
// Keep the synchronous CommonJS wrapper: upstream C now uses top-level await.
// binding.gyp / bindings/node/index.* / README / LICENSE / prebuilds are preserved.
for (const rel of [
'src/parser.c',
'src/scanner.c',
@ -308,8 +303,6 @@ function apply(key, opts = {}) {
'src/tree_sitter/array.h',
'src/tree_sitter/parser.h',
'bindings/node/binding.cc',
'bindings/node/index.js',
'bindings/node/index.d.ts',
]) {
copyFile(srcRoot, dest, rel);
}
@ -317,10 +310,12 @@ function apply(key, opts = {}) {
const pkgPath = path.join(dest, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.version = up.version;
if (up.kind === 'github') pkg._upstreamCommit = up.ref;
else delete pkg._upstreamCommit;
pkg._vendoredBy =
`gitnexus - re-vendored from ${g.npm ? `npm ${g.npm}@${up.version}` : `${g.github}@${up.ref}`} ` +
`by grammar-update-monitor on ABI ${abi}. Source-build inputs (parser.c/scanner.c/src/) refreshed; ` +
`the GitNexus-hardened binding.gyp + vendor README + prebuilds are preserved (prebuilds are ` +
`the GitNexus-hardened binding.gyp + synchronous loader + vendor README + prebuilds are preserved (prebuilds are ` +
`rebuilt by build-tree-sitter-prebuilds.yml on this version change). No scripts/dependencies here ` +
`(#836/#1728).`;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');

View file

@ -3,34 +3,46 @@
"grammars": {
"c": {
"name": "tree-sitter-c",
"upstream": { "npm": "tree-sitter-c" },
"hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping"
"upstream": {
"github": "tree-sitter/tree-sitter-c"
}
},
"objc": {
"name": "tree-sitter-objc",
"upstream": { "npm": "tree-sitter-objc" },
"hold": "Pinned at 3.0.2 for the Objective-C provider MVP; carries darwin/linux arm64+x64 prebuilds compatible with the current tree-sitter runtime (linux-arm64 built from vendored source because the upstream npm artifact is mislabeled)"
"upstream": {
"github": "tree-sitter-grammars/tree-sitter-objc"
}
},
"swift": {
"name": "tree-sitter-swift",
"upstream": { "npm": "tree-sitter-swift" }
"upstream": {
"npm": "tree-sitter-swift"
},
"hold": "Pinned to upstream 0.7.2-with-generated-files (7b7909f2): 0.7.3 and 0.7.4 re-parent a later top-level declaration after split conditional function headers. Keep until the declaration-ownership regression tests pass; see gitnexus/vendor/README.md."
},
"kotlin": {
"name": "tree-sitter-kotlin",
"upstream": { "npm": "tree-sitter-kotlin" },
"hold": "pinned to unreleased fwcd main commit c8ac3d26 for `fun interface` support (fwcd/tree-sitter-kotlin#169, closes #87) — npm latest (0.3.8) lacks the fix, so the monitor must NOT auto-revert (isNewer is strict-inequality: 0.3.8 != 0.4.0). Drop this hold and bump when upstream cuts a release that includes the fix"
"upstream": {
"github": "fwcd/tree-sitter-kotlin"
}
},
"dart": {
"name": "tree-sitter-dart",
"upstream": { "github": "UserNobody14/tree-sitter-dart" }
"upstream": {
"github": "UserNobody14/tree-sitter-dart"
}
},
"proto": {
"name": "tree-sitter-proto",
"upstream": { "github": "coder3101/tree-sitter-proto" }
"upstream": {
"github": "coder3101/tree-sitter-proto"
}
},
"zig": {
"name": "tree-sitter-zig",
"upstream": { "npm": "@tree-sitter-grammars/tree-sitter-zig" }
"upstream": {
"github": "tree-sitter-grammars/tree-sitter-zig"
}
}
}
}

View file

@ -9,9 +9,8 @@ name: Build tree-sitter prebuilds
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All seven are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a
# REQUIRED grammar)
# - tree-sitter-c (vendored source from upstream Git, including releases
# newer than npm; REQUIRED grammar)
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
@ -152,11 +151,11 @@ jobs:
// 'vendored' builds from gitnexus/vendor/<name> (which carries the C
// source + binding.gyp). Extend this list to cover a new grammar.
const REGISTRY = {
// c is vendored prebuild-only but BUILT from the published npm
// package (kind 'npm'), held at 0.21.4 — it closes upstream's 4/6
// c builds the exact upstream source snapshot in vendor/ so the
// prebuild matches its metadata — this also closes upstream's 4/6
// ARM gap (#2116) for a REQUIRED grammar that otherwise hard-fails
// install on toolchain-less ARM.
c: { name: 'tree-sitter-c', kind: 'npm' },
c: { name: 'tree-sitter-c', kind: 'vendored' },
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
// kotlin is vendored WITH its source (parser.c/scanner.c/binding.gyp),
@ -424,19 +423,20 @@ jobs:
# node-addon-api must match what the grammar's binding.cc expects.
# GitNexus hoists ^8 for the vendored grammars; npm grammars declare
# their own (do NOT pin it for npm grammars — let the dep resolve it).
# node-gyp >=12.1 recognizes Visual Studio 2026 on Windows ARM runners.
if [ "$KIND" = "vendored" ]; then
# Build from the vendored C source (carries parser.c + binding.gyp).
srcdir="$work/$NAME"
cp -R "$GITHUB_WORKSPACE/gitnexus/vendor/$NAME" "$srcdir"
rm -rf "$srcdir/prebuilds" "$srcdir/build" "$srcdir/node_modules"
npm install --no-audit --no-fund --ignore-scripts \
prebuildify@^6 node-gyp@^11 node-addon-api@^8
prebuildify@^6 node-gyp@12.4.0 node-addon-api@^8
pkgdir="$srcdir"
export npm_config_node_gyp="$work/node_modules/node-gyp/bin/node-gyp.js"
else
# Pull the published source-only package.
npm install --no-audit --no-fund --ignore-scripts \
"$NAME@${REF}" prebuildify@^6 node-gyp@^11
"$NAME@${REF}" prebuildify@^6 node-gyp@12.4.0
pkgdir="$work/node_modules/$NAME"
fi
@ -489,15 +489,11 @@ jobs:
cd "$probe"
# Pin tree-sitter to the repo's exact runtime peer so an ABI mismatch
# fails HERE, not in a user's install (mirrors the #1922 ABI gate).
# NOT --ignore-scripts: tree-sitter@0.21.1's tarball ships prebuilds for
# the common tuples but NOT linux-arm64 / win32-arm64, so on the arm64
# runners node-gyp-build must source-build the runtime — give it node-gyp
# + node-addon-api to do so. Where tree-sitter ships a prebuild (x64,
# darwin-arm64) node-gyp-build uses it and nothing compiles. The grammar
# .node we built is still loaded as a prebuild; only the runtime peer may
# compile. The grammar-vs-runtime ABI check still fires at setLanguage.
# Allow install scripts so node-gyp-build can compile the runtime if
# no matching runtime prebuild is available. The grammar built above
# is still loaded as a prebuild; setLanguage checks its runtime ABI.
npm install --no-audit --no-fund \
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
node-gyp-build@^4 node-gyp@12.4.0 node-addon-api@^8 tree-sitter@0.25.1
# The node script is single-quoted on purpose — its ${...} are JS
# template literals read from the environment, not shell expansions.
# shellcheck disable=SC2016

View file

@ -20,6 +20,9 @@ jobs:
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
id: filter
with:
# Generated parser diffs can exceed GitHub's PR-files API limits.
# Use the action's local Git comparison against the PR base instead.
token: ''
filters: |
web:
- 'gitnexus-web/**'

View file

@ -80,6 +80,7 @@ jobs:
# 3. The concurrency group key starts with `${{ github.workflow }}` or
# the literal `CI-` prefix (the documented ci.yml exception for
# reusable-workflow-safe grouping).
# The two EC2 workflows share an explicit resource lock through cleanup.
# Reusability is detected by parsing each workflow's `on:` block, not an
# allowlist, so new reusable workflows never produce false positives.
workflow-convention:

View file

@ -7,6 +7,41 @@ permissions:
contents: read
jobs:
tool-accuracy:
name: release / deterministic tool accuracy
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Install ripgrep for rename text search
run: |
sudo apt-get -o DPkg::Lock::Timeout=600 update
sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends ripgrep
- name: Check fixed answers against real tools
working-directory: gitnexus
run: node --import tsx bench/tool-accuracy/run.ts --check --out "$RUNNER_TEMP/tool-accuracy"
- name: Publish accuracy summary
if: always()
run: |
if [[ -f "$RUNNER_TEMP/tool-accuracy/accuracy.md" ]]; then
cat "$RUNNER_TEMP/tool-accuracy/accuracy.md" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload release accuracy evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-tool-accuracy
path: |
${{ runner.temp }}/tool-accuracy/accuracy.json
${{ runner.temp }}/tool-accuracy/accuracy.md
if-no-files-found: error
retention-days: 30
# Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report
# (carrying its slice of V8 coverage) with thresholds forced OFF — a single
# shard's partial coverage can't meet the gate. The coverage-merge job below
@ -165,7 +200,7 @@ jobs:
- id: gen
run: |
TOTAL=4 # cross-platform (windows/macOS) shards per OS
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
COV_TOTAL=4 # ubuntu coverage shards (merged before thresholds)
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
echo "shard totals must be >= 1" >&2; exit 1
fi
@ -333,7 +368,7 @@ jobs:
# exemption is revoked here and an ABI-broken Zig binding fails the
# job instead of being accepted as a clean absence.
- name: Run parser-loader ABI load-smoke (dynamic)
run: npx vitest run test/unit/parser-loader-abi.test.ts
run: npx vitest run test/unit/parser-loader-abi.test.ts test/unit/group/grpc-extractor.test.ts
env:
GITNEXUS_REQUIRE_ZIG: '1'
working-directory: gitnexus
@ -341,7 +376,7 @@ jobs:
# End-to-end smoke test for the #1728 packaging fix: pack the published
# tarball, install it globally into a temp prefix, and assert no junction
# creation (the EPERM root cause) plus working CLI plus vendor cleanliness
# (#836). Runs on windows-latest because that is the platform the fix
# (#836) plus native grammar loading from the bundled runtime. Runs on windows-latest because that is the platform the fix
# targets; the in-repo `npm ci` job above only exercises the dev-tree path
# and skips the tarball reify step where the historical EPERM occurred.
packaged-install-smoke:
@ -421,6 +456,10 @@ jobs:
# installed artifact, not just the source workflow that produced it.
node "$INSTALLED/scripts/assert-web-assets.mjs" "$INSTALLED/web"
# Every bundled/vendored grammar must load and parse from the
# installed tree (main thread + worker), not just the dev checkout.
node "$INSTALLED/scripts/assert-native-parsers.mjs" "$INSTALLED"
# #836 invariant: no node_modules/ or build/ under any vendor/*.
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
if [ -n "$BAD" ]; then
@ -1046,6 +1085,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Oracle controls replay the immutable historical task sources.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.18.0'
@ -1091,9 +1132,23 @@ jobs:
npm ci
npm run build
working-directory: gitnexus
# Release evaluation grades tasks with dependencies built at the pinned
# task commit; prove the hidden oracles against that same toolchain.
- name: Build the pinned task toolchain that grades release evaluation
working-directory: eval
run: |
set -euo pipefail
sha="$(uv run --locked --extra dev python -m workflow_bench.release_report task-sha)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
git -C .. worktree add --detach "${RUNNER_TEMP}/tasks-base" "$sha"
cd "${RUNNER_TEMP}/tasks-base"
npm ci --audit=false --fund=false
npm ci --prefix gitnexus --audit=false --fund=false
npm run build --prefix gitnexus
- name: Prove process-tree and sandbox containment
env:
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
GITNEXUS_ORACLE_TASK_DEPENDENCIES: ${{ runner.temp }}/tasks-base
run: >-
uv run --locked --extra dev python -m pytest
tests/test_process_control.py
@ -1102,8 +1157,11 @@ jobs:
tests/test_ce_plugin_runtime.py
tests/test_offline_sweep_integration.py
tests/test_mock_provider.py
tests/test_release_evaluation_smoke.py
tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant
tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout
tests/test_oracle_assets.py::test_shipped_oracles_reject_task_base_and_accept_reference_fixes
tests/test_oracle_assets.py::test_in_flight_retry_oracle_rejects_startup_only_repairs
-q --junitxml=pytest-ubuntu.xml
working-directory: eval
- name: Upload pytest execution report

View file

@ -71,6 +71,10 @@ jobs:
# deliberately contain use-before-init / unused-variable shapes).
- '**/test/fixtures/**'
- '**/test/**/fixtures/**'
# Parsed-only accuracy corpus includes deliberate command injection
# and broken Python imports/returns. It is never executed or packed;
# retain security analysis of the surrounding benchmark harness.
- 'gitnexus/bench/tool-accuracy/fixtures/**'
# GET /api/grep intentionally builds RegExp from the query string
# (literal=1 escapes). ReDoS is handled by worker terminate() —
# see SECURITY.md. Inline codeql[] comments do not clear the

View file

@ -51,8 +51,93 @@ concurrency:
cancel-in-progress: false
jobs:
release-gate:
name: Verify Docker release evidence
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
actions: read
outputs:
sha: ${{ steps.version.outputs.sha }}
version: ${{ steps.version.outputs.version }}
publish: ${{ steps.version.outputs.publish }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
persist-credentials: false
- name: Verify publishing mode and version tag
id: version
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }}
run: |
set -euo pipefail
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Reusable calls inherit the caller's event. An explicit tag is what
# distinguishes an RC publish call from a direct manual dry run.
if [[ "$EVENT_NAME" == 'pull_request' ]] || \
[[ "$EVENT_NAME" == 'workflow_dispatch' && -z "$INPUT_TAG" && "$INPUT_DRY_RUN" == 'true' ]]; then
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -n "$INPUT_TAG" ]]; then
TAG="$INPUT_TAG"
elif [[ "$EVENT_NAME" == 'push' && "$GITHUB_REF" == refs/tags/v* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
else
echo "::error::Docker publication requires a version tag; direct manual dispatch is dry-run only."
exit 1
fi
if ! [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then
echo "::error::Docker publication requires a v-prefixed semver tag."
exit 1
fi
TAG_VERSION="${BASH_REMATCH[1]}"
if ! TAG_SHA=$(git rev-parse --verify "refs/tags/$TAG^{commit}" 2>/dev/null) || \
[[ "$TAG_SHA" != "$(git rev-parse HEAD)" ]]; then
echo "::error::The checkout must be the commit referenced by the version tag."
exit 1
fi
PKG_VERSION=$(node -p "require('./gitnexus/package.json').version")
if [[ "$TAG_VERSION" != "$PKG_VERSION" ]]; then
echo "::error::Tag version does not match gitnexus/package.json version."
exit 1
fi
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "Version verified: $PKG_VERSION"
# One cheap run gates both image builds. Never consume an arbitrary
# accuracy artifact from another workflow or repeat the full CI suite.
- uses: ./.github/actions/setup-gitnexus
if: steps.version.outputs.publish == 'true'
with:
build: 'true'
- name: Install ripgrep for rename text search
if: steps.version.outputs.publish == 'true'
run: |
sudo apt-get -o DPkg::Lock::Timeout=600 update
sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends ripgrep
- name: Check fixed answers before Docker publication
if: steps.version.outputs.publish == 'true'
working-directory: gitnexus
run: node --import tsx bench/tool-accuracy/run.ts --check --out "$RUNNER_TEMP/tool-accuracy"
- name: Publish accuracy summary
if: always()
run: |
if [[ -f "$RUNNER_TEMP/tool-accuracy/accuracy.md" ]]; then
cat "$RUNNER_TEMP/tool-accuracy/accuracy.md" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Require paired agent evidence before stable Docker publication
if: steps.version.outputs.publish == 'true' && !contains(steps.version.outputs.version, '-')
uses: ./.github/actions/require-agent-release-evidence
build-push:
name: Build & Push ${{ matrix.image.name }}
needs: release-gate
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
@ -83,60 +168,12 @@ jobs:
slug: gitnexus
steps:
# Only the workflow_call path requires a non-empty `inputs.tag` — callers
# (publish.yml in RC mode) must pass the RC tag explicitly. On direct
# tag pushes the tag comes from `github.ref`, so `inputs.tag` is always
# empty and validating it here would break every real release (#1064).
# The downstream "Verify tag matches gitnexus/package.json version" step
# handles both event types by falling back to GITHUB_REF.
- name: Validate tag input
if: github.event_name == 'workflow_call'
shell: bash
env:
TAG_INPUT: ${{ inputs.tag }}
run: |
if [ -z "${TAG_INPUT}" ]; then
echo "::error::No tag provided to docker.yml — refusing to build/push."
exit 1
fi
# When triggered by workflow_call the caller passes the RC tag as an input;
# we check out that tag so the Dockerfile and package.json match the built image.
# For tag-push events github.ref is already the tag ref — no override needed.
# Build the immutable revision that passed the evidence gate, even if
# someone moves the version tag while this run is in progress.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
# ── Lock the docker image version to the npm package version ──────────
# Mirrors the check in publish.yml: refuse to build unless the git tag
# exactly matches `gitnexus/package.json`'s version. This guarantees
# `ghcr.io/<owner>/gitnexus:X.Y.Z` always corresponds to the same
# `gitnexus@X.Y.Z` published to npm — no drift, no surprises.
- name: Verify tag matches gitnexus/package.json version
id: version
if: github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
shell: bash
env:
# For workflow_call the tag comes from the caller input; for push events
# it is derived from GITHUB_REF (set to empty so the else-branch fires).
INPUT_TAG: ${{ inputs.tag }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG_VERSION="${INPUT_TAG#v}"
else
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
fi
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Tag does not follow semver: v$TAG_VERSION"
exit 1
fi
PKG_VERSION=$(node -p "require('./gitnexus/package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version (v$TAG_VERSION) does not match gitnexus/package.json version ($PKG_VERSION)"
exit 1
fi
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
echo "Version verified: $PKG_VERSION"
ref: ${{ needs.release-gate.outputs.sha }}
persist-credentials: false
# Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU
@ -149,7 +186,7 @@ jobs:
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
if: needs.release-gate.outputs.publish == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
@ -164,7 +201,7 @@ jobs:
# access token, NOT the account password) with write access to the
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
- name: Log in to Docker Hub
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
if: needs.release-gate.outputs.publish == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -202,7 +239,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag != '' }}
type=raw,value=${{ needs.release-gate.outputs.version }},enable=${{ inputs.tag != '' }}
# Transient 502s from GHCR / Docker Hub / GHA cache during multi-platform
# exports are retried inside `.github/actions/docker-build-push-retry`
@ -216,7 +253,7 @@ jobs:
context: .
file: ${{ matrix.image.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
push: ${{ needs.release-gate.outputs.publish == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image.slug }}
@ -235,7 +272,7 @@ jobs:
# Do NOT relax to `@.*` — that accepts signatures from any ref, including
# unprotected branches and PRs, and defeats the supply-chain guarantee.
- name: Sign image with Cosign (keyless)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
if: needs.release-gate.outputs.publish == 'true'
env:
# Cosign v2 (installed by sigstore/cosign-installer above) makes
# keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag
@ -257,7 +294,7 @@ jobs:
# is identical across registries (same build, same push), so consumers
# pulling from either GHCR or Docker Hub see the same provenance.
- name: Generate build provenance attestation (GHCR)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
if: needs.release-gate.outputs.publish == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
@ -265,7 +302,7 @@ jobs:
push-to-registry: true
- name: Generate build provenance attestation (Docker Hub)
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
if: needs.release-gate.outputs.publish == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}

View file

@ -1294,7 +1294,7 @@ jobs:
steps.claude-recheck.outcome == 'success'
# Use the low-level base action: the high-level GitHub action can restore
# project configuration from a moving base branch before invoking Claude.
uses: anthropics/claude-code-action/base-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1
uses: anthropics/claude-code-action/base-action@cab360f6565aa35a51d6ce9e43f1f4287c0a32ea # v1
env:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
@ -1447,7 +1447,7 @@ jobs:
if: >-
steps.precheck.outputs.repair_reason != '' &&
steps.repair-recheck.outcome == 'success'
uses: anthropics/claude-code-action/base-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1
uses: anthropics/claude-code-action/base-action@cab360f6565aa35a51d6ce9e43f1f4287c0a32ea # v1
env:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'

View file

@ -35,6 +35,14 @@
# security group, and IAM setup are documented privately, not in this
# repo — publishing the exact topology of a real, live AWS account
# isn't safe to do in a public repo even without literal secrets.
# Hosted jobs start and stop the existing instance through its protected
# OIDC role. Reuse GITNEXUS_EVOLUTION_AWS_ROLE_ARN and
# GITNEXUS_EVOLUTION_EC2_INSTANCE_ID secrets, plus the
# GITNEXUS_EVOLUTION_AWS_REGION and GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC
# variables (the actual weekly stop as DAY HH:MM UTC). These historical
# configuration names are assumed; verify their private environment values.
# Keep the external stop watchdog enabled;
# it remains the fallback if GitHub cleanup is interrupted.
# Accepted tradeoff: the box is stopped between runs (an EventBridge
# schedule starts it ~15min before the Saturday cron and stops it 24h
# later) but is not destroyed/recreated per run, so it isn't fully
@ -74,6 +82,16 @@
# Wall clock is quantised by ceil(cells_per_task / workers), and a review
# task is 9 cells cold, so 4 costs host contention for exactly the wall
# clock of 3. The next step up that buys anything is 5 (3 waves -> 2).
#
# Shared runner lock: this workflow and release-evaluation.yml use the one
# concurrency group gitnexus-evolution-runner, so neither can stop the instance
# during the other's run. GitHub keeps only ONE pending run per group, even with
# cancel-in-progress: false: a newer queued run of either workflow cancels the
# older pending run ("higher priority waiting request"). A release evaluation
# dispatched for a stable publish can therefore vanish while it waits. Do not
# queue evolution behind a pending release evaluation, and confirm the
# evaluation's "Start the dedicated EC2 runner" job actually ran before relying
# on its evidence; if the run shows Cancelled, dispatch it again.
name: GitNexus skill evolution
on:
@ -140,18 +158,137 @@ on:
required: false
default: true
type: boolean
runner_only:
description: 'Start, probe, and stop the runner without paid model calls'
required: false
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}
group: gitnexus-evolution-runner
cancel-in-progress: false
permissions: {}
jobs:
start-runner:
name: Start the dedicated EC2 runner
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 25
permissions:
contents: read
id-token: write
outputs:
run_attempt: ${{ github.run_attempt }}
cleanup_required: ${{ steps.boot.outputs.cleanup_required }}
stop_deadline_epoch: ${{ steps.boot.outputs.stop_deadline_epoch }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/ec2-runner
id: boot
with:
operation: start
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
check-runner:
needs: [start-runner]
name: Bound the wait for the evolution runner
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 10
permissions:
contents: read
actions: write # Cancel only this run if its dedicated runner stays offline.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# EC2 health does not prove the Actions service can accept a job.
- name: Verify the current run's native pickup probe
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/evolution-runner-ready.py
# A queued self-hosted job has no pickup timeout. Cancel this run so an
# offline probe cannot remain queued indefinitely and block later runs.
- name: Cancel this run when runner readiness fails
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
runner-ready:
needs: [start-runner]
name: Verify the runner service is online
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
# Partial reruns reuse successful startup outputs after cleanup stopped EC2.
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
environment: gitnexus-evolution
timeout-minutes: 5
permissions: {}
steps:
- name: Require startup in this run attempt
run: |
set -euo pipefail
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
exit 1
fi
- name: Verify runner pickup
run: |
set -euo pipefail
test -r /proc/uptime
git --version
uname -sm
watch-evolve-pickup:
name: Bound pickup of the paid evolution job
needs: [start-runner, check-runner, runner-ready]
if: inputs.runner_only != true
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: write # Cancel this run if evolve stays queued after the probe.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Require pickup of this run's paid evolution job
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/evolution-runner-ready.py --job-name "Propose, benchmark, and gate skill candidates"
- name: Cancel this run when evolution pickup fails
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
evolve:
name: Propose, benchmark, and gate skill candidates
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
inputs.runner_only != true &&
(
github.event_name == 'workflow_dispatch' ||
(
@ -159,7 +296,8 @@ jobs:
vars.GITNEXUS_EVOLUTION_WORKERS == '3'
)
)
runs-on: [self-hosted, linux, x64, gitnexus-evolution]
needs: [start-runner, check-runner, runner-ready]
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
# Gate promotion runs on a protected Environment. An admin must attach a
# deployment-branch rule (main only) and ideally scope the model and App
# secrets to it — server-side enforcement a dispatched non-main ref cannot bypass by
@ -199,6 +337,13 @@ jobs:
PROVIDER: ${{ inputs.provider || 'openai' }}
INCLUDE_EXPENSIVE: ${{ inputs.include_expensive && '1' || '' }}
steps:
- name: Require startup in this run attempt
run: |
set -euo pipefail
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
exit 1
fi
- name: Require the benchmark auth secret
env:
HAS_ANTHROPIC: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY != '' || secrets.GITNEXUS_BENCH_AUTH_TOKEN != '' }}
@ -492,7 +637,9 @@ jobs:
CE_PLUGIN_VERSION: 3.24.0
run: |
set -euo pipefail
./workflow_bench/run-evolution.sh --apply
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
(( remaining > 0 )) || { echo '::error::No time remains before the stop and evidence reserve.'; exit 1; }
timeout --signal=TERM --kill-after=60 "${remaining}s" ./workflow_bench/run-evolution.sh --apply
working-directory: eval
- name: Upload benchmark evidence
@ -621,3 +768,28 @@ jobs:
--head "${branch}" \
--title 'feat(skills): promoted evolution overlay' \
--body-file "${RUNNER_TEMP}/pr-body.md"
stop-runner:
name: Stop and verify the dedicated EC2 runner
needs: [start-runner, check-runner, runner-ready, watch-evolve-pickup, evolve]
if: >-
always() && github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
needs.start-runner.outputs.cleanup_required == 'true'
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 20
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/ec2-runner
with:
operation: stop
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}

View file

@ -2,19 +2,19 @@ name: Vendored grammar update monitor
# Periodically checks each vendored tree-sitter grammar against its
# source-of-origin and opens a PR re-vendoring any update that is ABI-COMPATIBLE
# with the pinned tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14, #1922). The version
# with the bundled tree-sitter@0.25.1 (LANGUAGE_VERSION 13–15, #1922). The version
# bump then triggers build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
# validates the prebuilds — so a re-vendor that is subtly wrong can never silently
# ship: its PR's CI goes red.
#
# ABI-INCOMPATIBLE updates (the common case — upstreams move to newer tree-sitter)
# are reported as a notice + job summary, NOT applied, so the monitor never opens
# doomed PRs. tree-sitter-c is MONITORED but report-only: it is ABI-pinned at
# 0.21.4 (#1242/#858), so an available c update is surfaced (notice + summary) but
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
# upgrade.
# ABI-INCOMPATIBLE updates (upstreams that move past the runtime's ABI range) are
# reported as a notice + job summary, NOT applied, so the monitor never opens
# doomed PRs. A grammar with a policy `hold` (today: tree-sitter-swift) is
# MONITORED but report-only: an available update is surfaced (notice + summary)
# but never auto-bumped — a maintainer re-vendors it deliberately once the hold's
# condition is met.
#
# The vendored set + per-grammar upstream coords + the tree-sitter-c hold live in
# The vendored set + per-grammar upstream coords + policy holds live in
# .github/vendored-grammars.json — the SHARED source of truth this monitor and
# tree-sitter-upgrade-readiness.yml both read, so the two workflows can never
# disagree about which grammars are vendored (#858). This monitor additionally
@ -125,7 +125,7 @@ jobs:
const body = [
`Automated re-vendor of **${name}** to \`${r.upstream}\` (from ${r.kind === 'npm' ? `npm \`${name}\`` : `\`${r.ref}\``}).`,
'',
`Verified ABI **${r.abi}** — compatible with the pinned \`tree-sitter@0.21.1\` (13–14).`,
`Verified ABI **${r.abi}** — compatible with the bundled \`tree-sitter@0.25.1\` (13–15).`,
'Source-build inputs refreshed; the GitNexus binding.gyp / README / prebuilds are preserved.',
'The version bump triggers `build-tree-sitter-prebuilds.yml` to rebuild + ABI-validate the',
'prebuilds — review its result before merging.',

View file

@ -108,7 +108,7 @@ jobs:
# Pinned to v7.2.0. Verify SHA via:
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
with:
config-name: release-drafter.yml
dry-run: true

View file

@ -287,6 +287,7 @@ jobs:
# id-token: write — npm provenance attestation.
contents: write
id-token: write
actions: read # Fetch exact-revision agent evidence from the trusted main workflow.
outputs:
# Two distinct step IDs feed this output; exactly one fires per run.
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
@ -614,6 +615,17 @@ jobs:
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
run: npm publish --dry-run --tag "$NPM_TAG"
# Every release carries this run's fixed-answer accuracy. Paid paired
# agent runs gate only stable releases; RCs are measured on a schedule.
- name: Download this release's tool accuracy
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-tool-accuracy
path: ${{ runner.temp }}/release-evidence
- name: Require paired agent evidence for this stable commit
if: needs.route.outputs.mode == 'stable'
uses: ./.github/actions/require-agent-release-evidence
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
# We create two refs and push atomically:
# v<RC_VERSION> → annotated tag on a detached release commit whose
@ -842,6 +854,12 @@ jobs:
echo "$NOTES" > /tmp/release-notes.md
echo "fallback=false" >> "$GITHUB_OUTPUT"
fi
{
printf '\n'
cat "$RUNNER_TEMP/release-evidence/accuracy.md"
printf '\n'
cat "$RUNNER_TEMP/release-evidence/agent-evaluation.md"
} >> /tmp/release-notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2
@ -853,11 +871,11 @@ jobs:
|| steps.vtag-gate.outputs.vtag }}
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
# Stable: prefer CHANGELOG body, fall back to auto-generated.
# RC: always auto-generated + the prerelease body block below.
body_path: >-
${{ needs.route.outputs.mode == 'stable' && steps.changelog.outputs.fallback == 'false'
&& '/tmp/release-notes.md' || '' }}
# Stable: CHANGELOG body (or auto-generated notes) plus measured
# evidence. RC: auto-generated + the prerelease body block below.
body_path: ${{ needs.route.outputs.mode == 'stable' && '/tmp/release-notes.md' || '' }}
files: ${{ runner.temp }}/release-evidence/*
fail_on_unmatched_files: true
generate_release_notes: >-
${{ needs.route.outputs.mode == 'rc'
|| steps.changelog.outputs.fallback == 'true' }}
@ -928,6 +946,7 @@ jobs:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
permissions:
contents: read
actions: read
packages: write
id-token: write
attestations: write

409
.github/workflows/release-evaluation.yml vendored Normal file
View file

@ -0,0 +1,409 @@
# Compare the exact candidate with the last stable release using trusted main's
# harness. Share the existing evolution runner, protected settings, and power lock.
#
# The shared group keeps only ONE pending run, even with cancel-in-progress:
# false: a newer queued skill-evolution or release-evaluation run cancels this
# run while it is pending. Before relying on a dispatched evaluation for a
# stable publish, confirm its "Start the dedicated EC2 runner" job ran; if the
# run shows Cancelled, dispatch it again once the group is free.
name: Release evaluation
on:
schedule:
- cron: '0 5 * * 3' # Wednesday 05:00 UTC
workflow_dispatch:
inputs:
runner_only:
description: Start, probe, and stop EC2 without paid model calls
type: boolean
default: false
candidate_ref:
description: 'Full commit SHA or version tag to evaluate; latest-rc resolves the newest published RC'
type: string
default: latest-rc
model:
description: 'Named model for both arms'
type: string
default: gpt-5.6-sol
effort:
description: 'Reasoning effort for both arms'
type: choice
default: xhigh
options: [low, medium, high, xhigh, max]
concurrency:
group: gitnexus-evolution-runner
cancel-in-progress: false
permissions: {}
jobs:
start-runner:
name: Start the dedicated EC2 runner
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 25
permissions:
contents: read
id-token: write
outputs:
run_attempt: ${{ github.run_attempt }}
cleanup_required: ${{ steps.boot.outputs.cleanup_required }}
stop_deadline_epoch: ${{ steps.boot.outputs.stop_deadline_epoch }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/ec2-runner
id: boot
with:
operation: start
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
check-runner:
needs: [start-runner]
name: Bound the wait for the evaluation runner
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 10
permissions:
contents: read
actions: write # Cancel only this run if its dedicated runner stays offline.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# EC2 health does not prove the Actions service can accept a job.
- name: Verify the current run's native pickup probe
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/evolution-runner-ready.py
# A queued self-hosted job has no pickup timeout. Cancel this run so an
# offline probe cannot remain queued indefinitely and block later runs.
- name: Cancel this run when runner readiness fails
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
runner-ready:
needs: [start-runner]
name: Verify the runner service is online
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
# Partial reruns reuse successful startup outputs after cleanup stopped EC2.
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
environment: gitnexus-evolution
timeout-minutes: 5
permissions: {}
steps:
- name: Require startup in this run attempt
run: |
set -euo pipefail
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
exit 1
fi
- name: Verify runner pickup
run: |
set -euo pipefail
test -r /proc/uptime
git --version
uname -sm
watch-evaluate-pickup:
name: Bound pickup of the paid evaluation job
needs: [start-runner, check-runner, runner-ready]
if: inputs.runner_only != true
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: write # Cancel this run if evaluate stays queued after the probe.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Require pickup of this run's paid evaluation job
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/evolution-runner-ready.py --job-name "Evaluate candidate and stable quality"
- name: Cancel this run when evaluation pickup fails
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
evaluate:
name: Evaluate candidate and stable quality
needs: [start-runner, check-runner, runner-ready]
if: >-
github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
inputs.runner_only != true &&
(github.event_name == 'workflow_dispatch' ||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
# Reuse the main-only protected model-secret environment. No tag/fork code
# runs as the harness, and no release or PR write token is available here.
environment: gitnexus-evolution
timeout-minutes: 1260
permissions:
contents: read
env:
INPUT_REF: ${{ inputs.candidate_ref || 'latest-rc' }}
MODEL: ${{ inputs.model || 'gpt-5.6-sol' }}
EFFORT: ${{ inputs.effort || 'xhigh' }}
steps:
- name: Require startup in this run attempt
run: |
set -euo pipefail
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Resolve immutable runtime revision
id: resolve
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Resolve last stable once; both runtimes use the same trusted tasks.
stable="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name')"
[[ "$stable" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo '::error::No stable release found.'; exit 1; }
stable_sha="$(git rev-parse --verify "$stable^{commit}")"
echo "stable_sha=$stable_sha" >> "$GITHUB_OUTPUT"
ref="$INPUT_REF"
if [[ "$ref" == 'latest-rc' ]]; then
ref="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq \
'[.[] | select(.prerelease and (.draft | not)) | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[0-9]+$"))] | sort_by(.published_at) | last | .tag_name // empty')"
fi
if ! [[ "$ref" =~ ^[0-9a-f]{40}$ || "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo '::error::No published RC found, or candidate_ref is not a full SHA/version tag.'
exit 1
fi
sha="$(git rev-parse --verify "$ref^{commit}")"
if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
# Manual stable candidates must already be reviewed on main.
git merge-base --is-ancestor "$sha" HEAD
else
# Versioned RC trees are published descendants, not ancestors of
# main. Accept their official published release rather than PR refs.
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$ref" --jq '.draft == false' | grep -qx true
fi
echo "Runtime: $sha"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.resolve.outputs.sha }}
path: candidate
persist-credentials: false
fetch-depth: 0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.resolve.outputs.stable_sha }}
path: stable
persist-credentials: false
fetch-depth: 0
- name: Initialize this run's measurement directory
run: |
rm -rf "$RUNNER_TEMP/release-evaluation"
mkdir -p "$RUNNER_TEMP/release-evaluation/public"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.18.0'
cache: npm
cache-dependency-path: candidate/gitnexus/package-lock.json
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: '0.11.23'
python-version: '3.13'
enable-cache: true
cache-dependency-glob: eval/uv.lock
# Both runtimes grade the pinned task source against one dependency tree
# built from that commit's lockfile, never the runtime under test's.
- name: Resolve pinned task revision
id: tasks
working-directory: eval
run: |
set -euo pipefail
sha="$(uv run --locked --extra dev python -m workflow_bench.release_report task-sha)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::The release task set has no single pinned commit.'; exit 1; }
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.tasks.outputs.sha }}
path: tasks-base
persist-credentials: false
fetch-depth: 0
- name: Install sandbox runtime and pinned Claude CLI
run: |
set -euo pipefail
sudo apt-get -o DPkg::Lock::Timeout=600 update
sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends bubblewrap ripgrep socat
apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
if [[ -r "$apparmor_userns" ]] && [[ "$(<"$apparmor_userns")" == '1' ]]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
cli="$RUNNER_TEMP/claude-canary"
install -d -m 0700 "$cli"
install -m 0600 .github/claude-canary-runtime/package{,-lock}.json "$cli/"
npm ci --prefix "$cli" --audit=false --fund=false
test "$("$cli/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = '2.1.214 (Claude Code)'
- name: Build pinned runtime and task dependencies
working-directory: eval
run: |
set -euo pipefail
for checkout in tasks-base stable candidate; do
uv run --locked --extra dev python -m workflow_bench.release_build \
--repo "$GITHUB_WORKSPACE/$checkout"
done
- name: Prepare pinned task set
working-directory: eval
run: |
set -euo pipefail
for runtime in stable candidate; do
uv run --locked --extra dev python -m workflow_bench.release_report prepare \
--repo "$GITHUB_WORKSPACE/$runtime" --task-repo "$GITHUB_WORKSPACE/tasks-base" \
--out "$RUNNER_TEMP/release-evaluation/$runtime" \
--model "$MODEL" --effort "$EFFORT" --runs 3
done
- name: Require real containment before paid sessions
working-directory: eval
env:
GITNEXUS_REQUIRE_BWRAP_CANARY: '1'
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
run: uv run --locked --extra dev python -m pytest tests/test_proposer_sandbox.py -q
- name: Materialize both release graphs before paid sessions
timeout-minutes: 120
working-directory: eval
run: |
set -euo pipefail
for runtime in stable candidate; do
uv run --locked --extra dev python -m workflow_bench.release_preflight \
--tasks "$RUNNER_TEMP/release-evaluation/$runtime/tasks.yaml" \
--gitnexus-root "$GITHUB_WORKSPACE/$runtime" \
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
done
- name: Run repeated paired agent sessions
timeout-minutes: 1140
working-directory: eval
env:
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
for runtime in stable candidate; do
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
(( remaining > 0 )) || { echo '::error::No time remains before the stop and evidence reserve.'; exit 1; }
timeout --signal=TERM --kill-after=60 "${remaining}s" \
uv run --locked --extra dev python -m workflow_bench.runner \
--tasks "$RUNNER_TEMP/release-evaluation/$runtime/tasks.yaml" \
--gitnexus-root "$GITHUB_WORKSPACE/$runtime" \
--arms baseline_nomcp baseline --runs 3 --include-expensive \
--workers 3 --timeout 1800 --model "$MODEL" --effort "$EFFORT" \
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude" \
--out "$RUNNER_TEMP/release-evaluation/$runtime/raw"
done
- name: Summarize complete or incomplete measurements
if: ${{ always() && steps.resolve.outputs.sha != '' }}
working-directory: eval
run: |
failed=0
for runtime in stable candidate; do
if [[ -f "$RUNNER_TEMP/release-evaluation/$runtime/metadata.json" ]]; then
uv run --locked --extra dev python -m workflow_bench.release_report summarize \
--results "$RUNNER_TEMP/release-evaluation/$runtime/raw/results.jsonl" \
--metadata "$RUNNER_TEMP/release-evaluation/$runtime/metadata.json" \
--out "$RUNNER_TEMP/release-evaluation/$runtime/public" || failed=1
else
echo "::error::$runtime evaluation preparation failed; no measurements exist."
failed=1
fi
done
exit "$failed"
- name: Require complete paired evidence and no task regression
if: ${{ always() && steps.resolve.outputs.sha != '' }}
working-directory: eval
run: |
uv run --locked --extra dev python -m workflow_bench.release_gate \
--candidate "$RUNNER_TEMP/release-evaluation/candidate/public/agent-evaluation.json" \
--candidate-sha '${{ steps.resolve.outputs.sha }}' \
--stable "$RUNNER_TEMP/release-evaluation/stable/public/agent-evaluation.json" \
--stable-sha '${{ steps.resolve.outputs.stable_sha }}' \
--out "$RUNNER_TEMP/release-evaluation/public"
- name: Collect sanitized evidence and publish summary
if: ${{ always() && steps.resolve.outputs.sha != '' }}
run: |
set -euo pipefail
public="$RUNNER_TEMP/release-evaluation/public"
mkdir -p "$public"
for extension in json md; do
candidate="$RUNNER_TEMP/release-evaluation/candidate/public/agent-evaluation.$extension"
stable="$RUNNER_TEMP/release-evaluation/stable/public/agent-evaluation.$extension"
if [[ -f "$candidate" ]]; then cp "$candidate" "$public/agent-evaluation.$extension"; fi
if [[ -f "$stable" ]]; then cp "$stable" "$public/stable-agent-evaluation.$extension"; fi
done
for report in release-quality-gate agent-evaluation; do
if [[ -f "$public/$report.md" ]]; then cat "$public/$report.md" >> "$GITHUB_STEP_SUMMARY"; fi
done
- name: Upload sanitized release evidence
if: ${{ always() && steps.resolve.outputs.sha != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-agent-evaluation-${{ steps.resolve.outputs.sha }}
path: |
${{ runner.temp }}/release-evaluation/public/agent-evaluation.json
${{ runner.temp }}/release-evaluation/public/agent-evaluation.md
${{ runner.temp }}/release-evaluation/public/stable-agent-evaluation.json
${{ runner.temp }}/release-evaluation/public/stable-agent-evaluation.md
${{ runner.temp }}/release-evaluation/public/release-quality-gate.json
${{ runner.temp }}/release-evaluation/public/release-quality-gate.md
if-no-files-found: error
retention-days: 30
stop-runner:
name: Stop and verify the dedicated EC2 runner
needs: [start-runner, check-runner, runner-ready, watch-evaluate-pickup, evaluate]
if: >-
always() && github.repository == 'abhigyanpatwari/GitNexus' &&
github.ref == 'refs/heads/main' &&
needs.start-runner.outputs.cleanup_required == 'true'
runs-on: ubuntu-latest
environment: gitnexus-evolution
timeout-minutes: 20
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/ec2-runner
with:
operation: stop
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}

View file

@ -1,8 +1,9 @@
name: Tree-sitter Upgrade Readiness
name: Tree-sitter Compatibility Workarounds
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
# with tree-sitter@0.25.0 without --legacy-peer-deps?
# Tracks the workarounds GitNexus carries for its bundled tree-sitter runtime:
# 1. Peer-dep patches — which npm grammars still reject the runtime, so
# prepare-tree-sitter-bundle.cjs must keep patching their manifests at pack
# time, and which patches a grammar bump would let us drop.
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json
# (c/swift/kotlin/dart/proto/objc) is classified by its vendored ABI, read
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
@ -30,6 +31,7 @@ on:
- '.github/scripts/test_check_tree_sitter_upgrade_readiness.py'
- '.github/vendored-grammars.json'
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
- 'gitnexus/scripts/prepare-tree-sitter-bundle.cjs'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -100,7 +102,7 @@ jobs:
- name: Annotate PR with readiness status
if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0'
run: |
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
echo "::notice::Tree-sitter compatibility workarounds remain. See job output for the full report."
# Issue mutation is isolated here so `issues: write` is only ever granted on the
# scheduled run (never on PRs). Consumes the report + exit_code via job outputs.
@ -113,18 +115,20 @@ jobs:
permissions:
issues: write
steps:
- name: Upsert tracking issue on blockers
- name: Upsert tracking issue while workarounds remain
if: needs.report.outputs.exit_code != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
REPORT: ${{ needs.report.outputs.report }}
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
const title = 'Tree-sitter compatibility workarounds';
// Adopt the issue opened under the pre-0.25 title instead of opening a twin.
const titles = [title, 'Tree-sitter 0.25 upgrade readiness'];
const report = process.env.REPORT;
const body = report + '\n\n' +
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
'Closes automatically when all blockers are resolved.</sub>';
'Closes automatically when no workarounds remain.</sub>';
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
@ -132,7 +136,7 @@ jobs:
labels: 'tree-sitter-drift',
per_page: 10,
});
const existing = open.find(i => i.title === title);
const existing = open.find(i => titles.includes(i.title));
if (existing) {
// Extract ready/total count for the changelog comment.
// The two report.match() regexes below are mirrored as
@ -156,7 +160,7 @@ jobs:
);
const blockerMatch = requireMatch(
'blocker count',
report.match(/\*\*Blocked\*\* — (\d+) grammars? /),
report.match(/\*\*Workarounds\*\* — (\d+) grammars? /),
);
const ready = readyMatch[1];
const total = readyMatch[2];
@ -189,7 +193,7 @@ jobs:
}
const today = new Date().toISOString().slice(0, 10);
let comment = `**${today}:** ${ready}/${total} npm-installed ready. ${blockers} blocker(s) remaining.`;
let comment = `**${today}:** ${ready}/${total} npm-installed accept the runtime. ${blockers} workaround(s) remaining.`;
if (changes.length > 0) {
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
} else {
@ -206,6 +210,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
title,
body,
});
core.info(`Updated existing issue #${existing.number}`);
@ -225,7 +230,9 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
const title = 'Tree-sitter compatibility workarounds';
// Adopt the issue opened under the pre-0.25 title instead of opening a twin.
const titles = [title, 'Tree-sitter 0.25 upgrade readiness'];
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
@ -233,13 +240,13 @@ jobs:
labels: 'tree-sitter-drift',
per_page: 10,
});
const existing = open.find(i => i.title === title);
const existing = open.find(i => titles.includes(i.title));
if (existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.',
body: 'No compatibility workarounds remain: every grammar runs on the bundled tree-sitter runtime as published. Closing automatically.',
});
await github.rest.issues.update({
owner: context.repo.owner,

View file

@ -95,6 +95,7 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
- `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization).
- Global single-slot (manual dispatch utilities): `${{ github.workflow }}`
- **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). Approved literal prefixes: `CI-` (`ci.yml`) and `docker-build-push-` (`docker.yml`). The `check-workflow-concurrency.py` validation script must be updated whenever a new approved literal prefix is added.
- **Shared EC2 resource:** `gitnexus-skill-evolution.yml` and `release-evaluation.yml` use the exact group `gitnexus-evolution-runner` with `cancel-in-progress: false`. They must serialize startup, paid work and hosted shutdown across both workflows. This exception is limited to those two files.
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
- **`cancel-in-progress` policy:**
@ -155,12 +156,36 @@ Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow
**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
### Native tree-sitter npm packages
The CLI pins and bundles the native `tree-sitter` runtime and its npm grammars.
Several compatible grammars still declare older runtime peer ranges. Root npm
`overrides` do not propagate to consumers, and older npm versions validate bundled
peers against their original ranges even when an override is configured. The
lockfile therefore pins the same eight audited peers to exactly `0.25.1`.
`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` runs during `postinstall`
and `prepack` to keep installed dependency manifests consistent with that lockfile
and the published bundle. It leaves grammar sources and native binaries unchanged
and rejects unexpected versions, peer ranges, or dependency layouts before writing
any manifests. The upstream ranges remain in the script only as validation inputs;
installed and published audited manifests require exactly `0.25.1`.
When upgrading these packages, update the exact pins, lockfile, and audited peer
list together. If regenerating the lockfile restores upstream's older peer ranges,
restore the same audited `0.25.1` peer pins before committing it. Verify ordinary
`npm ci` and a subsequent `npm install` complete without peer-resolution warnings.
Run the parser, extraction, CFG, and worker tests, then use a real
`npm pack` (including `prepack`) to test a fresh consumer's `npm install`,
`npm ci`, `npm ls`, and a subsequent dependency install. Check native parser
loading on the supported Node.js versions and release platforms. A successful
install in this checkout alone does not verify the published bundle.
### Vendored tree-sitter grammars
`.github/vendored-grammars.json` is the **single source of truth** for the vendored tree-sitter grammar **set** and each grammar's policy `hold` (the ones shipped from `gitnexus/vendor/<name>` rather than installed from npm). It lists each grammar's name, upstream coords (`npm` or `github`), and any `hold`. The monitor resolves upstreams from it; the readiness report keeps its own upstream-drift coords and reads vendored ABIs from `gitnexus/vendor/`. Two workflows read it:
- `grammar-update-monitor.yml` (`.github/scripts/update-vendored-grammars.mjs`) — weekly; opens auto-PRs re-vendoring ABI-compatible upstream updates.
- `tree-sitter-upgrade-readiness.yml` (`.github/scripts/check-tree-sitter-upgrade-readiness.py`) — daily; renders the tree-sitter-0.25 readiness report (issue #858), reading each vendored grammar's ABI from `gitnexus/vendor/<name>/src/parser.c`.
- `tree-sitter-upgrade-readiness.yml` (`.github/scripts/check-tree-sitter-upgrade-readiness.py`) — daily; renders the tree-sitter compatibility-workarounds report (pack-time peer patches, intentional pins, vendored holds; issue #858), reading each vendored grammar's ABI from `gitnexus/vendor/<name>/src/parser.c`.
Sharing the manifest keeps the two aligned: a consistency-guard test asserts the manifest set equals the `gitnexus/vendor/tree-sitter-*` directories. **When you vendor a new grammar (or remove one), update `.github/vendored-grammars.json` in the same change** — otherwise that guard fails CI and the readiness report regresses to `?` placeholders.
@ -184,7 +209,12 @@ routes between two modes based on the triggering event:
`gitnexus-claude-plugin/.codex-plugin/plugin.json`,
`.agents/plugins/marketplace.json`, and the matching `CHANGELOG.md` entry in
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
versions drift.
versions drift. Stable publication also requires paired agent evidence for
the exact tagged commit: run **Release evaluation** from `main` with
`candidate_ref` set to the full commit SHA, confirm the run started and
passed, and push the tag within seven days. Without that evidence, npm and
Docker publication fail. See `eval/workflow_bench/README.md` § Release
evaluation.
- **Release-candidate mode** — runs on every push to `main` (typically a
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
via `paths-ignore`. Publishes to the `rc` dist-tag with version
@ -197,6 +227,9 @@ routes between two modes based on the triggering event:
the cycle from `latest`.
- `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the
registry. First rc for a given base is `rc.1`.
- The RC is gated by CI, including the fixed-answer tool-accuracy check
(`gitnexus/bench/tool-accuracy/`); its report is attached to every
release. RCs do not wait for a paid agent evaluation.
- After the npm publish succeeds, the workflow calls `docker.yml` as a
reusable workflow to build and push the corresponding RC Docker images
(e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`, mirrored to

View file

@ -60,9 +60,9 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Analyze reports INCOMPLETE with a collapsed graph write
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`.
- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
- **Trigger:** Analysis rejects a staged graph write with expected and persisted relationship counts, or an in-place run prints `Repository indexed INCOMPLETELY`, exits nonzero, and records `incompleteReasons: ["graph-write-collapsed"]`.
- **Do:** Inspect COPY errors, available memory, and free disk space on the volume holding `.gitnexus/`; confirm no second `analyze` is running against the same repo, then run `npx gitnexus doctor`. For native buffer-pool exhaustion, see [RUNBOOK.md](RUNBOOK.md#memory--analyze-crashes) for `GITNEXUS_LBUG_BUFFER_POOL_SIZE` and memory-budget guidance. Re-run `npx gitnexus analyze --force` after addressing the cause.
- **Why:** Far fewer relationships are readable back than the pipeline produced. A staged build throws before publication and preserves the previous index, if any. In-place writes cannot roll back, so the incomplete stamp and nonzero exit prevent a partial graph from being presented as successful. Recognized buffer exhaustion also throws immediately during COPY or fallback inserts. The collapse check compares in-memory totals (including streamed rows) against the post-write count, stays unmeasurable when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
### MCP lists no repos

View file

@ -185,6 +185,30 @@ Orchestrator: `.github/workflows/ci.yml`.
Analyze re-execs Node with a **large old-space heap** when needed (`analyze.ts`). If you still OOM on huge repos, close other processes, avoid `--embeddings` for a first pass, or analyze a smaller path if supported by your workflow.
`Unable to allocate memory` / `The buffer pool is full` during graph writes
refers to LadybugDB's native buffer pool. Increasing `NODE_OPTIONS`,
`--memory-budget`, or worker heap limits does not resize that pool. Recognized
pool exhaustion aborts analysis instead of retrying with row skipping.
If the host has enough memory, set an explicit pool size in **bytes**, then
rebuild. For example, 3 GiB is `3221225472` bytes:
```bash
GITNEXUS_LBUG_BUFFER_POOL_SIZE=3221225472 npx gitnexus analyze --force
```
The override bypasses automatic pool sizing. Budget for the Node heap, parse
workers, other native allocations, and the OS alongside it; a larger pool can
exhaust the host. `GITNEXUS_LBUG_BUFFER_POOL_SIZE=0` restores LadybugDB's native
80%-of-RAM default, not an unlimited supply of memory. If memory is insufficient,
exclude generated/vendor directories or use a larger host.
A measured graph-write collapse aborts a staged build before publication,
leaving the previous index, if any, intact. An in-place run cannot roll back:
it records `graph-write-collapsed` and exits nonzero. Rebuild a previously
collapsed index with `--force` after addressing memory, disk space, or the
reported COPY error; do not rely on its incomplete query results.
---
## LadybugDB / lock errors

View file

@ -0,0 +1,191 @@
# Local import scoping
GitNexus resolves statically known local imports through their lexical owner and
actual declaration identity. A nearer name that is unresolved, unavailable, or
noncallable blocks an unrelated outer or workspace fallback. An absent name can
continue through the existing lookup rules. This is static analysis: it does not
execute loaders or infer arbitrary runtime initialization order.
## Shared contract
Language rules stay in providers. The shared
[name-claim lookup](../../gitnexus-shared/src/scope-resolution/name-claims.ts)
distinguishes `absent`, `blocked`, and `resolved` before filtering by target kind.
Its [regressions](../../gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts)
cover noncallable barriers, unresolved imports, type/value lookup, declaration
positions, and inferred aliases that retain their initializer's lookup position.
Calls, constructors, receiver/type inference, inheritance, namespace lookup, and
fallback consumers use these ownership facts rather than independently choosing a
same-named symbol.
The [shared records](../../gitnexus-shared/src/scope-resolution/types.ts) carry
ownership, activation positions, directive destinations, and lookup purpose as
serializable data. They retain declaration identity and ambiguity; they contain
no AST nodes or callbacks. `declaredAtScope` describes ownership, while
`runsOnlyWhenCalled` describes deferred execution. A function-local Rust `use` or
C `#include` is not deferred merely because of its position; Python and Ruby
loading can be. The
[function-local import chain tests](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts)
pin that distinction.
## Providers with expanded lexical support
These seven entries are part of the
[18-provider registry](../../gitnexus/src/core/ingestion/languages/index.ts).
The evidence links cover exact targets and negative cases, not just edge counts.
| Provider | Supported behavior | Source and regression evidence |
| --- | --- | --- |
| Python | Function-local named, aliased, and namespace imports; compiler-local ownership; ordered class lookup; `global`/`nonlocal` destinations. | [Lexical facts](../../gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts), [local imports](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts), [namespace semantics](../../gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts) |
| TypeScript | Literal genuine `require`, immediately awaited `import`, and import-equals; namespace, named, and renamed bindings; type/value separation. | [Loader collector](../../gitnexus/src/core/ingestion/languages/typescript/local-loaders.ts), [loader tests](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts), [type ownership](../../gitnexus/test/unit/scope-resolution/typescript/lexical-type-ownership.test.ts) |
| JavaScript | The same supported runtime loaders and lexical barriers, using JavaScript captures for JS/JSX. | [Captures](../../gitnexus/src/core/ingestion/languages/javascript/captures.ts), [JS/JSX loader cases](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts) |
| Vue | Local loader facts in extracted scripts, with original declaration graph positions preserved. Explicit JS/JSX uses its own semantic grammar inside the worker; TS reuses the cached tree. | [Script delegation](../../gitnexus/src/core/ingestion/languages/vue/captures.ts), [direct/pre-extracted parity](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts), [real workers](../../gitnexus/test/integration/local-import-worker-parity.test.ts) |
| Rust | Lexical `use`, producer identity for imported factories/types, and Cargo-aware crate/module identity. Unresolved explicit imports block unrelated fallback. | [Lexical facts](../../gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts), [import targets](../../gitnexus/src/core/ingestion/languages/rust/import-target.ts), [local scope](../../gitnexus/test/integration/resolvers/rust-import-scope.test.ts), [Cargo boundaries](../../gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts) |
| C++ | Position-sensitive local `using` declarations/directives, separate from includes; propagated header claims retain unresolved named `using` barriers. | [Captures](../../gitnexus/src/core/ingestion/languages/cpp/captures.ts), [local using](../../gitnexus/test/integration/resolvers/cpp-local-using.test.ts), [header using](../../gitnexus/test/integration/resolvers/cpp-local-using-headers.test.ts) |
| Zig | Literal `@import` namespace/member bindings, block activation, container hoisting, stable deep aliases, and inline `@import` receivers. | [Captures](../../gitnexus/src/core/ingestion/languages/zig/captures.ts), [local imports](../../gitnexus/test/integration/resolvers/zig-local-imports.test.ts), [static gating](../../gitnexus/test/integration/resolvers/zig-static-gating.test.ts) |
### Python boundaries
Compiler-local binders include parameters, assignment patterns, loop/with/except
targets, match captures, augmented assignment, deletion, and comprehension/walrus
bindings. Comprehension targets do not leak, and the first iterable is evaluated
outside the comprehension environment. Class bodies use ordered locals; methods
skip class locals for bare-name lookup while explicit instance receivers remain
available. Annotation-only declarations distinguish compiler ownership from a
runtime store. These cases are characterized in the
[Python local-import tests](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts).
Mixed import/value rebinding and conflicting import identities conservatively
abstain throughout the environment, including simple sequential rebinding; this
is not a control-flow evaluator. A definite `def`/`class` declaration can still
supply its own target after activation. The
[provider policy](../../gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts)
and [rebinding regressions](../../gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts)
define this boundary. There is no full annotation evaluator, `exec` evaluation,
or general closure scheduling model. A deferred `global`/`nonlocal` installer is
not assumed to have executed for unrelated callers.
Conditional imports with one compatible identity remain available for structural
reference resolution, including `TYPE_CHECKING` annotations and `try` imports.
Their runtime execution is not guaranteed by a resolved graph edge.
Default expressions look up names in the enclosing environment. FastAPI's
synthetic dependency graph caller is recorded separately as `callerScope`; it
does not move lexical lookup into the handler body. Both
[direct extraction](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts)
and [worker transport](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
check this distinction.
### JavaScript-family boundaries
Loader recognition checks AST shape and binding identity, including parameters,
catch/destructuring bindings, temporal-dead-zone ownership, and writes to the
loader or imported handle. CommonJS forwarding uses that same identity check.
Reassigning one destructured import leaves unchanged sibling re-exports intact.
Writes to ordinary locals retain their declared type and callable-value facts.
Unawaited dynamic imports remain Promise/file-dependency facts rather than module
namespace aliases. Computed specifiers, indirect loader/Promise chains, and
unsupported destructuring remain unresolved; see the
[loader regressions](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts).
Vue's explicit JS/JSX script requires an additional bounded semantic parse because
the worker's structural tree uses TypeScript. That parse runs inside the real
worker. The [parity test](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
witnesses returned worker facts; a main-thread fallback does not satisfy it.
### Rust, C++, and Zig boundaries
Rust uses Cargo editions, target roots, and module identities, including files
with multiple valid identities. Reusable per-analysis indexes bound repeated
lookup work; this is not a claim of a global indexing speedup. See
[Cargo metadata](../../gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts)
and its [tests](../../gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts).
Exact same-name inline `pub use` forwarding is supported. A forwarding alias that
changes symbol spelling through a file-only target conservatively remains
unresolved; ambiguous same-file homonyms and re-export cycles cannot select an
unrelated declaration. These limits are explicit in the
[Cargo fallback regressions](../../gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts).
Inferred receiver types retain their receiving declaration range. When same-block
shadowing replaces a retained type fact, earlier uses cannot consume the later
declaration's type and may remain unresolved; this is not full SSA inference. See
the [factory ownership regressions](../../gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts).
C++ includes keep their compilation-unit behavior alongside lexical `using`.
The header tests cover literal includes, declaration positions, unresolved named
using claims, and overload identity. They do not establish a full preprocessor,
namespace-alias evaluator, or arbitrary transitive `using namespace` chain support.
Zig nonliteral imports remain unresolved; its block-local names do not escape or
activate before their declarations.
## Retained provider behavior
The other eleven providers retain their existing import/loading models. This
work does not reinterpret every dependency construct as a local value import.
| Provider | Retained model and boundary | Evidence |
| --- | --- | --- |
| Ruby | Wildcard loading; method-local loading is deferred. The extractor recognizes string arguments to `require`, `require_relative`, and `load`. The linked behavioral tests establish `require`/`require_relative` and deferral, not arbitrary `load` execution. | [Extractor](../../gitnexus/src/core/ingestion/languages/ruby/captures.ts), [loading](../../gitnexus/test/integration/resolvers/ruby-scope.test.ts), [deferral](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts) |
| PHP | Namespace `use` imports are distinct from closure capture and trait `use`. The scope provider does not propagate names through `include`/`require`. | [Query](../../gitnexus/src/core/ingestion/languages/php/query.ts), [ownership](../../gitnexus/src/core/ingestion/languages/php/simple-hooks.ts), [resolver coverage](../../gitnexus/test/integration/resolvers/php.test.ts) |
| C | Header includes remain preprocessor/compilation-unit dependencies, including includes written inside functions; no full preprocessing is added. | [Include interpretation](../../gitnexus/src/core/ingestion/languages/c/interpret.ts), [include tests](../../gitnexus/test/unit/scope-resolution/c/c-imports.test.ts), [deferral boundary](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts) |
| Objective-C | Headers, modules, and compilation-unit siblings retain the dedicated provider rules. | [Provider guide](objective-c-provider.md), [resolver](../../gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts), [workspace tests](../../gitnexus/test/unit/scope-resolution/objective-c/objc-workspace.test.ts) |
| COBOL | `COPY` stays on the dedicated copybook path with module ownership. | [Interpretation](../../gitnexus/src/core/ingestion/languages/cobol/interpret.ts), [COPY target parity](../../gitnexus/test/unit/scope-resolution/cobol-import-target-parity.test.ts) |
| Go | File imports and package sibling visibility retain separate provider rules. | [Import ownership](../../gitnexus/src/core/ingestion/languages/go/simple-hooks.ts), [imports](../../gitnexus/test/unit/scope-resolution/go/go-imports.test.ts), [package siblings](../../gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts) |
| Java | Imports belong to the compilation unit. | [Ownership](../../gitnexus/src/core/ingestion/languages/java/simple-hooks.ts), [named/wildcard import resolution](../../gitnexus/test/integration/resolvers/java.test.ts) |
| Kotlin | File import headers retain their existing alias/package resolution. | [Header decomposition](../../gitnexus/src/core/ingestion/languages/kotlin/import-decomposer.ts), [resolver tests](../../gitnexus/test/integration/resolvers/kotlin.test.ts) |
| C# | Namespace/file `using` directives remain imports; resource `using` statements are not import directives. | [Directive query](../../gitnexus/src/core/ingestion/languages/csharp/query.ts), [import tests](../../gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts) |
| Swift | Imports and target/module visibility retain provider ownership. | [Ownership](../../gitnexus/src/core/ingestion/languages/swift/simple-hooks.ts), [import decomposition](../../gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts), [target siblings](../../gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts) |
| Dart | Library imports retain existing package identity and visibility rules. | [Ownership](../../gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts), [library/package regressions](../../gitnexus/test/integration/resolvers/dart.test.ts) |
## Taint provenance
Taint source, sink, and sanitizer matching uses the same lexical provenance.
A genuine external import may match its raw module name even when that package
has no indexed source. A sibling import, local shadow, or type-only import cannot
authorize that match. General copied-handle tracking is outside this support.
The [taint regressions](../../gitnexus/test/unit/taint/lexical-imports.test.ts)
cover extraction, emission, and function summaries, including abstention when
exact call-site positions are missing.
## Cache migration and verification evidence
[Parse-cache schema 136](../../gitnexus/src/storage/parse-cache.ts) replaces 133,
134, and 135. It invalidates overly broad Python/TS/JS binding barriers,
incorrect parameter-annotation lookup ownership, and embedded Vue grammar and
source positions. The same version gate applies to the
[durable ParsedFile store](../../gitnexus/src/storage/parsedfile-store.ts).
Incompatible records are rejected and rebuilt on the next analysis; unchanged
records can then be reused. The
[schema regression](../../gitnexus/test/unit/incremental-parse-cache.test.ts)
explicitly rejects valid schema-133, schema-134, and schema-135 records in both stores.
PDG-mode chunk keys also advance to `pdg:6`, so embedded-script CFGs with
incorrect call anchors are rebuilt before the resolved-callee join.
The [real-worker parity suite](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
witnesses worker boot, dispatch, and returned ParsedFiles, then compares complete
provider facts with direct extraction, including `callerScope` and C++ side-channel
data. Optional grammar availability is explicit in the test gate.
The [persistence suite](../../gitnexus/test/integration/resolvers/local-import-persistence.test.ts)
loads both stores afresh from disk. Its parse-cache chunks contain no embedded
ParsedFiles, so warm equality requires durable-store reuse. It checks identical
`CALLS`/`IMPORTS` identities with zero extraction and zero worker dispatch on an
unchanged warm run. Editing a local import target or shadowing `require` removes
the stale target, and a second warm run preserves the edited graph.
Both suites are in the [cross-platform manifest](../../gitnexus/scripts/cross-platform-tests.ts);
the [shard regression](../../gitnexus/test/unit/cross-platform-shard.test.ts)
ensures each is selected exactly once across the four Windows/macOS shards.
[CI](../../.github/workflows/ci-tests.yml) owns native execution on those systems;
Ubuntu's full suite discovers the tests normally. These are focused evidence
boundaries, not a statement that the final full suite or every platform has passed.
## Relationship to main and issue #3499
The integrated main baseline `50aa4be3` already includes #3502's nested Python
declaration ownership fix for the original #3499 case and #3504's aliased package
re-export fix. Their [Python integration coverage](../../gitnexus/test/integration/resolvers/python.test.ts)
and [module namespace coverage](../../gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts)
remain relevant. This branch extends that base with the lexical claims, provider
semantics, and persistence evidence described above. Issue coverage is **Related
#3499**; this document does not assert that all dynamic Python behavior is solved
or make an issue-status claim.

View file

@ -0,0 +1,99 @@
"""Baseline (no-GitNexus) guidance scrubbing contracts."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from workflow_bench import baseline_guidance
@pytest.mark.parametrize("launcher", ["npx", "bunx", "pnpm dlx"])
def test_repository_guidance_filters_launcher_flags_without_hiding_ordinary_tests(launcher):
guidance = (
f"Bootstrap with {launcher} --yes --package=gitnexus gitnexus@1.6.12.\n"
f"Validate with {launcher} --yes vitest run.\n"
)
assert baseline_guidance.ordinary_repository_guidance(guidance) == f"Validate with {launcher} --yes vitest run.\n"
def test_repository_guidance_does_not_backtrack_on_malformed_launcher_options():
# The old overlapping dash quantifiers take exponential time on this input.
# A subprocess deadline makes a recurrence fail without hanging pytest.
result = subprocess.run(
[
sys.executable,
"-c",
(
"from workflow_bench.baseline_guidance import ordinary_repository_guidance; "
"text = 'npx ' + '-- -' * 10000 + '\\n'; "
"assert ordinary_repository_guidance(text) == text"
),
],
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize(
"guidance",
[
"Build first.\n<!-- gitnexus:end -->\n",
"<!-- gitnexus:start -->\n<!-- gitnexus:start -->\nUse GitNexus.\n<!-- gitnexus:end -->\n",
"Build first.\n<!-- gitnexus:start -->\nUse GitNexus.\n",
],
)
def test_unbalanced_gitnexus_markers_fail_closed(guidance):
with pytest.raises(baseline_guidance.GuidanceError, match="unbalanced GitNexus markers"):
baseline_guidance.ordinary_repository_guidance(guidance)
def test_module_has_no_sandbox_import_cycle():
source = Path(baseline_guidance.__file__).read_text()
assert "proposer_sandbox" not in source
@pytest.mark.parametrize("indent", ["", " ", " ", " "])
def test_gitnexus_sections_are_stripped_under_commonmark_heading_indentation(indent):
guidance = (
"Build first.\n"
f"{indent}## GitNexus rules\n"
"Always consult the code graph before editing.\n"
"## Testing\n"
"Run the unit tests.\n"
)
assert baseline_guidance.ordinary_repository_guidance(guidance) == "Build first.\n## Testing\nRun the unit tests.\n"
@pytest.mark.parametrize(
"heading",
[
"## GitNexus rules ##",
"## GitNexus rules #####",
" ### GitNexus — Code Intelligence ###",
"## GitNexus rules ",
"##\tGitNexus rules",
],
)
def test_gitnexus_sections_are_stripped_for_every_commonmark_atx_form(heading):
guidance = (
f"Build first.\n{heading}\nAlways consult the code graph before editing.\n## Testing\nRun the unit tests.\n"
)
assert baseline_guidance.ordinary_repository_guidance(guidance) == "Build first.\n## Testing\nRun the unit tests.\n"
@pytest.mark.parametrize("line", ["#GitNexus rules", " ## GitNexus rules", "####### GitNexus rules"])
def test_lines_that_are_not_atx_headings_do_not_open_a_section(line):
guidance = f"{line}\nRun the unit tests.\n"
assert baseline_guidance.ordinary_repository_guidance(guidance) == guidance
@pytest.mark.parametrize("marker", ["-", "*", "+", "1.", "2)", "10.", " -"])
def test_gitnexus_list_items_drop_their_continuation_lines_for_every_commonmark_marker(marker):
guidance = f"{marker} Run npx gitnexus analyze\n before editing.\nBuild first.\n"
assert baseline_guidance.ordinary_repository_guidance(guidance) == "Build first.\n"

View file

@ -214,7 +214,7 @@ def _valid_cli_result() -> ManagedProcessResult:
)
def test_run_claude_passes_plugin_dir_explicitly_under_bare(monkeypatch, tmp_path: Path) -> None:
def test_run_claude_passes_plugin_dir_explicitly(monkeypatch, tmp_path: Path) -> None:
commands: list[list[str]] = []
def fake_run(command, **_kwargs):
@ -227,12 +227,10 @@ def test_run_claude_passes_plugin_dir_explicitly_under_bare(monkeypatch, tmp_pat
tmp_path,
claude_bin="claude",
timeout=5,
bare=True,
plugin_dirs=(SANDBOX_CE_PLUGIN,),
)
runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5, bare=True)
runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5)
assert "--bare" in commands[0]
assert commands[0][commands[0].index("--plugin-dir") + 1] == SANDBOX_CE_PLUGIN
assert "--plugin-dir" not in commands[1]

View file

@ -0,0 +1,197 @@
"""Both trusted workflows must own a complete, serialized EC2 lifecycle."""
from pathlib import Path
import json
import os
import re
import subprocess
import sys
import yaml
import pytest
ROOT = Path(__file__).resolve().parents[2]
def test_shared_ec2_lock_obeys_repository_concurrency_gate():
subprocess.run(
[sys.executable, str(ROOT / ".github/scripts/check-workflow-concurrency.py"), str(ROOT / ".github/workflows")],
check=True,
)
def test_shared_lock_exception_cannot_be_reused_by_unrelated_workflows(tmp_path):
source = "on: workflow_dispatch\nconcurrency:\n group: gitnexus-evolution-runner\n"
script = ROOT / ".github/scripts/check-workflow-concurrency.py"
allowed = tmp_path / "release-evaluation.yml"
allowed.write_text(source)
assert subprocess.run([sys.executable, str(script), str(tmp_path)], capture_output=True).returncode == 0
allowed.rename(tmp_path / "unrelated.yml")
assert subprocess.run([sys.executable, str(script), str(tmp_path)], capture_output=True).returncode == 1
def workflow(name):
return yaml.safe_load((ROOT / ".github/workflows" / name).read_text())
@pytest.mark.parametrize(
"name,paid", [("gitnexus-skill-evolution.yml", "evolve"), ("release-evaluation.yml", "evaluate")]
)
def test_instance_is_serialized_and_cleanup_is_hosted_even_after_failure(name, paid):
document = workflow(name)
assert document["concurrency"] == {"group": "gitnexus-evolution-runner", "cancel-in-progress": False}
jobs = document["jobs"]
start, stop = jobs["start-runner"], jobs["stop-runner"]
assert start["runs-on"] == stop["runs-on"] == "ubuntu-latest"
assert start["environment"] == stop["environment"] == "gitnexus-evolution"
assert "refs/heads/main" in start["if"] and "refs/heads/main" in stop["if"]
assert "always()" in stop["if"]
assert "cleanup_required == 'true'" in stop["if"]
assert paid in stop["needs"] and "start-runner" in stop["needs"]
assert start["permissions"] == stop["permissions"] == {"contents": "read", "id-token": "write"}
assert "self-hosted" in jobs[paid]["runs-on"]
assert set(jobs[paid]["needs"]) >= {"start-runner", "check-runner", "runner-ready"}
assert jobs["runner-ready"]["needs"] == ["start-runner"]
assert jobs["check-runner"]["needs"] == ["start-runner"]
for job, operation in ((start, "start"), (stop, "stop")):
action = next(step for step in job["steps"] if step.get("uses") == "./.github/actions/ec2-runner")
assert action["with"]["operation"] == operation
assert action["with"]["role-arn"] == "${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}"
assert action["with"]["instance-id"] == "${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}"
assert action["with"]["region"] == "${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}"
assert action["with"]["stop-schedule"] == "${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}"
def test_release_probe_skips_paid_calls_and_paid_job_has_pickup_watchdog():
jobs = workflow("release-evaluation.yml")["jobs"]
assert "inputs.runner_only != true" in jobs["evaluate"]["if"]
watch = jobs["watch-evaluate-pickup"]
assert watch["needs"] == jobs["evaluate"]["needs"]
assert "watch-evaluate-pickup" not in jobs["evaluate"]["needs"]
assert any('--job-name "Evaluate candidate and stable quality"' in step.get("run", "") for step in watch["steps"])
assert watch["steps"][-1]["if"] == "failure()"
@pytest.mark.parametrize(
"name,paid,watch",
[
("gitnexus-skill-evolution.yml", "evolve", "watch-evolve-pickup"),
("release-evaluation.yml", "evaluate", "watch-evaluate-pickup"),
],
)
def test_pickup_watchdog_names_the_paid_job_it_bounds(name, paid, watch):
jobs = workflow(name)["jobs"]
command = f'python3 .github/scripts/evolution-runner-ready.py --job-name "{jobs[paid]["name"]}"'
assert [step.get("run") for step in jobs[watch]["steps"]].count(command) == 1
def test_release_comparison_is_scheduled_or_manual_and_gates_outcomes():
document = workflow("release-evaluation.yml")
# PyYAML parses the bare `on` key as True.
assert set(document[True]) == {"schedule", "workflow_dispatch"}
steps = document["jobs"]["evaluate"]["steps"]
scripts = "\n".join(step.get("run", "") for step in steps)
assert "releases/latest" in scripts
assert "workflow_bench.release_gate" in scripts
assert "--stable-sha" in scripts and "--candidate-sha" in scripts
def test_both_runtimes_grade_tasks_against_one_pinned_task_dependency_checkout():
steps = workflow("release-evaluation.yml")["jobs"]["evaluate"]["steps"]
names = [step.get("name", step.get("uses", "")) for step in steps]
resolve = next(step for step in steps if step.get("id") == "tasks")
assert "workflow_bench.release_report task-sha" in resolve["run"]
assert 'echo "sha=$sha" >> "$GITHUB_OUTPUT"' in resolve["run"]
checkout = next(step for step in steps if step.get("with", {}).get("path") == "tasks-base")
assert checkout["with"]["ref"] == "${{ steps.tasks.outputs.sha }}"
assert checkout["with"]["persist-credentials"] is False
build = next(step for step in steps if "workflow_bench.release_build" in step.get("run", ""))
prepare = next(step for step in steps if "release_report prepare" in step.get("run", ""))
sessions = next(step for step in steps if "workflow_bench.runner" in step.get("run", ""))
assert steps.index(resolve) < steps.index(checkout) < steps.index(build) < steps.index(prepare)
# The task checkout is built in the same sandbox as both runtimes.
assert "for checkout in tasks-base stable candidate; do" in build["run"]
assert '--repo "$GITHUB_WORKSPACE/$checkout"' in build["run"]
# Task source and dependencies never come from the runtime under test,
# which reaches the sessions only through --gitnexus-root.
assert '--repo "$GITHUB_WORKSPACE/$runtime" --task-repo "$GITHUB_WORKSPACE/tasks-base"' in prepare["run"]
assert '--gitnexus-root "$GITHUB_WORKSPACE/$runtime"' in sessions["run"]
assert "tasks-base" not in sessions["run"]
assert names.count("Resolve pinned task revision") == 1
def test_both_release_graphs_are_materialized_before_any_paid_sessions():
steps = workflow("release-evaluation.yml")["jobs"]["evaluate"]["steps"]
preflight = next(step for step in steps if "workflow_bench.release_preflight" in step.get("run", ""))
paid = next(step for step in steps if "workflow_bench.runner" in step.get("run", ""))
assert steps.index(preflight) < steps.index(paid)
assert "for runtime in stable candidate; do" in preflight["run"]
assert "set -euo pipefail" in preflight["run"]
assert '--tasks "$RUNNER_TEMP/release-evaluation/$runtime/tasks.yaml"' in preflight["run"]
assert '--gitnexus-root "$GITHUB_WORKSPACE/$runtime"' in preflight["run"]
assert preflight.get("continue-on-error", False) is False
assert "secrets." not in json.dumps(preflight)
assert "if" not in paid # A failed preflight must skip the paid step.
@pytest.mark.parametrize(
"name,paid", [("gitnexus-skill-evolution.yml", "evolve"), ("release-evaluation.yml", "evaluate")]
)
@pytest.mark.parametrize(
"startup_attempt,current_attempt,fresh", [("1", "1", True), ("1", "2", False), ("", "2", False), ("2", "2", True)]
)
def test_partial_reruns_fail_on_hosted_runner_before_any_work(name, paid, startup_attempt, current_attempt, fresh):
jobs = workflow(name)["jobs"]
assert jobs["start-runner"]["outputs"]["run_attempt"] == "${{ github.run_attempt }}"
for name in ("runner-ready", paid):
job = jobs[name]
# Parse the actual conditional routing expression, including both JSON
# branches. A job-if skip would make the reusable workflow look green.
routing = re.fullmatch(
r"\$\{\{\s*fromJSON\(needs\.start-runner\.outputs\.run_attempt == github\.run_attempt\s*"
r"&&\s*'([^']+)'\s*\|\|\s*'([^']+)'\)\s*\}\}",
job["runs-on"],
)
assert routing is not None, job["runs-on"]
labels = json.loads(routing[1 if startup_attempt == current_attempt else 2])
assert labels == (["self-hosted", "linux", "x64", "gitnexus-evolution"] if fresh else ["ubuntu-latest"])
assert "run_attempt" not in job["if"]
guard = job["steps"][0]
assert not {"if", "uses", "env"}.intersection(guard)
assert "${{ needs.start-runner.outputs.run_attempt }}" in guard["run"]
script = guard["run"].replace("${{ needs.start-runner.outputs.run_attempt }}", startup_attempt)
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c", script],
env={**os.environ, "GITHUB_RUN_ATTEMPT": current_attempt},
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == (0 if fresh else 1), result.stderr
if not fresh:
assert "Re-run all jobs" in result.stdout
def test_both_cold_boot_workflows_wait_for_package_lock():
for name, paid in (("gitnexus-skill-evolution.yml", "evolve"), ("release-evaluation.yml", "evaluate")):
scripts = "\n".join(step.get("run", "") for step in workflow(name)["jobs"][paid]["steps"])
apt_commands = [line.strip() for line in scripts.splitlines() if line.strip().startswith("sudo apt-get ")]
assert len(apt_commands) == 2
assert "sudo apt-get -o DPkg::Lock::Timeout=600 update" in apt_commands
assert (
"sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends bubblewrap ripgrep socat"
in apt_commands
)
def test_shared_lifecycle_jobs_stay_identical_across_both_ec2_workflows():
# The two workflows duplicate the verified lifecycle instead of sharing a
# reusable workflow; this pins them together so an edit to one copy fails.
evaluation = workflow("release-evaluation.yml")["jobs"]
evolution = workflow("gitnexus-skill-evolution.yml")["jobs"]
renamed = {"watch-evaluate-pickup": "watch-evolve-pickup", "evaluate": "evolve"}
for job in ("start-runner", "check-runner", "runner-ready", "stop-runner"):
expected = {key: value for key, value in evaluation[job].items() if key != "name"}
expected["needs"] = [renamed.get(need, need) for need in expected.get("needs", [])]
actual = {key: value for key, value in evolution[job].items() if key != "name"}
actual["needs"] = actual.get("needs", [])
assert actual == expected, job

View file

@ -0,0 +1,425 @@
"""Exercise the dedicated EC2 lifecycle without accessing AWS."""
import importlib.util
import json
import os
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
import pytest
SCRIPT = Path(__file__).resolve().parents[2] / ".github/scripts/evolution-runner.py"
spec = importlib.util.spec_from_file_location("evolution_runner", SCRIPT)
lifecycle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lifecycle)
CONFIG = {
"AWS_REGION": "us-east-1",
"AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/evolution",
"EC2_INSTANCE_ID": "i-0123456789abcdef0",
"STOP_SCHEDULE_UTC": "SUN 03:00",
}
@pytest.fixture
def configured(monkeypatch, tmp_path):
for key, value in CONFIG.items():
monkeypatch.setenv(lifecycle.PREFIX + key, value)
output = tmp_path / "outputs"
monkeypatch.setenv("GITHUB_OUTPUT", str(output))
return output
def driver(monkeypatch, configured, states, *, statuses=None, errors=None):
calls = []
clock = [0.0]
errors = errors or {}
monkeypatch.setattr(lifecycle.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(lifecycle.time, "sleep", lambda value: clock.__setitem__(0, clock[0] + value))
monkeypatch.setattr(lifecycle, "stop_deadline", lambda schedule: 2000000000)
def run(command, **kwargs):
operation = command[2]
calls.append(operation)
assert command[3:7] == ["--instance-ids", CONFIG["EC2_INSTANCE_ID"], "--region", CONFIG["AWS_REGION"]]
assert 0 < kwargs["timeout"] <= 30
assert kwargs["capture_output"] and kwargs["text"]
if operation == "start-instances":
assert "cleanup_required=true" in configured.read_text()
problem = errors.get(operation)
if isinstance(problem, list):
# A list scripts one outcome per call; None lets that call succeed.
problem = problem.pop(0) if problem else None
if problem:
if isinstance(problem, BaseException):
raise problem
return SimpleNamespace(returncode=1, stdout="", stderr="private account details")
if operation == "describe-instances":
state = states.pop(0) if len(states) > 1 else states[0]
response = {
"Reservations": [{"Instances": [{"InstanceId": CONFIG["EC2_INSTANCE_ID"], "State": {"Name": state}}]}]
}
elif operation == "describe-instance-status":
status = statuses.pop(0) if statuses and len(statuses) > 1 else (statuses or ["ok"])[0]
response = {
"InstanceStatuses": [
{
"InstanceId": CONFIG["EC2_INSTANCE_ID"],
"SystemStatus": {"Status": status},
"InstanceStatus": {"Status": status},
}
]
}
else:
response = {}
return SimpleNamespace(returncode=0, stdout=json.dumps(response), stderr="")
monkeypatch.setattr(lifecycle.subprocess, "run", run)
return calls, clock
def test_start_waits_for_running_and_both_health_checks(monkeypatch, configured):
calls, _ = driver(
monkeypatch, configured, ["stopped", "pending", "running", "running"], statuses=["initializing", "ok"]
)
lifecycle.start(CONFIG, timeout=50)
assert calls.count("start-instances") == 1
assert calls.count("describe-instance-status") == 2
assert "cleanup_required=true" in configured.read_text()
assert "stop_deadline_epoch=2000000000" in configured.read_text()
def test_start_waits_out_an_existing_shutdown_before_restart(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["stopping", "stopped", "pending", "running"])
lifecycle.start(CONFIG, timeout=50)
assert calls.count("start-instances") == 1
assert calls.index("start-instances") == 2
def test_closed_startup_window_cannot_change_power_state(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["stopped"])
def closed_window(schedule):
raise ValueError("insufficient time")
monkeypatch.setattr(lifecycle, "stop_deadline", closed_window)
with pytest.raises(ValueError, match="insufficient time"):
lifecycle.start(CONFIG)
assert calls == []
assert not configured.exists()
@pytest.mark.parametrize("initial", ["running", "pending"])
def test_existing_active_dedicated_instance_is_claimed_for_cleanup(monkeypatch, configured, initial):
calls, _ = driver(monkeypatch, configured, [initial, "running"])
lifecycle.start(CONFIG, timeout=30)
assert "start-instances" not in calls
assert "cleanup_required=true" in configured.read_text()
def test_start_response_lost_still_attempts_stop_and_waits_for_stopped(monkeypatch, configured):
problem = subprocess.TimeoutExpired(["aws", CONFIG["EC2_INSTANCE_ID"]], 30, stderr="private details")
calls, _ = driver(
monkeypatch,
configured,
["stopped", "pending", "running", "stopping", "stopped"],
errors={"start-instances": problem},
)
with pytest.raises(RuntimeError, match="start-instances") as exc:
lifecycle.start(CONFIG, timeout=60)
assert "cleanup_required=true" in configured.read_text()
assert "stop_deadline_epoch" not in configured.read_text()
assert "stop-instances" in calls
assert calls[-1] == "describe-instances"
assert "private" not in str(exc.value)
assert CONFIG["EC2_INSTANCE_ID"] not in str(exc.value)
def test_instance_that_stops_again_after_start_fails_without_a_second_start(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["stopped", "pending", "stopped"])
with pytest.raises(RuntimeError, match="stopped again during bootstrap"):
lifecycle.start(CONFIG, timeout=60)
assert calls == [
"describe-instances",
"start-instances",
"describe-instances",
"describe-instances",
"describe-instances",
]
assert "cleanup_required=true" in configured.read_text()
assert "stop_deadline_epoch" not in configured.read_text()
def test_denied_start_still_cleans_up_claimed_instance(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["stopped", "stopped"], errors={"start-instances": "denied"})
with pytest.raises(RuntimeError, match="start-instances") as exc:
lifecycle.start(CONFIG, timeout=30)
assert calls == ["describe-instances", "start-instances", "describe-instances"]
assert "private" not in str(exc.value)
assert "cleanup_required=true" in configured.read_text()
def test_health_failure_attempts_bounded_cleanup(monkeypatch, configured, capsys):
calls, clock = driver(monkeypatch, configured, ["running"], statuses=["impaired"])
with pytest.raises(RuntimeError, match="startup/health deadline"):
lifecycle.start(CONFIG, timeout=20, cleanup_timeout=25)
assert "stop-instances" in calls
assert clock[0] == 45
assert "cleanup also failed" in capsys.readouterr().out
assert "stop_deadline_epoch" not in configured.read_text()
def test_stop_waits_for_pending_then_confirms_stopped(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["pending", "running", "stopping", "stopped"])
lifecycle.stop(CONFIG, timeout=40)
assert calls == [
"describe-instances",
"describe-instances",
"stop-instances",
"describe-instances",
"describe-instances",
]
def test_stop_is_idempotent(monkeypatch, configured):
calls, _ = driver(monkeypatch, configured, ["stopped"])
lifecycle.stop(CONFIG, timeout=20)
assert calls == ["describe-instances"]
def test_stop_command_acceptance_does_not_prove_stopped(monkeypatch, configured):
calls, clock = driver(monkeypatch, configured, ["running", "stopping"])
with pytest.raises(RuntimeError, match="shutdown deadline"):
lifecycle.stop(CONFIG, timeout=25)
assert calls.count("stop-instances") == 1
assert clock[0] == 25
def test_perpetually_pending_instance_has_bounded_shutdown(monkeypatch, configured):
calls, clock = driver(monkeypatch, configured, ["pending"])
with pytest.raises(RuntimeError, match="shutdown deadline"):
lifecycle.stop(CONFIG, timeout=25)
assert "stop-instances" not in calls
assert clock[0] == 25
def test_stop_lost_response_can_still_confirm_stopped(monkeypatch, configured):
calls, _ = driver(
monkeypatch,
configured,
["running", "stopping", "stopped"],
errors={"stop-instances": subprocess.TimeoutExpired("aws", 30)},
)
lifecycle.stop(CONFIG, timeout=40)
assert calls.count("stop-instances") == 1
@pytest.mark.parametrize("problem", [subprocess.TimeoutExpired(["aws", CONFIG["EC2_INSTANCE_ID"]], 30), "denied"])
def test_stop_keeps_observing_after_a_transient_describe_failure(monkeypatch, configured, problem):
calls, clock = driver(
monkeypatch, configured, ["running", "stopping", "stopped"], errors={"describe-instances": [problem]}
)
lifecycle.stop(CONFIG, timeout=60)
assert calls == [
"describe-instances",
"describe-instances",
"stop-instances",
"describe-instances",
"describe-instances",
]
assert clock[0] == 30
@pytest.mark.parametrize("problem", [subprocess.TimeoutExpired(["aws", CONFIG["EC2_INSTANCE_ID"]], 30), "denied"])
def test_persistent_describe_failure_still_raises_at_the_stop_deadline(monkeypatch, configured, problem):
calls, clock = driver(monkeypatch, configured, ["running"], errors={"describe-instances": problem})
with pytest.raises(RuntimeError, match="shutdown deadline expired after: EC2 describe-instances") as exc:
lifecycle.stop(CONFIG, timeout=25)
assert calls == ["describe-instances"] * 3
assert clock[0] == 25
assert "private" not in str(exc.value)
assert CONFIG["EC2_INSTANCE_ID"] not in str(exc.value)
def test_denied_stop_fails_bounded_without_private_diagnostics(monkeypatch, configured):
_, clock = driver(monkeypatch, configured, ["running"], errors={"stop-instances": "denied"})
with pytest.raises(RuntimeError, match="shutdown deadline") as exc:
lifecycle.stop(CONFIG, timeout=20)
assert clock[0] == 20
assert "private" not in str(exc.value)
@pytest.mark.parametrize("operation", ["start", "stop"])
@pytest.mark.parametrize("state", ["terminated", "shutting-down", "unknown"])
def test_unusable_state_fails_loud_without_power_changes(monkeypatch, configured, operation, state):
calls, _ = driver(monkeypatch, configured, [state])
with pytest.raises(RuntimeError, match="state"):
getattr(lifecycle, operation)(CONFIG, timeout=20)
assert calls == ["describe-instances"]
@pytest.mark.parametrize(
"response",
[
{"Reservations": []},
{"Reservations": [{"Instances": [{"InstanceId": "i-fffffffffffffffff", "State": {"Name": "running"}}]}]},
{
"Reservations": [
{"Instances": [{"InstanceId": CONFIG["EC2_INSTANCE_ID"]}, {"InstanceId": CONFIG["EC2_INSTANCE_ID"]}]}
]
},
],
)
def test_instance_identity_must_match_exactly(monkeypatch, response):
monkeypatch.setattr(lifecycle, "aws", lambda *args, **kwargs: response)
with pytest.raises(RuntimeError, match="exactly the configured instance"):
lifecycle.instance_state(CONFIG)
@pytest.mark.parametrize("field", ["SystemStatus", "InstanceStatus"])
def test_both_health_checks_are_required(monkeypatch, field):
response = {
"InstanceStatuses": [
{
"InstanceId": CONFIG["EC2_INSTANCE_ID"],
"SystemStatus": {"Status": "ok"},
"InstanceStatus": {"Status": "ok"},
}
]
}
response["InstanceStatuses"][0][field]["Status"] = "impaired"
monkeypatch.setattr(lifecycle, "aws", lambda *args, **kwargs: response)
assert not lifecycle.healthy(CONFIG)
def test_health_response_cannot_substitute_another_instance(monkeypatch):
response = {
"InstanceStatuses": [
{"InstanceId": "i-fffffffffffffffff", "SystemStatus": {"Status": "ok"}, "InstanceStatus": {"Status": "ok"}}
]
}
monkeypatch.setattr(lifecycle, "aws", lambda *args, **kwargs: response)
with pytest.raises(RuntimeError, match="configured instance"):
lifecycle.healthy(CONFIG)
def test_aws_command_timeout_never_exceeds_remaining_poll_budget(monkeypatch):
monkeypatch.setattr(lifecycle.time, "monotonic", lambda: 10)
def run(command, **kwargs):
assert kwargs["timeout"] == 2
return SimpleNamespace(returncode=0, stdout="{}")
monkeypatch.setattr(lifecycle.subprocess, "run", run)
assert lifecycle.aws(CONFIG, "describe-instances", until=12) == {}
@pytest.mark.parametrize(
"result",
[
SimpleNamespace(returncode=1, stdout="", stderr="private details"),
SimpleNamespace(returncode=0, stdout="private details"),
],
)
def test_aws_errors_do_not_echo_response(monkeypatch, result):
monkeypatch.setattr(lifecycle.subprocess, "run", lambda *args, **kwargs: result)
with pytest.raises(RuntimeError) as exc:
lifecycle.aws(CONFIG, "describe-instances")
assert "private" not in str(exc.value)
def test_missing_aws_cli_returns_safe_error(monkeypatch):
def missing(*args, **kwargs):
raise FileNotFoundError("private/path/aws")
monkeypatch.setattr(lifecycle.subprocess, "run", missing)
with pytest.raises(RuntimeError, match="command failed") as exc:
lifecycle.aws(CONFIG, "describe-instances")
assert "private" not in str(exc.value)
def test_command_is_not_started_after_deadline(monkeypatch):
monkeypatch.setattr(lifecycle.time, "monotonic", lambda: 10)
def unexpected(*args, **kwargs):
pytest.fail("AWS command ran after deadline")
monkeypatch.setattr(lifecycle.subprocess, "run", unexpected)
with pytest.raises(RuntimeError, match="deadline expired"):
lifecycle.aws(CONFIG, "describe-instances", until=10)
def test_configuration_reuses_only_historical_names(configured):
assert lifecycle.configuration() == CONFIG
@pytest.mark.parametrize("key", CONFIG)
def test_missing_configuration_fails_closed(monkeypatch, configured, key):
monkeypatch.delenv(lifecycle.PREFIX + key)
with pytest.raises(ValueError, match=lifecycle.PREFIX + key):
lifecycle.configuration()
@pytest.mark.parametrize(
("key", "value"),
[
("AWS_REGION", "us-east-1;echo bad"),
("AWS_ROLE_ARN", "arbitrary"),
("EC2_INSTANCE_ID", "--all"),
("STOP_SCHEDULE_UTC", "SUN 27:00"),
],
)
def test_invalid_configuration_is_rejected(monkeypatch, configured, key, value):
monkeypatch.setenv(lifecycle.PREFIX + key, value)
with pytest.raises(ValueError):
lifecycle.configuration()
def test_stop_deadline_caps_manual_dispatch_at_24_hours():
now = datetime(2026, 10, 7, 12, 0, tzinfo=UTC)
assert lifecycle.stop_deadline("SUN 03:00", now=now) == int((now + timedelta(hours=24)).timestamp())
def test_stop_deadline_obeys_the_actual_weekly_shutdown():
now = datetime(2026, 10, 10, 23, 0, tzinfo=UTC)
assert lifecycle.stop_deadline("SUN 03:00", now=now) == int(datetime(2026, 10, 11, 3, 0, tzinfo=UTC).timestamp())
@pytest.mark.parametrize("minute", [0, 30, 59])
def test_dispatch_during_stop_minute_is_rejected(minute):
with pytest.raises(ValueError, match="due now"):
lifecycle.stop_deadline("SUN 03:00", now=datetime(2026, 10, 11, 3, 0, minute, tzinfo=UTC))
def test_dispatch_without_startup_and_90_minute_reserve_is_rejected():
with pytest.raises(ValueError, match="insufficient time"):
lifecycle.stop_deadline("SUN 03:00", now=datetime(2026, 10, 11, 1, 21, tzinfo=UTC))
def test_cli_invokes_fake_aws_with_scoped_instance_and_confirms_state(tmp_path):
fake = tmp_path / "aws"
record = tmp_path / "calls.jsonl"
fake.write_text(
f"#!{sys.executable}\n"
"import json, sys\n"
f"with open({str(record)!r}, 'a') as log: log.write(json.dumps(sys.argv[1:]) + '\\n')\n"
"instance = sys.argv[sys.argv.index('--instance-ids') + 1]\n"
"print(json.dumps({'Reservations': [{'Instances': [{'InstanceId': instance, 'State': {'Name': 'stopped'}}]}]}))\n"
)
fake.chmod(0o755)
environment = {**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]}
environment.update({lifecycle.PREFIX + key: value for key, value in CONFIG.items()})
result = subprocess.run(
[sys.executable, str(SCRIPT), "stop"], env=environment, capture_output=True, text=True, timeout=10
)
assert result.returncode == 0, result.stderr + result.stdout
assert "confirmed stopped" in result.stdout
calls = [json.loads(line) for line in record.read_text().splitlines()]
assert len(calls) == 1
assert calls[0][:4] == ["ec2", "describe-instances", "--instance-ids", CONFIG["EC2_INSTANCE_ID"]]
assert "--force" not in calls[0]
assert CONFIG["EC2_INSTANCE_ID"] not in result.stdout
assert CONFIG["AWS_ROLE_ARN"] not in result.stdout

View file

@ -0,0 +1,171 @@
"""Verify bounded native runner pickup without AWS credentials or configuration."""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
SCRIPT = Path(__file__).resolve().parents[2] / ".github/scripts/evolution-runner-ready.py"
spec = importlib.util.spec_from_file_location("evolution_runner_ready", SCRIPT)
bootstrap = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bootstrap)
def pickup_driver(monkeypatch, responses, *, denied=False, timeouts=None):
calls = []
timeouts = [] if timeouts is None else timeouts
clock = [0.0]
monkeypatch.setenv("GITHUB_REPOSITORY", "abhigyanpatwari/GitNexus")
monkeypatch.setenv("GITHUB_RUN_ID", "1234")
monkeypatch.setattr(bootstrap.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(bootstrap.time, "sleep", lambda n: clock.__setitem__(0, clock[0] + n))
def run(command, **kwargs):
calls.append(command)
assert command == ["gh", "api", "repos/abhigyanpatwari/GitNexus/actions/runs/1234/jobs?per_page=100"]
timeouts.append(kwargs.pop("timeout"))
assert kwargs == {"capture_output": True, "text": True}
jobs = responses.pop(0) if len(responses) > 1 else responses[0]
return SimpleNamespace(returncode=int(denied), stdout=json.dumps({"jobs": jobs}), stderr="private details")
monkeypatch.setattr(bootstrap.subprocess, "run", run)
return calls
def probe(status, conclusion=None):
return {"name": "Verify the runner service is online", "status": status, "conclusion": conclusion}
def test_pickup_waits_for_this_runs_successful_native_probe(monkeypatch):
calls = pickup_driver(monkeypatch, [[], [probe("queued")], [probe("in_progress")], [probe("completed", "success")]])
bootstrap.wait_for_runner(timeout=40)
assert len(calls) == 4
@pytest.mark.parametrize(
"jobs", [[], [probe("queued")], [probe("in_progress")], [dict(probe("completed", "success"), name="other job")]]
)
def test_pickup_missing_offline_or_unrelated_job_has_bounded_wait(monkeypatch, jobs):
calls = pickup_driver(monkeypatch, [jobs])
with pytest.raises(RuntimeError, match="before the deadline"):
bootstrap.wait_for_runner(timeout=20)
assert len(calls) == 2
@pytest.mark.parametrize(("timeout", "expected"), [(35, [30, 25, 15, 5]), (30.5, [30, 20.5, 10.5, 0.5])])
def test_each_jobs_request_is_bounded_by_the_remaining_pickup_deadline(monkeypatch, timeout, expected):
timeouts = []
pickup_driver(monkeypatch, [[]], timeouts=timeouts)
with pytest.raises(RuntimeError, match="before the deadline"):
bootstrap.wait_for_runner(timeout=timeout)
# Under one second left must not round up past the deadline.
assert timeouts == expected
@pytest.mark.parametrize("conclusion", ["failure", "cancelled", "skipped", "timed_out"])
def test_pickup_failed_native_probe_blocks_evolution(monkeypatch, conclusion):
calls = pickup_driver(monkeypatch, [[probe("completed", conclusion)]])
with pytest.raises(RuntimeError, match="probe failed"):
bootstrap.wait_for_runner(timeout=20)
assert len(calls) == 1
def test_pickup_api_denial_hides_private_response(monkeypatch):
pickup_driver(monkeypatch, [[]], denied=True)
with pytest.raises(RuntimeError, match="jobs API") as exc:
bootstrap.wait_for_runner(timeout=20)
assert "private details" not in str(exc.value)
def test_pickup_refuses_another_repository_or_non_numeric_run(monkeypatch):
calls = pickup_driver(monkeypatch, [[]])
monkeypatch.setenv("GITHUB_REPOSITORY", "other/repository")
with pytest.raises(ValueError, match="current workflow run"):
bootstrap.wait_for_runner()
monkeypatch.setenv("GITHUB_REPOSITORY", "abhigyanpatwari/GitNexus")
monkeypatch.setenv("GITHUB_RUN_ID", "1234/cancel")
with pytest.raises(ValueError, match="current workflow run"):
bootstrap.wait_for_runner()
assert calls == []
EVOLVE = "Propose, benchmark, and gate skill candidates"
def evolution(status, conclusion=None):
return {"name": EVOLVE, "status": status, "conclusion": conclusion}
def test_evolution_watchdog_waits_for_actual_job_after_successful_probe(monkeypatch):
calls = pickup_driver(
monkeypatch,
[
[probe("completed", "success")],
[probe("completed", "success"), evolution("queued")],
[probe("completed", "success"), evolution("in_progress")],
],
)
bootstrap.wait_for_runner(timeout=40, job_name=EVOLVE)
assert len(calls) == 3
def test_evolution_watchdog_bounds_offline_runner_after_successful_probe(monkeypatch):
calls = pickup_driver(monkeypatch, [[probe("completed", "success"), evolution("queued")]])
with pytest.raises(RuntimeError, match="before the deadline"):
bootstrap.wait_for_runner(timeout=20, job_name=EVOLVE)
assert len(calls) == 2
@pytest.mark.parametrize("conclusion", ["success", "failure", "timed_out"])
def test_evolution_watchdog_accepts_already_finished_job_pickup(monkeypatch, conclusion):
calls = pickup_driver(monkeypatch, [[evolution("completed", conclusion)]])
bootstrap.wait_for_runner(timeout=20, job_name=EVOLVE)
assert len(calls) == 1
@pytest.mark.parametrize("conclusion", ["skipped", "cancelled"])
def test_evolution_watchdog_rejects_job_that_never_ran(monkeypatch, conclusion):
pickup_driver(monkeypatch, [[evolution("completed", conclusion)]])
with pytest.raises(RuntimeError, match="did not run"):
bootstrap.wait_for_runner(timeout=20, job_name=EVOLVE)
def test_reusable_workflow_probe_accepts_github_job_prefix(monkeypatch):
pickup_driver(
monkeypatch, [[dict(probe("completed", "success"), name="rc-evaluation / Verify the runner service is online")]]
)
bootstrap.wait_for_runner(timeout=20)
def test_release_watchdog_waits_for_named_paid_job(monkeypatch):
name = "Evaluate candidate and stable quality"
calls = pickup_driver(
monkeypatch, [[probe("completed", "success")], [{"name": f"rc-evaluation / {name}", "status": "in_progress"}]]
)
bootstrap.wait_for_runner(timeout=20, job_name=name)
assert len(calls) == 2
def test_reusable_probe_rejects_ambiguous_matching_jobs(monkeypatch):
pickup_driver(
monkeypatch,
[
[
probe("completed", "success"),
dict(probe("completed", "success"), name="rc-evaluation / Verify the runner service is online"),
]
],
)
with pytest.raises(RuntimeError, match="multiple"):
bootstrap.wait_for_runner(timeout=20)
@pytest.mark.parametrize("args", [["--evolve"], ["--job-name"], ["--job-name", " "], ["--job-name", EVOLVE, "extra"]])
def test_cli_accepts_only_the_native_probe_or_one_named_paid_job(args):
result = subprocess.run([sys.executable, str(SCRIPT), *args], env={}, capture_output=True, text=True, timeout=10)
assert result.returncode == 1
assert result.stdout == "::error::expected no arguments or --job-name NAME\n"

View file

@ -662,8 +662,6 @@ def test_run_proposer_hides_the_hidden_harness_and_keeps_the_full_tool_surface(m
# and before the sandbox is prepared around it.
assert events[:2] == ["sanitize", "prepare"]
assert [clone.name for clone in sanitized] == ["clone"]
# Not --bare: bare ignores --tools and would cost the proposer Grep/Glob.
assert captured.get("bare", False) is False
assert captured["allowed_tools"] == evolve.PROPOSER_ALLOWED_TOOLS
assert captured["settings_json"] == FakeSandbox.settings_json

View file

@ -161,7 +161,7 @@ def _stub_provisioning(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.fail(f"{FULL_SWEEP_ENV}=1 but bubblewrap is absent")
return
monkeypatch.setattr(runner, "trusted_gitnexus_runtime_mounts", lambda: ())
monkeypatch.setattr(runner, "trusted_gitnexus_runtime_mounts", lambda **_kwargs: ())
def materialize(worktree, *, sanitized_head=None, **_kwargs):
# The one-clone registry guard reads this before any session runs.

View file

@ -8,10 +8,12 @@ import os
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from workflow_bench import oracle_assets, runner
from workflow_bench.evolution import evaluate_candidate
@ -549,6 +551,140 @@ def test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout
assert sentinel.read_text() == "ran:candidate-workspace"
@pytest.fixture(scope="module")
def scenario_control_base(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, dict[str, dict[str, object]]]:
"""Use the immutable task source with the dependencies that grade it.
Release evaluation grades tasks with a toolchain built at the pinned task
commit. CI points GITNEXUS_ORACLE_TASK_DEPENDENCIES at that build; local runs
fall back to the current checkout's dependencies.
"""
repository = Path(__file__).resolve().parents[2]
dependencies = Path(os.environ.get("GITNEXUS_ORACLE_TASK_DEPENDENCIES") or repository)
if not (dependencies / "gitnexus" / "node_modules" / ".bin" / "vitest").is_file():
pytest.skip("GitNexus Vitest dependencies are not installed")
tasks = yaml.safe_load((repository / "eval/workflow_bench/tasks.scenarios.yaml").read_text())["tasks"]
refs = {task["ref"] for task in tasks}
assert refs == {"c4ecf398de637cf779ab2238e69958cfd5d601ce"}
ref = next(iter(refs))
environment = {**os.environ, "GIT_NO_LAZY_FETCH": "1"}
revision = subprocess.run(
["git", "cat-file", "-e", f"{ref}^{{commit}}"],
cwd=repository,
env=environment,
check=False,
capture_output=True,
)
if revision.returncode:
pytest.skip("pinned v1.6.12 task history is not available in this checkout")
workspace = tmp_path_factory.mktemp("oracle-controls")
archive = workspace / "task-base.tar"
with archive.open("wb") as output:
archived = subprocess.run(
[
"git",
"archive",
ref,
"gitnexus/src",
"gitnexus/vendor",
"gitnexus/package.json",
"gitnexus-shared/package.json",
],
cwd=repository,
env=environment,
stdout=output,
stderr=subprocess.PIPE,
check=False,
timeout=120,
)
if archived.returncode:
pytest.skip("pinned task source blobs are unavailable; fetch v1.6.12 before running oracle controls")
base = workspace / "base"
base.mkdir()
if not hasattr(tarfile, "data_filter"):
pytest.skip("oracle controls need tarfile extraction filters (Python 3.11.4+)")
with tarfile.open(archive) as source:
source.extractall(base, filter="data")
for relative in ("node_modules", "gitnexus/node_modules", "gitnexus-shared/dist"):
(base / relative).symlink_to(dependencies / relative, target_is_directory=True)
subprocess.run(["git", "init", "--quiet", str(base)], check=True, capture_output=True)
return base, {task["id"]: task for task in tasks}
def _oracle_control_candidate(base: Path, destination: Path) -> Path:
shutil.copytree(base, destination, symlinks=True, ignore=shutil.ignore_patterns("vendor"))
(destination / "gitnexus/vendor").symlink_to(base / "gitnexus/vendor", target_is_directory=True)
return destination
def _verify_shipped_oracle(candidate: Path, task: dict[str, object]) -> subprocess.CompletedProcess[str]:
snapshot = capture_task_oracle(task)
with staged_task_oracle(candidate, snapshot) as staged:
return subprocess.run(
["/bin/sh", "-c", snapshot.command],
cwd=candidate,
env={**os.environ, oracle_assets.ORACLE_ENV_VAR: str(staged)},
capture_output=True,
text=True,
check=False,
timeout=180,
)
@pytest.mark.skipif(os.name == "nt", reason="oracle controls use POSIX dependency symlinks and shell commands")
@pytest.mark.parametrize(
("task_id", "reference"),
[
("trivial-status-json-alias", "trivial-status-json-alias"),
("inv-bug-c-system-include", "inv-bug-c-system-include"),
("inv-feature-list-repos-filter", "inv-feature-list-repos-filter"),
("cross-module-parse-retry", "cross-module-parse-retry"),
("cross-module-parse-retry", "cross-module-parse-retry-pool"),
],
)
def test_shipped_oracles_reject_task_base_and_accept_reference_fixes(
tmp_path: Path,
scenario_control_base: tuple[Path, dict[str, dict[str, object]]],
task_id: str,
reference: str,
) -> None:
base, tasks = scenario_control_base
candidate = _oracle_control_candidate(base, tmp_path / "candidate")
negative = _verify_shipped_oracle(candidate, tasks[task_id])
assert negative.returncode == 1, negative.stdout + negative.stderr
assert "AssertionError" in negative.stdout + negative.stderr
patches = Path(__file__).resolve().parents[1] / "workflow_bench/oracles/reference"
subprocess.run(["git", "apply", str(patches / f"{reference}.patch")], cwd=candidate, check=True)
positive = _verify_shipped_oracle(candidate, tasks[task_id])
assert positive.returncode == 0, positive.stdout + positive.stderr
@pytest.mark.skipif(os.name == "nt", reason="oracle controls use POSIX dependency symlinks and shell commands")
@pytest.mark.parametrize(
"reference",
["cross-module-parse-retry-startup-only", "cross-module-parse-retry-pool-startup-only"],
)
def test_in_flight_retry_oracle_rejects_startup_only_repairs(
tmp_path: Path,
scenario_control_base: tuple[Path, dict[str, dict[str, object]]],
reference: str,
) -> None:
base, tasks = scenario_control_base
candidate = _oracle_control_candidate(base, tmp_path / "candidate")
patches = Path(__file__).resolve().parents[1] / "workflow_bench/oracles/reference"
subprocess.run(["git", "apply", str(patches / f"{reference}.patch")], cwd=candidate, check=True)
rejected = _verify_shipped_oracle(candidate, tasks["cross-module-parse-retry"])
output = rejected.stdout + rejected.stderr
assert rejected.returncode == 1, output
assert "AssertionError" in output
assert "recovers the same file after two transient" in output
assert "stops after two retries" in output
def test_weakened_authored_tests_cannot_produce_a_promotion_decision() -> None:
incumbent = runner.aggregate([{"class": "demo", "resolved": True, **_metrics()} for _ in range(3)])
candidate = runner.aggregate(

View file

@ -18,6 +18,8 @@ import pytest
from workflow_bench import runner, runner_artifacts
from workflow_bench import proposer_sandbox
from workflow_bench import release_build
from workflow_bench.mock_provider import MockProvider, Reply
from workflow_bench.process_control import ManagedProcessResult, run_managed
@ -40,7 +42,7 @@ from workflow_bench.proposer_sandbox import (
SANDBOX_WORKSPACE,
ReadOnlyMount,
SandboxError,
_runtime_mount_args,
runtime_mount_args,
build_claude_settings,
build_sandbox_environment,
_force_rmtree,
@ -56,6 +58,453 @@ from workflow_bench.review_scoring import REVIEW_OUTPUT, parse_review_output
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets as stage_immutable_task_assets
def test_nomcp_sandbox_hides_graph_and_guidance_without_exposing_the_cli(tmp_path):
clone = tmp_path / "clone"
(clone / ".gitnexus").mkdir(parents=True)
(clone / ".gitnexus" / "lbug").write_text("prebuilt graph")
(clone / ".gitnexus" / "run.cjs").write_text("supplied CLI fallback")
for name in ("AGENTS.md", "CLAUDE.md"):
(clone / name).write_text("MUST use GitNexus before editing; bootstrap with npx gitnexus.")
bwrap = tmp_path / "bwrap"
bwrap.write_text("#!/bin/sh\nexit 0\n")
bwrap.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
bwrap_bin=bwrap,
preflight=False,
gitnexus_available=False,
) as sandbox:
mounts = {mount.target: mount.source for mount in sandbox.read_only_mounts}
assert SANDBOX_GITNEXUS_CLI not in mounts
assert not (sandbox.private_root / "gitnexus").exists()
assert not list(mounts["/workspace/.gitnexus"].iterdir())
for name in ("AGENTS.md", "CLAUDE.md"):
note = mounts[f"/workspace/{name}"].read_text()
assert "GitNexus tools are unavailable" in note
assert "MUST use GitNexus" not in note
assert "GITNEXUS_INVOCATION=gitnexus" not in mounts[SANDBOX_SHELL_PREFIX].read_text()
# Mounts alter the model's view, never the credited task source or patch.
assert (clone / ".gitnexus" / "lbug").read_text() == "prebuilt graph"
assert "MUST use GitNexus" in (clone / "AGENTS.md").read_text()
def test_filtered_guidance_is_excluded_from_all_agent_work_evidence(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
for name in ("AGENTS.md", "CLAUDE.md"):
(clone / name).write_text("Run repository tests.\nMUST use GitNexus before editing.\n")
(clone / "source.txt").write_text("original\n")
subprocess.run(["git", "init", "--quiet", str(clone)], check=True)
subprocess.run(["git", "-C", str(clone), "add", "."], check=True)
subprocess.run([
"git", "-C", str(clone), "-c", "user.name=test", "-c", "user.email=test@invalid",
"commit", "--quiet", "-m", "base",
], check=True)
sha = subprocess.check_output(["git", "-C", str(clone), "rev-parse", "HEAD"], text=True).strip()
bwrap = tmp_path / "fake-bwrap"
bwrap.write_text("#!/bin/sh\nexit 0\n")
bwrap.chmod(0o700)
with prepare_sandbox(
clone=clone, claude_bin=sys.executable, bwrap_bin=bwrap, preflight=False, gitnexus_available=False,
) as session:
assert set(session.synthetic_guidance_paths) == {"AGENTS.md", "CLAUDE.md"}
# Materialize the overlay in this private fixture to exercise real Git
# even on hosts that cannot create user namespaces.
for mount in session.read_only_mounts:
if mount.target.removeprefix('/workspace/') in session.synthetic_guidance_paths:
(clone / mount.target.removeprefix('/workspace/')).write_bytes(mount.source.read_bytes())
local = replace(session, backend="host-unsafe", command_prefix=[])
before = runner_artifacts.implementation_diff_digest(local, sha)
assert runner_artifacts.diff_churn(local, sha) == {
"diff_files": 0, "diff_insertions": 0, "diff_deletions": 0,
}
assert runner_artifacts.capture_patch(local, clone, sha) == b""
assert runner_artifacts.diff_churn(local, sha) == {
"diff_files": 0, "diff_insertions": 0, "diff_deletions": 0,
}
assert runner_artifacts.implementation_diff_digest(local, sha) == before
assert runner_artifacts.capture_patch(local, clone, sha) == b""
(clone / "source.txt").write_text("agent change\n")
assert runner_artifacts.implementation_diff_digest(local, sha) != before
assert runner_artifacts.diff_churn(local, sha) == {
"diff_files": 1, "diff_insertions": 1, "diff_deletions": 1,
}
patch = runner_artifacts.capture_patch(local, clone, sha)
assert b"source.txt" in patch and b"AGENTS.md" not in patch and b"CLAUDE.md" not in patch
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_guidance_overlays_produce_no_agent_patch_or_churn(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
(clone / "CLAUDE.md").write_text("Run tests.\nMUST use GitNexus before editing.\n")
subprocess.run(["git", "init", "--quiet", str(clone)], check=True)
subprocess.run(["git", "-C", str(clone), "add", "."], check=True)
subprocess.run([
"git", "-C", str(clone), "-c", "user.name=test", "-c", "user.email=test@invalid",
"commit", "--quiet", "-m", "base",
], check=True)
sha = subprocess.check_output(["git", "-C", str(clone), "rev-parse", "HEAD"], text=True).strip()
with prepare_sandbox(clone=clone, claude_bin=sys.executable, gitnexus_available=False) as sandbox:
assert runner_artifacts.capture_patch(sandbox, clone, sha) == b""
assert runner_artifacts.diff_churn(sandbox, sha) == {
"diff_files": 0, "diff_insertions": 0, "diff_deletions": 0,
}
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_candidate_lifecycle_cannot_replace_harness_or_read_tokens(tmp_path, monkeypatch):
candidate = tmp_path / "candidate"
core = candidate / "gitnexus"
core.mkdir(parents=True)
harness = tmp_path / "trusted-harness.py"
harness.write_text("trusted main evaluator\n")
monkeypatch.setenv("GITNEXUS_BENCH_OPENAI_API_KEY", "canary-token")
monkeypatch.setenv("ONNXRUNTIME_NODE_INSTALL", "cuda12")
attack = (
"const fs=require('fs');\n"
f"const target={json.dumps(str(harness))};\n"
"const visible=fs.existsSync(target);\n"
"try { fs.writeFileSync(target,'forged evaluator'); } catch {}\n"
"try { fs.appendFileSync('.git/config','[core]\\n\\tfsmonitor = forged\\n'); } catch {}\n"
"fs.writeFileSync('receipt.json', JSON.stringify({visible, token:"
"process.env.GITNEXUS_BENCH_OPENAI_API_KEY ?? null,"
"onnxInstall:process.env.ONNXRUNTIME_NODE_INSTALL ?? null,"
"interfaces:Object.keys(require('os').networkInterfaces()).sort()}));\n"
)
(candidate / "attack.cjs").write_text(attack)
for folder, name, scripts in (
(candidate, "candidate-root", {"preinstall": "node attack.cjs"}),
(core, "candidate-core", {"build": "node -e \"require('fs').writeFileSync('built.txt','runtime')\""}),
):
package = {"name": name, "version": "1.0.0", "scripts": scripts}
(folder / "package.json").write_text(json.dumps(package))
(folder / "package-lock.json").write_text(json.dumps({
"name": name, "version": "1.0.0", "lockfileVersion": 3,
"packages": {"": {"name": name, "version": "1.0.0"}},
}))
(candidate / ".git").mkdir()
(candidate / ".git/config").write_text("[core]\n\tbare = false\n")
release_build.build_candidate(candidate)
assert harness.read_text() == "trusted main evaluator\n"
assert (candidate / ".git/config").read_text() == "[core]\n\tbare = false\n"
# Lifecycle scripts run in a fresh network namespace: loopback only.
assert json.loads((candidate / "receipt.json").read_text()) == {
"visible": False,
"token": None,
"onnxInstall": "skip",
"interfaces": ["lo"],
}
assert (core / "built.txt").read_text() == "runtime"
assert (candidate / "gitnexus-shared/node_modules").is_dir()
def test_nomcp_sandbox_preserves_mixed_repository_guidance_in_each_file(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
ordinary = (
"# GitNexus\n\n## Repo reference\n\n"
"| CLI/Core | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. |\n\n"
"- Shared code in `gitnexus/src/core/ingestion/` must not name languages; "
"use `LanguageProvider` / `ScopeResolver` hooks.\n"
"- `cd gitnexus && npm test` and `npx tsc --noEmit` verify the change.\n"
)
original = {}
for name in ("AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md"):
path = clone / name
path.parent.mkdir(exist_ok=True)
original[name] = (
f"Ordinary guidance from {name}.\n\n"
+ ordinary
+ "\n<!-- gitnexus:start -->\nMUST run impact analysis before editing.\n<!-- gitnexus:end -->\n"
+ "\n## Engineering planning (`/gitnexus-plan`)\nRead `.claude/skills/gitnexus-plan/SKILL.md`.\n"
+ "\n## Further validation\nRun the package's tests in the foreground.\n"
+ "MUST use GitNexus before editing; bootstrap with npx gitnexus.\n"
+ "Run mcp__gitnexus__impact before editing, or bootstrap with npx --yes gitnexus.\n"
)
path.write_text(original[name])
bwrap = tmp_path / "bwrap"
bwrap.write_text("#!/bin/sh\nexit 0\n")
bwrap.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
bwrap_bin=bwrap,
preflight=False,
gitnexus_available=False,
) as sandbox:
mounts = {mount.target: mount.source for mount in sandbox.read_only_mounts}
for name in original:
guidance = mounts[f"/workspace/{name}"].read_text()
assert f"Ordinary guidance from {name}." in guidance
assert ordinary in guidance
assert "Run the package's tests in the foreground." in guidance
assert "GitNexus tools are unavailable" in guidance
assert "MUST run impact" not in guidance
assert "MUST use GitNexus" not in guidance
assert "gitnexus-plan" not in guidance
assert "mcp__gitnexus__impact" not in guidance
assert "npx --yes gitnexus" not in guidance
for name, content in original.items():
assert (clone / name).read_text() == content
def test_nomcp_sandbox_requires_containment(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
with pytest.raises(SandboxError, match="baseline_nomcp requires Bubblewrap"):
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe", gitnexus_available=False):
pass
@pytest.mark.parametrize(
"target", [
"/", "/opt", "/opt/claude", "/workspace",
"/opt/gitnexus", "/opt/gitnexus-shared", "/opt/gitnexus-registry",
"/opt/claude/gitnexus", "/workspace/.gitnexus",
"/opt/gitnexus/dist", "/opt/gitnexus-shared/dist", "/opt/gitnexus-registry/registry.json",
"/opt/claude/gitnexus/child", "/workspace/.gitnexus/lbug",
"//opt", "//opt/gitnexus", "/opt//gitnexus", "/opt/./gitnexus/", "//workspace/.gitnexus",
]
)
def test_nomcp_sandbox_refuses_supplied_graph_or_runtime_mounts(tmp_path, target):
clone = tmp_path / "clone"
clone.mkdir()
with pytest.raises(SandboxError, match="baseline_nomcp cannot mount GitNexus"):
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
gitnexus_available=False,
read_only_mounts=(ReadOnlyMount(tmp_path / "runtime", target),),
):
pass
@pytest.mark.parametrize("target", ["opt/gitnexus", "", "/opt/safe/../gitnexus", "/workspace/../opt"])
def test_nomcp_sandbox_rejects_nonabsolute_or_parent_traversing_mount_targets(tmp_path, target):
clone = tmp_path / "clone"
clone.mkdir()
with pytest.raises(SandboxError, match="baseline_nomcp mount target must be absolute"):
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
gitnexus_available=False,
read_only_mounts=(ReadOnlyMount(tmp_path / "runtime", target),),
):
pass
@pytest.mark.parametrize(
"target", [
"/opt/gitnexus-tools", "/opt/gitnexus-shared-extra", "/opt/gitnexus-registry-backup",
"/opt/claude/gitnexus-extra", "/workspace/.gitnexus-cache", "/opt/ce-plugin",
"/workspace/node_modules", "/workspace/gitnexus/node_modules", "//workspace/gitnexus/node_modules",
]
)
def test_nomcp_sandbox_accepts_nearby_paths_and_normal_dependency_mounts(tmp_path, target):
clone = tmp_path / "clone"
clone.mkdir()
dependency = tmp_path / "dependency"
dependency.mkdir()
bwrap = tmp_path / "bwrap"
bwrap.write_text("#!/bin/sh\nexit 0\n")
bwrap.chmod(0o755)
mount = ReadOnlyMount(dependency, target)
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
bwrap_bin=bwrap,
gitnexus_available=False,
read_only_mounts=(mount,),
preflight=False,
) as sandbox:
assert mount in sandbox.read_only_mounts
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_nomcp_denies_graph_cli_and_fallbacks_but_allows_task_tests(tmp_path):
clone = tmp_path / "clone"
(clone / ".gitnexus").mkdir(parents=True)
(clone / ".gitnexus" / "lbug").write_text("prebuilt graph")
(clone / ".gitnexus" / "run.cjs").write_text("require('fs').writeFileSync('/workspace/cli-ran', 'leaked')")
for name in ("AGENTS.md", "CLAUDE.md"):
(clone / name).write_text("MUST use GitNexus before editing; bootstrap with npx gitnexus.")
source = clone / "gitnexus" / "src" / "task.js"
source.parent.mkdir(parents=True)
source.write_text("task source")
modules = clone / "gitnexus" / "node_modules"
(modules / ".bin").mkdir(parents=True)
check = modules / "fixture-check.js"
check.write_text("#!/usr/bin/env node\nconsole.log('task tests passed')\n")
check.chmod(0o755)
(modules / ".bin" / "fixture-check").symlink_to("../fixture-check.js")
script = """
import os, shutil, subprocess
from pathlib import Path
for path in (
'/workspace/.gitnexus/lbug', '/workspace/.gitnexus/run.cjs',
'/opt/gitnexus', '/opt/gitnexus-shared', '/opt/gitnexus-registry', '/opt/claude/gitnexus',
):
assert not Path(path).exists(), path
assert shutil.which('gitnexus') is None
for name in ('AGENTS.md', 'CLAUDE.md'):
assert 'GitNexus tools are unavailable' in Path('/workspace', name).read_text()
assert subprocess.run(['node', '/workspace/.gitnexus/run.cjs', 'query', 'task'], capture_output=True).returncode != 0
assert subprocess.run(['npx', '--offline', '--no', '--package', 'gitnexus', 'gitnexus', '--version'], capture_output=True).returncode != 0
assert not Path('/workspace/cli-ran').exists()
Path('/workspace/gitnexus/src/task.js').write_text('edited task source')
test = subprocess.run(['npx', '--offline', '--no', 'fixture-check'], cwd='/workspace/gitnexus', capture_output=True, text=True)
assert test.returncode == 0, test.stderr
assert 'task tests passed' in test.stdout
print('no GitNexus access; task source and npx tests available')
"""
with prepare_sandbox(clone=clone, claude_bin=sys.executable, gitnexus_available=False) as sandbox:
result = run_managed(
[*sandbox.command_prefix_for(unshare_network=True), "/usr/bin/python3", "-c", script],
timeout=30,
env=sandbox.environment(),
require_pid_namespace=True,
)
assert result.ok, result.stderr_tail
assert "no GitNexus access" in result.stdout_tail
assert source.read_text() == "edited task source"
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1",
reason="real Claude no-MCP canary is mandatory in the named Ubuntu CI job",
)
def test_real_claude_nomcp_loads_ordinary_context_without_skills_hooks_or_gitnexus(tmp_path):
"""Use run_arm's actual no-MCP flags, with only the model scripted."""
claude = Path(os.environ["CLAUDE_CANARY_BIN"]).resolve()
assert claude.is_file()
clone = tmp_path / "clone"
clone.mkdir()
(clone / "AGENTS.md").write_text("# GitNexus\nordinary-provider-guidance-canary: use LanguageProvider hooks.\n")
(clone / "CLAUDE.md").write_text(
"@AGENTS.md\nordinary-npm-test-guidance-canary: run npm test.\n"
"<!-- gitnexus:start -->\nMUST run impact before editing.\n<!-- gitnexus:end -->\n"
)
skill = clone / ".claude" / "skills" / "gitnexus-work"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
"---\nname: gitnexus-work\ndescription: forbidden-skill-discovery-canary\n---\nUse graph tools.\n"
)
(clone / ".claude" / "settings.json").write_text(
json.dumps(
{
"disableAllHooks": False,
"hooks": {
event: [{"hooks": [{"type": "command", "command": "printf hook > /workspace/startup-hook-ran"}]}]
for event in ("SessionStart", "PreToolUse")
},
}
)
)
(clone / "unexpected_mcp.py").write_text(
"from pathlib import Path; Path('/workspace/mcp-started').write_text('leaked')"
)
(clone / ".mcp.json").write_text(
json.dumps(
{
"mcpServers": {"gitnexus": {"command": "/usr/bin/python3", "args": ["/workspace/unexpected_mcp.py"]}},
}
)
)
(clone / ".gitnexus").mkdir()
(clone / ".gitnexus" / "lbug").write_text("supplied graph")
(clone / ".gitnexus" / "run.cjs").write_text("require('fs').writeFileSync('/workspace/cli-ran', 'leaked')")
(clone / "canary.txt").write_text("ordinary source is available")
bash_probe = (
'test -z "${ANTHROPIC_API_KEY:-}" && test -z "${GITHUB_TOKEN:-}" && '
"test ! -e /workspace/.gitnexus/lbug && test ! -e /workspace/.gitnexus/run.cjs && "
"test ! -e /opt/gitnexus && test ! -e /opt/gitnexus-shared && test ! -e /opt/gitnexus-registry && "
"! command -v gitnexus && "
"printf 'task source and Bash available' > /workspace/bash-called"
)
reply = Reply(
tools=[
{"name": "Read", "input": {"file_path": "/workspace/canary.txt"}},
{"name": "Skill", "input": {"skill": "gitnexus-work"}},
{"name": "mcp__gitnexus__list_repos", "input": {}},
{"name": "Bash", "input": {"command": bash_probe}},
]
)
with prepare_sandbox(clone=clone, claude_bin=claude, gitnexus_available=False) as sandbox:
with MockProvider(replies=[reply]) as provider:
args = SimpleNamespace(
claude_bin=str(claude),
timeout=60,
model="claude-canary-20260718",
effort="high",
auth_token="offline-canary-key",
base_url=provider.base_url,
)
record = runner.run_arm(
"baseline_nomcp",
{
"prompt": "Inspect the source, attempt the requested tools, and finish.",
"verify": "test -s /workspace/bash-called",
},
clone,
args,
sandbox=sandbox,
)
assert record["ok"] is True, record.get("error_detail")
assert record["authored_tests_passed"] is True, record.get("authored_test_output")
first = provider.requests[0].body
# Claude 2.1.214 sends CLAUDE.md/imported AGENTS.md as a startup
# system-reminder text block in its first user message.
initial_user = first["messages"][0]
assert initial_user["role"] == "user"
reminders = [
block["text"] for block in initial_user["content"]
if block.get("type") == "text" and "<system-reminder>" in block.get("text", "")
and "Contents of /workspace/CLAUDE.md (" in block["text"]
]
assert len(reminders) == 1
context = reminders[0]
assert "Contents of /workspace/AGENTS.md" in context
assert "ordinary-npm-test-guidance-canary" in context
assert "ordinary-provider-guidance-canary" in context
assert "forbidden-skill-discovery-canary" not in json.dumps(first)
advertised = {tool["name"] for tool in first.get("tools", [])}
assert "Read" in advertised and "Bash" in advertised
assert advertised <= {tool for tool in runner.BUILTIN_AGENT_TOOLS if tool != "Skill"}
assert "Skill" not in advertised
assert not any(name.startswith("mcp__") for name in advertised)
results = {
block["tool_use_id"]: block
for request in provider.requests
for message in request.body.get("messages", [])
if isinstance(message.get("content"), list)
for block in message["content"]
if block.get("type") == "tool_result"
}
assert results["toolu_mock_0"].get("is_error") is not True
assert results["toolu_mock_1"].get("is_error") is True
assert results["toolu_mock_2"].get("is_error") is True
assert results["toolu_mock_3"].get("is_error") is not True
assert (clone / "bash-called").read_text() == "task source and Bash available"
for marker in ("startup-hook-ran", "mcp-started", "cli-ran"):
assert not (clone / marker).exists(), marker
@pytest.mark.parametrize("entry", ["directory", "relative-link", "absolute-link"])
def test_review_preparation_rejects_a_reused_artifact_directory(tmp_path, entry):
clone = tmp_path / "clone"
@ -413,6 +862,10 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
assert str(clone.resolve()) in argv
assert "/workspace" in argv
assert sandbox.claude_bin == "/opt/claude/claude"
bwrap_index = argv.index(proposer_sandbox.SANDBOX_BWRAP)
assert argv[bwrap_index - 2:bwrap_index + 1] == [
"--ro-bind", str(bwrap.resolve()), proposer_sandbox.SANDBOX_BWRAP,
]
assert sandbox.transcript_projects.parent.name == ".claude"
shell_prefix_index = argv.index(SANDBOX_SHELL_PREFIX)
assert argv[shell_prefix_index - 2] == "--ro-bind"
@ -485,7 +938,7 @@ def test_runtime_mounts_bind_the_resolved_node_to_a_fresh_sandbox_path(monkeypat
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: "/opt/hostedtoolcache/node/22.18.0/x64/bin/node" if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
node_index = args.index("/opt/hostedtoolcache/node/22.18.0/x64/bin/node")
assert args[node_index - 1] == "--ro-bind"
assert args[node_index + 1] == SANDBOX_NODE
@ -511,7 +964,7 @@ def test_runtime_mounts_bind_the_node_prefix_so_npx_and_npm_resolve(monkeypatch,
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
prefix_index = args.index(str(prefix))
assert args[prefix_index - 1] == "--ro-bind"
assert args[prefix_index + 1] == SANDBOX_NODE_PREFIX
@ -538,7 +991,7 @@ def test_runtime_mounts_skip_the_prefix_bind_for_an_unrecognized_node_layout(mon
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(bare / "node") if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert str(tmp_path) not in args
# the node bind itself is unaffected -- SANDBOX_NODE still works.
@ -555,7 +1008,7 @@ def test_runtime_mounts_skip_the_prefix_bind_without_npx_beside_node(monkeypatch
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
@ -572,7 +1025,7 @@ def test_runtime_mounts_bind_a_real_tool_cache_layout(monkeypatch, tmp_path) ->
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
prefix_index = args.index(SANDBOX_NODE_PREFIX)
assert args[prefix_index - 2] == "--ro-bind"
assert args[prefix_index - 1] == str(prefix)
@ -587,14 +1040,14 @@ def test_runtime_mounts_skip_the_prefix_bind_when_it_is_already_bound(monkeypatc
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: "/usr/local/bin/node" if name == "node" else None,
)
args = _runtime_mount_args()
args = runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert args[args.index("/usr/local/bin/node") + 1] == SANDBOX_NODE
def test_runtime_mounts_skip_the_node_bind_when_node_is_unresolvable(monkeypatch) -> None:
monkeypatch.setattr("workflow_bench.proposer_sandbox.shutil.which", lambda name: None)
args = _runtime_mount_args()
args = runtime_mount_args()
assert SANDBOX_NODE not in args
@ -1002,7 +1455,7 @@ def test_preflight_failure_is_returned_before_a_model_command(monkeypatch, tmp_p
monkeypatch.setattr("workflow_bench.proposer_sandbox.run_managed", fail)
monkeypatch.setattr(
"workflow_bench.proposer_sandbox._runtime_mount_args",
"workflow_bench.proposer_sandbox.runtime_mount_args",
lambda: runtime_mounts,
)
with pytest.raises(SandboxError, match="preflight"):
@ -1120,6 +1573,172 @@ def test_real_bubblewrap_denies_parent_read_and_allows_clone_write(tmp_path: Pat
assert (clone / "allowed").read_text() == "ok"
def test_mcp_boundary_has_only_readonly_inputs_and_private_state() -> None:
server = json.loads(runner.sandbox_mcp_config())["mcpServers"]["gitnexus"]
command = [server["command"], *server["args"]]
assert command[:3] == ["/usr/bin/env", "-i", proposer_sandbox.SANDBOX_BWRAP]
for option in ("--unshare-user", "--unshare-pid", "--unshare-net", "--unshare-ipc", "--unshare-uts"):
assert option in command
index = command.index("--cap-drop")
assert command[index:index + 2] == ["--cap-drop", "ALL"]
assert "--bind" not in command
assert "--clearenv" in command
for path in (SANDBOX_WORKSPACE, proposer_sandbox.SANDBOX_GITNEXUS, SANDBOX_NODE):
assert any(command[index:index + 3] == ["--ro-bind", path, path] for index in range(len(command)))
for path in ("/home", "/tmp", "/run"):
assert any(command[index:index + 2] == ["--tmpfs", path] for index in range(len(command)))
assert proposer_sandbox.SANDBOX_CLAUDE not in command
assert proposer_sandbox.SANDBOX_EVIDENCE not in command
assert proposer_sandbox.SANDBOX_REVIEW_OUTPUT not in command
def test_unsafe_host_mcp_retains_explicit_credential_free_diagnostic_launch(tmp_path: Path) -> None:
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
config = json.loads(sandbox.host_text(runner.sandbox_mcp_config(unsafe_host=True)))
server = config["mcpServers"]["gitnexus"]
command = [server["command"], *server["args"]]
assert command[:2] == ["/usr/bin/env", "-i"]
assert "--unshare-pid" not in command
assert f"HOME={sandbox.home}" in command
assert f"TMPDIR={sandbox.temp}" in command
assert f"GITNEXUS_MCP_ALLOWED_REPOS={clone}" in command
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_mcp_startup_and_tools_cannot_apply_credited_patches(tmp_path: Path) -> None:
clone = tmp_path / "clone"
(clone / ".gitnexus").mkdir(parents=True)
source = clone / "source.txt"
source.write_text("unsolved")
(clone / "oracle.txt").write_text("trusted oracle")
graph = tmp_path / "graph"
graph.mkdir()
(graph / "metadata.json").write_text("trusted graph")
runtime = tmp_path / "runtime"
entrypoint = runtime / "dist" / "cli" / "index.js"
entrypoint.parent.mkdir(parents=True)
shared = tmp_path / "shared"
shared.mkdir()
(shared / "package.json").write_text("trusted shared")
registry = tmp_path / "registry"
registry.mkdir()
(registry / "registry.json").write_text("trusted registry")
entrypoint.write_text("""
const fs = require('fs');
const net = require('net');
const readline = require('readline');
const [parentPid, parentNamespace, port] = process.argv.slice(-3);
async function probe(phase) {
const readable = ['/workspace/source.txt', '/workspace/oracle.txt',
'/workspace/.gitnexus/metadata.json', '/opt/gitnexus-registry/registry.json',
'/opt/gitnexus-shared/package.json', '/opt/gitnexus/dist/cli/index.js'];
const reads = readable.map(path => fs.readFileSync(path, 'utf8').length > 0);
const blocked = readable.map(path => {
try { fs.writeFileSync(path, 'known solution'); return false; } catch { return true; }
});
let creationBlocked = false;
try { fs.writeFileSync('/workspace/credited-patch', 'known solution'); }
catch { creationBlocked = true; }
const agentStateAbsent = !fs.existsSync('/home/agent/agent-token') &&
!fs.existsSync('/tmp/agent-token') && !fs.existsSync('/review-output') && !fs.existsSync('/evidence');
// PID numbers can repeat in nested namespaces. A same-numbered process
// belongs to this namespace, not the credential-bearing agent's namespace.
const parentHidden = !fs.existsSync('/proc/' + parentPid + '/ns/pid') ||
fs.readlinkSync('/proc/' + parentPid + '/ns/pid') !== parentNamespace;
let credentialAbsent = !process.env.ANTHROPIC_API_KEY;
for (const pid of fs.readdirSync('/proc').filter(value => /^\\d+$/.test(value))) {
try { credentialAbsent &&= !fs.readFileSync('/proc/' + pid + '/environ', 'utf8')
.includes('nested-mcp-canary-secret'); } catch {}
}
const capZero = /^CapEff:\\s+0+$/m.test(fs.readFileSync('/proc/self/status', 'utf8'));
const privateState = phase === 'startup' ? !fs.existsSync('/tmp/mcp-state') :
fs.readFileSync('/tmp/mcp-state', 'utf8') === 'startup';
fs.writeFileSync('/tmp/mcp-state', phase);
fs.writeFileSync('/home/agent/mcp-state', phase);
const networkBlocked = await new Promise(resolve => {
const socket = net.connect({host: '127.0.0.1', port: Number(port)});
socket.once('connect', () => { socket.destroy(); resolve(false); });
socket.once('error', () => resolve(true));
socket.setTimeout(1000, () => { socket.destroy(); resolve(true); });
});
return {phase, reads, blocked, creationBlocked, agentStateAbsent, parentHidden,
credentialAbsent, capZero, privateState, networkBlocked};
}
(async () => {
process.stdout.write(JSON.stringify(await probe('startup')) + '\\n');
const input = readline.createInterface({input: process.stdin});
for await (const line of input) {
const request = JSON.parse(line);
if (request.method !== 'tools/call') throw new Error('expected tool request');
process.stdout.write(JSON.stringify({jsonrpc: '2.0', id: request.id,
result: await probe('tool')}) + '\\n');
}
})().catch(error => { process.stderr.write(String(error)); process.exit(1); });
""")
original_runtime = entrypoint.read_bytes()
mounts = [
ReadOnlyMount(runtime, proposer_sandbox.SANDBOX_GITNEXUS),
ReadOnlyMount(shared, proposer_sandbox.SANDBOX_GITNEXUS_SHARED),
ReadOnlyMount(registry, proposer_sandbox.SANDBOX_GITNEXUS_REGISTRY),
ReadOnlyMount(graph, f"{SANDBOX_WORKSPACE}/.gitnexus"),
]
server = json.loads(runner.sandbox_mcp_config())["mcpServers"]["gitnexus"]
command = [server["command"], *server["args"]]
# The credential-bearing agent stays alive while its MCP child attempts
# startup and tool-handler attacks. A same-namespace env scrub is not enough.
launcher = f"""
import json, os, socket, subprocess
from pathlib import Path
Path('/home/agent/agent-token').write_text('nested-mcp-canary-secret')
Path('/tmp/agent-token').write_text('nested-mcp-canary-secret')
listener = socket.socket()
listener.bind(('127.0.0.1', 0))
listener.listen()
command = {command!r} + [str(os.getpid()), os.readlink('/proc/self/ns/pid'),
str(listener.getsockname()[1])]
result = subprocess.run(command, input=json.dumps({{'jsonrpc': '2.0', 'id': 1,
'method': 'tools/call', 'params': {{'name': 'apply_known_solution'}}}}) + '\\n',
text=True, capture_output=True, timeout=10)
assert result.returncode == 0, result.stderr
assert not Path('/home/agent/mcp-state').exists()
assert not Path('/tmp/mcp-state').exists()
assert Path('/home/agent/agent-token').read_text() == 'nested-mcp-canary-secret'
assert Path('/tmp/agent-token').read_text() == 'nested-mcp-canary-secret'
Path('/workspace/agent-patch').write_text('agent implementation')
print(result.stdout, end='')
"""
with prepare_sandbox(
clone=clone, claude_bin=sys.executable, read_only_mounts=mounts, preflight=True,
) as sandbox:
result = sandbox.run(
["/usr/bin/python3", "-c", launcher], timeout=15,
env=sandbox.environment(auth_token="nested-mcp-canary-secret"),
)
assert result.ok, result.stderr_tail
startup, response = [json.loads(line) for line in result.stdout_tail.splitlines()]
tool = response["result"]
assert [startup["phase"], tool["phase"]] == ["startup", "tool"]
for probe in (startup, tool):
assert all(probe["reads"])
assert all(probe["blocked"])
for field in ("creationBlocked", "agentStateAbsent", "parentHidden", "credentialAbsent",
"capZero", "privateState", "networkBlocked"):
assert probe[field], (probe["phase"], field)
assert source.read_text() == "unsolved"
assert (clone / "oracle.txt").read_text() == "trusted oracle"
assert (graph / "metadata.json").read_text() == "trusted graph"
assert (registry / "registry.json").read_text() == "trusted registry"
assert (shared / "package.json").read_text() == "trusted shared"
assert entrypoint.read_bytes() == original_runtime
assert not (clone / "credited-patch").exists()
assert (clone / "agent-patch").read_text() == "agent implementation"
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
@ -1564,3 +2183,21 @@ def test_real_bubblewrap_lets_a_review_artifact_be_written_atomically(tmp_path:
# consumes it before leaving the scope.
_verdict, findings = parse_review_output(review_output)
assert findings == ()
def test_nomcp_sandbox_refuses_unbalanced_repository_guidance(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
(clone / "AGENTS.md").write_text("Build first.\n<!-- gitnexus:start -->\nUse GitNexus.\n")
bwrap = tmp_path / "bwrap"
bwrap.write_text("#!/bin/sh\nexit 0\n")
bwrap.chmod(0o755)
with pytest.raises(SandboxError, match="unbalanced GitNexus markers"):
with prepare_sandbox(
clone=clone,
claude_bin=sys.executable,
bwrap_bin=bwrap,
preflight=False,
gitnexus_available=False,
):
pass

View file

@ -0,0 +1,168 @@
"""Candidate builds must fail closed before any uncontained lifecycle script runs."""
import json
from pathlib import Path
import pytest
from workflow_bench import release_build
from workflow_bench.proposer_sandbox import SandboxError, bwrap_base_args
def write_lockfiles(repo: Path, gitnexus_entries: dict | None = None) -> None:
(repo / "gitnexus").mkdir(exist_ok=True)
for lockfile in release_build.LOCKFILES:
extra = gitnexus_entries if lockfile.startswith("gitnexus/") else None
packages = {"": {"name": "candidate"}, **(extra or {})}
(repo / lockfile).write_text(json.dumps({"lockfileVersion": 3, "packages": packages}))
def test_candidate_build_refuses_unavailable_containment_before_npm(monkeypatch, tmp_path):
calls = []
def unavailable():
raise SandboxError("no containment")
monkeypatch.setattr(release_build, "preflight_bubblewrap", unavailable)
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
write_lockfiles(tmp_path)
with pytest.raises(SandboxError, match="no containment"):
release_build.build_candidate(tmp_path)
assert calls == []
def test_candidate_build_rejects_symlinked_checkout_before_execution(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
link = tmp_path / "candidate"
link.symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(SandboxError, match="real directory"):
release_build.build_candidate(link)
assert calls == []
def test_candidate_build_clears_tokens_and_mounts_only_candidate_writable(monkeypatch, tmp_path):
calls = []
monkeypatch.setenv('ONNXRUNTIME_NODE_INSTALL', 'cuda12')
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path('/usr/bin/bwrap'))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append((a, k)))
write_lockfiles(tmp_path)
release_build.build_candidate(tmp_path)
assert len(calls) == 2
for (command,), kwargs in calls:
assert kwargs == {"timeout": 1200, "env": {}}
assert '--clearenv' in command
at = command.index('--bind')
assert command[at + 1:at + 3] == [str(tmp_path), '/workspace']
assert command.count('--bind') == 1
environment = {command[i + 1]: command[i + 2] for i, v in enumerate(command) if v == '--setenv'}
assert environment == {
'PATH': release_build.SANDBOX_PATH,
'HOME': '/home/build',
'ONNXRUNTIME_NODE_INSTALL': 'skip',
}
assert '--unshare-pid' in command and '--cap-drop' in command
def test_candidate_downloads_without_scripts_then_runs_every_script_offline(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path('/usr/bin/bwrap'))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a[0]))
write_lockfiles(tmp_path)
release_build.build_candidate(tmp_path)
download, scripts = calls
assert "--unshare-net" not in download
assert download[-1].splitlines()[1:] == [
"npm ci --ignore-scripts --audit=false --fund=false",
"npm ci --prefix gitnexus --ignore-scripts --audit=false --fund=false",
]
assert "--unshare-net" in scripts
assert scripts[-1].splitlines()[1:] == [
"npm rebuild --foreground-scripts",
"npm rebuild --prefix gitnexus --foreground-scripts",
"npm run build --prefix gitnexus",
"mkdir -p gitnexus-shared/node_modules",
]
def test_candidate_build_mounts_git_metadata_read_only_over_the_writable_checkout(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path('/usr/bin/bwrap'))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
(tmp_path / ".git").mkdir()
write_lockfiles(tmp_path)
release_build.build_candidate(tmp_path)
assert len(calls) == 2
for (command,) in calls:
# bwrap applies mounts in order, so the read-only overlay must follow the writable bind.
at = command.index('--bind')
assert command[at:at + 6] == ['--bind', str(tmp_path), '/workspace', '--ro-bind', str(tmp_path / ".git"), '/workspace/.git']
def test_candidate_build_uses_the_shared_bubblewrap_preamble_with_all_capabilities_dropped(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path("/usr/bin/bwrap"))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
write_lockfiles(tmp_path)
release_build.build_candidate(tmp_path)
(download,), (scripts,) = calls
preamble = bwrap_base_args(cap_drop_all=True)
assert download[1 : 1 + len(preamble)] == preamble
offline = bwrap_base_args(unshare_network=True, cap_drop_all=True)
assert scripts[1 : 1 + len(offline)] == offline
at = preamble.index("--cap-drop")
assert preamble[at : at + 2] == ["--cap-drop", "ALL"]
assert "--unshare-net" not in preamble
assert "--cap-drop" not in bwrap_base_args()
@pytest.mark.parametrize(
"entry",
[
{"resolved": "http://169.254.169.254/latest/meta-data/x.tgz"},
{"resolved": "http://127.0.0.1:8080/x.tgz"},
{"resolved": "git+ssh://git@internal.example/x.git"},
{"resolved": "https://registry.npmjs.org.evil.example/x.tgz"},
{"resolved": "../../outside", "link": True},
{"resolved": "/etc", "link": True},
],
ids=["metadata", "loopback", "git", "lookalike-host", "link-escape", "link-absolute"],
)
def test_candidate_lockfile_cannot_steer_downloads_off_the_public_registry(monkeypatch, tmp_path, entry):
calls = []
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path("/usr/bin/bwrap"))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
write_lockfiles(tmp_path, {"node_modules/x": entry})
with pytest.raises(ValueError, match="outside https://registry.npmjs.org/"):
release_build.build_candidate(tmp_path)
assert calls == []
@pytest.mark.parametrize("config", [".npmrc", "gitnexus/.npmrc"])
def test_candidate_npm_configuration_is_refused_before_any_download(monkeypatch, tmp_path, config):
calls = []
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
write_lockfiles(tmp_path)
(tmp_path / config).write_text("registry=http://127.0.0.1:4873/\n")
with pytest.raises(ValueError, match="must not ship npm configuration"):
release_build.build_candidate(tmp_path)
assert calls == []
def test_registry_and_in_checkout_workspace_links_are_allowed(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(release_build, "preflight_bubblewrap", lambda: Path("/usr/bin/bwrap"))
monkeypatch.setattr(release_build, "run_checked", lambda *a, **k: calls.append(a))
write_lockfiles(
tmp_path,
{
"node_modules/x": {"resolved": "https://registry.npmjs.org/x/-/x-1.0.0.tgz"},
"node_modules/gitnexus-shared": {"resolved": "../gitnexus-shared", "link": True},
},
)
release_build.build_candidate(tmp_path)
assert len(calls) == 2
def test_shipped_lockfiles_satisfy_the_registry_policy():
release_build.require_registry_only_install(Path(__file__).resolve().parents[2])

View file

@ -0,0 +1,229 @@
"""Native, contained prepare → paired runner → hidden grading → public report."""
import json
import os
import subprocess
import sys
from functools import partial
from pathlib import Path
import pytest
import yaml
from workflow_bench import oracle_assets, release_preflight, release_report, runner, runner_tasks
from workflow_bench.mock_provider import MockProvider, Reply
pytestmark = pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1",
reason="the Ubuntu containment job provisions the real pinned CLI, runtime, and Bubblewrap",
)
def test_native_paired_evaluator_produces_valid_report_and_keeps_negative_results(tmp_path, monkeypatch):
root = Path(__file__).resolve().parents[2]
repo = tmp_path / "candidate"
(repo / "src").mkdir(parents=True)
(repo / "package.json").write_text('{"type":"module"}\n')
(repo / "src/sum.js").write_text("export function total(a, b) { return a - b; }\n")
for arguments in (
["init", "-q"],
["add", "."],
["-c", "user.name=Canary", "-c", "user.email=canary@example.test", "commit", "-qm", "fixture"],
):
subprocess.run(["git", "-C", str(repo), *arguments], check=True, capture_output=True)
sha = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
oracles = tmp_path / "hidden"
oracles.mkdir()
(oracles / "sum.test.mjs").write_text(
"import assert from 'node:assert/strict';\nimport {total} from '../src/sum.js';\nassert.equal(total(1, 2), 3);\n"
)
suite = tmp_path / "suite.yaml"
suite.write_text(
yaml.safe_dump(
{
"tasks": [
{
"id": "release-smoke",
"class": "bug-fix",
"repo": str(repo),
"ref": sha,
"prompt": "Repair the total function and inspect available repository tools.",
"verify": "node --check src/sum.js",
"oracle": {
"command": 'node "$GITNEXUS_BENCH_ORACLE_ROOT/sum.test.mjs"',
"files": [{"source": "sum.test.mjs", "target": "sum.test.mjs"}],
},
}
]
}
)
)
# Substitute only the small corpus. Provisioning, sessions, hidden grading,
# row accounting and report validation all remain their production code.
capture = partial(oracle_assets.capture_task_oracles, root=oracles)
monkeypatch.setattr(release_report, "capture_task_oracles", capture)
monkeypatch.setattr(runner, "capture_task_oracles", capture)
monkeypatch.setattr(runner_tasks, "capture_task_oracles", capture)
monkeypatch.setattr(release_report, "suite_binding", partial(release_report.suite_binding, suite))
prepared = tmp_path / "prepared"
model = "claude-canary-20260718"
monkeypatch.setattr(
sys,
"argv",
[
"release-report",
"prepare",
"--repo",
str(repo),
"--task-repo",
str(repo),
"--out",
str(prepared),
"--model",
model,
"--effort",
"high",
"--runs",
"3",
],
)
release_report.main()
meta = json.loads((prepared / "metadata.json").read_text())
assert meta["runtime_sha"] == sha and meta["harness_sha"] == release_report._git_sha(root)
# Exercise actual graph construction/copying before even starting a model
# provider. This must leave no measurements that could count as evidence.
release_preflight.preflight_release(
yaml.safe_load((prepared / "tasks.yaml").read_text())["tasks"],
gitnexus_root=root,
claude_bin=Path(os.environ["CLAUDE_CANARY_BIN"]),
)
assert not (prepared / "raw").exists()
initial = []
counts = {"baseline_nomcp": 0, "baseline": 0}
def arm_for(body):
text = "\n".join(
block.get("text", "")
for message in body.get("messages", [])
if message.get("role") == "user" and isinstance(message.get("content"), list)
for block in message["content"]
if block.get("type") == "text"
)
return "baseline_nomcp" if runner.GITNEXUS_UNAVAILABLE_NOTE in text else "baseline"
class Scripted(MockProvider):
def next_reply(self):
body = self.requests[-1].body
if any(
block.get("type") == "tool_result"
for message in body.get("messages", [])
if isinstance(message.get("content"), list)
for block in message["content"]
):
return Reply(text="Finished.")
arm = arm_for(body)
initial.append((arm, body))
counts[arm] += 1
# One real failed repair proves negative outcomes remain measured.
operator = "-" if arm == "baseline" and counts[arm] == 3 else "+"
return Reply(
tools=[
*([{"name": "mcp__gitnexus__list_repos", "input": {}}] if arm == "baseline" else []),
{
"name": "Bash",
"input": {
"command": f"printf '%s\\n' 'export function total(a, b) {{ return a {operator} b; }}' > /workspace/src/sum.js"
},
},
]
)
raw = prepared / "raw"
with Scripted() as provider:
monkeypatch.setattr(
sys,
"argv",
[
"runner",
"--tasks",
str(prepared / "tasks.yaml"),
"--gitnexus-root",
str(root),
"--arms",
"baseline_nomcp",
"baseline",
"--runs",
"3",
"--workers",
"1",
"--timeout",
"60",
"--model",
model,
"--effort",
"high",
"--claude-bin",
os.environ["CLAUDE_CANARY_BIN"],
"--base-url",
provider.base_url,
"--anthropic-api-key",
"offline-canary-key",
"--out",
str(raw),
],
)
assert runner.main() in (None, 0)
assert counts == {"baseline_nomcp": 3, "baseline": 3}
assert len(initial) == 6
rows = [json.loads(line) for line in (raw / "results.jsonl").read_text().splitlines()]
assert len(rows) == 6
assert all(row["ok"] is True and row["sandbox_backend"] == "bwrap" for row in rows)
assert sum(row["resolved"] for row in rows) == 5
assert sum(row["oracle_passed"] is False for row in rows) == 1
for arm, body in initial:
advertised = {tool["name"] for tool in body["tools"]}
assert "Skill" not in advertised
if arm == "baseline_nomcp":
assert not any(name.startswith("mcp__") for name in advertised)
mcp_results = [
block
for request in provider.requests
if arm_for(request.body) == "baseline"
for message in request.body.get("messages", [])
if isinstance(message.get("content"), list)
for block in message["content"]
if block.get("type") == "tool_result" and block.get("tool_use_id") == "toolu_mock_0"
]
assert len(mcp_results) == 3
assert all(block.get("is_error") is not True for block in mcp_results)
public = prepared / "public"
monkeypatch.setattr(
sys,
"argv",
[
"release-report",
"summarize",
"--results",
str(raw / "results.jsonl"),
"--metadata",
str(prepared / "metadata.json"),
"--out",
str(public),
],
)
release_report.main()
report = json.loads((public / "agent-evaluation.json").read_text())
assert report["complete"] is True, report["problems"]
assert report["arms"]["baseline_nomcp"]["solved"] == 3
assert report["arms"]["baseline"]["solved"] == 2
monkeypatch.setattr(
sys,
"argv",
["release-report", "check", "--evidence", str(public / "agent-evaluation.json"), "--runtime-sha", sha],
)
release_report.main()
assert "oracle-failed" in (public / "agent-evaluation.json").read_text()
assert "offline-canary-key" not in (public / "agent-evaluation.json").read_text()
assert "-1" in (public / "agent-evaluation.md").read_text()

View file

@ -0,0 +1,348 @@
import json
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from workflow_bench import release_evidence
from workflow_bench.release_evidence import eligible_runs
from workflow_bench.release_report import build_report
RUNTIME = "a" * 40
HARNESS = "e" * 40
EVALUATOR = "9" * 40
DIGEST = "b" * 64
PINS = {"one": {"sha": "c" * 40, "oracle_digest": "d" * 64}}
TRUSTED_RUN = {
"conclusion": "success",
"head_branch": "main",
"event": "workflow_dispatch",
"head_repository": {"full_name": "owner/repo"},
}
def test_only_successful_trusted_release_workflow_runs_are_eligible():
trusted = {**TRUSTED_RUN, "id": 10, "head_sha": HARNESS}
candidates = [
trusted,
*[
{**trusted, **overrides}
for overrides in (
{"id": 11, "conclusion": "failure"},
{"id": 12, "head_branch": "feature"},
{"id": 13, "event": "pull_request"},
{"id": 14, "head_repository": {"full_name": "fork/repo"}},
{"id": "15"},
{"id": 16, "head_sha": "E" * 40},
{"id": 17, "head_sha": None},
)
],
]
assert eligible_runs({"workflow_runs": candidates}, "owner/repo") == [(10, HARNESS)]
def make_report(*, harness_sha: str = HARNESS, runtime_sha: str = RUNTIME) -> dict[str, Any]:
metadata = dict(
runtime_sha=runtime_sha,
harness_sha=harness_sha,
task_set_digest=DIGEST,
model="gpt-6.1-sol",
effort="medium",
runs=3,
tasks=PINS,
)
measured = [
dict(
task="one",
run=i,
arm=arm,
model=metadata["model"],
effort="medium",
task_base_sha=PINS["one"]["sha"],
oracle_digest=PINS["one"]["oracle_digest"],
sandbox_backend="bwrap",
ok=True,
resolved=True,
authored_tests_passed=True,
oracle_passed=True,
error_kind=None,
transcript_missing=False,
cost_usd=1,
duration_s=1,
sandbox_dependency_content_digest="f" * 64,
)
for i in range(3)
for arm in ("baseline_nomcp", "baseline")
]
return build_report(measured, metadata, now=datetime.now(UTC))
@dataclass
class FakeGitHub:
"""Workflow runs (newest first), their artifact reports and one main comparison."""
runs: list[dict[str, Any]]
reports: dict[str, Any]
artifact: dict[str, Any]
comparison: dict[str, Any] = field(
default_factory=lambda: {"status": "ahead", "files": [{"filename": "gitnexus/src/cli/index.ts"}]}
)
endpoints: list[str] = field(default_factory=list)
downloads: list[list[str]] = field(default_factory=list)
download_options: list[dict[str, Any]] = field(default_factory=list)
def api(self, endpoint: str) -> dict[str, Any]:
self.endpoints.append(endpoint)
routes = {
"/compare/": self.comparison,
"/artifacts?": {"artifacts": [self.artifact]},
"/workflows/release-evaluation.yml/runs?": {"workflow_runs": self.runs},
}
return next(payload for marker, payload in routes.items() if marker in endpoint)
def download(self, command: list[str], **options: Any) -> subprocess.CompletedProcess[str]:
self.downloads.append(command)
self.download_options.append(options)
destination = Path(command[-1])
(destination / "agent-evaluation.json").write_text(json.dumps(self.reports[command[3]]))
(destination / "agent-evaluation.md").write_text("untrusted downloaded prose")
return subprocess.CompletedProcess(command, 0, "", "")
def add_run(self, run_id: int, report: Any, head_sha: str = HARNESS) -> None:
self.runs.append({**TRUSTED_RUN, "id": run_id, "head_sha": head_sha})
self.reports[str(run_id)] = report
@pytest.fixture
def github(monkeypatch) -> FakeGitHub:
fake = FakeGitHub(runs=[], reports={}, artifact={"name": f"release-agent-evaluation-{RUNTIME}", "expired": False})
fake.add_run(10, make_report())
monkeypatch.setattr(release_evidence, "suite_binding", lambda: ([], DIGEST, PINS))
monkeypatch.setattr(release_evidence, "_gh_json", fake.api)
monkeypatch.setattr(release_evidence.subprocess, "run", fake.download)
return fake
def download(out: Path) -> None:
release_evidence.download_evidence("owner/repo", RUNTIME, out, evaluator_sha=EVALUATOR)
def test_publisher_regenerates_summary_from_verified_exact_revision_cells(tmp_path, github):
download(tmp_path)
assert json.loads((tmp_path / "agent-evaluation.json").read_text()) == github.reports["10"]
summary = (tmp_path / "agent-evaluation.md").read_text()
assert "3/3" in summary
assert "untrusted downloaded prose" not in summary
assert github.downloads[0][:4] == ["gh", "run", "download", "10"]
assert f"repos/owner/repo/compare/{HARNESS}...{EVALUATOR}" in github.endpoints
def test_wrong_revision_inside_named_artifact_blocks_publishing(tmp_path, github):
github.reports["10"]["runtime_sha"] = "f" * 40
with pytest.raises(ValueError, match="different runtime"):
download(tmp_path)
assert not (tmp_path / "agent-evaluation.json").exists()
def test_expired_artifact_is_not_downloaded_or_accepted(tmp_path, github):
github.artifact["expired"] = True
with pytest.raises(ValueError, match="No complete paired"):
download(tmp_path)
assert github.downloads == []
def test_evidence_beyond_the_first_page_of_recent_runs_is_found(tmp_path, github, monkeypatch):
filler = [{**TRUSTED_RUN, "id": index, "event": "push", "head_sha": HARNESS} for index in range(100)]
# The first page holds only ineligible runs; valid evidence is on page 2.
run_pages = iter([{"workflow_runs": filler}, {"workflow_runs": github.runs}])
def api(endpoint):
return next(run_pages) if "/runs?" in endpoint else github.api(endpoint)
monkeypatch.setattr(release_evidence, "_gh_json", api)
download(tmp_path)
assert json.loads((tmp_path / "agent-evaluation.json").read_text()) == github.reports["10"]
assert github.downloads[0][:4] == ["gh", "run", "download", "10"]
def test_recent_runs_are_paged_within_the_evidence_window(monkeypatch):
endpoints = []
pages = iter([{"workflow_runs": [{"id": index} for index in range(100)]}, {"workflow_runs": []}])
monkeypatch.setattr(release_evidence, "_gh_json", lambda endpoint: endpoints.append(endpoint) or next(pages))
assert list(release_evidence._recent_runs("owner/repo")) == []
assert [endpoint[-6:] for endpoint in endpoints] == ["page=1", "page=2"]
assert all("created=%3E%3D" in endpoint for endpoint in endpoints)
@pytest.mark.parametrize(
"plant",
[
lambda folder, report: (folder / "other.json").write_text(json.dumps(report)),
lambda folder, report: (folder / "agent-evaluation.json").symlink_to(folder / "elsewhere.json"),
lambda folder, report: (folder / "agent-evaluation.json").symlink_to(folder / "missing.json"),
lambda folder, report: (folder / "agent-evaluation.json").write_text(" " * (2 * 1024 * 1024 + 1)),
],
ids=["missing", "symlink-existing-target", "symlink-dangling", "oversized"],
)
def test_unsafe_or_missing_artifact_file_blocks_publishing(tmp_path, github, monkeypatch, plant):
def planted(command, **options):
folder = Path(command[-1])
report = github.reports["10"]
(folder / "elsewhere.json").write_text(json.dumps(report))
plant(folder, report)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr(release_evidence.subprocess, "run", planted)
with pytest.raises(ValueError, match="invalid release evidence artifact"):
download(tmp_path / "out")
assert not (tmp_path / "out").exists()
def test_report_measured_on_different_task_pins_blocks_publishing(tmp_path, github, monkeypatch):
monkeypatch.setattr(release_evidence, "suite_binding", lambda: ([], DIGEST, {"other": {}}))
with pytest.raises(ValueError, match="task pins"):
download(tmp_path / "out")
assert not (tmp_path / "out").exists()
def test_report_from_a_different_harness_than_its_run_is_rejected(tmp_path, github):
# A trusted run's artifact cannot vouch for a report another harness produced.
github.reports["10"] = make_report(harness_sha="7" * 40)
with pytest.raises(ValueError, match=f"harness_sha is not the commit workflow run 10 ran \\({HARNESS}\\)"):
download(tmp_path / "out")
assert not (tmp_path / "out").exists()
assert not any("/compare/" in endpoint for endpoint in github.endpoints)
@pytest.mark.parametrize(
("comparison", "reason"),
[
({"status": "ahead", "files": [{"filename": "eval/workflow_bench/oracles/retry/test.mjs"}]}, "inputs changed"),
({"status": "ahead", "files": [{"filename": "x.py", "previous_filename": "eval/grade.py"}]}, "inputs changed"),
({"status": "ahead", "files": [{"filename": ".github/workflows/release-evaluation.yml"}]}, "inputs changed"),
({"status": "ahead", "files": [{"filename": ".github/claude-canary-runtime/package.json"}]}, "inputs changed"),
({"status": "diverged", "files": []}, "not an ancestor"),
({"status": "behind", "files": []}, "not an ancestor"),
({"status": "ahead", "files": [{"filename": f"docs/{i}.md"} for i in range(300)]}, "truncated"),
({"status": "ahead"}, "truncated"),
({"status": "ahead", "files": [{"sha": "f" * 40}]}, "malformed compare file entry"),
],
ids=["oracle", "renamed-out", "workflow", "agent-cli", "diverged", "behind", "file-cap", "no-files", "malformed"],
)
def test_evidence_from_a_harness_that_main_has_since_changed_is_rejected(tmp_path, github, comparison, reason):
github.comparison = comparison
with pytest.raises(ValueError, match=reason):
download(tmp_path / "out")
assert not (tmp_path / "out").exists()
@pytest.mark.parametrize(
("comparison", "evaluator"),
[
({"status": "identical", "files": []}, HARNESS),
(
{"status": "ahead", "files": [{"filename": "gitnexus/eval/x.ts", "previous_filename": "docs/eval/x"}]},
EVALUATOR,
),
],
ids=["same-commit", "unrelated-change"],
)
def test_harness_stays_current_while_main_leaves_evaluation_inputs_alone(monkeypatch, comparison, evaluator):
endpoints = []
monkeypatch.setattr(release_evidence, "_gh_json", lambda endpoint: endpoints.append(endpoint) or comparison)
release_evidence.require_current_harness("owner/repo", HARNESS, evaluator)
assert endpoints == [f"repos/owner/repo/compare/{HARNESS}...{evaluator}"]
def test_a_rejected_newer_run_does_not_hide_older_valid_evidence(tmp_path, github):
github.runs.clear()
github.add_run(30, make_report(runtime_sha="f" * 40))
github.add_run(20, make_report(harness_sha="7" * 40))
github.add_run(10, make_report())
download(tmp_path)
assert [command[3] for command in github.downloads] == ["30", "20", "10"]
assert json.loads((tmp_path / "agent-evaluation.json").read_text()) == github.reports["10"]
def test_without_valid_evidence_every_rejected_run_is_listed(tmp_path, github):
github.runs.clear()
github.add_run(30, make_report(runtime_sha="f" * 40))
github.add_run(20, ["not", "a", "report"])
github.add_run(10, {"harness_sha": HARNESS})
with pytest.raises(ValueError) as rejected:
download(tmp_path / "out")
lines = str(rejected.value).splitlines()
assert lines[1:] == [
"Rejected evidence:",
"- run 30: release evidence belongs to a different runtime or task set",
f"- run 20: report harness_sha is not the commit workflow run 20 ran ({HARNESS})",
"- run 10: incomplete or unsupported release evidence",
]
assert not (tmp_path / "out").exists()
def test_rejection_list_is_bounded(tmp_path, github):
github.runs.clear()
for run_id in range(25, 0, -1):
github.add_run(run_id, make_report(runtime_sha="f" * 40))
with pytest.raises(ValueError) as rejected:
download(tmp_path / "out")
lines = str(rejected.value).splitlines()
assert len(lines) == 2 + release_evidence.MAX_REASONS + 1
assert lines[-1] == "- ... and 5 more rejected run(s)"
def test_artifact_download_is_time_bounded(tmp_path, github):
download(tmp_path)
assert github.download_options[0]["timeout"] == release_evidence.GH_TIMEOUT_S
def test_api_requests_are_time_bounded(monkeypatch):
calls = []
def gh(command, **options):
calls.append((command, options.get("timeout")))
return subprocess.CompletedProcess(command, 0, json.dumps({"ok": True}), "")
monkeypatch.setattr(release_evidence.subprocess, "run", gh)
assert release_evidence._gh_json("repos/owner/repo") == {"ok": True}
assert calls == [(["gh", "api", "repos/owner/repo"], release_evidence.GH_TIMEOUT_S)]
@pytest.mark.parametrize(
("failure", "reason"),
[
(subprocess.TimeoutExpired(["gh"], 120), "gh run timed out after 120s"),
(subprocess.CalledProcessError(1, ["gh"], stderr="HTTP 502 token=secret"), "gh run failed with exit status 1"),
],
ids=["timeout", "error"],
)
def test_stalled_or_failed_download_rejects_that_run_without_leaking_output(
tmp_path, github, monkeypatch, failure, reason
):
def fail(command, **options):
raise failure
monkeypatch.setattr(release_evidence.subprocess, "run", fail)
with pytest.raises(ValueError, match=f"- run 10: {reason}$") as rejected:
download(tmp_path / "out")
assert "secret" not in str(rejected.value)
assert not (tmp_path / "out").exists()
@pytest.mark.parametrize(
("repo", "sha", "evaluator"),
[
("owner/repo;rm", RUNTIME, EVALUATOR),
("owner/repo", "a" * 39, EVALUATOR),
("owner/repo", "A" * 40, EVALUATOR),
("owner/repo", RUNTIME, "main"),
],
)
def test_malformed_repository_or_revision_is_rejected_before_any_api_call(tmp_path, github, repo, sha, evaluator):
with pytest.raises(ValueError, match="full release commit SHA"):
release_evidence.download_evidence(repo, sha, tmp_path / "out", evaluator_sha=evaluator)
assert github.endpoints == []

View file

@ -0,0 +1,280 @@
"""RC quality decisions must be based on comparable, complete measured reports."""
from copy import deepcopy
from datetime import UTC, datetime, timedelta
import json
import subprocess
import sys
import pytest
from workflow_bench.release_report import build_report, suite_binding
CANDIDATE = "a" * 40
STABLE = "b" * 40
DEPENDENCIES = "9" * 64
NOW = datetime.now(UTC)
def reports():
_, digest, pins = suite_binding()
meta = dict(
runtime_sha=CANDIDATE,
harness_sha="c" * 40,
task_set_digest=digest,
model="gpt-6.1-sol",
effort="medium",
runs=3,
tasks=pins,
)
rows = [
dict(
task=task,
run=run,
arm=arm,
model=meta["model"],
effort=meta["effort"],
task_base_sha=pin["sha"],
oracle_digest=pin["oracle_digest"],
sandbox_backend="bwrap",
sandbox_dependency_content_digest=DEPENDENCIES,
ok=True,
resolved=run != 0,
authored_tests_passed=True,
oracle_passed=run != 0,
error_kind="oracle-failed" if run == 0 else None,
transcript_missing=False,
cost_usd=1.0,
duration_s=60.0,
)
for task, pin in pins.items()
for run in range(3)
for arm in ("baseline_nomcp", "baseline")
]
candidate = build_report(rows, meta, now=NOW)
stable = build_report(rows, {**meta, "runtime_sha": STABLE}, now=NOW)
return candidate, stable
def decide(candidate, stable):
from workflow_bench.release_gate import compare_reports
_, digest, pins = suite_binding()
return compare_reports(
candidate,
stable,
candidate_sha=CANDIDATE,
stable_sha=STABLE,
task_set_digest=digest,
task_pins=pins,
now=NOW,
)
def rebuild(report):
return build_report(report["per_run"], report, now=NOW)
def set_outcome(report, task, run, resolved):
row = next(r for r in report["per_run"] if (r["task"], r["run"], r["arm"]) == (task, run, "baseline"))
row.update(resolved=resolved, oracle_passed=resolved, error_kind=None if resolved else "oracle-failed")
def test_equal_quality_passes_with_recomputed_per_task_counts():
candidate, stable = reports()
result = decide(candidate, stable)
assert result["passed"] is True
assert result["candidate_sha"] == CANDIDATE
assert result["stable_sha"] == STABLE
assert result["problems"] == []
assert all(task["solve_delta"] == 0 for task in result["tasks"].values())
def test_improvement_elsewhere_cannot_hide_a_task_regression():
candidate, stable = reports()
first, second, *_ = candidate["tasks"]
set_outcome(candidate, first, 1, False)
set_outcome(candidate, second, 0, True)
result = decide(rebuild(candidate), stable)
assert result["passed"] is False
assert result["tasks"][first]["solve_delta"] == -1
assert result["tasks"][second]["solve_delta"] == 1
assert any(first in problem for problem in result["problems"])
def test_task_improvement_passes_without_claiming_statistical_significance():
candidate, stable = reports()
set_outcome(candidate, next(iter(candidate["tasks"])), 0, True)
result = decide(rebuild(candidate), stable)
assert result["passed"] is True
assert sum(task["solve_delta"] for task in result["tasks"].values()) == 1
@pytest.mark.parametrize("side", [0, 1])
@pytest.mark.parametrize(
"mutate",
[
lambda report: report.update(runtime_sha="f" * 40),
lambda report: report.update(complete=False),
lambda report: report.update(generated_at=(NOW - timedelta(days=8)).isoformat()),
lambda report: report.update(generated_at=(NOW + timedelta(minutes=5)).isoformat()),
lambda report: report["per_run"].pop(),
lambda report: report["arms"]["baseline"].update(solved=999),
lambda report: report["per_run"][0].update(transcript_missing=True),
lambda report: report["per_run"][0].update(cost_usd=None),
],
)
def test_invalid_evidence_on_either_side_cannot_pass(side, mutate):
measured = list(reports())
mutate(measured[side])
result = decide(*measured)
assert result["passed"] is False
assert result["problems"]
@pytest.mark.parametrize("field,value", [("harness_sha", "f" * 40), ("model", "gpt-5.6-sol"), ("effort", "high")])
def test_comparison_rejects_different_harness_or_model_settings(field, value):
candidate, stable = reports()
stable[field] = value
if field in ("model", "effort"):
for row in stable["per_run"]:
row[field] = value
result = decide(candidate, rebuild(stable))
assert result["passed"] is False
assert any(field in problem for problem in result["problems"])
def test_comparison_rejects_different_repetition_counts():
candidate, stable = reports()
stable["runs"] = 4
extra = [deepcopy(row) for row in stable["per_run"] if row["run"] == 2]
for row in extra:
row["run"] = 3
stable["per_run"].extend(extra)
result = decide(candidate, rebuild(stable))
assert result["passed"] is False
assert any("runs" in problem for problem in result["problems"])
def test_both_reports_must_match_the_trusted_oracle_pins():
candidate, stable = reports()
for report in (candidate, stable):
for task in report["tasks"].values():
task["oracle_digest"] = "f" * 64
for row in report["per_run"]:
row["oracle_digest"] = "f" * 64
result = decide(rebuild(candidate), rebuild(stable))
assert result["passed"] is False
assert any("pins" in problem for problem in result["problems"])
def first_task_rows(report):
task = next(iter(report["tasks"]))
return [row for row in report["per_run"] if row["task"] == task]
@pytest.mark.parametrize(
"side,mutate",
[
# Stable graded the task with another toolchain than the candidate.
(1, lambda rows: [row.update(sandbox_dependency_content_digest="8" * 64) for row in rows]),
# One candidate cell drifted from the other cells of the same task.
(0, lambda rows: rows[0].update(sandbox_dependency_content_digest="8" * 64)),
],
)
def test_comparison_rejects_tasks_graded_against_different_dependencies(side, mutate):
measured = list(reports())
mutate(first_task_rows(measured[side]))
result = decide(*(rebuild(report) for report in measured))
assert result["passed"] is False
assert result["tasks"] == {}
assert result["problems"] == [
f"{next(iter(measured[0]['tasks']))}: candidate and stable were not graded against identical task dependencies"
]
@pytest.mark.parametrize(
"mutate",
[
lambda row: row.pop("sandbox_dependency_content_digest"),
lambda row: row.update(sandbox_dependency_content_digest=None),
lambda row: row.update(sandbox_dependency_content_digest=["9" * 64]),
lambda row: row.update(sandbox_dependency_content_digest="9" * 63),
lambda row: row.update(sandbox_dependency_content_digest="not-a-dependency-digest"),
],
ids=["missing", "none", "list", "short", "not-hex"],
)
@pytest.mark.parametrize("side,label", [(0, "candidate"), (1, "stable")])
def test_evidence_without_a_recorded_dependency_digest_is_invalid(side, label, mutate):
# Each row must bind its task dependencies, so even identical bad values on
# both sides fail as invalid evidence before the pairing check.
measured = list(reports())
mutate(first_task_rows(measured[side])[0])
result = decide(*(rebuild(report) for report in measured))
assert result["passed"] is False
assert result["problems"] == [f"{label}: invalid, incomplete, stale, or mismatched evidence/pins"]
def run_gate_cli(tmp_path, out):
return subprocess.run(
[
sys.executable,
"-m",
"workflow_bench.release_gate",
"--candidate",
str(tmp_path / "candidate.json"),
"--candidate-sha",
CANDIDATE,
"--stable",
str(tmp_path / "stable.json"),
"--stable-sha",
STABLE,
"--out",
str(out),
],
capture_output=True,
text=True,
)
@pytest.mark.parametrize(
"write_candidate",
[
lambda path: None, # The candidate summary never ran.
lambda path: path.write_text('{"schema": '), # Torn or corrupt JSON.
],
)
def test_cli_missing_or_unreadable_report_saves_failed_evidence(tmp_path, write_candidate):
_, stable = reports()
write_candidate(tmp_path / "candidate.json")
(tmp_path / "stable.json").write_text(json.dumps(stable))
out = tmp_path / "public"
result = run_gate_cli(tmp_path, out)
assert result.returncode == 1, result.stderr
assert "Traceback" not in result.stderr
evidence = json.loads((out / "release-quality-gate.json").read_text())
assert evidence["passed"] is False
assert evidence["problems"] == ["candidate: invalid, incomplete, stale, or mismatched evidence/pins"]
prose = (out / "release-quality-gate.md").read_text()
assert "**FAIL**" in prose
assert "- candidate: invalid" in prose
@pytest.mark.parametrize("regression", [False, True])
def test_cli_exit_status_and_saved_evidence_reflect_quality(tmp_path, regression):
candidate, stable = reports()
if regression:
set_outcome(candidate, next(iter(candidate["tasks"])), 1, False)
candidate = rebuild(candidate)
for name, report in (("candidate", candidate), ("stable", stable)):
(tmp_path / f"{name}.json").write_text(json.dumps(report))
out = tmp_path / "public"
result = run_gate_cli(tmp_path, out)
assert result.returncode == int(regression), result.stderr
evidence = json.loads((out / "release-quality-gate.json").read_text())
assert evidence["passed"] is (not regression)
prose = (out / "release-quality-gate.md").read_text()
assert CANDIDATE in prose and STABLE in prose
assert "no-GitNexus" in prose
assert "statistical" in prose

View file

@ -0,0 +1,103 @@
"""Release preflight must exercise actual materialization without paid sessions."""
import json
import subprocess
import sys
from functools import partial
from pathlib import Path
import pytest
import yaml
from workflow_bench import oracle_assets, release_preflight, runner_tasks, task_assets
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.sanitized_graph import SanitizedGraphSnapshot
@pytest.fixture
def prepared(monkeypatch, tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
(repo / "source").write_text("source")
for args in (
["init", "-q"], ["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "fixture"],
):
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
sha = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
task = dict(id="release-probe", repo=str(repo), ref=sha, prompt="test", verify="true", **{"class": "test"})
hidden = tmp_path / "hidden"
hidden.mkdir()
(hidden / "check.py").write_text("assert True\n")
task["oracle"] = {
"command": 'python "$GITNEXUS_BENCH_ORACLE_ROOT/check.py"',
"files": [{"source": "check.py", "target": "check.py"}],
}
monkeypatch.setattr(runner_tasks, "capture_task_oracles", partial(oracle_assets.capture_task_oracles, root=hidden))
builds = []
probes = []
monkeypatch.setattr(release_preflight, "preflight_bubblewrap", lambda: Path("/fake/bwrap"))
monkeypatch.setattr(release_preflight, "require_claude_sandbox_helpers", lambda: None)
monkeypatch.setattr(release_preflight, "trusted_gitnexus_runtime_mounts", lambda **_: ())
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_: False)
# Only graph construction/containment are substituted. Binding resolution,
# capture, graph materialization, staging, and cleanup are production code.
def build(task, *, repo, resolved_sha, parent, cache, **kwargs):
builds.append(resolved_sha)
seed = parent / f"graph-{len(builds)}"
seed.mkdir()
(seed / "index").write_bytes(b"graph")
assets = cache.prepare({"sandbox_copy": ["index"]}, repo=seed, resolved_sha=resolved_sha)
return SanitizedGraphSnapshot(assets=assets, sanitized_head=resolved_sha)
stage = release_preflight.stage_task_assets
def checked_stage(task, *, repo, clone, snapshot):
assert (clone / "index").read_bytes() == b"graph"
probes.append(clone)
return stage(task, repo=repo, clone=clone, snapshot=snapshot)
monkeypatch.setattr(release_preflight, "prepare_sanitized_graph", build)
monkeypatch.setattr(release_preflight, "stage_task_assets", checked_stage)
return task, builds, probes
def test_preflight_materializes_every_task_but_builds_shared_graph_once(prepared, capsys, tmp_path):
task, builds, probes = prepared
release_preflight.preflight_release(
[task, {**task, "id": "second-task"}], gitnexus_root=tmp_path, claude_bin=Path("claude"),
)
assert builds == [task["ref"]]
assert len(probes) == 2
assert all(not probe.parent.exists() for probe in probes)
assert "graph snapshot 5 bytes" in capsys.readouterr().out
def test_copy_failure_aborts_preflight_and_cli_without_evidence(prepared, monkeypatch, tmp_path):
task, builds, probes = prepared
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 4)
with pytest.raises(SandboxError, match="cannot reflink"):
release_preflight.preflight_release([task], gitnexus_root=tmp_path, claude_bin=Path("claude"))
assert probes == [] # Failure must happen in real graph materialization.
tasks_file = tmp_path / "tasks.yaml"
tasks_file.write_text(yaml.safe_dump({"tasks": [task]}))
monkeypatch.setattr(sys, "argv", [
"preflight", "--tasks", str(tasks_file), "--gitnexus-root", str(tmp_path), "--claude-bin", "claude",
])
with pytest.raises(SystemExit) as error:
release_preflight.main()
assert error.value.code == 2
assert builds == [task["ref"], task["ref"]]
assert not list(tmp_path.rglob("results.jsonl"))
def test_preflight_rejects_task_assets_that_replace_the_graph(prepared, tmp_path):
task, _, _ = prepared
index = Path(task["repo"]) / ".gitnexus"
index.mkdir()
(index / "meta.json").write_text(json.dumps({"fake": True}))
with pytest.raises(SandboxError, match="prebuilt graph"):
release_preflight.preflight_release(
[{**task, "sandbox_copy": [".gitnexus/meta.json"]}], gitnexus_root=tmp_path, claude_bin=Path("claude"),
)

View file

@ -0,0 +1,282 @@
"""Release comparisons must retain negative outcomes and reject incomplete evidence."""
from datetime import UTC, datetime, timedelta
import json
import subprocess
import pytest
import yaml
from workflow_bench import release_report
from workflow_bench.release_report import build_report, pinned_task_sha, render_markdown, validate_report
SHA = "a" * 40
DIGEST = "b" * 64
NOW = datetime(2026, 10, 6, tzinfo=UTC)
def metadata():
return {
"runtime_sha": SHA,
"harness_sha": "c" * 40,
"task_set_digest": DIGEST,
"model": "gpt-6.1-sol",
"effort": "medium",
"runs": 3,
"tasks": {"task-one": {"sha": "d" * 40, "oracle_digest": "e" * 64}},
}
def rows():
return [
{
"task": "task-one",
"run": run,
"arm": arm,
"model": "gpt-6.1-sol",
"effort": "medium",
"task_base_sha": "d" * 40,
"oracle_digest": "e" * 64,
"sandbox_backend": "bwrap",
"sandbox_dependency_content_digest": "9" * 64,
"ok": True,
"resolved": arm == "baseline_nomcp" or run != 1,
"authored_tests_passed": True,
"oracle_passed": arm == "baseline_nomcp" or run != 1,
"error_kind": "oracle-failed" if arm == "baseline" and run == 1 else None,
"transcript_missing": False,
"cost_usd": 1.0 if arm == "baseline_nomcp" else 1.25,
"duration_s": 60.0 if arm == "baseline_nomcp" else 75.0,
"internal_path": "/home/private/instructions.md",
}
for run in range(3)
for arm in ("baseline_nomcp", "baseline")
]
def test_report_keeps_failed_solutions_and_compares_against_no_gitnexus():
report = build_report(rows(), metadata(), now=NOW)
assert report["complete"] is True
assert report["arms"]["baseline_nomcp"]["solved"] == 3
assert report["arms"]["baseline"]["solved"] == 2
assert report["paired"]["solve_delta"] == -1
assert report["paired"]["mean_cost_change_pct"] == 25.0
assert report["paired"]["mean_wall_change_pct"] == 25.0
assert "internal_path" not in report["per_run"][0]
assert report["per_run"][0]["sandbox_dependency_content_digest"] == "9" * 64
assert "baseline_nomcp" in render_markdown(report)
assert "n=3" in render_markdown(report)
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
@pytest.mark.parametrize(
"mutation",
[
lambda rs: rs.pop(),
lambda rs: rs.append(dict(rs[0])),
lambda rs: rs[0].update(cost_usd=None),
lambda rs: rs[0].update(cost_usd=float("nan")),
lambda rs: rs[0].update(cost_usd=[float("nan")]),
lambda rs: rs[0].update(duration_s={"wall": float("inf")}),
lambda rs: rs[0].update(duration_s=-1),
lambda rs: rs[0].update(task_base_sha="f" * 40),
lambda rs: rs[0].update(oracle_digest="f" * 64),
lambda rs: rs[0].update(model="different-model"),
lambda rs: rs[0].update(sandbox_backend="host-unsafe"),
lambda rs: rs[0].update(error_kind="session-error", ok=False),
lambda rs: rs[0].update(transcript_missing=True),
lambda rs: rs[0].update(resolved="true"),
lambda rs: rs[0].update(oracle_passed=False),
],
)
def test_partial_or_untrustworthy_measurements_cannot_pass_release_gate(mutation):
measured = rows()
mutation(measured)
report = build_report(measured, metadata(), now=NOW)
assert report["complete"] is False
assert report["problems"]
json.dumps(report, allow_nan=False)
with pytest.raises(ValueError, match="incomplete"):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
@pytest.mark.parametrize(
"change",
[
{"runtime_sha": "f" * 40},
{"task_set_digest": "f" * 64},
{"generated_at": (NOW - timedelta(days=8)).isoformat()},
{"generated_at": (NOW + timedelta(seconds=1)).isoformat()},
{"runs": 2},
],
)
def test_evidence_is_bound_to_revision_task_set_repetitions_and_age(change):
report = build_report(rows(), metadata(), now=NOW)
report.update(change)
with pytest.raises(ValueError):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
def test_gate_recomputes_measurements_instead_of_trusting_totals():
report = build_report(rows(), metadata(), now=NOW)
report["arms"]["baseline"]["solved"] = 3
with pytest.raises(ValueError, match="summary"):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
def test_torn_result_file_publishes_incomplete_evidence(tmp_path, monkeypatch):
from workflow_bench.release_report import main
results = tmp_path / "results.jsonl"
results.write_text(json.dumps(rows()[0]) + '\n{"unfinished":')
meta = tmp_path / "metadata.json"
meta.write_text(json.dumps(metadata()))
out = tmp_path / "public"
monkeypatch.setattr(
"sys.argv",
["release-report", "summarize", "--results", str(results), "--metadata", str(meta), "--out", str(out)],
)
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
report = json.loads((out / "agent-evaluation.json").read_text())
assert report["complete"] is False
assert "invalid measurement" in report["problems"][0]
assert "Incomplete evidence" in (out / "agent-evaluation.md").read_text()
def test_validate_report_binds_the_trusted_task_pins():
report = build_report(rows(), metadata(), now=NOW)
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, task_pins=metadata()["tasks"], now=NOW)
other = {"task-one": {"sha": "d" * 40, "oracle_digest": "f" * 64}}
with pytest.raises(ValueError, match="pins"):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, task_pins=other, now=NOW)
def commit_repo(path, content):
path.mkdir()
(path / "marker.txt").write_text(content)
for arguments in (
["init", "-q"],
["add", "."],
["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-qm", content],
):
subprocess.run(["git", "-C", str(path), *arguments], check=True, capture_output=True)
return subprocess.check_output(["git", "-C", str(path), "rev-parse", "HEAD"], text=True).strip()
@pytest.fixture
def prepare_suite(tmp_path, monkeypatch):
task_sha = commit_repo(tmp_path / "tasks-base", "pinned task commit")
runtimes = {name: commit_repo(tmp_path / name, name) for name in ("stable", "candidate")}
tasks = [
{"id": task_id, "repo": "~/GitNexus", "ref": task_sha, "prompt": "p", "verify": "true"}
for task_id in ("task-one", "task-two")
]
pins = {task["id"]: {"sha": task_sha, "oracle_digest": "e" * 64} for task in tasks}
monkeypatch.setattr(release_report, "suite_binding", lambda: ([dict(task) for task in tasks], DIGEST, pins))
return tmp_path, task_sha, runtimes
def run_prepare(monkeypatch, root, runtime, task_repo):
out = root / "prepared" / runtime
monkeypatch.setattr(
"sys.argv",
[
"release-report",
"prepare",
"--repo",
str(root / runtime),
"--task-repo",
str(task_repo),
"--out",
str(out),
"--model",
"gpt-6.1-sol",
"--effort",
"medium",
],
)
release_report.main()
return yaml.safe_load((out / "tasks.yaml").read_text())["tasks"], json.loads((out / "metadata.json").read_text())
def test_prepare_grades_both_runtimes_against_one_task_dependency_checkout(prepare_suite, monkeypatch):
root, _, runtimes = prepare_suite
prepared = {name: run_prepare(monkeypatch, root, name, root / "tasks-base") for name in runtimes}
(stable_tasks, stable_meta), (candidate_tasks, candidate_meta) = prepared["stable"], prepared["candidate"]
# Task source and staged dependencies come from the pinned task checkout,
# identical for both runtimes; only runtime_sha differs.
assert {task["repo"] for task in stable_tasks + candidate_tasks} == {str((root / "tasks-base").resolve())}
assert stable_tasks == candidate_tasks
assert stable_meta["runtime_sha"] == runtimes["stable"]
assert candidate_meta["runtime_sha"] == runtimes["candidate"]
def test_prepare_rejects_a_dependency_checkout_at_another_commit(prepare_suite, monkeypatch):
root, _, _ = prepare_suite
with pytest.raises(SystemExit) as exc:
run_prepare(monkeypatch, root, "candidate", root / "candidate")
assert exc.value.code == 2
assert not (root / "prepared" / "candidate" / "tasks.yaml").exists()
def test_task_sha_names_the_single_pinned_task_commit(prepare_suite, monkeypatch, capsys):
_, task_sha, _ = prepare_suite
monkeypatch.setattr("sys.argv", ["release-report", "task-sha"])
release_report.main()
assert capsys.readouterr().out == f"{task_sha}\n"
def test_tasks_pinned_to_different_commits_cannot_share_a_dependency_checkout():
assert pinned_task_sha([{"ref": "a" * 40}, {"ref": "a" * 40}]) == "a" * 40
with pytest.raises(ValueError, match="one pinned commit"):
pinned_task_sha([{"ref": "a" * 40}, {"ref": "b" * 40}])
def test_shipped_release_tasks_share_one_pinned_commit():
tasks, _, _ = release_report.suite_binding()
# The workflow builds exactly one dependency checkout for the whole suite.
assert pinned_task_sha(tasks) == tasks[0]["ref"]
@pytest.mark.parametrize(
"smuggle",
[
lambda report: report["per_run"][0].update(internal_path="/home/runner/private/transcript.jsonl"),
lambda report: report.update(transcripts=["private"]),
],
ids=["per-run-field", "top-level-field"],
)
def test_evidence_with_fields_outside_the_published_schema_is_rejected(smuggle):
report = json.loads(json.dumps(build_report(rows(), metadata(), now=NOW)))
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
smuggle(report)
with pytest.raises(ValueError, match="outside the published report schema"):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
@pytest.mark.parametrize("field", ["cost_usd", "duration_s"])
def test_oversized_integer_measurements_mark_evidence_incomplete_instead_of_crashing(field):
measured = rows()
measured[0][field] = 10**400
report = build_report(measured, metadata(), now=NOW)
assert report["complete"] is False
assert any("untrustworthy measurement" in problem for problem in report["problems"])
@pytest.mark.parametrize("retype", [bool, float], ids=["bool", "float"])
def test_evidence_with_a_retyped_summary_value_is_rejected(retype):
measured = rows()
# Leave exactly one solved MCP repetition so the count is 1 (== True).
solved = [row for row in measured if row["arm"] == "baseline" and row["resolved"]]
for row in solved[1:]:
row.update(resolved=False, oracle_passed=False, error_kind="oracle-failed")
report = json.loads(json.dumps(build_report(measured, metadata(), now=NOW)))
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)
assert report["arms"]["baseline"]["solved"] == 1
# Python equality treats True == 1 == 1.0; the published JSON must not.
report["arms"]["baseline"]["solved"] = retype(report["arms"]["baseline"]["solved"])
with pytest.raises(ValueError, match="outside the published report schema"):
validate_report(report, runtime_sha=SHA, task_set_digest=DIGEST, now=NOW)

View file

@ -486,6 +486,7 @@ def _stub_cell_dependencies(monkeypatch, tmp_path):
monkeypatch.setattr(runner, "ce_plugin_mounts_for_arm", lambda *_a, **_k: [])
monkeypatch.setattr(runner, "ce_plugin_dir_for_arm", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "prepare_sandbox", lambda **_k: nullcontext(SimpleNamespace(run=None)))
monkeypatch.setattr(runner, "seed_evaluated_skills", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "skill_fingerprint", lambda *_a, **_k: "skill-digest")
monkeypatch.setattr(runner, "require_skill_fingerprint", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "_sandbox_git", lambda *_a, **_k: "c" * 40)
@ -1101,3 +1102,46 @@ def test_enforce_phase_workspace_can_require_an_untouched_workspace(tmp_path):
(tmp_path / "tracked.py").write_text("the review edited the code it was reviewing\n")
with pytest.raises(ValueError, match="changed the read-only workspace"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=None)
class _PatchSinkSandbox:
"""Sandbox double whose sink either writes a fixed patch or fails."""
synthetic_guidance_paths = ()
def __init__(self, worktree: Path, *, patch: bytes | None) -> None:
self.worktree = worktree
self.patch = patch
def run(self, command, **_kwargs):
if self.patch is None:
return ManagedProcessResult(
state="exited", returncode=1, stdout_tail="", stderr_tail="git diff failed", duration_s=0.0
)
sink_output = command[5] # sink script argv: limit, output path, git command
host_path = self.worktree / sink_output.removeprefix("/workspace/")
host_path.write_bytes(self.patch)
return ManagedProcessResult(state="exited", returncode=0, stdout_tail="", stderr_tail="", duration_s=0.0)
def _failing_rmtree(path, *_args, **_kwargs):
raise PermissionError(13, "Permission denied", str(path))
def test_capture_patch_returns_read_patch_and_reports_when_artifact_cleanup_fails(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(runner_artifacts.shutil, "rmtree", _failing_rmtree)
patch = runner_artifacts.capture_patch(_PatchSinkSandbox(tmp_path, patch=b"diff --git a b\n"), tmp_path, "abc")
assert patch == b"diff --git a b\n"
assert "patch artifact cleanup failed" in capsys.readouterr().err
def test_capture_patch_cleanup_failure_does_not_replace_the_session_failure(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(runner_artifacts.shutil, "rmtree", _failing_rmtree)
with pytest.raises(ManagedProcessError) as caught:
runner_artifacts.capture_patch(_PatchSinkSandbox(tmp_path, patch=None), tmp_path, "abc")
assert any("patch artifact cleanup failed" in note for note in caught.value.__notes__)
assert capsys.readouterr().err == ""

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import os
import hashlib
import stat
import subprocess
from pathlib import Path, PurePosixPath
@ -117,12 +118,13 @@ def test_default_buffered_fallback_budget_covers_a_realistic_large_asset(
monkeypatch,
tmp_path: Path,
) -> None:
# 20 MiB exceeds the old 16 MiB default but must fit comfortably under
# the current default, proving the real (non-monkeypatched) budget
# constant is sized for a realistic large sandbox_copy asset such as the
# harness's own pre-built graph index, not just tiny fixtures.
payload = os.urandom(20 * 1024 * 1024)
repo, task = _repo_and_task(tmp_path, {"large": payload})
# Exercise a valid snapshot above 512 MiB. Use a sparse source to avoid a
# huge Python allocation; capture and materialization still copy every byte.
repo, task = _repo_and_task(tmp_path, {"large": b"graph-start"})
source = repo / "large"
with source.open("r+b") as stream:
stream.seek(513 * 1024 * 1024)
stream.write(b"graph-end")
clone = tmp_path / "clone"
clone.mkdir()
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
@ -131,7 +133,9 @@ def test_default_buffered_fallback_budget_covers_a_realistic_large_asset(
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
snapshot.materialize(clone)
assert (clone / "large").read_bytes() == payload
with source.open("rb") as original, (clone / "large").open("rb") as copied:
assert hashlib.file_digest(copied, "sha256").digest() == hashlib.file_digest(original, "sha256").digest()
assert (clone / "large").stat().st_ino != source.stat().st_ino
def test_large_asset_without_reflink_fails_before_publish_and_cleans_staging(

View file

@ -250,9 +250,14 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
# Carries the real-CLI identity probe, which needs CLAUDE_CANARY_BIN -
# set only on this job. Omitted from this list it skipped everywhere.
"tests/test_mock_provider.py",
# Paired prepare/session/oracle/report composition uses that same native
# CLI and containment; it must execute here rather than skip everywhere.
"tests/test_release_evaluation_smoke.py",
# These also need runtime dependencies absent from the locked pytest job.
"tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant",
"tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout",
"tests/test_oracle_assets.py::test_shipped_oracles_reject_task_base_and_accept_reference_fixes",
"tests/test_oracle_assets.py::test_in_flight_retry_oracle_rejects_startup_only_repairs",
"-q",
"--junitxml=pytest-ubuntu.xml",
]
@ -346,6 +351,8 @@ def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_asset
"inv-feature-list-repos-filter",
]
assert skipped == ["cross-module-parse-retry"]
# v1.6.12 predates all four fixes, including the C fix already on main.
assert {task["ref"] for task in tasks} == {"c4ecf398de637cf779ab2238e69958cfd5d601ce"}
assert all(not task.get("sandbox_copy") for task in tasks)
assert all(task["sandbox_dependencies"] for task in tasks)
assert all(

View file

@ -7,6 +7,8 @@ import os
import shutil
import subprocess
import sys
from contextlib import nullcontext
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
@ -15,7 +17,7 @@ import pytest
from workflow_bench import evolve, runner, runner_artifacts, runner_sessions, runtime_mounts
from workflow_bench.evolution import skill_fingerprint
from workflow_bench.process_control import ManagedProcessError, ManagedProcessResult
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.proposer_sandbox import SandboxError, build_claude_settings
from workflow_bench.runner import snapshot_plan_docs
@ -195,12 +197,11 @@ def test_run_claude_forwards_xhigh_effort_to_every_session(monkeypatch, tmp_path
assert captured[captured.index("--effort") + 1] == "xhigh"
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
# Outside --bare, the built-in toolset defaults to everything (subagents,
# WebFetch, Task, ...) and --allowedTools only pre-approves within that —
# it does not narrow it. --tools is what actually restricts the set, so a
# non-bare arm session must pass it or it silently gets a far wider
# toolset than intended.
def test_run_claude_restricts_tools_via_tools_flag(monkeypatch, tmp_path):
# The built-in toolset defaults to everything (subagents, WebFetch, Task,
# ...) and --allowedTools only pre-approves within that -- it does not
# narrow it. --tools is what actually restricts the set, so an arm session
# must pass it or it silently gets a far wider toolset than intended.
captured: list[str] = []
def fake_run(command, **kwargs):
@ -213,7 +214,6 @@ def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp
tmp_path,
claude_bin="claude",
timeout=5,
bare=False,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
tools_idx = captured.index("--tools")
@ -222,29 +222,6 @@ def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp
assert captured[allowed_idx + 1 : allowed_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
def test_run_claude_omits_tools_flag_under_bare(monkeypatch, tmp_path):
# --bare already hard-restricts to Bash/Edit/Read on its own (a Claude
# Code design choice, not something --tools/--allowedTools can widen or
# narrow further), so bare sessions must not also pass --tools.
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
bare=True,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
assert "--tools" not in captured
assert "--allowedTools" in captured
@pytest.mark.parametrize(
("proc", "expected_kind"),
[
@ -335,18 +312,15 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
]
assert "Write" in review_tools
assert "Edit" not in review_tools
assert implementation == [
*runner.BUILTIN_AGENT_TOOLS,
*runner.GITNEXUS_READ_ONLY_TOOLS,
*runner.GITNEXUS_MUTATING_TOOLS,
]
assert implementation == read_only
assert not (set(runner.GITNEXUS_MUTATING_TOOLS) & set(implementation))
assert no_mcp == list(runner.BUILTIN_AGENT_TOOLS)
assert not any(tool.startswith("mcp__") for tool in no_mcp)
captured: list[dict[str, object]] = []
def fake_run_claude(*args, **kwargs):
captured.append(dict(kwargs))
captured.append(dict(kwargs, prompt=args[0]))
return session_record()
monkeypatch.setattr(runner, "run_claude", fake_run_claude)
@ -364,18 +338,176 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
assert captured[0]["allowed_tools"] == read_only # planning
assert captured[1]["allowed_tools"] == review_tools # review
assert captured[2]["allowed_tools"] == implementation
assert captured[3]["allowed_tools"] == list(runner.BUILTIN_AGENT_TOOLS)
assert captured[3]["allowed_tools"] == [tool for tool in runner.BUILTIN_AGENT_TOOLS if tool != "Skill"]
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
assert captured[3]["disallowed_tools"] == ["Skill", "mcp__gitnexus"]
assert "GitNexus tools are unavailable" in captured[3]["prompt"]
# --bare hard-disables the Skill tool and every mcp__* tool regardless of
# --allowedTools (a Claude Code design choice, not something the harness
# can override) -- every arm here except baseline_nomcp needs Skill
# and/or MCP tools, so only baseline_nomcp may still run under --bare.
assert captured[0]["bare"] is False # workflow: planning session
assert captured[1]["bare"] is False # review
assert captured[2]["bare"] is False # workflow_direct
assert captured[3]["bare"] is True # baseline_nomcp
# Both tool-enabled and no-MCP arms retain ordinary CLAUDE.md startup
# context. The latter explicitly disables all skills/commands instead.
assert all("bare" not in session for session in captured[:4])
assert captured[3]["disable_slash_commands"] is True
def test_nomcp_and_tool_arms_load_repository_context_with_the_same_startup_mode(monkeypatch, tmp_path):
commands = []
def capture_cli(command, **kwargs):
commands.append(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", capture_cli)
monkeypatch.setattr(runner, "run_verify", lambda *_a, **_k: (True, "ok"))
sandbox = fake_sandbox(tmp_path)
sandbox.settings_json = build_claude_settings()
for arm in ("baseline", "baseline_nomcp"):
runner.run_arm(
arm, {"prompt": "Edit source and run its tests.", "verify": "true"}, tmp_path, bench_args(), sandbox=sandbox
)
assert len(commands) == 2
assert all("--bare" not in command for command in commands), "ordinary CLAUDE.md context must load in both arms"
assert "--disable-slash-commands" not in commands[0]
assert "--disable-slash-commands" in commands[1]
assert commands[1][commands[1].index("--mcp-config") + 1] == '{"mcpServers":{}}'
builtin_end = commands[1].index("--allowedTools")
baseline_tools = commands[1][commands[1].index("--tools") + 1 : builtin_end]
assert "Skill" not in baseline_tools
assert not any(tool.startswith("mcp__") for tool in baseline_tools)
for command in commands:
settings = json.loads(command[command.index("--settings") + 1])
assert settings["disableAllHooks"] is True
def _probe_cell_provisioning(monkeypatch, tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
dependencies = runner.ReadOnlyMount(tmp_path / "dependencies", "/workspace/gitnexus/node_modules")
runtime = runner.ReadOnlyMount(tmp_path / "runtime", runner.SANDBOX_GITNEXUS)
registry = runner.ReadOnlyMount(tmp_path / "registry", runner.SANDBOX_GITNEXUS_REGISTRY)
graph_calls = []
registry_calls = []
sandbox_calls = []
def materialize(worktree, **_kwargs):
graph_calls.append(worktree)
index = worktree / ".gitnexus"
index.mkdir()
(index / "lbug").write_text("prebuilt graph")
def registry_mount(worktree, *_args):
registry_calls.append(worktree)
return registry
def probe_sandbox(**kwargs):
sandbox_calls.append(kwargs)
raise SandboxError("provisioning probe complete")
monkeypatch.setattr(runner, "make_worktree", lambda *_a: clone)
monkeypatch.setattr(runner, "sanitize_clone_for_hidden_oracles", lambda *_a: "b" * 40)
monkeypatch.setattr(runner, "stage_task_assets", lambda *_a, **_k: [dependencies])
monkeypatch.setattr(runner, "isolated_gitnexus_registry_mount", registry_mount)
monkeypatch.setattr(runner, "prepare_sandbox", probe_sandbox)
monkeypatch.setattr(runner, "remove_clone", lambda *_a: None)
out = tmp_path / "out"
out.mkdir()
ctx = runner.TaskCellContext(
task={"id": "fixture", "prompt": "Edit the GitNexus source and run its tests."},
oracle_snapshot=SimpleNamespace(digest="oracle", command_digest="command", manifest_digest="manifest"),
repo=tmp_path / "repo",
task_sha="a" * 40,
graph_snapshot=SimpleNamespace(digest="graph", manifest_digest="graph-manifest", materialize=materialize),
graph_snapshot_error=None,
asset_snapshot=SimpleNamespace(
digest="assets",
manifest_digest="assets-manifest",
dependency_content_digest="deps",
dependency_manifest_digest="deps-manifest",
),
asset_snapshot_error=None,
args=bench_args(proposer_model=None),
out_dir=out,
ce_plugin_snapshot=None,
trees_dir=tmp_path,
bwrap_bin=tmp_path / "bwrap",
runtime_mounts=(runtime,),
candidate_overlay=None,
overlay_digest=None,
)
return ctx, clone, dependencies, runtime, registry, graph_calls, registry_calls, sandbox_calls
def test_nomcp_cell_does_not_provision_a_graph_registry_or_runtime(monkeypatch, tmp_path):
ctx, clone, dependencies, _, _, graph_calls, registry_calls, sandbox_calls = _probe_cell_provisioning(
monkeypatch, tmp_path
)
record = runner.run_cell(ctx, 0, "baseline_nomcp")
assert not graph_calls, "baseline_nomcp was supplied a prebuilt GitNexus graph"
assert not (clone / ".gitnexus" / "lbug").exists()
assert not registry_calls, "baseline_nomcp was supplied a repository registry"
assert sandbox_calls[0]["read_only_mounts"] == [dependencies]
assert sandbox_calls[0]["gitnexus_available"] is False
assert record["sanitized_graph_snapshot_digest"] is None
def test_graph_enabled_cell_retains_its_graph_registry_and_runtime(monkeypatch, tmp_path):
ctx, clone, dependencies, runtime, registry, graph_calls, registry_calls, sandbox_calls = _probe_cell_provisioning(
monkeypatch, tmp_path
)
runner.run_cell(ctx, 0, "workflow_direct")
assert graph_calls == [clone]
assert registry_calls == [clone]
assert sandbox_calls[0]["read_only_mounts"] == [dependencies, runtime, registry]
def test_nomcp_cell_does_not_require_an_available_graph(monkeypatch, tmp_path):
ctx, _, dependencies, _, _, _, _, sandbox_calls = _probe_cell_provisioning(monkeypatch, tmp_path)
ctx = replace(ctx, graph_snapshot=None, graph_snapshot_error=RuntimeError("graph unavailable"))
runner.run_cell(ctx, 0, "baseline_nomcp")
assert sandbox_calls[0]["read_only_mounts"] == [dependencies]
@pytest.mark.parametrize(
"arm",
["workflow", "workflow_direct", "candidate_workflow", "candidate_workflow_direct", "review", "baseline_nomcp"],
)
def test_cell_seeds_selected_release_skills_before_setup_and_fingerprinting(monkeypatch, tmp_path, arm):
ctx, clone, *_ = _probe_cell_provisioning(monkeypatch, tmp_path)
ctx.task["setup"] = "fixture setup"
release = tmp_path / "release"
ctx.args.gitnexus_root = release
execution_arm = runner.CANDIDATE_ARMS.get(arm, arm)
if arm in runner.CANDIDATE_ARMS:
ctx = replace(ctx, candidate_overlay=tmp_path / "overlay", overlay_digest="candidate")
events = []
sandbox = fake_sandbox(clone)
sandbox.run = lambda *_a, **_k: events.append("setup") or fake_cli_result("")
monkeypatch.setattr(runner, "prepare_sandbox", lambda **_k: nullcontext(sandbox))
monkeypatch.setattr(
runner,
"seed_evaluated_skills",
lambda source, worktree, **kwargs: events.append(("seed", source, worktree, kwargs["arm"])),
)
monkeypatch.setattr(runner, "skill_fingerprint", lambda *_a: events.append("fingerprint") or "digest")
monkeypatch.setattr(runner, "require_skill_fingerprint", lambda *_a, **_k: events.append("check"))
monkeypatch.setattr(runner, "_sandbox_git", lambda *_a: "c" * 40)
monkeypatch.setattr(runner, "implementation_diff_digest", lambda *_a, **_k: "")
monkeypatch.setattr(runner, "apply_candidate_overlay", lambda *_a, **_k: events.append("overlay") or "candidate")
def finish_probe(*_a, **_k):
events.append("session")
raise SandboxError("session probe complete")
monkeypatch.setattr(runner, "run_arm", finish_probe)
runner.run_cell(ctx, 0, arm)
expected = ["fingerprint", "setup", "check", "session"]
if arm != "baseline_nomcp":
expected.insert(0, ("seed", release, clone, execution_arm))
if arm in runner.CANDIDATE_ARMS:
expected[-1:-1] = ["overlay", "fingerprint"]
assert events == expected
def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tmp_path):
@ -402,9 +534,13 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
assert runner.SANDBOX_GITNEXUS_ENTRYPOINT in command_line
assert not any(value.startswith("/workspace/") for value in command_line)
assert f"GITNEXUS_HOME={runner.SANDBOX_GITNEXUS_REGISTRY}" in command_line
assert "GITNEXUS_MCP_ALLOWED_REPOS=/workspace" in command_line
assert "GITNEXUS_MCP_DEFAULT_REPO=/workspace" in command_line
for key, value in (
("GITNEXUS_HOME", runner.SANDBOX_GITNEXUS_REGISTRY),
("GITNEXUS_MCP_ALLOWED_REPOS", "/workspace"),
("GITNEXUS_MCP_DEFAULT_REPO", "/workspace"),
):
index = command_line.index(key)
assert command_line[index - 1:index + 2] == ["--setenv", key, value]
mounts = runner.trusted_gitnexus_runtime_mounts()
assert [(mount.source, mount.target) for mount in mounts] == [
(runtime / "dist", f"{runner.SANDBOX_GITNEXUS}/dist"),
@ -450,8 +586,24 @@ def _install_pinned_runtime(root: Path) -> None:
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
def test_runtime_mounts_use_the_explicit_release_checkout(monkeypatch, tmp_path):
harness = tmp_path / "harness"
release = tmp_path / "release"
_install_pinned_runtime(harness)
_install_pinned_runtime(release)
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", harness)
mounts = runner.trusted_gitnexus_runtime_mounts(root=release)
assert all(mount.source.is_relative_to(release) for mount in mounts)
args = runner.build_parser().parse_args(
["--tasks", "fixture.yaml", "--model", "fixture-model", "--gitnexus-root", str(release)]
)
assert args.gitnexus_root == release
@pytest.mark.parametrize("explicit_root", [False, True])
def test_runtime_mounts_reuse_primary_checkout_node_modules_from_a_worktree(
monkeypatch, tmp_path
monkeypatch, tmp_path, explicit_root
) -> None:
primary = tmp_path / "primary"
worktree = tmp_path / "worktree"
@ -464,9 +616,9 @@ def test_runtime_mounts_reuse_primary_checkout_node_modules_from_a_worktree(
target_is_directory=True,
)
(worktree / ".git").write_text(f"gitdir: {primary / '.git' / 'worktrees' / 'wt'}\n")
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", worktree)
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", tmp_path / "different-harness" if explicit_root else worktree)
mounts = runner.trusted_gitnexus_runtime_mounts()
mounts = runner.trusted_gitnexus_runtime_mounts(root=worktree if explicit_root else None)
by_target = {mount.target: mount.source for mount in mounts}
assert by_target[f"{runner.SANDBOX_GITNEXUS}/node_modules"] == (
@ -656,7 +808,7 @@ def test_resolved_implementation_without_repository_work_fails_closed():
@pytest.mark.skipif(os.name == "nt", reason="sandbox patch streaming uses POSIX executable paths")
def test_capture_patch_materializes_only_the_bounded_prefix(tmp_path):
def test_capture_patch_materializes_only_the_bounded_prefix(tmp_path, monkeypatch):
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "--quiet", str(repo)], check=True)
@ -688,6 +840,8 @@ def test_capture_patch_materializes_only_the_bounded_prefix(tmp_path):
changed.write_text("changed line\n" * 100_000)
class LocalSandbox:
synthetic_guidance_paths = ()
def run(self, command, **kwargs):
translated = [
str(repo) + item.removeprefix("/workspace") if item.startswith("/workspace/") else item
@ -709,11 +863,19 @@ def test_capture_patch_materializes_only_the_bounded_prefix(tmp_path):
duration_s=0.0,
)
read_bytes = runner_artifacts._bounded_regular_bytes
materialized_sizes = []
def inspect_bounded_sink(path, *, limit):
materialized_sizes.append(path.stat().st_size)
return read_bytes(path, limit=limit)
monkeypatch.setattr(runner_artifacts, "_bounded_regular_bytes", inspect_bounded_sink)
patch = runner.capture_patch(LocalSandbox(), repo, orig_sha)
assert len(patch) == runner.MAX_PATCH_BYTES
materialized = next(repo.glob(".wfbench-artifact-*/final.patch"))
assert materialized.stat().st_size == runner.MAX_PATCH_BYTES
assert materialized_sizes == [runner.MAX_PATCH_BYTES]
assert not list(repo.glob(".wfbench-artifact-*"))
def test_workflow_never_starts_work_after_failed_or_invalid_planning(monkeypatch, tmp_path):

View file

@ -8,19 +8,19 @@ report.
## What it compares
| Arm | Sessions | Notes |
| --------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `workflow` | `gitnexus-plan` on the task, then `gitnexus-work` on the produced plan | The skills must be installed (`gitnexus setup`, or repo-local `.claude/skills/`) |
| `candidate_workflow` | same sessions as `workflow`, with a candidate skill overlay | Paired with `workflow` on the same task/ref/model |
| `workflow_direct` | one `gitnexus-work` direct-mode session | The middle option — execution discipline without a planning pass |
| `candidate_workflow_direct` | same session as `workflow_direct`, with a candidate skill overlay | Paired with `workflow_direct` on the same task/ref/model |
| `ce_workflow` | `ce-plan` on the task, then `ce-work` on the produced plan | External comparator: the explicitly supplied, pinned compound-engineering plugin's plan→work family |
| `ce_workflow_direct` | one `ce-work` direct-mode session | External comparator paired with `workflow_direct` |
| `review` | one `gitnexus-review` session over an immutable historical PR snapshot | Emits strict `review-output.json`; hidden human labels score quality after the session |
| `candidate_review` | the same review with a `gitnexus-review` candidate overlay | Paired with `review` on the same case/ref/model/runtime |
| `ce_review` | one pinned `ce-code-review` session over the same changes | External comparator paired with both review arms |
| `baseline` | one session with the identical task text | `--disallowedTools Skill` so it cannot borrow the workflow; same repo, same MCP tools |
| `baseline_nomcp` | like baseline, graph tools also disallowed | Separates the workflow-discipline question from the GitNexus-tools question (off by default) |
| Arm | Sessions | Notes |
| --------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `workflow` | `gitnexus-plan` on the task, then `gitnexus-work` on the produced plan | The skills must be installed (`gitnexus setup`, or repo-local `.claude/skills/`) |
| `candidate_workflow` | same sessions as `workflow`, with a candidate skill overlay | Paired with `workflow` on the same task/ref/model |
| `workflow_direct` | one `gitnexus-work` direct-mode session | The middle option — execution discipline without a planning pass |
| `candidate_workflow_direct` | same session as `workflow_direct`, with a candidate skill overlay | Paired with `workflow_direct` on the same task/ref/model |
| `ce_workflow` | `ce-plan` on the task, then `ce-work` on the produced plan | External comparator: the explicitly supplied, pinned compound-engineering plugin's plan→work family |
| `ce_workflow_direct` | one `ce-work` direct-mode session | External comparator paired with `workflow_direct` |
| `review` | one `gitnexus-review` session over an immutable historical PR snapshot | Emits strict `review-output.json`; hidden human labels score quality after the session |
| `candidate_review` | the same review with a `gitnexus-review` candidate overlay | Paired with `review` on the same case/ref/model/runtime |
| `ce_review` | one pinned `ce-code-review` session over the same changes | External comparator paired with both review arms |
| `baseline` | one session with the identical task text | `--disallowedTools Skill` so it cannot borrow the workflow; same repo, same MCP tools |
| `baseline_nomcp` | one session without installed GitNexus tools, graph, or workflow skills | Separates the workflow-discipline question from the GitNexus-tools question (off by default) |
Every arm runs in a fresh detached git worktree of the task's `ref`, once per
`--runs`. The model-visible `verify` command is recorded as
@ -69,6 +69,125 @@ Output: `results/wfbench-<timestamp>/results.jsonl` (every run, with session
ids for transcript drill-down) and `report.md` (medians per task per arm,
plus a savings row: input / cache / output tokens, cost, wall time).
## Release evaluation
### RC regression decision
`workflow_bench.release_gate` compares a candidate report with a freshly
measured stable-runtime report. Both reports must use the same trusted harness,
task/oracle pins, model, reasoning effort and repetition count. Each task must
solve at least as many repetitions with the candidate as with stable; gains on
one task cannot offset losses on another. Invalid, stale or incomplete evidence
fails closed. Cost, duration and both no-GitNexus comparisons remain descriptive
measurements, not additional pass criteria or claims of statistical significance.
```bash
uv run --locked --extra dev python -m workflow_bench.release_gate \
--candidate candidate/agent-evaluation.json --candidate-sha <full-rc-sha> \
--stable stable/agent-evaluation.json --stable-sha <full-stable-sha> \
--out public
```
The command writes `release-quality-gate.json` and `release-quality-gate.md`
and exits nonzero on rejection. Its caller must authenticate the reports and
resolve the published stable and exact candidate revisions independently;
self-reported metadata does not establish provenance. Release evaluation
resolves the latest published stable release and the requested candidate
itself, then measures both with main's trusted harness. The comparison is a
regression signal for published RCs; it does not block RC publication.
### Current workflow
Following [discussion #3493](https://github.com/abhigyanpatwari/GitNexus/discussions/3493#discussioncomment-18772530),
release evidence is split by cost:
1. Every CI run, RC and stable release runs the offline
[tool-accuracy corpus](../../gitnexus/bench/tool-accuracy/README.md) against
the real ingestion pipeline and tools. Its fixed answers cover #3486–3491
and #3497–3499. `accuracy.json` and `accuracy.md` report every failing answer,
precision/recall and source/fixture revisions. The initial reviewed gaps
remain failures in the accuracy total. The gate rejects new/worsened failures,
missing outputs and repaired allowances that have not been removed.
2. **Release evaluation**, before each stable release and weekly against the
latest RC, runs three fresh paired repetitions of every scenario
on both candidate and stable runtimes, including the expensive task, using
`baseline_nomcp` and MCP `baseline`.
Every task starts at the immutable v1.6.12 commit; all four tasks remain
unsolved there. The hidden graders have tested negative/positive controls,
including retry implementations in either the pipeline or worker pool.
Run **Release evaluation** from `main` before cutting a stable tag. Set
`candidate_ref` to the full, already-reviewed stable commit SHA. The workflow
uses main's trusted harness and `--gitnexus-root` to select a separate immutable
runtime checkout.
Candidate installs run in Bubblewrap with only that checkout (minus `.git`)
writable, system tools read-only, and a cleared environment. Locked downloads
run without package scripts; every lifecycle script then runs with no network. The trusted harness
and host command files are not mounted into the candidate build.
Before any paid sessions, `workflow_bench.release_preflight` builds and
materializes the real sanitized graphs for both runtimes on the runner's temp
filesystem. A copy or containment failure stops the workflow at that point.
Buffered copies use the same 2 GiB hard ceiling as snapshot capture, including
on filesystems without reflinks. This preflight adds an offline graph build per
runtime; the measured runner rebuilds its own graphs to retain its existing
provenance and cache lifecycle. Preflight output is not release evidence.
The report records runtime/harness SHAs, task and oracle digests, model/effort,
every repetition, solve counts, cost and agent wall time. Failed
solutions stay in the denominator. Infrastructure/session failures, missing
costs, incomplete pairs or failed containment make evidence incomplete.
Stable publishing requires a successful default-branch evaluation for that
**exact commit, measured by main's current evaluator**, no more than seven days
old. The publish job loads `eval/` from main's head rather than the release
commit, so later task changes on main do not strand an already-measured
release. A report counts only when its `harness_sha` is the commit its
scheduled or manually dispatched `main` run executed, and GitHub's comparison
from that commit to main's head changes nothing under `eval/`,
`.github/workflows/release-evaluation.yml` or `.github/claude-canary-runtime/`.
An unavailable or truncated (300-file) comparison rejects the run. Any change
to the harness, graders, task pins or agent CLI therefore requires a fresh
Release evaluation. Runs are tried newest first; a rejected run does not hide an
older valid one, and when none passes the error lists each rejection.
It validates task pins, individual cells and recomputed totals before publishing to npm
or either Docker registry. Docker publication also runs the cheap accuracy
gate once before both image builds, then builds the verified immutable commit.
Every release attaches the cheap accuracy evidence; stable releases also attach
and include their paired agent report. RCs publish after CI without waiting for
a paid run. The weekly comparison checks that the latest RC preserves each
covered task's MCP solve count relative to stable. The comparison does not require GitNexus to beat the no-MCP arm or claim that
four tasks establish general improvements.
The Wednesday release comparison reuses the existing schedule controls:
`GITNEXUS_EVOLUTION_ENABLED=true` and `GITNEXUS_EVOLUTION_WORKERS=3` enable
both weekly workflows; disabling evolution also disables scheduled release
comparisons. No additional opt-in variable or model credential is required.
Manual release evaluation remains available independently. Scheduled runs
resolve the newest published RC to its
commit before execution. The protected `gitnexus-evolution` environment remains
restricted to main and supplies the existing model secrets. Evaluation runs on
the dedicated self-hosted EC2 runner. The paid step has a 19-hour cap inside a
21-hour job and also stops 90 minutes before the configured external shutdown,
leaving time to upload summaries when a benchmark step fails. Raw transcripts, internal instructions, local
paths and session files are never uploaded or attached to releases.
The no-GitNexus arm receives no indexed graph, registry, runtime mounts or CLI
wrapper, and inherited graph-first directives are removed while ordinary
repository development and test guidance is preserved. Both arms load that
guidance through the same startup mode. Skills/commands and GitNexus MCP tools
are disabled in the no-tool arm, and repository/plugin hooks are disabled in
both arms. It fails closed when Bubblewrap is unavailable. It retains the repository source and
ordinary test dependencies required by the tasks; rebuilding tools from that
source is not prevented. The comparison therefore measures access to the
provided GitNexus tools and their readable compiled runtime on these GitNexus
development tasks. That runtime can contain later fixes absent from a task's
historical base, so the comparison cannot isolate tool assistance from access
to those implementations.
This corpus uses a **fresh index at each task base**. A nightly stale Hermes
index is a separate condition and must not be pooled with it. Import the
reporter's sanitized task bundle and Hermes fresh/stale fixtures when available;
private repositories and raw transcripts are outside the current corpus.
## Trust model — fail-closed Linux containment
Task files and candidate prose remain untrusted executable inputs. Every
@ -77,11 +196,22 @@ preflighted Bubblewrap boundary with a private home/config/temp, a
self-contained clone, a PID namespace, bounded process-tree ownership, and a
deny-by-default environment. Task-declared dependency roots are mounted
read-only, while graph assets are rebuilt by the harness as described below.
Claude runs in bare,
`dontAsk` mode with strict clone-local MCP configuration; Bash children do
Claude uses `dontAsk` mode with strict clone-local MCP configuration and
repository/plugin hooks disabled. Both implementation baselines load ordinary
repository instructions; the no-tool arm disables skills and uses an empty
MCP configuration. Bash children do
not inherit the model credential and their network sandbox denies all
domains.
The candidate MCP server runs in a separate nested Bubblewrap boundary. Its
workspace (including the graph), registry, compiled runtime and shared package
mounts are read-only. Its home and temporary directories are private tmpfs,
and it has a fresh PID namespace and `/proc`, no network or capabilities, and
no model credentials or agent state. MCP startup and tool handlers therefore
cannot write patches that would be credited to the agent; the agent retains
its writable workspace for implementation tasks. Mutating MCP tools are not
granted in any phase.
Prebuilt task `.gitnexus` assets are rejected. For each task commit, the
harness creates the deterministic parentless snapshot first, removes every
analyzer-visible path or stored source reference to the benchmark harness,
@ -120,8 +250,8 @@ redacts the event objects, and stores the canonical redacted JSONL with a
digest. Files written beneath the agent's `$HOME` are never trusted as
evidence.
Bare mode is deliberately non-interactive: it does not consult a stored
Claude login/keychain or `ANTHROPIC_AUTH_TOKEN`. Supply an Anthropic API key
Sessions use a private home without stored Claude login/keychain state, and
do not inherit `ANTHROPIC_AUTH_TOKEN`. Supply an Anthropic API key
through `GITNEXUS_BENCH_ANTHROPIC_API_KEY` (preferred) or `--anthropic-api-key`;
the harness maps it to `ANTHROPIC_API_KEY` only for the trusted Claude parent
and scrubs it from agent-launched tools. `GITNEXUS_BENCH_AUTH_TOKEN` and
@ -179,6 +309,64 @@ already-running instance therefore exits in-process instead of vanishing when
the box stops — a cancelled GitHub job skips even `if: always()`, which is
how run 33962002890 lost 51 finished sessions. Local runs are uncapped.
Both skill evolution and release evaluation manage the existing dedicated EC2
runner through hosted startup and cleanup jobs. They share a concurrency group
for the whole run, so neither can stop the instance during the other's work.
GitHub keeps only one pending run per group, even with `cancel-in-progress:
false`: a newer queued run of either workflow cancels the older pending one.
A release evaluation dispatched for a stable publish can therefore be dropped
while it waits behind a running evolution. Do not queue another run behind a
pending release evaluation, and confirm its **Start the dedicated EC2 runner**
job ran before relying on its evidence. If the run shows Cancelled, dispatch it
again once the group is free.
Startup waits for EC2 running and both health checks, then a native pickup probe
must finish within five minutes. If the probe fails, the hosted check cancels
its own run to clear the queued job. A second hosted watchdog bounds pickup of
the paid job. Only these watchdogs have Actions write permission.
The protected `gitnexus-evolution` environment holds these four settings
(configured and verified by the live probes below):
| Setting | Kind | Purpose |
| --- | --- | --- |
| `GITNEXUS_EVOLUTION_AWS_ROLE_ARN` | Secret | AWS role assumed through GitHub OIDC |
| `GITNEXUS_EVOLUTION_EC2_INSTANCE_ID` | Secret | Dedicated runner instance |
| `GITNEXUS_EVOLUTION_AWS_REGION` | Variable | Instance region |
| `GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC` | Variable | Actual weekly EventBridge stop, `DAY HH:MM` UTC |
No long-lived AWS credential is stored. The AWS action obtains short-lived
credentials using GitHub OIDC. The role trusts only this repository's
`gitnexus-evolution` environment and permits EC2 DescribeInstances and
DescribeInstanceStatus, with StartInstances and StopInstances scoped to the
dedicated instance. Missing configuration or access fails closed.
The workflow does not create IAM roles, instances, secrets or schedules.
A main-branch dispatch of either workflow with `runner_only=true` starts the
instance, proves native runner pickup, then stops it without paid model calls.
Cleanup runs on a hosted runner after success or failure and verifies EC2 is
actually `stopped`; an accepted StopInstances response alone does not pass.
Startup failures also attempt immediate cleanup. Keep the existing EventBridge
stop watchdog enabled because interrupted or force-cancelled GitHub cleanup
cannot guarantee shutdown. Paid work respects the earlier of the configured
weekly stop and a 24-hour cap, with a 90-minute evidence reserve.
Choose **Re-run all jobs** after a failed run to repeat startup and pickup.
Partial retries cannot reuse a previous attempt's startup: the affected probe
or paid job runs on a hosted runner and fails promptly with that instruction,
instead of waiting on the stopped instance.
PR tests use a fake AWS CLI to cover transitions, denial, lost responses,
timeouts, cleanup and workflow wiring. On 2026-10-09, `runner_only=true`
dispatches of both workflows started the instance, ran the native pickup probe
on the dedicated runner and verified the instance stopped (runs 37893007912 and
37895899767, from temporary branches with a runner-only guard exception).
CI separately runs a six-cell paired evaluator canary using the real pinned
Claude CLI, Bubblewrap, built MCP runtime, hidden grading and public report
commands. Only its small corpus and local model replies are scripted. One
failed repair must stay in the report. This proves execution and accounting;
the canary is not paid-model performance evidence for a stable release.
A review generation is 6 tasks × 3 arms × 3 runs. Serial workers=1 at ~19
minutes per session is a 16-hour job (run 33962002890). Two harness changes
cut that without shrinking the gate:

View file

@ -0,0 +1,97 @@
"""Baseline (no-GitNexus) arm: strip GitNexus guidance from inherited repository docs.
Pure text handling only, so the sandbox module can import it without a cycle.
"""
from __future__ import annotations
import re
class GuidanceError(ValueError):
"""Repository guidance cannot be safely reduced to ordinary development guidance."""
GITNEXUS_UNAVAILABLE_NOTE = (
"GitNexus tools are unavailable in this baseline_nomcp arm. "
"No GitNexus graph, repository registry, MCP server, or harness CLI is supplied; "
"the Skill tool is disabled. Use source reads, search, and repository tests "
"to complete the task. Do not invoke or bootstrap GitNexus with npx or .gitnexus/run.cjs.\n"
)
_GITNEXUS_TOOL_GUIDANCE = re.compile(
r"(?:\.gitnexus/run\.cjs|\b(?:npx|bunx|pnpm\s+dlx)\s+(?:--?\w[\w-]*(?:=[^\s`]+)?\s+)*gitnexus(?:@|\b)|"
r"\bmcp__gitnexus(?:__|\b)|\bgitnexus:(?:start|end)\b|"
r"\bgitnexus-(?:plan|work|review|lfg|exploring|impact-analysis|debugging|refactoring|guide|cli)\b|"
r"\b(?:use|run|bootstrap|invoke)\s+(?:\*\*|`)?gitnexus\b|"
r"\bgitnexus\s+(?:setup|analyze|query|context|impact|detect-changes)\b)",
re.IGNORECASE,
)
# A CommonMark list item: bullet (-, *, +) or ordered marker (1-9 digits then
# . or )), followed by a space, tab or end of line. Its indented continuation
# lines belong to the item and are dropped with it.
_LIST_ITEM = re.compile(r"^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)")
def _atx_heading(line: str) -> tuple[int, str] | None:
"""Return (level, text) for a CommonMark ATX heading, else None.
Up to three leading spaces, 1-6 '#', then a space or end of line; an
optional closing run of '#' preceded by a space is not part of the text.
"""
match = re.match(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$", line.rstrip("\r\n"))
if match is None:
return None
text = re.sub(r"(?:^|[ \t]+)#+$", "", match.group(2) or "").strip()
return len(match.group(1)), text
def ordinary_repository_guidance(text: str) -> str:
"""Remove marked/tool-specific instructions, preserving development guidance."""
kept: list[str] = []
marked = False
section_level: int | None = None
skip_continuation = False
for line in text.splitlines(keepends=True):
marker = re.fullmatch(r"\s*<!--\s*gitnexus:(start|end)\s*-->\s*", line, re.IGNORECASE)
if marker is not None:
starts = marker.group(1).lower() == "start"
if starts == marked:
raise GuidanceError("repository guidance contains unbalanced GitNexus markers")
marked = starts
continue
if marked:
continue
heading = _atx_heading(line)
if heading is not None:
level, title = heading
if section_level is not None and level <= section_level:
section_level = None
skip_continuation = False
if section_level is None and (
_GITNEXUS_TOOL_GUIDANCE.search(line)
or re.fullmatch(r"GitNexus(?:\s+rules|\s+[—-]\s+Code Intelligence)\s*", title, re.IGNORECASE)
):
section_level = level
if section_level is not None:
continue
elif section_level is not None:
continue
if not line.strip():
skip_continuation = False
elif skip_continuation:
if line[:1].isspace():
continue
skip_continuation = False
if _GITNEXUS_TOOL_GUIDANCE.search(line):
skip_continuation = _LIST_ITEM.match(line) is not None
continue
kept.append(line)
if marked:
raise GuidanceError("repository guidance contains unbalanced GitNexus markers")
return "".join(kept)

View file

@ -1,36 +1,173 @@
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Worker } from 'node:worker_threads';
import { describe, expect, it, vi } from 'vitest';
import { dispatchChunkParse } from '../gitnexus/src/core/ingestion/parsing-processor.js';
import {
WorkerPoolInitializationError,
type WorkerPool,
} from '../gitnexus/src/core/ingestion/workers/worker-pool.js';
import { dispatchChunkParseRound } from '../gitnexus/src/core/ingestion/parsing-processor.js';
import { createWorkerPool } from '../gitnexus/src/core/ingestion/workers/worker-pool.js';
// Static promise-timer imports otherwise keep real time while the oracle's
// clock advances. Both standard backoff APIs must use the same test clock.
vi.mock('node:timers/promises', async (importOriginal) => {
const timers = await importOriginal<Record<string, unknown>>();
const delay = (milliseconds: number, value?: unknown) =>
new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds, value));
return { ...timers, setTimeout: delay, default: { ...timers, setTimeout: delay } };
});
const files = [{ path: 'src/retry.ts', content: 'export const retry = true;\n' }];
const recovered = {
nodes: [],
relationships: [],
parsedFiles: [
{
filePath: files[0].path,
moduleScope: `module:${files[0].path}`,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
},
],
skippedLanguages: {},
};
const transports = ['message', 'event', 'exit'] as const;
type Transport = (typeof transports)[number];
function startupFailure(crashClass: 'transient-exhausted' | 'deterministic-startup') {
return new WorkerPoolInitializationError('hidden oracle worker failure', [], [], crashClass);
async function exercise(transport: Transport, failures: number, deterministic = false) {
const directory = mkdtempSync(path.join(tmpdir(), 'wfbench-worker-'));
const workerFile = path.join(directory, 'worker.js');
writeFileSync(workerFile, '// injected worker transport\n');
const failure = deterministic
? new SyntaxError('invalid file syntax')
: Object.assign(new Error('EAGAIN: temporary worker resource failure'), { code: 'EAGAIN' });
const attempts: Array<{ path: string | undefined; at: number }> = [];
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
// Exercise actual in-flight recovery/quarantine, not a synchronous caller
// rejection. Retry policy may live in the pool or in its pipeline caller.
class OracleWorker extends EventEmitter {
private dead = false;
constructor() {
super();
queueMicrotask(() => this.emit('message', { type: 'ready' }));
}
postMessage(message: { type: string; files?: Array<{ path: string }> }) {
if (message.type === 'sub-batch') {
const filePath = message.files?.[0]?.path;
attempts.push({ path: filePath, at: Date.now() });
queueMicrotask(() => {
if (this.dead) return;
this.emit('message', { type: 'starting-file', path: filePath });
if (attempts.length <= failures) {
if (transport === 'message') {
this.emit('message', {
type: 'error',
error: `${failure.name}: ${failure.message}`,
errorStack: failure.stack,
});
} else if (transport === 'event') {
this.dead = true;
this.emit('error', failure);
this.emit('exit', 1);
} else {
this.dead = true;
this.emit('exit', deterministic ? 65 : 75);
}
return;
}
this.emit('message', { type: 'progress', filesProcessed: files.length });
this.emit('message', { type: 'sub-batch-done' });
});
} else if (message.type === 'flush') {
queueMicrotask(() => this.emit('message', { type: 'result', data: recovered }));
}
}
async terminate() {
if (!this.dead) {
this.dead = true;
this.emit('exit', 0);
}
return 0;
}
unref() {
return this;
}
}
const pool = createWorkerPool(pathToFileURL(workerFile), 1, {
workerFactory: () => new OracleWorker() as unknown as Worker,
stallMsProbe: () => 0,
subBatchIdleTimeoutMs: 120_000,
});
try {
let outcome:
| { results?: Awaited<ReturnType<typeof dispatchChunkParseRound>>; error?: unknown }
| undefined;
// Ingestion uses the round API; the single-chunk API delegates to it.
void dispatchChunkParseRound([{ items: files }], pool).then(
(results) => {
outcome = { results };
},
(error) => {
outcome = { error };
},
);
await vi.advanceTimersByTimeAsync(60_000);
expect(outcome, 'bounded retries must settle').toBeDefined();
return {
...outcome,
attempts,
parsedPaths:
outcome?.results
?.flat()
.flatMap((result) => result.parsedFiles?.map((file) => file.filePath) ?? []) ?? [],
quarantined: pool.getQuarantinedPaths?.() ?? [],
};
} finally {
await pool.terminate();
vi.clearAllTimers();
vi.useRealTimers();
rmSync(directory, { recursive: true, force: true });
}
}
describe('hidden oracle: bounded parse-worker retry', () => {
it('retries a transient dispatch twice and returns the recovered result', async () => {
const dispatch = vi
.fn()
.mockRejectedValueOnce(startupFailure('transient-exhausted'))
.mockRejectedValueOnce(startupFailure('transient-exhausted'))
.mockResolvedValueOnce([]);
const pool = { size: 1, dispatch, terminate: vi.fn() } as unknown as WorkerPool;
describe('hidden oracle: bounded in-flight parse-worker retry', () => {
it.each(transports)(
'recovers the same file after two transient %s failures with backoff',
async (transport) => {
const outcome = await exercise(transport, 2);
expect(outcome.error).toBeUndefined();
expect(outcome.results).toEqual([[recovered]]);
expect(outcome.attempts).toHaveLength(3);
expect(outcome.attempts.map((attempt) => attempt.path)).toEqual(Array(3).fill(files[0].path));
expect(outcome.attempts[1].at).toBeGreaterThan(outcome.attempts[0].at);
expect(outcome.attempts[2].at).toBeGreaterThan(outcome.attempts[1].at);
expect(outcome.quarantined).not.toContain(files[0].path);
},
);
await expect(dispatchChunkParse(files, pool)).resolves.toEqual([]);
expect(dispatch).toHaveBeenCalledTimes(3);
});
it.each(transports)(
'stops after two retries of a transient %s failure and reports the file',
async (transport) => {
const outcome = await exercise(transport, Infinity);
expect(outcome.attempts).toHaveLength(3);
expect(outcome.attempts[1].at).toBeGreaterThan(outcome.attempts[0].at);
expect(outcome.attempts[2].at).toBeGreaterThan(outcome.attempts[1].at);
expect(outcome.parsedPaths).not.toContain(files[0].path);
expect(outcome.error !== undefined || outcome.quarantined.includes(files[0].path)).toBe(true);
},
);
it('does not retry a deterministic parse-worker failure', async () => {
const failure = startupFailure('deterministic-startup');
const dispatch = vi.fn().mockRejectedValue(failure);
const pool = { size: 1, dispatch, terminate: vi.fn() } as unknown as WorkerPool;
await expect(dispatchChunkParse(files, pool)).rejects.toBe(failure);
expect(dispatch).toHaveBeenCalledTimes(1);
it.each(transports)('does not retry a deterministic %s parse failure', async (transport) => {
const outcome = await exercise(transport, Infinity, true);
expect(outcome.attempts).toHaveLength(1);
expect(outcome.parsedPaths).not.toContain(files[0].path);
expect(outcome.error !== undefined || outcome.quarantined.includes(files[0].path)).toBe(true);
});
});

View file

@ -1,25 +1,28 @@
import { describe, expect, it } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import { SCOPE_RESOLVERS } from '../gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.js';
import { cScopeResolver } from '../gitnexus/src/core/ingestion/languages/c/scope-resolver.js';
describe('hidden oracle: C system headers do not bind to repository decoys', () => {
it('rejects a stdio.h decoy while preserving local-header resolution', () => {
const resolver = SCOPE_RESOLVERS.get(SupportedLanguages.C);
expect(resolver).toBeDefined();
const files = new Set(['src/stdio.h', 'include/util.h', 'src/main.c']);
const context = { parsedFiles: [] };
it.each([
['stdio.h', true, null],
['project.h', true, null],
['stdio.h', false, 'src/stdio.h'],
['util.h', false, 'include/util.h'],
] as const)(
'resolves %s with isSystem=%s from its include form',
(targetRaw, isSystem, expected) => {
const files = new Set(['src/stdio.h', 'include/project.h', 'include/util.h', 'src/main.c']);
const parsedImport = { kind: 'wildcard' as const, targetRaw, isSystem };
const external = resolver!.resolveImportTarget(
'stdio.h',
'src/main.c',
files,
undefined,
context,
);
const local = resolver!.resolveImportTarget('util.h', 'src/main.c', files, undefined, context);
expect(external).toBeNull();
expect(local).toBe('include/util.h');
});
// Both spellings name stdio.h. Preserve the parsed include form;
// refusing every header by name would break the quoted local include.
const resolved = cScopeResolver.resolveImportTarget(
parsedImport.targetRaw,
'src/main.c',
files,
undefined,
{ parsedFiles: [], parsedImport },
);
expect(resolved).toBe(expected);
},
);
});

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { LocalBackend } from '../gitnexus/src/mcp/local/local-backend.js';
import { MCP_TOOLS } from '../gitnexus/src/mcp/tools.js';
import { GITNEXUS_TOOLS } from '../gitnexus/src/mcp/tools.js';
const repositories = [
{ name: 'Alpha', path: '/repos/z-alpha' },
@ -16,11 +16,11 @@ describe('hidden oracle: list_repos name_contains', () => {
};
backend.listRepos = vi.fn().mockResolvedValue(repositories.map((repo) => ({ ...repo })));
const first = await backend.listReposPage({
const first = await backend.callTool('list_repos', {
name_contains: 'ALP',
limit: 1,
offset: 0,
} as never);
});
expect(first.repositories.map((repo) => repo.name)).toEqual(['Alpha']);
expect(first.pagination).toMatchObject({
total: 2,
@ -29,18 +29,27 @@ describe('hidden oracle: list_repos name_contains', () => {
nextOffset: 1,
});
const second = await backend.listReposPage({
const second = await backend.callTool('list_repos', {
name_contains: 'alp',
limit: 1,
offset: 1,
} as never);
});
expect(second.repositories.map((repo) => repo.name)).toEqual(['alphabet']);
expect(second.pagination).toMatchObject({ total: 2, returned: 1, hasMore: false });
expect(second.pagination).not.toHaveProperty('nextOffset');
const absent = await backend.callTool('list_repos', { name_contains: 'missing' });
expect(absent.repositories).toEqual([]);
expect(absent.pagination).toMatchObject({ total: 0, returned: 0, hasMore: false });
expect(absent.pagination).not.toHaveProperty('nextOffset');
const unfiltered = await backend.callTool('list_repos', {});
expect(unfiltered.repositories.map((repo) => repo.name)).toEqual(['Alpha', 'alphabet', 'Beta']);
expect(unfiltered.pagination).toMatchObject({ total: 3, returned: 3, hasMore: false });
});
it('advertises the optional filter on the MCP tool schema', () => {
const tool = MCP_TOOLS.find((candidate) => candidate.name === 'list_repos');
const tool = GITNEXUS_TOOLS.find((candidate) => candidate.name === 'list_repos');
expect(tool?.inputSchema.properties).toHaveProperty('name_contains');
expect(tool?.inputSchema.required ?? []).not.toContain('name_contains');
});

View file

@ -0,0 +1,22 @@
--- a/gitnexus/src/core/ingestion/workers/worker-pool.ts
+++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts
@@ -1351,7 +1351,18 @@
);
}
dispatchInFlight = true;
- return dispatchGroupsInner<TInput, TResult>(groups, onProgress).finally(() => {
+ const dispatchWithRetry = async (): Promise<TResult[][]> => {
+ for (let attempt = 0; ; attempt++) {
+ try {
+ return await dispatchGroupsInner<TInput, TResult>(groups, onProgress);
+ } catch (error) {
+ if (!(error instanceof WorkerPoolInitializationError) ||
+ error.crashClass !== 'transient-exhausted' || attempt >= 2) throw error;
+ await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
+ }
+ }
+ };
+ return dispatchWithRetry().finally(() => {
dispatchInFlight = false;
});
};

View file

@ -0,0 +1,35 @@
--- a/gitnexus/src/core/ingestion/workers/worker-pool.ts
+++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts
@@ -1461,6 +1461,7 @@
// no exclusion attribution, requeueRemainder quarantines items[0]
// as a best-guess culprit to break the death loop.
const unattributedJobDeaths: Map<number, number> = new Map();
+ const fileRetries = new Map<string, number>();
let completedFiles = 0;
let activeWorkers = 0;
let stopped = false;
@@ -2045,10 +2046,20 @@
const recoverAndResume = async (reason: string, excludePaths: readonly string[]) => {
busySlots.delete(workerIndex);
inFlightProgress[workerIndex] = 0;
- requeueRemainder(job, excludePaths);
- // Keep recovery in flight so another slot finishing cannot settle
- // this dispatch before the replacement is ready for the next one.
- await handleWorkerDeath(workerIndex, reason, excludePaths);
+ const failedPath = excludePaths[0];
+ const transient = !/\bSyntaxError\b/.test(reason) &&
+ (/\bEAGAIN\b/.test(reason) || /exited with code 75\b/.test(reason));
+ const retries = failedPath ? fileRetries.get(failedPath) ?? 0 : 0;
+ const retry = !!failedPath && transient && retries < 2;
+ if (retry) fileRetries.set(failedPath, retries + 1);
+ else requeueRemainder(job, excludePaths);
+ // Do not quarantine a classified transient file until its budget is
+ // exhausted. Keep recovery active until replacement and backoff end.
+ await handleWorkerDeath(workerIndex, reason, retry ? [] : excludePaths);
+ if (retry && !stopped) {
+ await new Promise<void>((resolve) => setTimeout(resolve, 10 * 2 ** retries));
+ if (!stopped) jobs.unshift(job);
+ }
activeWorkers--;
if (stopped) return;
// Slot may have been dropped or respawned. Kick the current slot

View file

@ -0,0 +1,45 @@
--- a/gitnexus/src/core/ingestion/parsing-processor.ts
+++ b/gitnexus/src/core/ingestion/parsing-processor.ts
@@ -1,3 +1,4 @@
+import { setTimeout as delay } from 'node:timers/promises';
import type { NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import type { SymbolTableWriter } from './model/index.js';
@@ -6,7 +7,7 @@
import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './call-processor.js';
import type { ParsedFile } from 'gitnexus-shared';
-import { WorkerPool } from './workers/worker-pool.js';
+import { WorkerPool, WorkerPoolInitializationError } from './workers/worker-pool.js';
import type { DispatchGroup } from './workers/worker-pool.js';
import type { SkippedPath } from './workers/clone-safety.js';
import type { CfgSkipCounts } from './cfg/collect.js';
@@ -268,12 +269,22 @@
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
if (dispatchGroups.every((group) => group.items.length === 0)) return groups.map(() => []);
- const perGroup = await workerPool.dispatchGroups<ParseWorkerInput, ParseWorkerResult>(
- dispatchGroups,
- (filesProcessed) => {
- onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
- },
- );
+ let perGroup: ParseWorkerResult[][];
+ for (let attempt = 0; ; attempt++) {
+ try {
+ perGroup = await workerPool.dispatchGroups<ParseWorkerInput, ParseWorkerResult>(
+ dispatchGroups,
+ (filesProcessed) => {
+ onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
+ },
+ );
+ break;
+ } catch (error) {
+ if (!(error instanceof WorkerPoolInitializationError) ||
+ error.crashClass !== 'transient-exhausted' || attempt >= 2) throw error;
+ await delay(10 * 2 ** attempt);
+ }
+ }
const chunkResults = perGroup.flat();
// Skipped-language telemetry (worker output, independent of the merge).

View file

@ -0,0 +1,116 @@
--- a/gitnexus/src/core/ingestion/parsing-processor.ts
+++ b/gitnexus/src/core/ingestion/parsing-processor.ts
@@ -1,3 +1,4 @@
+import { setTimeout as delay } from 'node:timers/promises';
import type { NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import type { SymbolTableWriter } from './model/index.js';
@@ -268,12 +269,32 @@
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
if (dispatchGroups.every((group) => group.items.length === 0)) return groups.map(() => []);
- const perGroup = await workerPool.dispatchGroups<ParseWorkerInput, ParseWorkerResult>(
- dispatchGroups,
- (filesProcessed) => {
- onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
- },
- );
+ const perGroup: ParseWorkerResult[][] = dispatchGroups.map(() => []);
+ const inputPaths = new Set(dispatchGroups.flatMap((group) => group.items.map((item) => item.path)));
+ const retries = new Map<string, number>();
+ let pendingGroups = dispatchGroups;
+ for (;;) {
+ const attemptResults = await workerPool.dispatchGroups<ParseWorkerInput, ParseWorkerResult>(
+ pendingGroups,
+ (filesProcessed) => {
+ onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
+ },
+ );
+ attemptResults.forEach((results, index) => perGroup[index].push(...results));
+ const retryPaths = (workerPool.getRetryablePaths?.() ?? []).filter(
+ (p) => inputPaths.has(p) && (retries.get(p) ?? 0) < 2,
+ );
+ if (retryPaths.length === 0 || !workerPool.resumeRetryablePaths) break;
+ const attempt = Math.max(...retryPaths.map((p) => retries.get(p) ?? 0));
+ await delay(10 * 2 ** attempt);
+ for (const p of retryPaths) retries.set(p, (retries.get(p) ?? 0) + 1);
+ workerPool.resumeRetryablePaths(retryPaths);
+ const retrySet = new Set(retryPaths);
+ pendingGroups = dispatchGroups.map((group) => ({
+ ...group,
+ items: group.items.filter((item) => retrySet.has(item.path)),
+ }));
+ }
const chunkResults = perGroup.flat();
// Skipped-language telemetry (worker output, independent of the merge).
--- a/gitnexus/src/core/ingestion/workers/worker-pool.ts
+++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts
@@ -173,6 +173,10 @@
* absence gracefully.
*/
getQuarantinedPaths?(): readonly string[];
+
+ /** Classified transient failures that a caller may retry after backoff. */
+ getRetryablePaths?(): readonly string[];
+ resumeRetryablePaths?(paths: readonly string[]): void;
/**
* Throughput / health snapshot for operator observability. Surfaced at
@@ -1143,6 +1147,7 @@
// 1100+ LOC of pool plumbing. Public worker-pool API is unchanged —
// `getQuarantinedPaths()` still returns the same defensive copy.
const quarantine = createQuarantine();
+ const retryablePaths = new Set<string>();
const initialReadinessFailures: string[] = [];
// Per-slot consecutive-failure counter (F6): replaces the prior pool-wide
// scalar so a chronically-failing slot trips the breaker on its own
@@ -1764,8 +1769,13 @@
) => {
if (stopped) return;
consecutiveFailuresPerSlot[workerIndex]++;
+ const transient = !/\bSyntaxError\b/.test(reason) &&
+ (/\bEAGAIN\b/.test(reason) || /exited with code 75\b/.test(reason));
for (const p of excludePaths) {
- if (p) quarantine.add(p);
+ if (!p) continue;
+ quarantine.add(p);
+ if (transient) retryablePaths.add(p);
+ else retryablePaths.delete(p);
}
if (consecutiveFailuresPerSlot[workerIndex] >= poolOptions.consecutiveFailureThreshold) {
tripBreaker(
@@ -2435,6 +2445,12 @@
terminate,
size,
getQuarantinedPaths: () => quarantine.snapshot(),
+ getRetryablePaths: () => [...retryablePaths].filter((p) => quarantine.has(p)),
+ resumeRetryablePaths: (paths) => {
+ for (const p of paths) {
+ if (retryablePaths.delete(p)) quarantine.remove(p);
+ }
+ },
getStats: () => ({
size,
activeSlots: activeSlots.size,
--- a/gitnexus/src/core/ingestion/workers/quarantine.ts
+++ b/gitnexus/src/core/ingestion/workers/quarantine.ts
@@ -28,6 +28,8 @@
export interface Quarantine {
/** Mark `path` as known-bad for the remainder of this pool's life. */
add(path: string): void;
+ /** Release a classified transient failure for a caller-controlled retry. */
+ remove(path: string): void;
/** Whether `path` has been quarantined. */
has(path: string): boolean;
/** Defensive copy of every quarantined path. */
@@ -50,6 +52,9 @@
add: (path) => {
paths.add(path);
},
+ remove: (path) => {
+ paths.delete(path);
+ },
has: (path) => paths.has(path),
snapshot: () => Array.from(paths),
get size() {

View file

@ -0,0 +1,12 @@
--- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
+++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
@@ -81,7 +81,8 @@
// this process. Runs BEFORE `populateOwners`.
applyCaptureSideChannel: applyCStaticLinkageSideChannel,
- resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => {
+ if (context?.parsedImport?.kind === 'wildcard' && context.parsedImport.isSystem) return null;
// Augment allFilePaths with .h files discovered via loadResolutionConfig
// since the phase only passes .c files to the C resolver but #include
// targets .h files classified as C++ in language detection.

View file

@ -0,0 +1,32 @@
--- a/gitnexus/src/mcp/local/local-backend.ts
+++ b/gitnexus/src/mcp/local/local-backend.ts
@@ -2567,7 +2567,7 @@
* Codepoint comparison (not `localeCompare`) keeps page boundaries stable
* across machines/locales, matching the existing `refreshRepos` ordering.
*/
- async listReposPage(params?: { limit?: unknown; offset?: unknown } | null): Promise<{
+ async listReposPage(params?: { limit?: unknown; offset?: unknown; name_contains?: unknown } | null): Promise<{
repositories: RepoListing[];
pagination: ListReposPagination;
}> {
@@ -2578,7 +2578,9 @@
// One consistent snapshot per call (listRepos refreshes the registry once),
// sorted into a stable total order before slicing.
- const all = await this.listRepos();
+ const all = (await this.listRepos()).filter((repo) =>
+ typeof params?.name_contains !== 'string' || repo.name.toLowerCase().includes(params.name_contains.toLowerCase()),
+ );
all.sort(
(a, b) =>
compareCodeUnits(a.name.toLowerCase(), b.name.toLowerCase()) ||
--- a/gitnexus/src/mcp/tools.ts
+++ b/gitnexus/src/mcp/tools.ts
@@ -108,6 +108,7 @@
inputSchema: {
type: 'object',
properties: {
+ name_contains: { type: 'string', description: 'Case-insensitive repository name substring' },
limit: {
type: 'integer',
description: `Max repositories to return in this page (default: ${LIST_REPOS_DEFAULT_LIMIT}, min: 1, max: ${LIST_REPOS_MAX_LIMIT}). Values outside [1, ${LIST_REPOS_MAX_LIMIT}] are rejected.`,

View file

@ -0,0 +1,11 @@
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -292,7 +292,7 @@
.command('status')
.description('Show index status for the current repo or a registered index')
.option('-r, --repo <name>', 'Registered repository alias or path (works after checkout removal)')
- .option('--json', 'Emit machine-readable index and analyzer provenance')
+ .option('-j, --json', 'Emit machine-readable index and analyzer provenance')
.addHelpText('after', () => t('help.identityCache.environment'))
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));

View file

@ -1,27 +1,29 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('hidden oracle: status -j JSON alias', () => {
it('returns the same machine-readable status shape as --json', () => {
const executable = path.resolve('node_modules/.bin/tsx');
const options = {
cwd: process.cwd(),
encoding: 'utf8' as const,
env: { ...process.env, NO_COLOR: '1' },
timeout: 60_000,
};
const short = spawnSync(executable, ['src/cli/index.ts', 'status', '-j'], options);
const long = spawnSync(executable, ['src/cli/index.ts', 'status', '--json'], options);
// Node's loader runs the candidate source without the tsx CLI's IPC socket.
const command = ['--import', 'tsx', 'src/cli/index.ts', 'status'];
const short = spawnSync(process.execPath, [...command, '-j'], options);
const long = spawnSync(process.execPath, [...command, '--json'], options);
expect(short.error).toBeUndefined();
expect(short.status).toBe(0);
expect(short.stderr.trim()).toBe('');
// The command banner is written to stderr, including in JSON mode.
expect(short.stderr).toBe(long.stderr);
expect(long.error).toBeUndefined();
expect(long.status).toBe(0);
const shortPayload = JSON.parse(short.stdout) as Record<string, unknown>;
const longPayload = JSON.parse(long.stdout) as Record<string, unknown>;
expect(shortPayload.schemaVersion).toBe(1);
expect(shortPayload).toHaveProperty('status');
expect(shortPayload).toHaveProperty('repository');
expect(shortPayload).toEqual(longPayload);
});

View file

@ -8,6 +8,10 @@ export default {
setupFiles: [],
globalSetup: [],
passWithNoTests: false,
// Cold source CLI loads can exceed Vitest's five-second default.
testTimeout: 120_000,
// Declared dependency trees are mounted read-only during verification.
cache: false,
include: ['../.wfbench-oracle-*/*.oracle.test.ts'],
exclude: [],
},

View file

@ -16,6 +16,7 @@ from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlsplit
from .baseline_guidance import GITNEXUS_UNAVAILABLE_NOTE, GuidanceError, ordinary_repository_guidance
from .process_control import ManagedProcessResult, run_managed
@ -39,6 +40,7 @@ SANDBOX_PYTHON3 = "/opt/claude/python3"
SANDBOX_GITNEXUS_CLI = "/opt/claude/gitnexus"
SANDBOX_GIT_EXCLUDES = "/opt/claude/git-excludes"
SANDBOX_NODE = "/opt/claude/node"
SANDBOX_BWRAP = "/opt/claude/bwrap"
SANDBOX_NODE_PREFIX = "/opt/claude/nodejs"
# Vite transpiles a TypeScript config into <node_modules>/.vite-temp before it
# loads anything, so a read-only dependency mount makes `vitest` die with EROFS
@ -161,7 +163,7 @@ def prepare_review_workspace(sandbox: SandboxSession, artifact_name: str) -> Pat
except OSError as exc:
raise SandboxError(f"review artifact directory is unavailable: {output.parent}") from exc
clone = _real_directory(sandbox.clone, label="review clone")
clone = real_directory(sandbox.clone, label="review clone")
if sandbox.backend != "bwrap":
return output
created: list[str] = []
@ -208,6 +210,8 @@ class SandboxSession:
claude_host_bin: Path
command_prefix: list[str]
read_only_mounts: tuple[ReadOnlyMount, ...]
# Harness-authored, immutable overlays must not appear as agent work.
synthetic_guidance_paths: tuple[str, ...] = ()
@property
def require_pid_namespace(self) -> bool:
@ -337,7 +341,7 @@ class SandboxSession:
return []
additional: list[ReadOnlyMount] = []
clone = _real_directory(self.clone, label="sandbox clone")
clone = real_directory(self.clone, label="sandbox clone")
for raw_path in read_only_paths:
lexical = raw_path.expanduser().absolute()
try:
@ -536,8 +540,8 @@ def build_sandbox_environment(
token = auth_token.strip()
if not token:
raise SandboxError("model auth token must not be blank")
# Every benchmark/proposer invocation uses Claude's --bare mode,
# which intentionally ignores OAuth/keychain/AUTH_TOKEN credentials.
# Explicit API-key auth works for normal benchmark startup; private
# HOME carries no OAuth/keychain state.
env["ANTHROPIC_API_KEY"] = token
if base_url is not None:
env["ANTHROPIC_BASE_URL"] = _validated_base_url(base_url)
@ -547,12 +551,9 @@ def build_sandbox_environment(
def build_claude_settings(*, sandbox_enabled: bool = True) -> str:
"""Inline settings that keep every Bash sandboxed and pre-approve the tools.
Deliberately hook-free: headless ``claude -p`` (2.1.247) never dispatches
``PreToolUse``, whatever source the hook is declared in — inline
``--settings``, a settings file, project/user/local ``--setting-sources``,
or a trusted project entry in ``~/.claude.json``. Confinement therefore
rests only on mechanisms the CLI honors in this mode: the sandbox policy
below, ``--tools``/``--allowedTools``, and the bwrap mounts.
Inline settings disable repository/plugin hooks even when ordinary
CLAUDE.md startup context is loaded. Confinement rests on the sandbox
policy below, ``--tools``/``--allowedTools``, and the bwrap mounts.
"""
permissions = {
@ -561,6 +562,7 @@ def build_claude_settings(*, sandbox_enabled: bool = True) -> str:
if sandbox_enabled:
permissions["disableBypassPermissionsMode"] = "disable"
settings = {
"disableAllHooks": True,
"sandbox": {
"enabled": sandbox_enabled,
"failIfUnavailable": sandbox_enabled,
@ -603,8 +605,8 @@ def build_claude_settings(*, sandbox_enabled: bool = True) -> str:
# non-default mode only emits a warning and never takes effect.
# Under "default" a tool runs without a prompt only if it matches an
# allow rule, so pre-approve the proposer's exact tool surface. Bash
# is the only writable tool under --bare (it writes the candidate
# overlay) and stays sandbox-confined by the sandbox.* policy above.
# is the only writable tool (it writes the candidate overlay) and
# stays sandbox-confined by the sandbox.* policy above.
**permissions,
},
"env": (
@ -619,7 +621,9 @@ def build_claude_settings(*, sandbox_enabled: bool = True) -> str:
return json.dumps(settings, sort_keys=True, separators=(",", ":"))
def _runtime_mount_args() -> list[str]:
def runtime_mount_args() -> list[str]:
"""Read-only system/runtime binds shared by every Bubblewrap sandbox."""
args: list[str] = []
system_trees = ("/usr", "/bin", "/lib", "/lib64")
for raw in system_trees:
@ -687,10 +691,78 @@ def _runtime_mount_args() -> list[str]:
return args
def _create_shell_prefix_wrapper(private_root: Path) -> Path:
def bwrap_base_args(*, unshare_network: bool = False, cap_drop_all: bool = False) -> list[str]:
"""Namespace, lifetime, runtime-mount and /proc,/dev preamble shared by sandboxes.
Callers append their own filesystem binds, environment and command after
this preamble, so a hardening change here reaches every Bubblewrap user.
"""
return [
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
*(["--unshare-net"] if unshare_network else []),
"--die-with-parent",
"--new-session",
*(["--cap-drop", "ALL"] if cap_drop_all else []),
*runtime_mount_args(),
"--proc",
"/proc",
"--dev",
"/dev",
]
def mcp_sandbox_prefix() -> list[str]:
"""Confine candidate MCP code inside an already-contained agent session.
Sources are paths in the outer namespace, never host paths. Only code,
graph and repository reads cross this boundary; agent state and writable
mounts stay outside it. Clear credentials before even launching Bubblewrap.
"""
args = [
"/usr/bin/env", "-i", SANDBOX_BWRAP,
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
"--unshare-net", "--die-with-parent", "--new-session", "--cap-drop", "ALL",
]
for path in ("/usr", "/bin", "/lib", "/lib64", "/etc/ssl", "/etc/hosts",
"/etc/resolv.conf", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group"):
args += ["--ro-bind-try", path, path]
for path in (SANDBOX_WORKSPACE, SANDBOX_GITNEXUS, SANDBOX_NODE):
args += ["--ro-bind", path, path]
# Minimal canaries and runtimes without shared packages need not supply
# these mounts. Missing optional paths must not weaken required binds.
for path in (SANDBOX_GITNEXUS_SHARED, SANDBOX_GITNEXUS_REGISTRY, SANDBOX_NODE_PREFIX):
args += ["--ro-bind-try", path, path]
args += [
"--proc", "/proc", "--dev", "/dev",
"--tmpfs", "/run", "--tmpfs", SANDBOX_TMP,
"--tmpfs", "/home", "--dir", SANDBOX_HOME,
"--clearenv",
]
environment = {
"HOME": SANDBOX_HOME,
"TMPDIR": SANDBOX_TMP,
"GITNEXUS_HOME": SANDBOX_GITNEXUS_REGISTRY,
"GITNEXUS_MCP_ALLOWED_REPOS": SANDBOX_WORKSPACE,
"GITNEXUS_MCP_DEFAULT_REPO": SANDBOX_WORKSPACE,
"PATH": f"{SANDBOX_NODE_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin",
"LANG": "C.UTF-8",
"GIT_TERMINAL_PROMPT": "0",
}
for key, value in environment.items():
args += ["--setenv", key, value]
return [*args, "--chdir", SANDBOX_WORKSPACE, "--"]
def _create_shell_prefix_wrapper(private_root: Path, *, gitnexus_available: bool = True) -> Path:
"""Create Claude's immutable clean-environment command adapter."""
wrapper = private_root / "shell-prefix"
gitnexus_invocation = "GITNEXUS_INVOCATION=gitnexus " if gitnexus_available else ""
wrapper.write_text(
"#!/bin/bash\n"
"set -eu\n"
@ -699,7 +771,7 @@ def _create_shell_prefix_wrapper(private_root: Path) -> Path:
f"HOME={SANDBOX_HOME} USER=agent LOGNAME=agent TMPDIR={SANDBOX_TMP} "
f"PATH={SANDBOX_PATH} LANG=C.UTF-8 LC_ALL=C.UTF-8 TERM=dumb "
f"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.excludesFile GIT_CONFIG_VALUE_0={SANDBOX_GIT_EXCLUDES} "
"GITNEXUS_INVOCATION=gitnexus "
f"{gitnexus_invocation}"
'/bin/bash -c "$1"\n'
)
wrapper.chmod(0o500)
@ -758,22 +830,7 @@ def preflight_bubblewrap(bwrap_bin: Path | str | None = None) -> Path:
if sys.platform != "linux":
raise SandboxError(f"Bubblewrap containment is supported only on Linux/WSL2, not {sys.platform}")
bwrap = _resolve_executable(bwrap_bin, "bwrap")
command = [
str(bwrap),
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--die-with-parent",
"--new-session",
*_runtime_mount_args(),
"--proc",
"/proc",
"--dev",
"/dev",
"--",
"/usr/bin/true",
]
command = [str(bwrap), *bwrap_base_args(), "--", "/usr/bin/true"]
result = run_managed(command, timeout=10, require_pid_namespace=True)
if not result.ok:
raise SandboxError(f"Bubblewrap namespace preflight failed: {result.detail or result.stderr_tail[-1000:]}")
@ -826,7 +883,7 @@ def require_claude_sandbox_helpers() -> None:
_resolve_executable(None, "socat")
def _real_directory(path: Path, *, label: str) -> Path:
def real_directory(path: Path, *, label: str) -> Path:
"""Return an absolute directory path without accepting any symlink hop."""
lexical = path.expanduser().absolute()
@ -845,6 +902,7 @@ def _real_directory(path: Path, *, label: str) -> Path:
return lexical
def _safe_repo_source(repo: Path, relative: str, *, label: str) -> tuple[Path, Path]:
candidate = PurePosixPath(relative)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
@ -945,18 +1003,7 @@ def _sandbox_command_prefix(
) -> list[str]:
args = [
str(bwrap),
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
*(["--unshare-net"] if unshare_network else []),
"--die-with-parent",
"--new-session",
*_runtime_mount_args(),
"--proc",
"/proc",
"--dev",
"/dev",
*bwrap_base_args(unshare_network=unshare_network),
"--tmpfs",
"/run",
"--ro-bind" if read_only_workspace else "--bind",
@ -1141,6 +1188,27 @@ def sandbox_workspace_write_boundary(
yield
def baseline_gitnexus_mounts(clone: Path, private_root: Path) -> tuple[ReadOnlyMount, ...]:
"""Hide inherited index/bootstrap bytes and graph-first startup guidance."""
empty_index = private_root / "empty-gitnexus"
empty_index.mkdir(mode=0o500)
_prepare_clone_target(clone, PurePosixPath(".gitnexus"), directory=True, label="baseline index mask")
mounts = [ReadOnlyMount(empty_index, f"{SANDBOX_WORKSPACE}/.gitnexus")]
for index, name in enumerate(("AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md")):
if os.path.lexists(clone / name):
_prepare_clone_target(clone, PurePosixPath(name), directory=False, label="baseline guidance")
try:
ordinary = ordinary_repository_guidance(_evidence_bytes(clone / name, ()).decode("utf-8"))
except GuidanceError as error:
raise SandboxError(str(error)) from None
guidance = private_root / f"repository-guidance-{index}.md"
guidance.write_text(ordinary.rstrip() + "\n\n" + GITNEXUS_UNAVAILABLE_NOTE)
guidance.chmod(0o400)
mounts.append(ReadOnlyMount(guidance, f"{SANDBOX_WORKSPACE}/{name}"))
return tuple(mounts)
@contextmanager
def prepare_sandbox(
*,
@ -1150,14 +1218,34 @@ def prepare_sandbox(
read_only_mounts: Sequence[ReadOnlyMount] = (),
preflight: bool = True,
backend: str = "bwrap",
gitnexus_available: bool = True,
) -> Iterator[SandboxSession]:
"""Create private host backing dirs and one virtualized command."""
# Validate the lexical path before resolving it. Resolving first would
# erase the evidence that the caller supplied a symlinked clone root.
clone = _real_directory(clone, label="sandbox clone")
clone = real_directory(clone, label="sandbox clone")
if backend not in ("bwrap", "host-unsafe"):
raise SandboxError(f"unknown sandbox backend: {backend}")
if not gitnexus_available:
if backend != "bwrap":
raise SandboxError("baseline_nomcp requires Bubblewrap; host-unsafe cannot isolate GitNexus access")
forbidden = (
SANDBOX_GITNEXUS,
SANDBOX_GITNEXUS_SHARED,
SANDBOX_GITNEXUS_REGISTRY,
SANDBOX_GITNEXUS_CLI,
f"{SANDBOX_WORKSPACE}/.gitnexus",
)
for mount in read_only_mounts:
target = PurePosixPath(mount.target)
if not target.is_absolute() or ".." in target.parts:
raise SandboxError(f"baseline_nomcp mount target must be absolute without '..': {mount.target}")
# Linux treats a double-slash root as '/'; PurePosixPath preserves
# it, so normalize it before comparing namespace mount coverage.
target = PurePosixPath("/", *target.parts[1:])
if any(target.is_relative_to(path) or PurePosixPath(path).is_relative_to(target) for path in forbidden):
raise SandboxError(f"baseline_nomcp cannot mount GitNexus tools or graph assets: {mount.target}")
if preflight:
bwrap = preflight_bubblewrap(bwrap_bin) if backend == "bwrap" else preflight_unsafe_host()
if backend == "bwrap":
@ -1172,9 +1260,8 @@ def prepare_sandbox(
for directory in (home, temp):
directory.mkdir(mode=0o700)
directory.chmod(0o700)
shell_prefix = _create_shell_prefix_wrapper(private_root)
shell_prefix = _create_shell_prefix_wrapper(private_root, gitnexus_available=gitnexus_available)
python3_wrapper = _create_python3_wrapper(private_root)
gitnexus_wrapper = _create_gitnexus_wrapper(private_root)
git_excludes = _create_git_excludes(private_root)
# Claude may discover user-level skills below HOME. Keep the rest of HOME
# writable for normal CLI state, but overlay an immutable empty skills root
@ -1182,16 +1269,30 @@ def prepare_sandbox(
user_skills = home / ".claude" / "skills"
user_skills.mkdir(parents=True, mode=0o500)
user_skills.chmod(0o500)
protected_mounts = (
*read_only_mounts,
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
ReadOnlyMount(source=python3_wrapper, target=SANDBOX_PYTHON3),
ReadOnlyMount(source=gitnexus_wrapper, target=SANDBOX_GITNEXUS_CLI),
ReadOnlyMount(source=git_excludes, target=SANDBOX_GIT_EXCLUDES),
)
primary: BaseException | None = None
try:
if gitnexus_available:
baseline_mounts: tuple[ReadOnlyMount, ...] = ()
else:
baseline_mounts = baseline_gitnexus_mounts(clone, private_root)
protected_mounts = (
*read_only_mounts,
*(
(ReadOnlyMount(source=bwrap, target=SANDBOX_BWRAP),)
if backend == "bwrap" and gitnexus_available
else ()
),
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
ReadOnlyMount(source=python3_wrapper, target=SANDBOX_PYTHON3),
*(
(ReadOnlyMount(source=_create_gitnexus_wrapper(private_root), target=SANDBOX_GITNEXUS_CLI),)
if gitnexus_available
else baseline_mounts
),
ReadOnlyMount(source=git_excludes, target=SANDBOX_GIT_EXCLUDES),
)
command_prefix = (
_sandbox_command_prefix(
bwrap=bwrap,
@ -1214,6 +1315,11 @@ def prepare_sandbox(
claude_host_bin=claude,
command_prefix=command_prefix,
read_only_mounts=protected_mounts,
synthetic_guidance_paths=tuple(
PurePosixPath(mount.target).relative_to(SANDBOX_WORKSPACE).as_posix()
for mount in baseline_mounts
if mount.target != f"{SANDBOX_WORKSPACE}/.gitnexus"
),
)
except BaseException as exc:
primary = exc

View file

@ -0,0 +1,137 @@
"""Build a candidate without exposing the trusted evaluator or host environment."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path, PurePosixPath
from .process_control import run_checked
from .proposer_sandbox import SANDBOX_PATH, bwrap_base_args, preflight_bubblewrap, real_directory
REGISTRY = "https://registry.npmjs.org/"
LOCKFILES = ("package-lock.json", "gitnexus/package-lock.json")
def require_registry_only_install(repo: Path) -> None:
"""Fail unless the online install can only fetch from the public npm registry.
The download phase has network, and npm fetches every lockfile ``resolved``
URL. A candidate must not steer it at other hosts (loopback, private
networks, cloud metadata) through its lockfiles or an npm config file.
"""
for config in (".npmrc", "gitnexus/.npmrc"):
if os.path.lexists(repo / config):
raise ValueError(f"release candidate must not ship npm configuration: {config}")
for lockfile in LOCKFILES:
try:
packages = json.loads((repo / lockfile).read_text())["packages"]
except (OSError, ValueError, KeyError, TypeError) as exc:
raise ValueError(f"release candidate lockfile is unreadable: {lockfile}") from exc
if not isinstance(packages, dict):
raise ValueError(f"release candidate lockfile is malformed: {lockfile}")
root = PurePosixPath(lockfile).parent
for name, entry in packages.items():
resolved = entry.get("resolved") if isinstance(entry, dict) else None
if resolved is None or (isinstance(resolved, str) and resolved.startswith(REGISTRY)):
continue
# Workspace links stay inside the checkout and fetch nothing.
target = PurePosixPath(os.path.normpath(root / resolved)) if isinstance(resolved, str) else None
if (
entry.get("link") is True
and target is not None
and not target.is_absolute()
and ".." not in target.parts
):
continue
raise ValueError(f"release candidate lockfile {lockfile} resolves {name!r} outside {REGISTRY}")
def build_candidate(repo: Path) -> None:
"""Install dependencies with network, then run lifecycle scripts without it.
Only the checkout, minus git metadata, is writable in either phase.
"""
repo = real_directory(repo, label="release candidate")
# Host git later runs in this checkout (e.g. actions/checkout cleanup), so
# lifecycle scripts must not plant git config such as core.fsmonitor.
git_dir = repo / ".git"
git_mask = ["--ro-bind", str(git_dir), "/workspace/.git"] if os.path.lexists(git_dir) else []
require_registry_only_install(repo)
bwrap = preflight_bubblewrap()
def sandboxed(script: list[str], *, offline: bool) -> list[str]:
return [
str(bwrap),
*bwrap_base_args(unshare_network=offline, cap_drop_all=True),
"--tmpfs",
"/tmp",
"--dir",
"/home/build",
"--bind",
str(repo),
"/workspace",
*git_mask,
"--clearenv",
"--setenv",
"PATH",
SANDBOX_PATH,
"--setenv",
"HOME",
"/home/build",
# CPU binaries ship in the npm package. Optional CUDA downloads
# cannot run during the network-isolated lifecycle phase.
"--setenv",
"ONNXRUNTIME_NODE_INSTALL",
"skip",
"--chdir",
"/workspace",
"--",
"/bin/bash",
"--noprofile",
"--norc",
"-c",
"\n".join(["set -euo pipefail", *script]),
]
# Locked downloads need the network but run no package code. Every
# lifecycle script (dependency installs, prepare, build) then runs with no
# network, so candidate-controlled code cannot reach host-local services.
# No host home, evaluator checkout, GitHub command files or tokens are mounted.
run_checked(
sandboxed(
[
"npm ci --ignore-scripts --audit=false --fund=false",
"npm ci --prefix gitnexus --ignore-scripts --audit=false --fund=false",
],
offline=False,
),
timeout=1200,
env={},
)
run_checked(
sandboxed(
[
"npm rebuild --foreground-scripts",
"npm rebuild --prefix gitnexus --foreground-scripts",
"npm run build --prefix gitnexus",
"mkdir -p gitnexus-shared/node_modules",
],
offline=True,
),
timeout=1200,
env={},
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True, type=Path)
build_candidate(parser.parse_args().repo)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,186 @@
"""Find successful default-branch evidence for one exact release revision.
Run this module from the current main evaluator tree (``--evaluator-sha``), not
from the release commit: evidence counts only when the run that produced it
measured with a harness whose evaluation inputs still match that tree.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import tempfile
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from .release_report import render_markdown, suite_binding, validate_report
SHA = re.compile(r"[0-9a-f]{40}")
# Paths whose content defines what an agent evaluation measures: harness,
# graders, task pins, the evaluation workflow and its pinned agent CLI.
HARNESS_PATHS = ("eval/", ".github/workflows/release-evaluation.yml", ".github/claude-canary-runtime/")
# GitHub's compare API lists at most 300 changed files for a whole comparison.
COMPARE_FILE_LIMIT = 300
GH_TIMEOUT_S = 120
MAX_REASONS = 20
def eligible_runs(payload: dict[str, Any], repo: str) -> list[tuple[int, str]]:
"""Trusted main runs, newest first as listed, with the commit each one ran."""
return [
(run["id"], run["head_sha"])
for run in payload.get("workflow_runs", [])
if run.get("conclusion") == "success"
and run.get("head_branch") == "main"
and run.get("event") in ("schedule", "workflow_dispatch")
and run.get("head_repository", {}).get("full_name") == repo
and type(run.get("id")) is int
and isinstance(run.get("head_sha"), str)
and SHA.fullmatch(run["head_sha"])
]
def _gh(args: list[str]) -> str:
try:
return subprocess.run(["gh", *args], check=True, capture_output=True, text=True, timeout=GH_TIMEOUT_S).stdout
except subprocess.TimeoutExpired:
raise ValueError(f"gh {args[0]} timed out after {GH_TIMEOUT_S}s") from None
except subprocess.CalledProcessError as exc:
# gh's stderr is not echoed: keep the failure bounded and free of request details.
raise ValueError(f"gh {args[0]} failed with exit status {exc.returncode}") from None
def _gh_json(endpoint: str) -> dict[str, Any]:
return json.loads(_gh(["api", endpoint]))
def _recent_runs(repo: str) -> Iterator[tuple[int, str]]:
# Reports expire after seven days; a run can start up to a day earlier.
since = (datetime.now(UTC) - timedelta(days=8)).date().isoformat()
page = 1
while True:
payload = _gh_json(
f"repos/{repo}/actions/workflows/release-evaluation.yml/runs"
f"?branch=main&status=success&created=%3E%3D{since}&per_page=100&page={page}"
)
yield from eligible_runs(payload, repo)
if len(payload.get("workflow_runs", [])) < 100:
return
page += 1
def require_current_harness(repo: str, harness_sha: str, evaluator_sha: str) -> None:
"""Fail unless main reached ``evaluator_sha`` from ``harness_sha`` without touching evaluation inputs."""
comparison = _gh_json(f"repos/{repo}/compare/{harness_sha}...{evaluator_sha}")
if comparison.get("status") not in ("identical", "ahead"):
raise ValueError(f"harness {harness_sha} is not an ancestor of the current main evaluator {evaluator_sha}")
files = comparison.get("files")
if not isinstance(files, list) or len(files) >= COMPARE_FILE_LIMIT:
raise ValueError("cannot prove the harness is current: the compare file list is missing or truncated")
changed: set[str] = set()
for entry in files:
if not isinstance(entry, dict) or not isinstance(entry.get("filename"), str):
raise ValueError("cannot prove the harness is current: malformed compare file entry")
# A rename out of a harness path changes the harness too.
names = (entry["filename"], entry.get("previous_filename"))
changed.update(name for name in names if isinstance(name, str) and name.startswith(HARNESS_PATHS))
if changed:
raise ValueError(
f"evaluation inputs changed on main since harness {harness_sha} "
f"({len(changed)} file(s), e.g. {min(changed)}); re-run Release evaluation"
)
def _verified_report(
repo: str,
run_id: int,
head_sha: str,
artifact_name: str,
*,
runtime_sha: str,
evaluator_sha: str,
digest: str,
pins: dict[str, Any],
) -> tuple[str, dict[str, Any]] | None:
"""The run's validated report text and data; None when the run has no live artifact."""
artifacts = _gh_json(f"repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100")
if not any(
item.get("name") == artifact_name and not item.get("expired") for item in artifacts.get("artifacts", [])
):
return None
with tempfile.TemporaryDirectory(prefix="release-evidence-") as folder:
_gh(["run", "download", str(run_id), "--repo", repo, "--name", artifact_name, "--dir", folder])
source = Path(folder) / "agent-evaluation.json"
if not source.is_file() or source.is_symlink() or source.stat().st_size > 2 * 1024 * 1024:
raise ValueError("invalid release evidence artifact")
source_text = source.read_text()
report = json.loads(source_text)
if not isinstance(report, dict) or report.get("harness_sha") != head_sha:
raise ValueError(f"report harness_sha is not the commit workflow run {run_id} ran ({head_sha})")
require_current_harness(repo, head_sha, evaluator_sha)
# The tree running this module is that harness's evaluator, so its task
# binding is the one the run measured, not the release commit's.
validate_report(report, runtime_sha=runtime_sha, task_set_digest=digest, task_pins=pins)
return source_text, report
def download_evidence(repo: str, runtime_sha: str, out: Path, *, evaluator_sha: str) -> None:
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo) or not all(
SHA.fullmatch(sha) for sha in (runtime_sha, evaluator_sha)
):
raise ValueError("expected a repository name and full release commit SHA and evaluator SHA")
_, digest, pins = suite_binding()
artifact_name = f"release-agent-evaluation-{runtime_sha}"
rejected: list[str] = []
for run_id, head_sha in _recent_runs(repo):
try:
verified = _verified_report(
repo,
run_id,
head_sha,
artifact_name,
runtime_sha=runtime_sha,
evaluator_sha=evaluator_sha,
digest=digest,
pins=pins,
)
except (ValueError, KeyError, TypeError) as exc:
# A rejected run never hides an older valid one; every reason is reported.
reason = str(exc) if isinstance(exc, ValueError) else f"malformed report ({type(exc).__name__})"
rejected.append(f"run {run_id}: {reason[:300]}")
continue
if verified is None:
continue
source_text, report = verified
out.mkdir(parents=True, exist_ok=True)
(out / "agent-evaluation.json").write_text(source_text)
# Regenerate prose from verified numbers; never trust downloaded Markdown.
(out / "agent-evaluation.md").write_text(render_markdown(report))
print(f"Verified agent evidence from workflow run {run_id} (harness {head_sha}) for {runtime_sha}.")
return
details = "".join(f"\n- {reason}" for reason in rejected[:MAX_REASONS])
if len(rejected) > MAX_REASONS:
details += f"\n- ... and {len(rejected) - MAX_REASONS} more rejected run(s)"
raise ValueError(
"No complete paired agent evaluation for this exact commit from the current main evaluator. "
"Run Release evaluation on main with candidate_ref set to the stable commit before publishing."
+ (f"\nRejected evidence:{details}" if rejected else "")
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True)
parser.add_argument("--runtime-sha", required=True)
parser.add_argument("--evaluator-sha", required=True, help="main commit whose eval/ tree runs this module")
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args()
download_evidence(args.repo, args.runtime_sha, args.out, evaluator_sha=args.evaluator_sha)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,162 @@
"""Reject per-task quality regressions between pinned RC and stable measurements.
The caller must resolve the candidate and published stable SHAs from trusted
release metadata. This command validates measurements; it does not establish
artifact provenance, select a release, or authorize publication by itself.
"""
from __future__ import annotations
import argparse
from datetime import UTC, datetime
import json
from pathlib import Path
import re
from typing import Any
from .release_report import suite_binding, validate_report
from .task_assets import DEPENDENCY_CONTENT_BINDING_FIELD
def compare_reports(
candidate: dict[str, Any],
stable: dict[str, Any],
*,
candidate_sha: str,
stable_sha: str,
task_set_digest: str,
task_pins: dict[str, Any],
now: datetime | None = None,
) -> dict[str, Any]:
measured_at = now or datetime.now(UTC)
result: dict[str, Any] = {
"schema": "gitnexus.release-quality-gate/v1",
"generated_at": measured_at.isoformat(),
"candidate_sha": candidate_sha,
"stable_sha": stable_sha,
"passed": False,
"problems": [],
"tasks": {},
"measurements": {},
}
for label, report, sha in (("candidate", candidate, candidate_sha), ("stable", stable, stable_sha)):
try:
validate_report(
report, runtime_sha=sha, task_set_digest=task_set_digest, task_pins=task_pins, now=measured_at
)
except (ValueError, KeyError, TypeError, AttributeError, OverflowError):
# Do not echo untrusted report values or filesystem details into
# the public artifact. Full measurements remain in their reports.
result["problems"].append(f"{label}: invalid, incomplete, stale, or mismatched evidence/pins")
if result["problems"]:
return result
for field in ("harness_sha", "task_set_digest", "tasks", "model", "effort", "runs"):
if candidate[field] != stable[field]:
result["problems"].append(f"candidate and stable use different {field}")
# Every cell of a task, on both sides, must be graded against the same
# staged dependency bytes (toolchain, test runner, shared build).
for task in task_pins:
dependencies = {
digest if isinstance(digest := row.get(DEPENDENCY_CONTENT_BINDING_FIELD), str) else None
for report in (candidate, stable)
for row in report["per_run"]
if row["task"] == task
}
if len(dependencies) != 1 or not re.fullmatch(r"[0-9a-f]{64}", next(iter(dependencies)) or ""):
result["problems"].append(
f"{task}: candidate and stable were not graded against identical task dependencies"
)
if result["problems"]:
return result
# Preserve both no-MCP comparisons and measured costs without turning a
# small stochastic benchmark into a claim of statistical non-inferiority.
result["measurements"] = {
label: {"arms": report["arms"], "vs_no_gitnexus": report["paired"]}
for label, report in (("candidate", candidate), ("stable", stable))
}
result.update(
harness_sha=candidate["harness_sha"],
task_set_digest=task_set_digest,
model=candidate["model"],
effort=candidate["effort"],
runs=candidate["runs"],
)
for task in task_pins:
counts = {
label: sum(row["resolved"] for row in report["per_run"] if row["task"] == task and row["arm"] == "baseline")
for label, report in (("candidate", candidate), ("stable", stable))
}
delta = counts["candidate"] - counts["stable"]
result["tasks"][task] = {**counts, "solve_delta": delta}
if delta < 0:
result["problems"].append(
f"{task}: RC solved {counts['candidate']}/{candidate['runs']}; stable solved {counts['stable']}/{stable['runs']}"
)
result["passed"] = not result["problems"]
return result
def render_markdown(result: dict[str, Any]) -> str:
lines = [
"## RC quality comparison",
"",
f"Candidate `{result['candidate_sha']}`; stable `{result['stable_sha']}`.",
f"**{'PASS' if result['passed'] else 'FAIL'}** — no task may lose solved repetitions versus stable.",
"",
]
if result["tasks"]:
lines += ["| Task | RC solved | Stable solved | Delta |", "| --- | --- | --- | --- |"]
for task, values in result["tasks"].items():
lines.append(f"| {task} | {values['candidate']} | {values['stable']} | {values['solve_delta']:+d} |")
lines += ["", *[f"- {problem}" for problem in result["problems"]], ""]
if result["measurements"]:
lines += [
"| Runtime | Solved vs no-GitNexus | Mean MCP cost | Mean MCP duration |",
"| --- | --- | --- | --- |",
]
for label, values in result["measurements"].items():
mcp = values["arms"]["baseline"]
delta = values["vs_no_gitnexus"]["solve_delta"]
lines.append(f"| {label} | {delta:+d} | ${mcp['mean_cost_usd']:.4f} | {mcp['mean_wall_s']:.1f}s |")
lines += [
"",
"The no-GitNexus comparison, cost and duration are descriptive; they are not additional pass criteria.",
"This conservative per-task rule measures observed outcomes, not statistical significance or all workloads.",
"The publishing workflow must separately verify provenance and bind this decision to its exact candidate.",
"",
]
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--candidate", required=True, type=Path)
parser.add_argument("--candidate-sha", required=True)
parser.add_argument("--stable", required=True, type=Path)
parser.add_argument("--stable-sha", required=True)
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args()
_, digest, pins = suite_binding()
reports = []
for path in (args.candidate, args.stable):
try:
reports.append(json.loads(path.read_text()))
except (OSError, ValueError):
reports.append({})
result = compare_reports(
*reports,
candidate_sha=args.candidate_sha,
stable_sha=args.stable_sha,
task_set_digest=digest,
task_pins=pins,
)
args.out.mkdir(parents=True, exist_ok=True)
(args.out / "release-quality-gate.json").write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
(args.out / "release-quality-gate.md").write_text(render_markdown(result))
raise SystemExit(0 if result["passed"] else 1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,73 @@
"""Build and materialize release graphs before any paid agent sessions."""
from __future__ import annotations
import argparse
import tempfile
from pathlib import Path
from typing import Any
import yaml
from .process_control import ManagedProcessError, cancellation_scope
from .proposer_sandbox import SandboxError, preflight_bubblewrap, require_claude_sandbox_helpers
from .runner_tasks import resolve_task_bindings, select_tasks
from .runtime_mounts import trusted_gitnexus_runtime_mounts
from .sanitized_graph import prepare_sanitized_graph, validate_no_prebuilt_graph_assets
from .task_assets import TaskAssetCache, stage_task_assets
def preflight_release(tasks: list[dict[str, Any]], *, gitnexus_root: Path, claude_bin: Path) -> None:
"""Exercise the real snapshot/copy path on the same temp filesystem as the runner.
No provider or session code is involved. Graphs are rebuilt by the paid
runner afterwards, preserving its existing provenance and cache lifecycle.
"""
bwrap_bin = preflight_bubblewrap()
require_claude_sandbox_helpers()
mounts = trusted_gitnexus_runtime_mounts(root=gitnexus_root)
with (
tempfile.TemporaryDirectory(prefix="wfbench-preflight-") as directory,
TaskAssetCache(Path(directory) / ".task-assets") as cache,
):
bindings = resolve_task_bindings(tasks, task_asset_cache=cache)
graphs = {}
for task, binding in zip(tasks, bindings, strict=True):
validate_no_prebuilt_graph_assets(task)
repo = Path(binding["repo_identity"])
sha = binding["resolved_sha"]
key = (str(repo), sha)
if key not in graphs:
graphs[key] = prepare_sanitized_graph(
task, repo=repo, resolved_sha=sha, parent=Path(directory), cache=cache,
claude_bin=claude_bin, bwrap_bin=bwrap_bin, runtime_mounts=mounts,
)
graph = graphs[key]
assets = cache.prepare(task, repo=repo, resolved_sha=sha, expected_dependency_binding=binding)
print(f"Preflight {task['id']}: graph snapshot {graph.assets.total_bytes} bytes", flush=True)
with tempfile.TemporaryDirectory(prefix="probe-", dir=directory) as probe:
clone = Path(probe)
graph.materialize(clone, sanitized_head=graph.sanitized_head)
stage_task_assets(task, repo=repo, clone=clone, snapshot=assets)
print(f"Preflight {task['id']}: materialization passed", flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tasks", required=True, type=Path)
parser.add_argument("--gitnexus-root", required=True, type=Path)
parser.add_argument("--claude-bin", required=True, type=Path)
args = parser.parse_args()
try:
document = yaml.safe_load(args.tasks.read_text())
if not isinstance(document, dict) or not isinstance(document.get("tasks"), list):
raise ValueError("task file must contain a tasks list")
tasks, _ = select_tasks(document["tasks"], include_expensive=True)
with cancellation_scope(handle_signals=True):
preflight_release(tasks, gitnexus_root=args.gitnexus_root, claude_bin=args.claude_bin)
except (ManagedProcessError, OSError, SandboxError, RuntimeError, ValueError, yaml.YAMLError) as exc:
parser.error(str(exc))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,306 @@
"""Pinned, paired release measurements using the existing workflow benchmark."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import statistics
import subprocess
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import yaml
from .oracle_assets import capture_task_oracles
from .runner_tasks import normalized_model_identifier, select_tasks
from .task_assets import DEPENDENCY_CONTENT_BINDING_FIELD
SCHEMA = "gitnexus.release-evaluation/v1"
ARMS = ("baseline_nomcp", "baseline")
TASKS = Path(__file__).with_name("tasks.scenarios.yaml")
META_FIELDS = ("runtime_sha", "harness_sha", "task_set_digest", "model", "effort", "runs", "tasks")
ROW_FIELDS = (
"task",
"run",
"arm",
"model",
"effort",
"task_base_sha",
"oracle_digest",
"sandbox_backend",
DEPENDENCY_CONTENT_BINDING_FIELD,
"ok",
"resolved",
"authored_tests_passed",
"oracle_passed",
"error_kind",
"transcript_missing",
"cost_usd",
"duration_s",
"input_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
"output_tokens",
)
def suite_binding(path: Path = TASKS) -> tuple[list[dict[str, Any]], str, dict[str, Any]]:
tasks, _ = select_tasks(yaml.safe_load(path.read_text())["tasks"], include_expensive=True)
snapshots = capture_task_oracles(tasks)
definitions = []
pins = {}
for task, oracle in zip(tasks, snapshots, strict=True):
if not re.fullmatch(r"[0-9a-f]{40}", task.get("ref", "")):
raise ValueError(f"release task {task['id']} must pin a full commit SHA")
definitions.append({**{k: v for k, v in task.items() if k != "repo"}, "oracle_digest": oracle.digest})
pins[task["id"]] = {"sha": task["ref"], "oracle_digest": oracle.digest}
digest = hashlib.sha256(json.dumps(definitions, sort_keys=True).encode()).hexdigest()
return tasks, digest, pins
def pinned_task_sha(tasks: list[dict[str, Any]]) -> str:
"""The one task commit whose built checkout supplies every task's dependencies."""
refs = {task["ref"] for task in tasks}
if len(refs) != 1:
raise ValueError("release tasks must share one pinned commit so both runtimes grade one dependency checkout")
return refs.pop()
def _finite_nonnegative(value: Any) -> bool:
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
return False
try:
return math.isfinite(value)
except OverflowError: # JSON integers can exceed float range.
return False
def _json_safe(value: Any) -> Any:
"""Replace non-finite floats at any depth so failure receipts still serialize."""
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def build_report(rows: list[Any], metadata: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]:
report = {key: metadata[key] for key in META_FIELDS}
report.update(schema=SCHEMA, generated_at=(now or datetime.now(UTC)).isoformat())
runs, tasks = report["runs"], report["tasks"]
if type(runs) is not int or runs < 3 or not tasks:
raise ValueError("release evaluation requires tasks and at least three runs per arm")
normalized_model_identifier(report["model"])
for field in ("runtime_sha", "harness_sha"):
if not re.fullmatch(r"[0-9a-f]{40}", report[field]):
raise ValueError(f"invalid {field}")
problems = []
measured = {}
safe_rows = []
expected = {(task, run, arm) for task in tasks for run in range(runs) for arm in ARMS}
for index, row in enumerate(rows):
if not isinstance(row, dict):
problems.append(f"row {index}: invalid measurement")
continue
# Invalid rows still need a public, JSON-serializable failure receipt.
safe_rows.append({key: _json_safe(row[key]) for key in ROW_FIELDS if key in row})
task, run, arm = row.get("task"), row.get("run"), row.get("arm")
if not isinstance(task, str) or type(run) is not int or not isinstance(arm, str):
problems.append(f"row {index}: invalid cell identity")
continue
key = task, run, arm
if key not in expected or key in measured:
problems.append(f"row {index}: unexpected or duplicate cell")
continue
measured[key] = row
pin = tasks[task]
valid = (
row.get("task_base_sha") == pin["sha"]
and row.get("oracle_digest") == pin["oracle_digest"]
and row.get("model") == report["model"]
and row.get("effort") == report["effort"]
and row.get("sandbox_backend") == "bwrap"
and row.get("ok") is True
and type(row.get("resolved")) is bool
and type(row.get("authored_tests_passed")) is bool
and type(row.get("oracle_passed")) is bool
and row["resolved"] == (row["authored_tests_passed"] and row["oracle_passed"])
and row.get("error_kind") in (None, "verify-failed", "oracle-failed")
and row.get("transcript_missing") is False
and _finite_nonnegative(row.get("cost_usd"))
and _finite_nonnegative(row.get("duration_s"))
and isinstance(row.get(DEPENDENCY_CONTENT_BINDING_FIELD), str)
and re.fullmatch(r"[0-9a-f]{64}", row[DEPENDENCY_CONTENT_BINDING_FIELD]) is not None
)
if not valid:
problems.append(f"{task}/{arm}/{run}: untrustworthy measurement")
if expected != set(measured):
problems.append(f"missing {len(expected - set(measured))} paired cell(s)")
report.update(complete=not problems, problems=problems, per_run=safe_rows, arms={}, paired={})
if problems:
return report
for arm in ARMS:
arm_rows = [measured[task, run, arm] for task in tasks for run in range(runs)]
report["arms"][arm] = {
"cells": len(arm_rows),
"solved": sum(row["resolved"] for row in arm_rows),
"mean_cost_usd": statistics.mean(row["cost_usd"] for row in arm_rows),
"mean_wall_s": statistics.mean(row["duration_s"] for row in arm_rows),
}
baseline, tool = (report["arms"][arm] for arm in ARMS)
report["paired"] = {"solve_delta": tool["solved"] - baseline["solved"]}
for metric, label in (("mean_cost_usd", "cost"), ("mean_wall_s", "wall")):
report["paired"][f"mean_{label}_change_pct"] = (
100 * (tool[metric] - baseline[metric]) / baseline[metric] if baseline[metric] else None
)
return report
def render_markdown(report: dict[str, Any]) -> str:
lines = [
"## Agent evaluation",
"",
f"Runtime `{report['runtime_sha']}`; harness `{report['harness_sha']}`.",
f"Model `{report['model']}`, effort `{report['effort']}`, n={report['runs']} per task per arm.",
"No-GitNexus `baseline_nomcp` is compared with MCP `baseline`; neither uses workflow skills.",
"",
]
if not report["complete"]:
lines += ["**Incomplete evidence; release validation failed.**", "", *[f"- {p}" for p in report["problems"]]]
else:
lines += [
"| Arm | Hidden tests + authored checks | Mean cost | Mean agent wall time |",
"| --- | --- | --- | --- |",
]
for arm, values in report["arms"].items():
lines.append(
f"| {arm} | {values['solved']}/{values['cells']} | ${values['mean_cost_usd']:.4f} | {values['mean_wall_s']:.1f}s |"
)
paired = report["paired"]
lines += ["", f"Paired solve delta: {paired['solve_delta']:+d}."]
for metric in ("cost", "wall"):
value = paired[f"mean_{metric}_change_pct"]
lines.append(
f"Mean {metric} change vs no GitNexus: {'unavailable (zero baseline)' if value is None else f'{value:+.1f}%'}."
)
lines += [
"",
"Negative agent outcomes remain in the denominator. Indexing and grading are excluded from agent cost/time.",
"The MCP arm also has readable compiled runtime code; this comparison includes that access and cannot isolate tool assistance from access to later fixes.",
"This small pinned task set is release evidence; it does not establish general gains or statistical significance.",
"",
]
return "\n".join(lines)
def validate_report(
report: dict[str, Any],
*,
runtime_sha: str,
task_set_digest: str,
task_pins: dict[str, Any] | None = None,
now: datetime | None = None,
) -> None:
if report.get("schema") != SCHEMA or report.get("complete") is not True:
raise ValueError("incomplete or unsupported release evidence")
if report.get("runtime_sha") != runtime_sha or report.get("task_set_digest") != task_set_digest:
raise ValueError("release evidence belongs to a different runtime or task set")
if task_pins is not None and report.get("tasks") != task_pins:
raise ValueError("release task pins do not match the measured task set")
measured_at = datetime.fromisoformat(report["generated_at"])
if measured_at.tzinfo is None or not timedelta(0) <= (now or datetime.now(UTC)) - measured_at <= timedelta(days=7):
raise ValueError("release evidence must be at most seven days old and not future-dated")
rebuilt = build_report(report["per_run"], report, now=measured_at)
if not rebuilt["complete"] or any(rebuilt[key] != report[key] for key in ("arms", "paired", "problems")):
raise ValueError("release summary does not match its measured cells")
# Publication copies this document verbatim, so it must be exactly the
# field-whitelisted rebuild: no extra top-level or per-run fields.
# Compare serialized JSON, not Python values: True == 1 and 1.0 == 1 there.
if json.dumps(rebuilt, allow_nan=False, sort_keys=True) != json.dumps(report, allow_nan=False, sort_keys=True):
raise ValueError("release evidence carries fields outside the published report schema")
def _git_sha(repo: Path) -> str:
return subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, text=True, capture_output=True
).stdout.strip()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
prepare = commands.add_parser("prepare")
prepare.add_argument("--repo", required=True, type=Path, help="runtime under test (recorded as runtime_sha)")
prepare.add_argument(
"--task-repo",
required=True,
type=Path,
help="built checkout of the pinned task commit; supplies every task's source and dependencies",
)
prepare.add_argument("--out", required=True, type=Path)
prepare.add_argument("--model", required=True)
prepare.add_argument("--effort", required=True)
prepare.add_argument("--runs", type=int, default=3)
summarize = commands.add_parser("summarize")
summarize.add_argument("--results", required=True, type=Path)
summarize.add_argument("--metadata", required=True, type=Path)
summarize.add_argument("--out", required=True, type=Path)
check = commands.add_parser("check")
check.add_argument("--evidence", required=True, type=Path)
check.add_argument("--runtime-sha", required=True)
commands.add_parser("task-sha", help="print the pinned task commit for the dependency checkout")
args = parser.parse_args()
if args.command == "prepare":
if args.runs < 3:
parser.error("release evaluation requires at least three runs per arm")
tasks, digest, pins = suite_binding()
# Candidate and stable must grade identical task source and toolchains;
# the runtime under test is supplied only through runner --gitnexus-root.
if _git_sha(args.task_repo) != pinned_task_sha(tasks):
parser.error("--task-repo must be checked out at the pinned task commit")
args.out.mkdir(parents=True, exist_ok=True)
for task in tasks:
task["repo"] = str(args.task_repo.resolve())
(args.out / "tasks.yaml").write_text(yaml.safe_dump({"tasks": tasks}, sort_keys=False))
metadata = dict(
runtime_sha=_git_sha(args.repo),
harness_sha=_git_sha(Path(__file__).resolve().parents[2]),
task_set_digest=digest,
model=normalized_model_identifier(args.model),
effort=args.effort,
runs=args.runs,
tasks=pins,
)
(args.out / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
elif args.command == "summarize":
rows = []
for line in args.results.read_text().splitlines() if args.results.is_file() else []:
if not line.strip():
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
rows.append(None) # A torn/invalid row is incomplete evidence.
report = build_report(rows, json.loads(args.metadata.read_text()))
args.out.mkdir(parents=True, exist_ok=True)
(args.out / "agent-evaluation.json").write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
(args.out / "agent-evaluation.md").write_text(render_markdown(report))
if not report["complete"]:
raise SystemExit(1)
elif args.command == "task-sha":
print(pinned_task_sha(suite_binding()[0]))
else:
_, digest, pins = suite_binding()
report = json.loads(args.evidence.read_text())
validate_report(report, runtime_sha=args.runtime_sha, task_set_digest=digest, task_pins=pins)
print("Pinned paired agent evidence verified.")
if __name__ == "__main__":
main()

View file

@ -106,6 +106,7 @@ from .review_scoring import (
parse_review_output,
score_review,
)
from .baseline_guidance import GITNEXUS_UNAVAILABLE_NOTE
from .proposer_sandbox import (
SANDBOX_GITNEXUS as SANDBOX_GITNEXUS,
SANDBOX_GITNEXUS_REGISTRY,
@ -469,13 +470,10 @@ def run_arm(
model=args.model,
build_sandbox_environment=environment_builder,
)
# --bare hard-disables the Skill tool and every mcp__* tool — by Claude
# Code design, not a bug (--allowedTools can't restore what --bare
# removes). Every arm except baseline_nomcp needs Skill and/or MCP tools,
# so only baseline_nomcp can keep --bare's tighter isolation; the rest
# rely on ANTHROPIC_API_KEY alone (the sandboxed HOME has no OAuth/
# keychain state to conflict with it).
bare = arm == "baseline_nomcp"
# All arms load ordinary repository context (CLAUDE.md) through the same
# startup mode. The no-MCP arm instead disables skills/commands and
# restricts its exact tool surface, with an empty MCP config and no
# supplied graph/runtime/CLI mounts.
progress_label = transcript_output_prefix or f"{task.get('id', 'task')}-{arm}"
common = {
"progress_label": progress_label,
@ -491,10 +489,9 @@ def run_arm(
read_only_paths=_evaluated_skill_roots(worktree, arm),
),
"require_pid_namespace": getattr(sandbox, "require_pid_namespace", True),
"bare": bare,
"settings_json": sandbox.settings_json,
"strict_mcp_config": True,
"mcp_config_json": host_text(sandbox_mcp_config()),
"mcp_config_json": host_text(sandbox_mcp_config(unsafe_host=backend == "host-unsafe")),
"transcript_projects": sandbox.transcript_projects,
"transcript_cwd": Path(SANDBOX_WORKSPACE),
"transcript_wait_seconds": 5,
@ -642,16 +639,16 @@ def run_arm(
# question: no skills AND no graph tools.
sessions.append(
run_claude(
BASELINE_PROMPT.format(task=task["prompt"]),
BASELINE_PROMPT.format(task=task["prompt"]) + "\n\n" + GITNEXUS_UNAVAILABLE_NOTE,
worktree,
disallowed_tools=["Skill", "mcp__gitnexus"],
**{
**common,
"disable_slash_commands": True,
"mcp_config_json": '{"mcpServers":{}}',
"allowed_tools": allowed_agent_tools(
implementation=True,
include_mcp=False,
),
"allowed_tools": [
tool for tool in allowed_agent_tools(implementation=True, include_mcp=False) if tool != "Skill"
],
},
)
)
@ -1190,13 +1187,16 @@ def run_cell(ctx: TaskCellContext, run_idx: int, arm: str) -> dict[str, Any]:
worktree: Path | None = None
record: dict[str, Any] | None = None
cleanup_error: OSError | None = None
execution_arm = CANDIDATE_ARMS.get(arm, arm)
gitnexus_available = execution_arm != "baseline_nomcp"
try:
if ctx.asset_snapshot_error is not None:
raise RuntimeError(f"task asset snapshot preparation failed: {ctx.asset_snapshot_error}")
if ctx.graph_snapshot_error is not None:
raise RuntimeError(f"sanitized graph snapshot preparation failed: {ctx.graph_snapshot_error}")
if ctx.graph_snapshot is None:
raise RuntimeError("sanitized graph snapshot is unavailable")
if gitnexus_available:
if ctx.graph_snapshot_error is not None:
raise RuntimeError(f"sanitized graph snapshot preparation failed: {ctx.graph_snapshot_error}")
if ctx.graph_snapshot is None:
raise RuntimeError("sanitized graph snapshot is unavailable")
if ctx.asset_snapshot is None:
raise RuntimeError("task asset snapshot is unavailable")
if ctx.clone_template is not None:
@ -1207,15 +1207,20 @@ def run_cell(ctx: TaskCellContext, run_idx: int, arm: str) -> dict[str, Any]:
else:
worktree = make_worktree(ctx.repo, ctx.task_sha, ctx.trees_dir)
sanitized_head = sanitize_clone_for_hidden_oracles(worktree)
ctx.graph_snapshot.materialize(worktree, sanitized_head=sanitized_head)
if gitnexus_available:
assert ctx.graph_snapshot is not None
ctx.graph_snapshot.materialize(worktree, sanitized_head=sanitized_head)
dependency_mounts = stage_task_assets(
task,
repo=ctx.repo,
clone=worktree,
snapshot=ctx.asset_snapshot,
)
registry_mount = isolated_gitnexus_registry_mount(worktree, ctx.trees_dir)
execution_arm = CANDIDATE_ARMS.get(arm, arm)
gitnexus_mounts = (
[*ctx.runtime_mounts, isolated_gitnexus_registry_mount(worktree, ctx.trees_dir)]
if gitnexus_available
else []
)
ce_mounts = ce_plugin_mounts_for_arm(execution_arm, ctx.ce_plugin_snapshot)
with prepare_sandbox(
clone=worktree,
@ -1223,12 +1228,12 @@ def run_cell(ctx: TaskCellContext, run_idx: int, arm: str) -> dict[str, Any]:
bwrap_bin=ctx.bwrap_bin,
read_only_mounts=[
*dependency_mounts,
*ctx.runtime_mounts,
registry_mount,
*gitnexus_mounts,
*ce_mounts,
],
preflight=False,
backend=ctx.sandbox_backend,
gitnexus_available=gitnexus_available,
) as sandbox:
# Capture the BASE (pre-overlay) skill digest — identical
# for the incumbent and candidate arms — then run the
@ -1236,12 +1241,12 @@ def run_cell(ctx: TaskCellContext, run_idx: int, arm: str) -> dict[str, Any]:
# candidate overlay is applied only afterwards, so setup
# can never observe candidate prose and both arms share
# byte-identical pre-overlay state.
# Historical review SHAs may predate gitnexus-review. Seed
# the current evaluated skill first so fingerprinting and
# the model see the same incumbent prose on every case.
if execution_arm == "review":
# Historical task SHAs may predate the selected release skills.
# Seed them before fingerprinting/setup so every task evaluates
# the same release prose, with candidate overlays applied later.
if execution_arm in ("review", "workflow", "workflow_direct"):
seed_evaluated_skills(
HARNESS_ROOT,
getattr(args, "gitnexus_root", None) or HARNESS_ROOT,
worktree,
sandbox=sandbox,
arm=execution_arm,
@ -1413,9 +1418,11 @@ def run_cell(ctx: TaskCellContext, run_idx: int, arm: str) -> dict[str, Any]:
"sandbox_dependency_manifest_digest": (
ctx.asset_snapshot.dependency_manifest_digest if ctx.asset_snapshot is not None else None
),
"sanitized_graph_snapshot_digest": (ctx.graph_snapshot.digest if ctx.graph_snapshot is not None else None),
"sanitized_graph_snapshot_digest": (
ctx.graph_snapshot.digest if gitnexus_available and ctx.graph_snapshot is not None else None
),
"sanitized_graph_manifest_digest": (
ctx.graph_snapshot.manifest_digest if ctx.graph_snapshot is not None else None
ctx.graph_snapshot.manifest_digest if gitnexus_available and ctx.graph_snapshot is not None else None
),
"oracle_digest": ctx.oracle_snapshot.digest,
"oracle_command_digest": ctx.oracle_snapshot.command_digest,
@ -1947,6 +1954,12 @@ def build_parser() -> argparse.ArgumentParser:
],
)
parser.add_argument("--claude-bin", default="claude")
parser.add_argument(
"--gitnexus-root",
type=Path,
default=HARNESS_ROOT,
help="checkout supplying the pinned GitNexus runtime and evaluated skills (defaults to the harness checkout)",
)
parser.add_argument(
"--ce-plugin-dir",
type=Path,
@ -2130,7 +2143,7 @@ def main() -> None:
bwrap_bin = preflight_bubblewrap()
sandbox_backend = "bwrap"
require_claude_sandbox_helpers()
runtime_mounts = trusted_gitnexus_runtime_mounts()
runtime_mounts = trusted_gitnexus_runtime_mounts(root=args.gitnexus_root)
except SandboxError as exc:
parser.error(str(exc))
raise AssertionError("ArgumentParser.error() returned unexpectedly")
@ -2179,7 +2192,7 @@ def _comparator_reuse_expectation(
for arm in args.arms:
execution = CANDIDATE_ARMS.get(arm, arm)
if execution in EVALUATED_ARM_SKILLS:
skill_digests[arm] = skill_fingerprint(HARNESS_ROOT, execution)
skill_digests[arm] = skill_fingerprint(getattr(args, "gitnexus_root", None) or HARNESS_ROOT, execution)
else:
skill_digests[arm] = None
task_locks: dict[str, TaskReuseBinding] = {}

View file

@ -5,8 +5,10 @@ from __future__ import annotations
import hashlib
import os
import re
import shlex
import shutil
import stat
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
@ -467,6 +469,10 @@ def _prepare_untracked_for_diff(sandbox: SandboxSession) -> None:
_sandbox_git(sandbox, ["add", "--intent-to-add", "-A"])
def _synthetic_guidance_exclusions(sandbox: SandboxSession) -> list[str]:
return [f":(exclude,literal){path}" for path in sandbox.synthetic_guidance_paths]
def implementation_diff_digest(
sandbox: SandboxSession,
orig_sha: str,
@ -479,10 +485,11 @@ def implementation_diff_digest(
raise ValueError(f"unsafe git object id: {orig_sha!r}")
if prepare_untracked:
_prepare_untracked_for_diff(sandbox)
exclusions = " ".join(shlex.quote(path) for path in _synthetic_guidance_exclusions(sandbox))
command = (
"/usr/bin/git -c core.fsmonitor=false diff --no-ext-diff --no-textconv --binary "
f"{orig_sha} -- . ':(exclude)docs/plans' ':(exclude).claude/skills' "
"| /usr/bin/sha256sum"
f"{exclusions} | /usr/bin/sha256sum"
)
result = sandbox.run(
["/bin/sh", "-c", command],
@ -519,6 +526,7 @@ def diff_churn(
".",
":(exclude)docs/plans",
":(exclude).claude/skills",
*_synthetic_guidance_exclusions(sandbox),
],
)
return parse_shortstat(output)
@ -595,11 +603,32 @@ if returncode:
"--",
".",
":(exclude).wfbench-artifact-*",
*_synthetic_guidance_exclusions(sandbox),
]
result = sandbox.run(command, timeout=60, env=build_sandbox_environment())
if not result.ok:
raise ManagedProcessError(command, result)
return _bounded_regular_bytes(patch, limit=MAX_PATCH_BYTES)
primary: BaseException | None = None
try:
result = sandbox.run(command, timeout=60, env=build_sandbox_environment())
if not result.ok:
raise ManagedProcessError(command, result)
return _bounded_regular_bytes(patch, limit=MAX_PATCH_BYTES)
except BaseException as exc:
primary = exc
raise
finally:
# The returned bytes are the evidence; this temporary sink must not
# become an agent edit in later Git diffs or repeated captures.
try:
shutil.rmtree(artifact_dir)
except OSError as cleanup:
# A failed removal must neither discard the patch already read nor
# replace the real failure. The leftover directory is excluded from
# the patch pathspec above and dies with the disposable clone, so
# it cannot corrupt the evidence; still report it rather than hide it.
detail = f"patch artifact cleanup failed: {artifact_dir}: {type(cleanup).__name__}: {cleanup}"
if primary is not None:
primary.add_note(detail)
else:
print(f"warning: {detail}", file=sys.stderr, flush=True)
def enforce_work_evidence(

View file

@ -26,6 +26,7 @@ from .proposer_sandbox import (
SANDBOX_TMP,
SANDBOX_WORKSPACE,
SandboxError,
mcp_sandbox_prefix,
redact_text,
)
@ -365,29 +366,33 @@ GITNEXUS_MUTATING_TOOLS = ("mcp__gitnexus__rename",)
BUILTIN_AGENT_TOOLS = ("Read", "Grep", "Glob", "Edit", "Write", "Bash", "Skill")
def sandbox_mcp_config() -> str:
"""Credential-free MCP configuration using only the pinned harness runtime."""
def sandbox_mcp_config(*, unsafe_host: bool = False) -> str:
"""Launch the pinned MCP runtime behind its own read-only boundary.
The explicitly unsafe diagnostic backend retains its credential-free host
launch; it cannot establish nested namespaces or produce release evidence.
"""
entrypoint = PurePosixPath(SANDBOX_GITNEXUS_ENTRYPOINT)
workspace = PurePosixPath(SANDBOX_WORKSPACE)
if not entrypoint.is_absolute() or entrypoint == workspace or workspace in entrypoint.parents:
raise SandboxError(f"GitNexus MCP executable must stay outside {SANDBOX_WORKSPACE}")
prefix = mcp_sandbox_prefix() if not unsafe_host else [
"/usr/bin/env", "-i",
f"HOME={SANDBOX_HOME}", f"TMPDIR={SANDBOX_TMP}",
f"GITNEXUS_HOME={SANDBOX_GITNEXUS_REGISTRY}",
f"GITNEXUS_MCP_ALLOWED_REPOS={SANDBOX_WORKSPACE}",
f"GITNEXUS_MCP_DEFAULT_REPO={SANDBOX_WORKSPACE}",
"PATH=/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8", "GIT_TERMINAL_PROMPT=0",
]
config = {
"mcpServers": {
"gitnexus": {
"type": "stdio",
"command": "/usr/bin/env",
"command": prefix[0],
"args": [
"-i",
f"HOME={SANDBOX_HOME}",
f"TMPDIR={SANDBOX_TMP}",
f"GITNEXUS_HOME={SANDBOX_GITNEXUS_REGISTRY}",
f"GITNEXUS_MCP_ALLOWED_REPOS={SANDBOX_WORKSPACE}",
f"GITNEXUS_MCP_DEFAULT_REPO={SANDBOX_WORKSPACE}",
"PATH=/usr/local/bin:/usr/bin:/bin",
"LANG=C.UTF-8",
"GIT_TERMINAL_PROMPT=0",
*prefix[1:],
SANDBOX_NODE,
SANDBOX_GITNEXUS_ENTRYPOINT,
"mcp",
@ -404,11 +409,12 @@ def allowed_agent_tools(
include_mcp: bool = True,
allow_edit: bool = True,
) -> list[str]:
# Retain the caller parameter for compatibility; all MCP phases are
# read-only so only the agent's own tools can author credited changes.
del implementation
tools = [tool for tool in BUILTIN_AGENT_TOOLS if allow_edit or tool != "Edit"]
if include_mcp:
tools.extend(GITNEXUS_READ_ONLY_TOOLS)
if include_mcp and implementation:
tools.extend(GITNEXUS_MUTATING_TOOLS)
return tools
@ -627,7 +633,6 @@ def run_claude(
expected_skill: str | None = None,
command_prefix: list[str] | None = None,
require_pid_namespace: bool = False,
bare: bool = False,
settings_json: str | None = None,
strict_mcp_config: bool = False,
allowed_tools: list[str] | None = None,
@ -658,8 +663,6 @@ def run_claude(
"stream-json",
"--verbose",
]
if bare:
cmd.append("--bare")
for plugin_dir in plugin_dirs:
cmd += ["--plugin-dir", plugin_dir]
if settings_json is not None:
@ -667,13 +670,11 @@ def run_claude(
if strict_mcp_config:
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
if allowed_tools:
# --bare's own hard-coded Bash/Edit/Read ceiling already scopes bare
# sessions; outside --bare the built-in toolset defaults to
# everything (subagents, WebFetch, Task, ...), so --tools is needed
# to actually restrict it — --allowedTools only pre-approves within
# whatever set is available, it does not narrow that set.
if not bare:
cmd += ["--tools", *allowed_tools]
# The built-in toolset defaults to everything (subagents, WebFetch,
# Task, ...), so --tools is needed to actually restrict it --
# --allowedTools only pre-approves within whatever set is available,
# it does not narrow that set.
cmd += ["--tools", *allowed_tools]
cmd += ["--allowedTools", *allowed_tools]
if disable_slash_commands:
cmd.append("--disable-slash-commands")

View file

@ -197,6 +197,7 @@ def _validated_runtime_component(
*,
directory: bool,
allow_primary_worktree_symlink: bool = False,
checkout_root: Path | None = None,
) -> ReadOnlyMount:
"""Validate one direct runtime component before exposing only that path."""
@ -208,7 +209,7 @@ def _validated_runtime_component(
except OSError as exc:
raise SandboxError(f"pinned GitNexus runtime component is unavailable: {source}: {exc}") from exc
if stat.S_ISLNK(mode) and allow_primary_worktree_symlink and directory:
primary = _primary_checkout_root(HARNESS_ROOT)
primary = _primary_checkout_root(checkout_root if checkout_root is not None else HARNESS_ROOT)
if primary is None:
raise SandboxError(f"pinned GitNexus runtime component must be a real {kind}: {source}")
expected = primary / "gitnexus" / relative
@ -235,15 +236,16 @@ def _validated_runtime_component(
return ReadOnlyMount(source=source, target=target)
def trusted_gitnexus_runtime_mounts() -> tuple[ReadOnlyMount, ...]:
def trusted_gitnexus_runtime_mounts(*, root: Path | None = None) -> tuple[ReadOnlyMount, ...]:
"""Expose only the files needed by the pinned CLI and linked shared package."""
checkout = _validated_runtime_root(root if root is not None else HARNESS_ROOT, label="pinned GitNexus checkout")
runtime = _validated_runtime_root(
HARNESS_ROOT / "gitnexus",
checkout / "gitnexus",
label="pinned GitNexus runtime",
)
shared = _validated_runtime_root(
HARNESS_ROOT / "gitnexus-shared",
checkout / "gitnexus-shared",
label="pinned GitNexus shared runtime",
)
node_modules = _validated_runtime_component(
@ -252,8 +254,9 @@ def trusted_gitnexus_runtime_mounts() -> tuple[ReadOnlyMount, ...]:
f"{SANDBOX_GITNEXUS}/node_modules",
directory=True,
allow_primary_worktree_symlink=True,
checkout_root=checkout,
)
primary = _primary_checkout_root(HARNESS_ROOT)
primary = _primary_checkout_root(checkout)
if primary is not None:
try:
primary_shared = _validated_runtime_root(

View file

@ -32,24 +32,20 @@ from .proposer_sandbox import (
ReadOnlyMount,
SandboxError,
_prepare_clone_target,
_real_directory,
real_directory,
)
# The shipped index is roughly 428 MiB. These are containment limits rather
# than expected-size assertions: they admit normal growth while preventing a
# task declaration from turning snapshot preparation into an unbounded walk.
# These are containment limits rather than expected-size assertions: they
# prevent a task declaration from turning snapshot preparation into an
# unbounded walk.
MAX_TASK_ASSET_ENTRIES = 100_000
MAX_TASK_ASSET_PATH_BYTES = 4_096
MAX_TASK_ASSET_BYTES = 2 * 1024 * 1024 * 1024
# The largest known real sandbox_copy asset in this harness is the shipped
# index above (~428 MiB estimated, ~290 MiB measured); budget comfortably
# above that so it can still materialize via buffered copy on a filesystem
# that cannot reflink (ext4 CI runners, 9p-backed dev mounts), while staying
# well below MAX_TASK_ASSET_BYTES so a genuinely oversized or malformed
# declaration still fails closed instead of silently paying for a slow full
# copy.
MAX_BUFFERED_FALLBACK_BYTES = 512 * 1024 * 1024
# Every accepted snapshot must also be materializable on filesystems without
# reflinks. Use the existing hard capture ceiling, so buffered copies stay
# bounded without imposing a filesystem-dependent acceptance limit.
MAX_BUFFERED_FALLBACK_BYTES = MAX_TASK_ASSET_BYTES
COPY_CHUNK_BYTES = 1024 * 1024
# linux/fs.h: #define FICLONE _IOW(0x94, 9, int)
@ -130,8 +126,8 @@ class TaskAssetSnapshot:
def materialize(self, clone: Path) -> None:
"""Replace every declared ``sandbox_copy`` root with its exact snapshot tree."""
clone = _real_directory(clone, label="asset-staging clone")
snapshot_root = _real_directory(self.root / "sandbox-copy", label="task asset snapshot")
clone = real_directory(clone, label="asset-staging clone")
snapshot_root = real_directory(self.root / "sandbox-copy", label="task asset snapshot")
staging = Path(tempfile.mkdtemp(prefix=".wfbench-assets-", dir=clone.parent))
fallback_bytes = 0
try:
@ -160,8 +156,8 @@ class TaskAssetSnapshot:
def dependency_mounts(self, clone: Path) -> list[ReadOnlyMount]:
"""Mount only immutable captured dependency roots into an arm clone."""
clone = _real_directory(clone, label="dependency clone")
snapshot_root = _real_directory(self.root, label="task asset snapshot")
clone = real_directory(clone, label="dependency clone")
snapshot_root = real_directory(self.root, label="task asset snapshot")
mounts: list[ReadOnlyMount] = []
for dependency in self.dependencies:
source = snapshot_root / Path(*dependency.snapshot_path.parts)
@ -219,7 +215,7 @@ class TaskAssetCache:
if self._closed:
raise SandboxError("task asset cache is already closed")
repo_identity = _real_directory(repo, label="task asset repository")
repo_identity = real_directory(repo, label="task asset repository")
declarations, relative_paths = _sandbox_copy_declarations(task)
dependency_declarations = _sandbox_dependency_declarations(task)
dependency_identity = tuple((declaration.source, declaration.target) for declaration in dependency_declarations)
@ -572,7 +568,7 @@ def _is_harness_sandbox_copy(relative: PurePosixPath) -> bool:
def _harness_sandbox_copy_root() -> Path:
return _real_directory(runtime_mounts.HARNESS_ROOT, label="harness sandbox_copy root")
return real_directory(runtime_mounts.HARNESS_ROOT, label="harness sandbox_copy root")
def _harness_sandbox_copy_identity(relative_paths: tuple[PurePosixPath, ...]) -> str:
@ -1114,7 +1110,7 @@ def stage_task_assets(
for containment tests and external callers.
"""
repo_identity = _real_directory(repo, label="task asset repository")
repo_identity = real_directory(repo, label="task asset repository")
declarations, _ = _sandbox_copy_declarations(task)
if snapshot is not None:
if snapshot.repo_identity != repo_identity or snapshot.declarations != declarations:

View file

@ -28,6 +28,8 @@
# cache only that sanitized graph for every arm. The target repository's
# prebuilt .gitnexus directory is never copied. Three declared dependency trees
# are mounted read-only; missing assets fail before model execution.
# Every task starts at the exact v1.6.12 commit, where all four tasks remain
# unsolved. In particular, moving main already contains the C include fix.
tasks:
# Overhead floor — measured 2026-07-11 (see README calibration): the
@ -36,7 +38,7 @@ tasks:
- id: trivial-status-json-alias
class: trivial
repo: ~/GitNexus
ref: main
ref: c4ecf398de637cf779ab2238e69958cfd5d601ce
sandbox_dependencies: &gitnexus_sandbox_dependencies
- source: node_modules
target: node_modules
@ -71,7 +73,7 @@ tasks:
- id: inv-bug-c-system-include
class: investigation-bug
repo: ~/GitNexus
ref: main
ref: c4ecf398de637cf779ab2238e69958cfd5d601ce
sandbox_dependencies: *gitnexus_sandbox_dependencies
prompt: >
Fix the C scope resolver so a system include such as <stdio.h> never
@ -95,7 +97,7 @@ tasks:
- id: inv-feature-list-repos-filter
class: investigation-feature
repo: ~/GitNexus
ref: main
ref: c4ecf398de637cf779ab2238e69958cfd5d601ce
sandbox_dependencies: *gitnexus_sandbox_dependencies
prompt: >
Add an optional "name_contains" filter parameter to the list_repos MCP
@ -121,12 +123,14 @@ tasks:
class: cross-module
expensive: true
repo: ~/GitNexus
ref: main
ref: c4ecf398de637cf779ab2238e69958cfd5d601ce
sandbox_dependencies: *gitnexus_sandbox_dependencies
prompt: >
Add bounded retry with backoff to the ingestion pipeline so a transient
parse-worker failure on a file is retried up to 2 times before the file
is marked failed, without retrying deterministic parse errors. Cover
is marked failed, without retrying deterministic parse errors. Treat
EAGAIN worker error messages/events and worker exit code 75 as transient;
SyntaxError messages/events and exit code 65 are deterministic. Cover
the retry/no-retry decision with unit tests in
gitnexus/test/unit/parse-retry.test.ts.
verify: cd gitnexus && npx tsc --noEmit && npx vitest run test/unit/parse-retry.test.ts

View file

@ -223,3 +223,22 @@ export {
stripGitSuffix,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
export type {
SourcePosition,
LookupPurpose,
NameClaim,
ScopeLookupPolicy,
NameLookupOptions,
} from './scope-resolution/types.js';
export {
lookupLexicalName,
nameClaimsFor,
selectClaimsAtScope,
rangesOverlap,
} from './scope-resolution/name-claims.js';
export type {
NameClaimResult,
NameClaimSources,
ScopeClaimSelection,
} from './scope-resolution/name-claims.js';

View file

@ -488,13 +488,14 @@ function makeEdgeDrafts(
workspace: WorkspaceIndex,
): ImportEdgeDraft[] {
const fromScope =
hooks.importsBindAtLexicalScope === true
(parsed.bindsAtLexicalScope ?? hooks.importsBindAtLexicalScope) === true
? (parsed.declaredAtScope ?? file.moduleScope)
: file.moduleScope;
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
if (parsed.kind === 'dynamic-unresolved') {
const base: ImportEdge = {
localName: parsed.localName,
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
targetFile: null,
targetExportedName: '',
kind: 'dynamic-unresolved',
@ -522,6 +523,7 @@ function makeEdgeDrafts(
if (targetFile === null) {
const base: ImportEdge = {
localName: extractLocalName(parsed),
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
targetFile: null,
targetExportedName: extractExportedName(parsed),
kind: edgeKindFor(parsed),
@ -560,6 +562,7 @@ function makeEdgeDrafts(
return targetFiles.map((tf) => {
const base: ImportEdge = {
localName: extractLocalName(parsed),
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
targetFile: tf,
targetExportedName: extractExportedName(parsed),
kind:
@ -611,22 +614,25 @@ function typeOnlyFor(parsed: ParsedImport): { typeOnly?: true } {
}
/**
* Re-carry both runtime-presence flags from an existing edge onto a derived
* one.
* Carry runtime-presence flags and source provenance onto a derived edge.
*
* `expandWildcard` builds each `wildcard-expanded` edge from scratch rather
* than spreading the source (three fields differ per exported name), so every
* field it does not name is dropped. That is exactly how both flags were lost
* once already. Naming the pair here keeps "these two travel together" in one
* place, so a third presence flag is added in one place too.
* field it does not name is dropped. Keep presence flags and the source range
* together here so wildcard expansion preserves both runtime policy and the
* location used to select the owning import claim.
*/
function carriedPresenceFlags(edge: Pick<ImportEdge, 'typeOnly' | 'runsOnlyWhenCalled'>): {
function carriedPresenceFlags(
edge: Pick<ImportEdge, 'typeOnly' | 'runsOnlyWhenCalled' | 'atRange'>,
): {
typeOnly?: true;
runsOnlyWhenCalled?: true;
atRange?: ImportEdge['atRange'];
} {
return {
...(edge.typeOnly === true ? { typeOnly: true } : {}),
...(edge.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {}),
...(edge.atRange !== undefined ? { atRange: edge.atRange } : {}),
};
}

View file

@ -0,0 +1,337 @@
/** Pure lexical ownership shared by registries, inference, and import provenance. */
import type {
BindingRef,
ImportEdge,
NameClaim,
NameLookupOptions,
Range,
Scope,
ScopeId,
ScopeLookup,
SourcePosition,
TypeRef,
} from './types.js';
export interface ScopeClaimSelection {
readonly status: 'absent' | 'blocked' | 'selected' | 'module';
readonly claims: readonly NameClaim[];
}
export interface NameClaimResult {
readonly status: 'absent' | 'blocked' | 'resolved';
readonly scope?: Scope;
readonly typeBinding?: TypeRef;
readonly bindings: readonly BindingRef[];
readonly imports: readonly ImportEdge[];
readonly claims: readonly NameClaim[];
}
export interface NameClaimSources {
readonly scopes: ScopeLookup;
readonly bindingsAt?: (scope: Scope, name: string) => readonly BindingRef[];
readonly importsAt?: (scope: Scope) => readonly ImportEdge[];
}
const ABSENT: NameClaimResult = Object.freeze({
status: 'absent',
bindings: [],
imports: [],
claims: [],
});
// Published scope arrays are readonly. Cache by the array, not the scope ID:
// finalization and cache hydration can replace a scope's import/claim snapshot.
const claimsByName = new WeakMap<readonly NameClaim[], ReadonlyMap<string, readonly NameClaim[]>>();
const importsByName = new WeakMap<
readonly ImportEdge[],
ReadonlyMap<string, readonly ImportEdge[]>
>();
const NO_CLAIMS: readonly NameClaim[] = Object.freeze([]);
const NO_IMPORTS: readonly ImportEdge[] = Object.freeze([]);
export function nameClaimsFor(scope: Scope, name: string): readonly NameClaim[] {
const claims = scope.nameClaims;
if (claims === undefined || claims.length === 0) return NO_CLAIMS;
let byName = claimsByName.get(claims);
if (byName === undefined) {
const indexed = new Map<string, NameClaim[]>();
for (const claim of claims) {
const bucket = indexed.get(claim.name);
if (bucket === undefined) indexed.set(claim.name, [claim]);
else bucket.push(claim);
}
byName = indexed;
claimsByName.set(claims, byName);
}
return byName.get(name) ?? NO_CLAIMS;
}
function importsFor(imports: readonly ImportEdge[], name: string): readonly ImportEdge[] {
if (imports.length === 0) return NO_IMPORTS;
let byName = importsByName.get(imports);
if (byName === undefined) {
const indexed = new Map<string, ImportEdge[]>();
for (const edge of imports) {
if (edge.kind === 'side-effect' || edge.kind === 'dynamic-resolved') continue;
const bucket = indexed.get(edge.localName);
if (bucket === undefined) indexed.set(edge.localName, [edge]);
else bucket.push(edge);
}
byName = indexed;
importsByName.set(imports, byName);
}
return byName.get(name) ?? NO_IMPORTS;
}
const compare = (a: SourcePosition, b: SourcePosition): number =>
a.startLine - b.startLine || a.startCol - b.startCol;
export function rangesOverlap(a: Range, b: Range): boolean {
return (
compare(a, { startLine: b.endLine, startCol: b.endCol }) < 0 &&
compare(b, { startLine: a.endLine, startCol: a.endCol }) < 0
);
}
/** Ownership is selected before target availability or target kind is inspected. */
export function selectClaimsAtScope(
scope: Scope,
name: string,
options: NameLookupOptions = {},
): ScopeClaimSelection {
const purpose = options.purpose ?? 'value';
const claims = nameClaimsFor(scope, name).filter(
(c) => c.purpose === undefined || c.purpose === 'both' || c.purpose === purpose,
);
if (claims.length === 0) return { status: 'absent', claims };
const active = claims.filter(
(c) =>
c.availableFrom === undefined ||
options.position === undefined ||
compare(c.availableFrom, options.position) <= 0,
);
if (active.length === 0) {
const first = claims.reduce((a, b) => (compare(a.range, b.range) <= 0 ? a : b));
return {
status:
first.inactive === 'outer' ? 'absent' : first.inactive === 'module' ? 'module' : 'blocked',
claims,
};
}
// Same-position alternatives retain ambiguity. Providers can mark uncertain
// rebinding explicitly blocked rather than claiming a control-flow winner.
const ordered = active.filter((c) => c.hoisted !== true);
const relevant = ordered.length > 0 ? ordered : active;
const latest = relevant.reduce((a, b) => (compare(a.range, b.range) >= 0 ? a : b)).range;
let selected = relevant.filter((c) => compare(c.range, latest) === 0);
if (selected.some((c) => c.merge === true))
selected = active.filter((c) => c.merge === true || compare(c.range, latest) === 0);
return {
status: selected.some((c) => c.kind === 'blocked' && c.redirect === undefined)
? 'blocked'
: 'selected',
claims: selected,
};
}
function moduleScope(start: Scope, scopes: ScopeLookup): Scope | undefined {
let scope: Scope | undefined = start;
const seen = new Set<ScopeId>();
while (scope !== undefined && !seen.has(scope.id)) {
if (scope.kind === 'Module') return scope;
seen.add(scope.id);
scope = scope.parent === null ? undefined : scopes.getScope(scope.parent);
}
return undefined;
}
function redirectScope(
scope: Scope,
name: string,
claim: NameClaim,
scopes: ScopeLookup,
): Scope | undefined {
if (claim.redirect === 'module') return moduleScope(scope, scopes);
let parent = scope.parent === null ? undefined : scopes.getScope(scope.parent);
const seen = new Set<ScopeId>();
while (parent !== undefined && !seen.has(parent.id)) {
seen.add(parent.id);
if (
parent.kind === 'Function' &&
(nameClaimsFor(parent, name).some((c) => c.redirect === undefined) ||
parent.lexicalNames?.has(name) ||
parent.bindings.has(name) ||
parent.typeBindings.has(name))
)
return parent;
parent = parent.parent === null ? undefined : scopes.getScope(parent.parent);
}
return undefined;
}
function matchesClaim(range: Range | undefined, claim: NameClaim): boolean {
return range === undefined || rangesOverlap(range, claim.range);
}
function matchesBindingClaim(range: Range | undefined, claim: NameClaim): boolean {
return (
range === undefined ||
(compare(claim.range, range) <= 0 &&
compare(
{ startLine: range.endLine, startCol: range.endCol },
{ startLine: claim.range.endLine, startCol: claim.range.endCol },
) <= 0)
);
}
export function lookupLexicalName(
startScope: ScopeId,
name: string,
sources: NameClaimSources,
options: NameLookupOptions = {},
): NameClaimResult {
let id: ScopeId | null = startScope;
let position = options.position;
let forcedClaims: readonly NameClaim[] | undefined;
const visited = new Set<ScopeId>();
while (id !== null && !visited.has(id)) {
visited.add(id);
const scope = sources.scopes.getScope(id);
if (scope === undefined) return { ...ABSENT, status: 'blocked' };
if (
scope.kind === 'Object' ||
(id !== startScope &&
((scope.lookupPolicy?.skipFromChildren &&
!scope.lookupPolicy.visibleNamesFromChildren?.includes(name)) ||
(options.skipEnclosingClasses && scope.kind === 'Class')))
) {
id = scope.lookupPolicy?.parentScope ?? scope.parent;
continue;
}
const selected: ScopeClaimSelection =
forcedClaims === undefined
? selectClaimsAtScope(scope, name, { ...options, position })
: { status: 'selected', claims: forcedClaims };
forcedClaims = undefined;
const redirected = selected.claims.find((c) => c.redirect !== undefined);
if (redirected !== undefined) {
const target = redirectScope(scope, name, redirected, sources.scopes);
if (target === undefined || target.id === id)
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
if (selected.status === 'selected' && redirected.kind !== 'blocked') {
forcedClaims = selected.claims.map((c) => ({ ...c, redirect: undefined }));
}
id = target.id;
position = undefined;
continue;
}
if (selected.status === 'module') {
const target = moduleScope(scope, sources.scopes);
if (target === undefined || target.id === id)
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
let skipped: Scope | undefined = scope;
const skippedIds = new Set<ScopeId>();
while (skipped !== undefined && skipped.id !== target.id && !skippedIds.has(skipped.id)) {
skippedIds.add(skipped.id);
if (skipped.lookupPolicy?.deferParentActivation) position = undefined;
skipped = skipped.parent === null ? undefined : sources.scopes.getScope(skipped.parent);
}
id = target.id;
continue;
}
if (selected.status === 'blocked')
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
const nameClaims = nameClaimsFor(scope, name);
const hasClaimsForName = nameClaims.length > 0;
const allBindings = sources.bindingsAt?.(scope, name) ?? scope.bindings.get(name) ?? [];
const allImports = importsFor(sources.importsAt?.(scope) ?? scope.imports ?? NO_IMPORTS, name);
let bindings = allBindings.filter(
(b) =>
b.availableFrom === undefined ||
position === undefined ||
compare(b.availableFrom, position) <= 0,
);
let imports = allImports;
if (selected.status === 'selected') {
bindings = bindings.filter((b) =>
selected.claims.some((c) =>
c.kind === 'import'
? b.via !== undefined && matchesClaim(b.via.atRange, c)
: c.kind === 'binding' &&
(b.origin === 'local' || b.declarationRange !== undefined) &&
matchesBindingClaim(b.declarationRange, c),
),
);
imports = allImports.filter((e) =>
selected.claims.some((c) => c.kind === 'import' && matchesClaim(e.atRange, c)),
);
} else if (hasClaimsForName) {
// A declaration in another namespace, or a not-yet-visible declaration,
// must not sneak back through the unfiltered legacy binding channels.
bindings = bindings.filter(
(b) =>
b.availableFrom !== undefined &&
b.declarationRange !== undefined &&
!nameClaims.some((c) => rangesOverlap(c.range, b.declarationRange!)),
);
imports = [];
}
const ownType = scope.typeBindings.get(name);
const typeFactOwnsName =
ownType !== undefined &&
!(scope.lookupPolicy?.nameClaimsComplete && ownType.source === 'assignment-inferred');
const owns =
selected.status === 'selected' ||
bindings.length > 0 ||
imports.length > 0 ||
scope.ownsReceivers?.has(name) === true ||
(!hasClaimsForName && (scope.lexicalNames?.has(name) === true || typeFactOwnsName));
if (owns) {
let typeBinding = ownType;
if (
selected.status === 'selected' &&
ownType?.bindingRange !== undefined &&
!selected.claims.some(
(c) =>
c.kind === 'import' ||
(c.kind === 'binding' && rangesOverlap(c.range, ownType.bindingRange!)),
)
) {
// A later assignment can infer the type of the already-owned binding.
// Check ownership after that fact binds its value, not at the producer's
// lookup position: a later shadowing let still reads the earlier binding
// in its initializer, but must not lend that binding its inferred type.
const factClaims =
ownType.source === 'constructor-inferred' || ownType.source === 'assignment-inferred'
? selectClaimsAtScope(scope, name, {
...options,
position: {
startLine: ownType.bindingRange.endLine,
startCol: ownType.bindingRange.endCol,
},
})
: undefined;
if (
factClaims?.status !== 'selected' ||
factClaims.claims.length !== selected.claims.length ||
!selected.claims.every(
(claim) => claim.kind === 'binding' && factClaims.claims.includes(claim),
)
)
typeBinding = undefined;
}
const usable = bindings.filter(
(b) => (options.purpose ?? 'value') !== 'value' || b.via?.typeOnly !== true,
);
return {
status:
usable.length > 0 ||
imports.some(
(e) => e.kind === 'namespace' && e.targetFile !== null && e.linkStatus !== 'unresolved',
)
? 'resolved'
: 'blocked',
scope,
typeBinding,
bindings: usable,
imports,
claims: selected.claims,
};
}
if (scope.lookupPolicy?.deferParentActivation) position = undefined;
id = scope.lookupPolicy?.parentScope ?? scope.parent;
}
return id === null ? ABSENT : { ...ABSENT, status: 'blocked' };
}

View file

@ -68,6 +68,12 @@ export type ReferenceKind =
export type CallForm = 'free' | 'member' | 'constructor' | 'index';
export interface ReferenceSite {
/** Graph caller for a synthesized invocation whose callable is looked up elsewhere.
* Never changes lexical resolution from `inScope` (for example FastAPI Depends). */
readonly callerScope?: ScopeId;
/** Lexical lookup anchor when it differs from the reference's structural owner. */
readonly lookupScope?: ScopeId;
readonly lookupPurpose?: import('./types.js').LookupPurpose;
/** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */
readonly name: string;
/**

View file

@ -24,7 +24,11 @@ export interface ClassRegistry {
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
* for the best answer.
*/
lookup(name: string, scope: ScopeId): readonly Resolution[];
lookup(
name: string,
scope: ScopeId,
options?: Pick<CoreLookupParams, 'lookupPosition' | 'lookupPurpose'>,
): readonly Resolution[];
}
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
@ -34,8 +38,12 @@ export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
ownerScopedContributor: null,
};
return {
lookup(name: string, scope: ScopeId) {
return lookupCore(name, scope, params, ctx);
lookup(
name: string,
scope: ScopeId,
options: Pick<CoreLookupParams, 'lookupPosition' | 'lookupPurpose'> = {},
) {
return lookupCore(name, scope, { ...params, lookupPurpose: 'type', ...options }, ctx);
},
};
}

View file

@ -17,7 +17,10 @@ import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { FIELD_KINDS } from './context.js';
export interface FieldLookupOptions {
export interface FieldLookupOptions extends Pick<
CoreLookupParams,
'lookupPosition' | 'lookupPurpose'
> {
readonly explicitReceiver?: { readonly name: string };
readonly ownerScopedContributor?: OwnerScopedContributor;
}
@ -30,6 +33,8 @@ export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
return {
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
const params: CoreLookupParams = {
lookupPosition: options.lookupPosition,
lookupPurpose: options.lookupPurpose,
acceptedKinds: FIELD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,

View file

@ -67,27 +67,23 @@
* - No caching. Callers that want memoization can wrap this function.
*/
import { lookupLexicalName } from '../name-claims.js';
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type {
BindingRef,
Callsite,
DefId,
LookupParams,
Resolution,
Scope,
ScopeId,
} from '../types.js';
import type { BindingRef, Callsite, DefId, LookupParams, Resolution, ScopeId } from '../types.js';
import type { OriginForTieBreak } from '../origin-priority.js';
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import { lookupQualified } from './lookup-qualified.js';
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
import { CLASS_KINDS } from './context.js';
// ─── Public entry point ─────────────────────────────────────────────────────
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
/** A separate namespace whose ownership is determined only by accepted kinds. */
readonly independentKindNamespace?: boolean;
readonly ownerScopedContributor: OwnerScopedContributor | null;
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
readonly callsite?: Callsite;
@ -132,15 +128,22 @@ export function lookupCore(
!IMPLICIT_RECEIVERS.includes(params.explicitReceiver.name);
const lexicalShadowed = skipLexical
? false
: walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
: walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate, params);
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
if (
(!lexicalShadowed || perCandidate.size > 0 || skipLexical) &&
params.useReceiverTypeBinding &&
ctx.methodDispatch !== undefined
) {
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
}
// ── Step 3: owner-scoped contributor ──────────────────────────────────
if (params.ownerScopedContributor !== null) {
if (
(!lexicalShadowed || perCandidate.size > 0 || skipLexical) &&
params.ownerScopedContributor !== null
) {
seedFromOwnerScopedContributor(
name,
params.ownerScopedContributor,
@ -222,32 +225,50 @@ function walkLexicalChain(
acceptedKinds: ReadonlySet<NodeLabel>,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
params: CoreLookupParams,
): boolean {
let currentId: ScopeId | null = startScope;
let depth = 0;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
if (scope === undefined) return false;
const bindings = scope.bindings.get(name);
if (bindings !== undefined && bindings.length > 0) {
for (const binding of bindings) {
if (!acceptedKinds.has(binding.def.type)) continue;
recordLexicalHit(perCandidate, binding, depth);
if (params.independentKindNamespace === true) {
let id: ScopeId | null = startScope;
let depth = 0;
const visited = new Set<ScopeId>();
while (id !== null && !visited.has(id)) {
visited.add(id);
const scope = ctx.scopes.getScope(id);
if (scope === undefined) return true;
const bindings = (scope.bindings.get(name) ?? []).filter((binding) =>
acceptedKinds.has(binding.def.type),
);
if (bindings.length > 0) {
for (const binding of bindings) recordLexicalHit(perCandidate, binding, depth);
return true;
}
return true; // hard shadow regardless of kind-filter survivorship
id = scope.lookupPolicy?.parentScope ?? scope.parent;
depth++;
}
currentId = scope.parent;
depth++;
return id !== null;
}
return false;
const options = { position: params.lookupPosition, purpose: params.lookupPurpose };
const claim = lookupLexicalName(startScope, name, { scopes: ctx.scopes }, options);
if (claim.status !== 'absent') {
let depth = 0;
let owner = ctx.scopes.getScope(startScope);
const visited = new Set<ScopeId>();
while (owner !== undefined && owner.id !== claim.scope?.id && !visited.has(owner.id)) {
visited.add(owner.id);
const parent = owner.lookupPolicy?.parentScope ?? owner.parent;
owner = parent === null ? undefined : ctx.scopes.getScope(parent);
depth++;
}
for (const binding of claim.bindings) {
if (acceptedKinds.has(binding.def.type)) recordLexicalHit(perCandidate, binding, depth);
}
return true;
}
const rootName = name.split('.', 1)[0]!;
return (
rootName !== name &&
lookupLexicalName(startScope, rootName, { scopes: ctx.scopes }, options).status !== 'absent'
);
}
function recordLexicalHit(
@ -310,12 +331,12 @@ function resolveReceiverOwner(
// ready resolveTypeRef call (that module is separate), we do a direct
// lookup and trust the caller to have populated the binding.
if (params.explicitReceiver !== undefined) {
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx, params);
}
// Implicit `self` / `this` — the scope's typeBindings should carry it.
for (const implicitName of IMPLICIT_RECEIVERS) {
const owner = lookupReceiverType(startScope, implicitName, ctx);
const owner = lookupReceiverType(startScope, implicitName, ctx, params);
if (owner !== undefined) return owner;
}
return undefined;
@ -353,38 +374,38 @@ function lookupReceiverType(
startScope: ScopeId,
receiverName: string,
ctx: RegistryContext,
params: CoreLookupParams,
): DefId | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) {
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
// can't claim the receiver type. No fallback — that's what
// `resolveTypeRef` would do, but we keep this path lean and let
// callers pre-resolve if they want the richer semantics.
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
if (candidateIds.length === 1) return candidateIds[0];
// Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via
// `resolveTypeRef` (#916) if they want the richer semantics. We
// intentionally do NOT re-implement a simple-name fallback here.
return undefined;
}
// The scope binds this receiver itself but carries no type for it — a
// JS/TS ordinary `function` whose `this` is bound at call time, not the
// enclosing instance (#2701). Stop rather than borrowing an enclosing
// scope's binding; see `Scope.ownsReceivers`. Mirrors the same gate in
// the ingestion-side twin of this walk, `findReceiverTypeBinding`.
if (scope.ownsReceivers?.has(receiverName) === true) return undefined;
currentId = scope.parent;
const claim = lookupLexicalName(
startScope,
receiverName,
{ scopes: ctx.scopes },
{
position: params.lookupPosition,
purpose: 'value',
},
);
const typeRef = claim.typeBinding;
if (typeRef === undefined) return undefined;
const typeClaim = lookupLexicalName(
typeRef.declaredAtScope,
typeRef.rawName,
{ scopes: ctx.scopes },
{
position: typeRef.lookupPosition,
purpose: typeRef.lookupPurpose ?? 'type',
},
);
if (typeClaim.status !== 'absent') {
const candidates = new Map(
typeClaim.bindings
.filter((binding) => CLASS_KINDS.includes(binding.def.type))
.map((binding) => [binding.def.nodeId, binding.def]),
);
return candidates.size === 1 ? candidates.keys().next().value : undefined;
}
return undefined;
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
return candidateIds.length === 1 ? candidateIds[0] : undefined;
}
function collectOwnedMembers(

View file

@ -32,6 +32,7 @@ export interface MacroRegistry {
export function buildMacroRegistry(ctx: RegistryContext): MacroRegistry {
const params: CoreLookupParams = {
acceptedKinds: MACRO_KINDS,
independentKindNamespace: true,
useReceiverTypeBinding: false,
ownerScopedContributor: null,
};

View file

@ -23,7 +23,10 @@ import { METHOD_KINDS } from './context.js';
* concise while still exposing the explicit-receiver + owner-contributor +
* arity knobs the RFC algorithm needs.
*/
export interface MethodLookupOptions {
export interface MethodLookupOptions extends Pick<
CoreLookupParams,
'lookupPosition' | 'lookupPurpose'
> {
/** Call-site arity for `provider.arityCompatibility`. */
readonly callsite?: Callsite;
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
@ -40,6 +43,8 @@ export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
return {
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
const params: CoreLookupParams = {
lookupPosition: options.lookupPosition,
lookupPurpose: options.lookupPurpose,
acceptedKinds: METHOD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,

View file

@ -38,7 +38,8 @@
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js';
import { lookupLexicalName } from './name-claims.js';
import type { BindingRef, ScopeLookup, TypeRef } from './types.js';
import type { DefIndex } from './def-index.js';
import type { QualifiedNameIndex } from './qualified-name-index.js';
@ -100,36 +101,27 @@ const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// ─── Main entry point ──────────────────────────────────────────────────────
export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null {
// Phase 1: scope-chain walk anchored at the declaration site.
let currentId: ScopeId | null = ref.declaredAtScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
// Cycle guard — a well-formed scope tree never loops, but a bug in the
// construction path should fail fast here rather than hanging.
if (visited.has(currentId)) return null;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return null; // broken chain = unresolvable
const bindings = scope.bindings.get(ref.rawName);
if (bindings !== undefined && bindings.length > 0) {
// At least one binding exists at this scope → it is the shadowing site.
// Either one of them qualifies, or the name is shadowed by a non-type.
for (const binding of bindings) {
if (!STRICT_ORIGINS.has(binding.origin)) continue;
if (TYPE_KINDS.has(binding.def.type)) {
return binding.def;
}
}
// Shadowed by a non-type / non-strict-origin binding. Fail fast — no
// global fallback, no walk to the parent.
return null;
}
currentId = scope.parent;
const options = { position: ref.lookupPosition, purpose: ref.lookupPurpose ?? ('type' as const) };
const claim = lookupLexicalName(
ref.declaredAtScope,
ref.rawName,
{ scopes: ctx.scopes },
options,
);
if (claim.status !== 'absent') {
const candidates = claim.bindings.filter(
(binding) => STRICT_ORIGINS.has(binding.origin) && TYPE_KINDS.has(binding.def.type),
);
const distinct = new Map(candidates.map((binding) => [binding.def.nodeId, binding.def]));
return distinct.size === 1 ? distinct.values().next().value! : null;
}
const rootName = ref.rawName.split('.', 1)[0]!;
if (
rootName !== ref.rawName &&
lookupLexicalName(ref.declaredAtScope, rootName, { scopes: ctx.scopes }, options).status !==
'absent'
)
return null;
// Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique
// type-kind hit; anything ambiguous returns null (strict: no guesses).

View file

@ -10,6 +10,7 @@
*/
import type { NodeLabel } from '../graph/types.js';
import type { SourcePosition } from './types.js';
export interface ParameterTypeClass {
/** Normalized base type, matching the coarse `parameterTypes` vocabulary when known. */
@ -60,6 +61,9 @@ export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Optional graph-source position (1-based line, 0-based column) for an
* embedded source buffer. Lexical scope identity remains in `nodeId`. */
graphPosition?: SourcePosition;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */

View file

@ -112,6 +112,9 @@ export type ParsedImport = ParsedImportSyntax & {
* Absent for legacy or synthesized imports. Binding semantics remain opt-in
* through FinalizeHooks.importsBindAtLexicalScope. */
readonly declaredAtScope?: ScopeId;
readonly atRange?: Range;
/** Per-import lexical binding override; useful when a provider also has file-wide dependencies. */
readonly bindsAtLexicalScope?: boolean;
};
type ParsedImportSyntax =
@ -526,6 +529,7 @@ export interface Callsite {
* bounded-fixpoint linking (RFC §3.2).
*/
export interface ImportEdge {
readonly atRange?: Range;
/** How this scope sees the imported name (after alias). */
readonly localName: string;
/** Namespace-import alias syntax, retained for providers whose unaliased imports bind a path prefix. */
@ -595,6 +599,9 @@ export interface ImportEdge {
* stamping first-class instead of reconstructing provenance from a side table.
*/
export interface BindingRef {
readonly availableFrom?: SourcePosition;
/** Source declaration range, independent of the target definition's identity. */
readonly declarationRange?: Range;
readonly def: SymbolDefinition;
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
@ -618,6 +625,10 @@ export interface BindingRef {
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
*/
export interface TypeRef {
/** Declaration that binds this value, independent of its type's lookup anchor. */
readonly bindingRange?: Range;
readonly lookupPosition?: SourcePosition;
readonly lookupPurpose?: LookupPurpose;
/**
* The type name AFTER the language's capture-time normalization — NOT
* necessarily what the source says. Every provider's `interpretTypeBinding`
@ -665,6 +676,49 @@ export interface TypeRef {
// ─── §2.2 Scope ─────────────────────────────────────────────────────────────
export interface SourcePosition {
readonly startLine: number;
readonly startCol: number;
}
export type LookupPurpose = 'value' | 'type';
/** Provider-owned name ownership; a claim does not require an indexed target. */
export interface NameClaim {
readonly name: string;
readonly range: Range;
readonly kind: 'binding' | 'import' | 'blocked';
/** Proven compatible declarations may accumulate their import paths. */
readonly merge?: boolean;
/** Hoisted declarations rank before active ordered rebinding at this scope. */
readonly hoisted?: boolean;
readonly purpose?: LookupPurpose | 'both';
readonly availableFrom?: SourcePosition;
readonly inactive?: 'blocked' | 'outer' | 'module';
readonly redirect?: 'module' | 'enclosing-function';
}
export interface ScopeLookupPolicy {
/** All lexical declarations have claims; inferred assignments alone do not declare names. */
readonly nameClaimsComplete?: boolean;
/** Reference scopes already identify the evaluating environment; do not infer
* a nested caller from physical source containment. */
readonly callerScopeIsAuthoritative?: boolean;
/** Exact lexical self names retained when other bindings are skipped by children. */
readonly visibleNamesFromChildren?: readonly string[];
readonly parentScope?: ScopeId;
/** The scope's locals are not an enclosing lexical environment. */
readonly skipFromChildren?: boolean;
/** Child execution is deferred, so source order in parents cannot imply a TDZ. */
readonly deferParentActivation?: boolean;
}
export interface NameLookupOptions {
readonly skipEnclosingClasses?: boolean;
readonly position?: SourcePosition;
readonly purpose?: LookupPurpose;
}
/**
* The canonical lexical-scope node. Forms the spine of the SemanticModel.
*
@ -672,6 +726,8 @@ export interface TypeRef {
* — deterministic, stable across reparses of the same source, interned.
*/
export interface Scope {
readonly nameClaims?: readonly NameClaim[];
readonly lookupPolicy?: ScopeLookupPolicy;
readonly id: ScopeId;
readonly parent: ScopeId | null;
readonly kind: ScopeKind;
@ -762,7 +818,7 @@ export interface Resolution {
* during the emit phase.
*/
export interface Reference {
/** Innermost lexical scope containing `atRange`. */
/** Scope owning the graph source; may differ from lexical lookup for a synthesized call. */
readonly fromScope: ScopeId;
readonly toDef: DefId;
/** Location of the reference in source. */
@ -818,6 +874,8 @@ export type RegistryContributor = unknown;
* RFC §4.4 for per-registry specializations.
*/
export interface LookupParams {
readonly lookupPosition?: SourcePosition;
readonly lookupPurpose?: LookupPurpose;
readonly acceptedKinds: readonly NodeLabel[];
/** Class lookups: false. Method/Field lookups: true. */
readonly useReceiverTypeBinding: boolean;

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"type": "module",
"scripts": {
@ -20,8 +20,8 @@
"dependencies": {
"@langchain/anthropic": "^1.5.8",
"@langchain/core": "^1.2.14",
"@langchain/google-genai": "^2.3.1",
"@langchain/langgraph": "^1.4.18",
"@langchain/google-genai": "^2.3.2",
"@langchain/langgraph": "^1.4.20",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.13",
"@sigma/edge-curve": "^3.1.0",
@ -46,7 +46,7 @@
"pandemonium": "^2.4.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-i18next": "^17.0.14",
"react-i18next": "^17.0.16",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.2.0",
@ -69,12 +69,12 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^12.0.1",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/coverage-v8": "5.0.0",
"jsdom": "^30.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vitest": "^4.1.10",
"vite": "^8.3.3",
"vitest": "5.0.0",
"wait-on": "^9.5.1"
},
"overrides": {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
describe.sequential('test setup storage isolation', () => {
describe('test setup storage isolation', { concurrent: false }, () => {
it('can seed a persisted i18n language inside one test', () => {
localStorage.setItem('gitnexus.lng', 'zh-CN');

View file

@ -726,7 +726,7 @@ Configure the behavior with these environment variables:
| `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` is the supported default. `icebug` and `auto` are **experimental** and currently behave identically: both try the optional `@ladybugmem/icebug` native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores the native 80%-of-RAM default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
```bash
@ -747,6 +747,27 @@ GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
### Analysis runs out of memory
If graph writes fail with `Unable to allocate memory` or `The buffer pool is
full`, LadybugDB's **native buffer pool** is exhausted. Analysis stops on these
errors rather than retrying with row skipping. This pool is separate from the
Node/V8 heap and parse-worker budgets; raising `NODE_OPTIONS`,
`--memory-budget`, or worker heap limits does not increase it.
On a host with memory to spare, override the pool in **bytes** and rebuild:
```bash
# 3 GiB native pool; leave room for Node, workers, other native memory, and the OS
GITNEXUS_LBUG_BUFFER_POOL_SIZE=3221225472 npx gitnexus analyze --force
```
An explicit value bypasses automatic pool sizing and can exhaust the host if
set too high. `0` restores LadybugDB's native 80%-of-RAM default; it does not
provide unlimited memory. Reduce the indexed scope or use more RAM when the
combined budgets do not fit. A collapsed staged build is discarded before
publication, preserving the previous index if one exists. In-place collapse
leaves an incomplete index and exits nonzero; recover with `--force` once the
underlying problem is fixed.
Memory management is automatic: `analyze` sizes its heap to the machine
(always below physical RAM), caps each parse worker, and — rather than
grinding into a GC death spiral or crash — stops early with a message telling

View file

@ -102,6 +102,7 @@
"r": 45
},
"heap_ceiling_bytes": {
"rust": 31300000,
"kotlin": 6800000,
"go": 4497696,
"dart": 11751300,
@ -119,6 +120,7 @@
"_heap_reading_note": "heap_reading_bytes records each measured large-corpus reading so the 0.5x floor is independent of its ceiling. Ceilings use the standard 1.5x allowance for cross-platform and Node heap-accounting differences; re-baseline the reading and ceiling together.",
"_kotlin_declared_package_gate": "#2960 replaces Kotlin\u0027s path-suffix cascade with one declared-package/module-binding index. The 32000-file arm measured 4516944 retained bytes (4.31 MiB), with a 6800000-byte ceiling. Correctness and reuse are pinned separately by kotlin-module-resolution.test.ts, external-import-conformance.test.ts, the shared index-reuse contract, and bench/kotlin-import-target.",
"heap_reading_bytes": {
"rust": 20853888,
"kotlin": 4516944,
"go": 2998464,
"dart": 7834200,
@ -137,7 +139,6 @@
"heap_bound_bytes": {
"cobol": 6200000,
"swift": 5200000,
"rust": 1048576,
"javascript": 1048576,
"typescript": 1048576,
"vue": 1048576,
@ -680,6 +681,12 @@
}
},
"rust": {
"_lexical_context_3532": "The fifth argument now reaches Rust's lexical module workspace index. Existing scale-arm counts and fingerprints remain unchanged. A declared sibling module resolves locally with context and to the crate-root decoy without it. Retained heap at 8000/32000 files measured 5186248/20853888 bytes (linear ratio 1.005); promote Rust from bounded to budgeted with the standard 1.5x ceiling and the existing floor/ratio gates.",
"context": {
"target": "local::X",
"with_context": "src/parent/local.rs",
"without_context": "src/local.rs"
},
"small": {
"files": 400,
"imports": 3200,

View file

@ -618,6 +618,8 @@ const HEAP_BUDGETED = [
'go',
'cpp',
'objc',
// Lexical module lookup retains a workspace index over parsed Rust files.
'rust',
// R retains the shared workspace file index (57.26 MiB at 32000 files, ratio
// 0.970 against the 1.25 budget), so it takes the full ceiling + floor + ratio.
'r',
@ -649,7 +651,7 @@ const HEAP_BUDGETED = [
* parameters cannot observe a context and are not listed, so their timed
* numbers stay on the three-argument shape.
*/
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'c', 'cpp', 'r'];
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp', 'r'];
/**
* Needs `node --expose-gc` to force collection for a clean delta; without it
@ -2035,7 +2037,15 @@ function resolveOne(lang, from, target, pass) {
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
);
}
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
if (lang === 'rust') {
return resolveRustImportTarget(
target,
from,
allFilePaths,
pass.config,
contextFor(pass, { kind: 'named', localName: 'X', importedName: 'X', targetRaw: target }),
);
}
// Quotes already stripped — `configs/zig.ts` strips them before this call in
// production too. `null` build config: this arm pins the path walk alone
// (see the header); the config legs are gated by their own unit tests.
@ -2475,6 +2485,37 @@ function measureHeap(lang) {
* argument at all.
*/
const CONTEXT_PROBE = {
// A declared sibling module outranks a crate-root file with the same name.
// Empty module tables on the scaling corpora keep their existing target set;
// this probe witnesses the lexical branch that reads the workspace index.
rust: {
from: 'src/parent/mod.rs',
target: 'local::X',
parsedFiles: (() => {
const from = probeFile('src/parent/mod.rs', [['Namespace', 'local']]);
const def = from.localDefs[0];
return [
{
...from,
scopes: [
{
id: from.moduleScope,
parent: null,
kind: 'Module',
filePath: from.filePath,
range: { startLine: 1, startCol: 0, endLine: 2, endCol: 0 },
bindings: new Map([['local', [{ def, origin: 'local' }]]]),
ownedDefs: [def],
imports: [],
typeBindings: new Map(),
},
],
},
probeFile('src/parent/local.rs', [['Struct', 'X']]),
probeFile('src/local.rs', [['Struct', 'X']]),
];
})(),
},
/**
* `use function App\Ns0\Dup;` where the CLASS `Dup` lives in `Dup.php` and
* the FUNCTION `Dup` lives in `Helpers.php`. PHP keeps the two in separate

View file

@ -1 +1 @@
b6754e5362716211a348f3aafda8bbc00082e3fb0e45cb3aa79a1be5e3df1a8b
44277912361a8783163b4ca9c5843e4d8f8444116b768881e617f26368bb1cba

View file

@ -155,7 +155,9 @@ function computeFingerprint() {
const perFixtureDigests = [];
for (const { key, absPath } of collectPythonFixtures()) {
const src = fs.readFileSync(absPath, 'utf8');
const matches = emitPythonScopeCaptures(src, absPath);
// Lexical policies carry scope IDs containing the input path. Keep the
// fingerprint independent of the checkout's absolute location.
const matches = emitPythonScopeCaptures(src, key);
groups += matches.length;
perFixtureDigests.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
}
@ -199,6 +201,9 @@ if (!CHECK) {
// the path is still linear (scaling ratio under budget). Re-baseline a
// legitimate capture change with `node --import tsx measure.mjs` (no --check)
// and commit the new baseline-fingerprint.txt deliberately.
// #3531: Tree-sitter 0.25 adds one zero-width module scope to each of the
// 15 empty __init__.py fixtures (3982 -> 3997 groups). All non-empty fixture
// captures and the synthetic DAO are unchanged; the golden test pins this.
const baselinePath = path.resolve(__dirname, 'baseline-fingerprint.txt');
const baseline = fs.readFileSync(baselinePath, 'utf8').trim();
const failures = [];

View file

@ -199,20 +199,19 @@
}
},
"countArm": {
"callDrops": 124,
"totalDropsAllKinds": 170,
"callDrops": 129,
"totalDropsAllKinds": 167,
"bySiteKind": {
"call": 124,
"read": 27,
"call": 129,
"read": 19,
"write": 19
},
"callDropsByExtension": {
".java": 49,
".py": 16,
".py": 20,
".ts": 15,
".zig": 11,
".cs": 8,
".ts": 7,
".cpp": 7,
".tsx": 6,
".go": 5,
".php": 4,
@ -223,17 +222,17 @@
".swift": 1
},
"callDropsByShape": {
"chain-field": 60,
"chain-call": 27,
"no-chain": 23,
"chain-field": 56,
"chain-call": 32,
"no-chain": 27,
"<<unclassified>>": 11,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"in-program": 54,
"in-program": 56,
"external": 44,
"unknown": 26
"unknown": 29
}
}
}

View file

@ -38,7 +38,8 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96",
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "f18beeadd100e5479c2997876bc541412822cf3a4ae33b0d4a6ea6c35cae7b0d",
"scaling_budget": 1.5,
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
@ -77,7 +78,8 @@
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec."
},
"rust": {
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "8c9f271349ab0a35a32e6f064303d63e191e019daa6dab8c5fc80784717a3321",
"scaling_budget": 1.5,
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
@ -97,7 +99,8 @@
"fixture_count": 202
},
"php": {
"fingerprint": "6e92ed6e50c2ba4b383b97b9f9b550a99d2d647a30a2867b53ad94f24114952d",
"fingerprint": "745700bcd75c3f7bccff2e8085205f129c9c965d38ad2e291bba42d56afcd5e6",
"_rebaselined_3531": "The php-typed-property-dedup fixture class Mixed was renamed MixedProperties because mixed is a reserved PHP type. Only that fixture's text/ranges change; all 142 fixture files emit identical captures under the old and new grammars when given the same source. Capture groups remain 2241 and the scaling budget is unchanged.",
"_rebaselined_3502_attributed_function_fixture": "#3502 extends the existing php-nested-function fixture with an attributed, comment-interposed named function and a long-whitespace closure control. This is fixture-content growth only: the capture implementation now classifies existing captures instead of scanning source text, so it cannot mint or remove matches. The fixture corpus stays at 143 files; captured groups move 2237 -> 2241, while synthetic scaling counts stay 4258/13608. Prior 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075 -> 6e92ed6e50c2ba4b383b97b9f9b550a99d2d647a30a2867b53ad94f24114952d.",
"_rebaselined_3502_nested_function_fixture": "#3502 adds the php-nested-function fixture used by the resolver regression. The capture implementation and query are unchanged; this is fixture-corpus growth only. The unified harness moves from 141 to 143 fixture files and from 2203 to 2237 captured groups, while the synthetic scaling counts remain 4258/13608 and every other language matches its baseline. Prior 421cbd318c8857f840aa60904a675cbde32c6bb57b01e6c0f970d10db572b99e -> 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075.",
"scaling_budget": 1.5,
@ -197,7 +200,8 @@
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47",
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "bccca22b59d4c4d8aa4766c69092f29912fa035f08f037a639de3beaa725eaf4",
"_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,
@ -226,7 +230,8 @@
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467."
},
"javascript": {
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "857b1257a001f8741412ea08a4f43d9253cc4addfa63421f3013b3ff77a390c9",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
@ -268,7 +273,8 @@
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2."
},
"typescript-deep-chain": {
"fingerprint": "c4dd97ad7be50a554930237f79035993ffa67957de6dc8c1d6606518071204b2",
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "3f41a3c6d9161f95c0c906cc979e6ef853c25b39052c5cab274dba2aabf270ba",
"scaling_budget": 1.5,
"_added": "#3354 review: one TS `handler || w === \"k0\" || \u2026` chain, operand count = entity count (250 -> 800). Guards callable-flow value-alternative expansion (valueAlternatives + per-leaf visibility walks) against per-leaf cost that grows with chain depth: before the fix this measured 7.5-8.0 (super-linear; 8000 operands overflowed the stack), after it 1.11-1.22. Synthetic-only fingerprint (no fixture corpus); identical before and after the fix."
}

View file

@ -366,7 +366,11 @@ function median(xs) {
}
function timeEmit(emit, src, file, reps) {
emit(src, `warmup-${file}`); // warm parser/query JIT (not counted)
// Stabilize parser/query JIT before collecting each size's samples.
// Keep sizes in separate batches so large allocations cannot bias small runs.
for (let i = 0; i < WARMUP_REPS; i++) {
emit(src, `warmup-${file}`);
}
const samples = [];
let count = 0;
for (let i = 0; i < reps; i++) {
@ -380,7 +384,8 @@ function timeEmit(emit, src, file, reps) {
const SMALL = 250;
const LARGE = 800;
const REPS = 7;
const WARMUP_REPS = 7;
const REPS = 21;
function measureLang(lang) {
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
@ -389,7 +394,8 @@ function measureLang(lang) {
const fixtures =
lang.fixturePrefix === undefined ? [] : collectFixtures(lang.fixturePrefix, lang.exts);
for (const { key, absPath } of fixtures) {
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
// Scope policies contain file identities; hash stable fixture paths.
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), key);
groups += matches.length;
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
}
@ -422,7 +428,13 @@ function measureLang(lang) {
// ---- run ----
const CHECK = process.argv.includes('--check');
const results = LANGS.map(measureLang);
const results = [];
for (const lang of LANGS) {
results.push(measureLang(lang));
// Let native tree/node finalizers run between languages. Longer sampling
// otherwise retains their allocations for the entire synchronous batch.
await new Promise(setImmediate);
}
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');

View file

@ -1,8 +1,8 @@
{
"_comment": "Baselines for bench/scope-emission/measure.mjs --check (#2699), one entry per language. `scopes` is an EXACT count over a synthetic corpus fixed in measure.mjs — a correctness gate, not a timing one, so drift means the emitted scope set moved and must be explained, never re-baselined to make CI green. The two emit-side filters this guards (function-body blocks, and blocks that declare no binding) cut block scopes 19389 -> 5331 on a 762-file TypeScript corpus and took the block-scope overhead from ~+10% to ~+2% of analyze wall time. `@scope.block` = 400 is 2 per module: only the two `if`/`else` branches that declare `const chosen`. If that number jumps, the filters regressed and every scope-chain walk in every function got deeper. BOTH languages are baselined because the filters are implemented twice — FUNCTION_BODY_OWNER_TYPES in typescript/captures.ts and JS_FUNCTION_BODY_OWNER_TYPES in javascript/captures.ts, each with its own blockDeclaresBinding — so a TypeScript-only gate would let a JavaScript-only regression ship green. The two agree exactly on this corpus; that is a measured result, not an invariant the gate depends on. `emit_ms_budget` carries deliberate headroom for shared CI runners and exists to catch an order-of-magnitude regression, not a few percent.",
"_comment": "Baselines for bench/scope-emission/measure.mjs --check (#2699, #3532). Exact scope counts over the fixed synthetic corpus guard function-body suppression and removal of blocks without bindings. Each module has four Block scopes: two const-chosen branches, the lexical for environment, and the catch environment. Lookup-policy and name-claims captures decorate scopes and are excluded from this scope census; bench/scope-capture pins their full content. TypeScript and JavaScript retain separate baselines. emit_ms_budget is unchanged and carries headroom for shared CI runners.",
"typescript": {
"scopes": {
"@scope.block": 400,
"@scope.block": 800,
"@scope.class": 200,
"@scope.function": 1400,
"@scope.module": 200
@ -11,7 +11,7 @@
},
"javascript": {
"scopes": {
"@scope.block": 400,
"@scope.block": 800,
"@scope.class": 200,
"@scope.function": 1400,
"@scope.module": 200

View file

@ -24,7 +24,8 @@
* `blockDeclaresBinding` and its own `BLOCK_BINDING_CHILD_TYPES` — so a
* TypeScript-only bench would let a JavaScript-only regression ship green.
*
* On this corpus the two currently agree exactly (2 blocks per module, 2200
* Loop and catch environments also carry lexical ownership. On this corpus
* the two currently agree exactly (4 blocks per module, 2600
* scopes). That is a measured result, not a required invariant: the fixtures
* are structurally parallel and the TS-only syntax they drop carries no extra
* scopes. Each language is still gated against its OWN baseline, because the
@ -54,7 +55,8 @@ const { emitJsScopeCaptures } =
*
* Deliberately mixes the shapes the filters discriminate between:
* - function/method/arrow bodies → block scope must be SUPPRESSED
* - `if`/`else`/`for`/`while`/`try` → suppressed when they declare nothing
* - empty `if`/`else`/`while`/`try` → suppressed
* - lexical `for` and `catch` → environment scope REQUIRED
* - blocks declaring `let`/`const` → block scope REQUIRED (shadowing)
* - a block declaring only `var` → suppressed (`var` hoists past it)
*/
@ -164,6 +166,8 @@ const measure = ({ ext, emit, moduleSource }) => {
for (const { path, source } of corpus) {
for (const match of emit(source, path)) {
for (const key of Object.keys(match)) {
// These decorate an existing scope; they do not create scopes.
if (key === '@scope.lookup-policy' || key === '@scope.name-claims') continue;
if (key.startsWith('@scope.')) counts.set(key, (counts.get(key) ?? 0) + 1);
}
}

View file

@ -0,0 +1 @@
/results/

View file

@ -0,0 +1,73 @@
# Deterministic tool accuracy
This small release corpus implements the cheap tool-level checks proposed in
[discussion #3493, comment 18772530](https://github.com/abhigyanpatwari/GitNexus/discussions/3493#discussioncomment-18772530).
The fixed answers come from local fixture source and public issue repros; the
current engine output never defines the expected answer.
With the repository's dependencies installed, run from `gitnexus/`:
```bash
node --import tsx bench/tool-accuracy/run.ts --check --out bench/tool-accuracy/results
npm test -- test/unit/tool-accuracy-score.test.ts
```
The runner uses the real `runPipelineFromRepo`, CSV/database loader, metadata
writer and `LocalBackend.callTool`. It indexes one small temporary TS/Python
repository with PDG enabled and one parser worker. Community/process discovery
is unnecessary for these checks and is disabled. Native parser workers require
the normal build produced by `npm ci`; run `npm run build` after worker changes.
The registry, database and fixture copy are isolated and removed after the run.
Fixture code is parsed, never executed. Extension installation is disabled,
accidental JavaScript fetches fail, and neither embeddings nor paid models run.
`accuracy.json` contains source SHA, working-tree status, fixture SHA-256, issue
IDs, expected/actual answers, precision/recall and raw tool outputs.
`accuracy.md` gives the same fixed-answer verdicts and concise failed answers.
`--out` selects a report directory. Without `--check`, valid observations can be
reported while a new fixed-answer failure is investigated; execution/output
errors always exit nonzero. `--check` additionally enforces the regression gate.
| Fixture / checks | Public repro | Fixed-answer contract |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms. |
| `src/server/`, `test/stub.test.ts` | [#3487](https://github.com/abhigyanpatwari/GitNexus/issues/3487) | Server beats a same-path test stub; `app.all` has method `*`; Map.get is absent; helper-registered literal route is present. |
| `src/web/` | [#3488](https://github.com/abhigyanpatwari/GitNexus/issues/3488) | Both the base-URL template passed to a configured wrapper and a direct literal fetch link their consumer files. |
| `src/security.ts`, `src/server/inline.ts` | [#3489](https://github.com/abhigyanpatwari/GitNexus/issues/3489), [#3490](https://github.com/abhigyanpatwari/GitNexus/issues/3490), [#3491](https://github.com/abhigyanpatwari/GitNexus/issues/3491) | Named and inline unsafe handlers reach the sink; guarded and constant commands have zero taint; statement and function-hop lines are 1-based. |
| `pkg/` | [#3497](https://github.com/abhigyanpatwari/GitNexus/issues/3497) | Direct, chained module-return helper and local module-return helper resolve to the same target. |
| `mod.py`, `callers.py` | [#3498](https://github.com/abhigyanpatwari/GitNexus/issues/3498) | Direct, conditional, boolean and module-qualified annotated factories resolve to C.m. |
| `nested*.py`, `local_import.py` | [#3499](https://github.com/abhigyanpatwari/GitNexus/issues/3499) | Imports resolve within their lexical scopes; unrelated nested declarations and sibling-local imports cannot capture bare calls. |
| `summary.py`, 501 deterministic drop records | [#3497](https://github.com/abhigyanpatwari/GitNexus/issues/3497) | Production `summarizeUnresolvedReceivers` retains 500 names and reports one omitted name; querying a capped-out name requires `lower-bound`, while a complete empty summary permits `exact`. |
The 501-name case supplies known suppressed outcomes to the production summary
writer, persists its actual output as metadata and calls real `impact`. It tests
the cap/consumer contract without requiring 501 parser fixtures. It does not
claim parser coverage for those synthetic records. Python checks read actual
`context.outgoing.calls` symbol UIDs, preserving lexical/class ownership.
## Known gaps and repairs
The initial source baseline is
`dd096e690c06e5d54ab3595c63b83f15e04b6191`: **11/31 fixed answers pass, 20 fail**.
Main at `50aa4be3b2c2c9a1561fc44878e5d8f87b99d16e` repaired three #3487 route
checks and all four #3499 checks, and their allowances were removed: **18/31
pass, 13 fail**.
The JSON report records every desired and actual answer independently of gate
status. Callback and guard taint findings are currently documented limitations;
their stronger desired answers remain visible as known gaps.
`known-gaps.json` is an explicit, reviewed list of failing check IDs with issue,
reason and observed baseline answers. There is no wildcard, skip, automatic
baseline-update command or production-engine fix in this corpus. The gate:
- Rejects new failing checks, unknown/malformed allowances and missing/malformed
tool outputs, exceptions, truncation and incomplete fixture extraction.
- Rejects a previously correct fact disappearing or a new incorrect fact
appearing inside an existing gap. Partial repairs can still proceed.
- Rejects a passing check whose allowance remains: **remove the allowance in the
PR that repairs the case**, so the fixed answer protects future releases.
Changing a fixture requires reviewing its fixed answers and the line anchors in
`FIXTURE_ANCHORS` (`expectations.ts`); the runner and a unit test fail when an anchor
no longer matches its fixture line. Broadening an allowance requires review of the actual repro;
never change an expected answer simply to match current output.

View file

@ -0,0 +1,258 @@
/**
* The single place that pins fixture line numbers. run.ts buckets observations by these anchors
* and the fixed answers below are built from them, so a fixture edit is one reviewed change here.
* `anchorDrift` fails loudly (run.ts and a unit test) when a fixture line no longer holds the
* pinned text, instead of letting observations drift into the wrong bucket.
*/
export const FIXTURE_ANCHORS = {
writerCloseDeclaration: { file: 'rename/writer.ts', line: 2, text: 'close(): void {}' },
writerCloseCall: { file: 'rename/writer.ts', line: 8, text: 'writer.close();' },
callerCloseCall: { file: 'rename/caller.ts', line: 3, text: 'writer.close();' },
writerComment: {
file: 'rename/writer.ts',
line: 10,
text: '// close is a comment, not a reference.',
},
writerString: { file: 'rename/writer.ts', line: 11, text: "export const title = 'close';" },
runGit: {
file: 'src/security.ts',
line: 4,
text: 'export function runGit(args: string): string {',
},
blameSummary: {
file: 'src/security.ts',
line: 7,
text: 'export function blameSummary(ref: string): string {',
},
handleUnsafe: {
file: 'src/security.ts',
line: 14,
text: 'export function handleUnsafe(req: any): string {',
},
directUnsafe: {
file: 'src/security.ts',
line: 22,
text: 'export function directUnsafe(req: any): void {',
},
directUnsafeSource: {
file: 'src/security.ts',
line: 23,
text: 'const command = req.query.command as string;',
},
directUnsafeSink: { file: 'src/security.ts', line: 24, text: 'execSync(command);' },
} as const satisfies Record<string, { file: string; line: number; text: string }>;
const A = FIXTURE_ANCHORS;
/** Anchors whose fixture line (1-based) no longer holds the pinned text; empty when in sync. */
export function anchorDrift(readLines: (file: string) => string[]): string[] {
return Object.entries(FIXTURE_ANCHORS).flatMap(([name, anchor]) => {
const actual = readLines(anchor.file)[anchor.line - 1]?.trim();
return actual === anchor.text
? []
: [
`${name}: ${anchor.file}:${anchor.line} is ${JSON.stringify(actual)}, expected ${JSON.stringify(anchor.text)}`,
];
});
}
/** Fixed answers reviewed from the fixture source, never learned from tool output. */
export const EXPECTATIONS = [
{
id: 'rename.references',
issue: 3486,
title: 'All resolved Writer.close references',
expected: [
`rename/caller.ts:${A.callerCloseCall.line}:writer.closeWriter();`,
`rename/writer.ts:${A.writerCloseDeclaration.line}:closeWriter(): void {}`,
`rename/writer.ts:${A.writerCloseCall.line}:writer.closeWriter();`,
],
},
{
id: 'rename.comments-strings',
issue: 3486,
title: 'Comments and strings receive no edits',
expected: [],
},
{
id: 'rename.homonyms',
issue: 3486,
title: 'Unrelated methods and functions receive no edits',
expected: [],
},
{
id: 'routes.production-handler',
issue: 3487,
title: 'Test stub cannot replace the server handler',
expected: ['GET /api/info src/server/api.ts'],
},
{
id: 'routes.all-method',
issue: 3487,
title: 'Express app.all is method agnostic',
expected: ['* /api/mcp src/server/api.ts'],
},
{ id: 'routes.map-lookup', issue: 3487, title: 'Map.get is not an HTTP route', expected: [] },
{
id: 'routes.helper-registration',
issue: 3487,
title: 'Literal route forwarded through a local helper',
expected: ['GET /api/progress src/server/api.ts'],
},
{
id: 'api.template-wrapper',
issue: 3488,
title: 'Base URL template passed to a fetch wrapper is a consumer',
expected: ['src/web/client.ts'],
},
{
id: 'api.literal-control',
issue: 3488,
title: 'Direct literal fetch consumer control',
expected: ['src/web/control.ts'],
},
{
id: 'explain.named-flow',
issue: 3489,
title: 'Named request handler reaches the command sink',
expected: ['handleUnsafe -> runGit command-injection'],
},
{
id: 'explain.inline-flow',
issue: 3489,
title: 'Inline request callback reaches the command sink',
expected: ['src/server/inline.ts -> runGit command-injection'],
},
{
id: 'explain.guard',
issue: 3490,
title: 'Strict SHA allow-list prevents command injection',
expected: [],
},
{
id: 'explain.constant-control',
issue: 3490,
title: 'Constant command has no request taint',
expected: [],
},
{
id: 'explain.cross-lines',
issue: 3491,
title: 'Cross-function source, sink and every hop are 1-based',
expected: [
`source handleUnsafe@${A.handleUnsafe.line}`,
`sink runGit@${A.runGit.line}`,
`hop 0 handleUnsafe@${A.handleUnsafe.line}`,
`hop 1 blameSummary@${A.blameSummary.line}`,
`hop 2 runGit@${A.runGit.line}`,
],
},
{
id: 'explain.intra-lines',
issue: 3491,
title: 'Statement-level source, sink and hops are 1-based',
expected: [
`function@${A.directUnsafe.line}`,
`source command@${A.directUnsafeSource.line}`,
`sink@${A.directUnsafeSink.line}`,
`hop 0 command@${A.directUnsafeSource.line}`,
`hop 1 command@${A.directUnsafeSink.line}`,
],
},
{
id: 'python.module-direct',
issue: 3497,
title: 'Direct imported-module receiver control',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.module-chain',
issue: 3497,
title: 'Module-return helper chained receiver',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.module-local',
issue: 3497,
title: 'Module-return helper assigned to a local',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.factory-direct',
issue: 3498,
title: 'Annotated factory direct assignment control',
expected: ['mod.py:C.m'],
},
{
id: 'python.factory-conditional',
issue: 3498,
title: 'Annotated factory through conditional expression',
expected: ['mod.py:C.m'],
},
{
id: 'python.factory-qualified',
issue: 3498,
title: 'Annotated factory through module alias',
expected: ['mod.py:C.m'],
},
{
id: 'python.factory-boolean',
issue: 3498,
title: 'Annotated factory through boolean expression',
expected: ['mod.py:C.m'],
},
{
id: 'python.nested-local-import',
issue: 3499,
title: 'Function-local import beats unrelated nested declaration',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.nested-module-import',
issue: 3499,
title: 'Module import beats unrelated nested declaration',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.nested-unbound',
issue: 3499,
title: 'Sibling cannot call an out-of-scope nested declaration',
expected: [],
},
{
id: 'python.local-import-control',
issue: 3499,
title: 'Function-local import without homonym control',
expected: ['pkg/facade.py:target'],
},
{
id: 'python.local-import-leak',
issue: 3499,
title: 'Function-local import cannot bind a sibling function',
expected: [],
},
{
id: 'summary.exact-control',
issue: 3497,
title: 'Complete empty drop summary permits exact',
expected: ['exact'],
},
{
id: 'summary.retained-lower-bound',
issue: 3497,
title: 'Retained dropped name requires lower-bound',
expected: ['lower-bound'],
},
{
id: 'summary.cap',
issue: 3497,
title: '501 names over 1001 sites retain 500 and explicitly omit the last name',
expected: ['kept=500 total=1001 omitted=1 target=false'],
},
{
id: 'summary.omitted-lower-bound',
issue: 3497,
title: 'Absent name in a truncated summary cannot claim exact',
expected: ['lower-bound'],
},
] satisfies Array<{ id: string; issue: number; title: string; expected: string[] }>;

Some files were not shown because too many files have changed in this diff Show more