diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8fa1f30c56..69fe9a8491 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -81,6 +81,7 @@ bin/fm-test-run.sh --lane portable-serial # portable serial remainder (watcher
bin/fm-test-run.sh --list-lanes # discover exact lane names, including the current CI serial shards
bin/fm-test-run.sh --check-coverage # prove portable shards + serial + serial shards + Herdr equal the full inventory
bin/fm-test-run.sh --all # deliberate complete regression (optional local full walk; not no-mistakes Test)
+bin/fm-test-run.sh --compare-commits
--output-dir /tmp/fm-test-partition # bounded detached base/head inventories and failure partition
bin/fm-test-isolation-proof.sh --list # proven parallel candidate set (Phase 2 owner)
bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json # re-run concurrent isolation proof only
[ "$(readlink CLAUDE.md)" = "AGENTS.md" ]
@@ -90,6 +91,7 @@ tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVE
`bin/fm-test-run.sh` is the single owner of behavior-suite selection, portable CI lane composition, optional local `--jobs` for the proven-isolated set only, per-script timing markers, family totals, the coverage guard, and the optional JSON timing artifact.
Its header and `--help` own the flags, family labels, lanes, and changed-file map; this section only documents the entry points.
+Its commit-comparison mode gives independent gates per-script hard bounds, detached local copies, independently reconciled inventories, honest measured-outcome coverage, and retry-confirmed transition buckets for inherited failures, timeouts, regressions, genuine fixes, coverage erosion, and flakes.
`bin/fm-test-isolation-proof.sh` remains the single owner of the Phase 2 concurrent isolation proof and the exact proven candidate set; see `docs/fm-test-isolation-proof.md`.
Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`.
Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk.
diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh
index 4ca26c865e..8450565181 100755
--- a/bin/fm-test-run.sh
+++ b/bin/fm-test-run.sh
@@ -4,7 +4,7 @@
# timing markers, and the complete-regression coverage guard.
#
# Selection modes (exactly one of: --all, --family, --changed, --lane,
-# --proven-isolated, or script paths):
+# --proven-isolated, --compare-commits, or script paths):
# fm-test-run.sh --all
# fm-test-run.sh --family
# fm-test-run.sh --changed [--base ]
@@ -12,6 +12,7 @@
# fm-test-run.sh --lane portable-serial-of (one CI serial shard)
# fm-test-run.sh --proven-isolated
# fm-test-run.sh tests/.test.sh [more scripts...]
+# fm-test-run.sh --compare-commits --output-dir
#
# Inspection (no execution):
# fm-test-run.sh --list --all
@@ -24,6 +25,18 @@
# Aggregation (no suite execution):
# fm-test-run.sh --aggregate-json [more lane.json...]
#
+# Base/head partition (isolated execution):
+# fm-test-run.sh --compare-commits --output-dir [--script-timeout ]
+# Resolves both refs once, checks each out detached in an independent local
+# clone, and writes base.json, head.json, and partition.json. Every independently
+# enumerated script receives passed, failed, timed_out, skipped, or errored;
+# an interrupted in-flight row remains running. Each script runs in its own
+# process group under a hard bound (default: 300s). Candidate introduced and
+# regressed transitions are re-run once on both sides before classification.
+# The command exits non-zero for confirmed introduced/regressed outcomes,
+# deleted or skipped coverage, inventory refusal, or checkout-integrity error;
+# inherited failures and explicitly recorded flakes do not fail by themselves.
+#
# Options:
# --json write a deterministic timing artifact after the run
# --list print selected script paths (one per line) and exit 0
@@ -42,6 +55,9 @@
# selected script is in the proven-isolated set
# (bin/fm-test-isolation-proof.sh --list). Cap is 8. Stateful
# families never schedule under --jobs.
+# --script-timeout N
+# per-script bound in seconds for --compare-commits (default: 300).
+# --output-dir D fresh artifact directory for --compare-commits.
# -h, --help print this header
#
# Per-script machine-parseable markers (stdout):
@@ -79,6 +95,10 @@ LIST_FAMILIES=0
LIST_LANES=0
CHECK_COVERAGE=0
AGGREGATE_OUT=
+COMPARE_BASE=
+COMPARE_HEAD=
+COMPARE_OUTPUT_DIR=
+SCRIPT_TIMEOUT=300
FAMILY=
LANE=
BASE_REF=origin/main
@@ -1193,6 +1213,625 @@ with open(out, "w", encoding="utf-8") as fh:
PY
}
+compare_commits() {
+ local base_ref=$1 head_ref=$2 output_dir=$3 script_timeout=$4
+ command -v python3 >/dev/null 2>&1 || die "--compare-commits requires python3"
+ python3 - "$ROOT" "$base_ref" "$head_ref" "$output_dir" "$script_timeout" <<'PY'
+import collections
+import json
+import os
+import pathlib
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+
+root = pathlib.Path(sys.argv[1]).resolve()
+base_ref, head_ref = sys.argv[2], sys.argv[3]
+output_dir = pathlib.Path(sys.argv[4]).resolve()
+timeout_seconds = int(sys.argv[5])
+active_proc = None
+inventories = []
+confirmations = {}
+temp_root = None
+termination = None
+
+
+def git(*args, cwd=root, check=True):
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=check, text=True,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ )
+
+
+def resolve_commit(ref):
+ try:
+ return git("rev-parse", "--verify", f"{ref}^{{commit}}").stdout.strip()
+ except subprocess.CalledProcessError as exc:
+ raise RuntimeError(f"commit not found: {ref}: {exc.stderr.strip()}") from exc
+
+
+def atomic_json(path, value):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ stage = path.with_name(path.name + ".tmp")
+ stage.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ os.replace(stage, path)
+
+
+def checkout_state(copy):
+ head = git("rev-parse", "HEAD", cwd=copy).stdout.strip()
+ detached = git("symbolic-ref", "-q", "HEAD", cwd=copy, check=False).returncode != 0
+ clean = git("status", "--porcelain", "--untracked-files=all", cwd=copy).stdout == ""
+ return head, detached, clean
+
+
+def inventory_summary(rows):
+ counts = collections.Counter(row["outcome"] for row in rows)
+ return {
+ "total": len(rows),
+ "passed": counts["passed"],
+ "failed": counts["failed"],
+ "timed_out": counts["timed_out"],
+ "skipped": counts["skipped"],
+ "errored": counts["errored"],
+ "running": counts["running"],
+ }
+
+
+def reconcile(doc):
+ expected = doc["expected_scripts"]
+ discovered = doc["discovered_scripts"]
+ inventoried = [row["path"] for row in doc["scripts"]]
+ missing = sorted(set(expected) - set(inventoried))
+ extra = sorted(set(inventoried) - set(expected))
+ missing_from_discovery = sorted(set(expected) - set(discovered))
+ extra_in_discovery = sorted(set(discovered) - set(expected))
+ duplicates = sorted(path for path, count in collections.Counter(inventoried).items() if count != 1)
+ ok = bool(
+ expected == discovered == inventoried
+ and not missing and not extra
+ and not missing_from_discovery and not extra_in_discovery
+ and not duplicates
+ )
+ doc["reconciliation"] = {
+ "accounted": ok,
+ "expected_count": len(expected),
+ "discovered_count": len(discovered),
+ "inventory_count": len(inventoried),
+ "missing": missing,
+ "extra": extra,
+ "missing_from_discovery": missing_from_discovery,
+ "extra_in_discovery": extra_in_discovery,
+ "duplicates": duplicates,
+ }
+ doc["summary"] = inventory_summary(doc["scripts"])
+ return ok
+
+
+def write_inventory(doc):
+ reconcile(doc)
+ atomic_json(output_dir / f"{doc['label']}.json", doc)
+
+
+def first_meaningful_line(path):
+ with path.open(encoding="utf-8", errors="replace") as stream:
+ for line in stream:
+ if line.strip():
+ return line.strip()
+ return ""
+
+
+def stop_process_group(proc):
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ try:
+ proc.wait(timeout=0.2)
+ return
+ except subprocess.TimeoutExpired:
+ pass
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ proc.wait()
+
+
+def tracked_test_scripts(copy, commit):
+ tree = subprocess.run(
+ ["git", "ls-tree", "-rz", "--name-only", commit, "--", "tests"],
+ cwd=copy, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ ).stdout
+ scripts = set()
+ for raw_path in tree.split(b"\0"):
+ if not raw_path:
+ continue
+ path = os.fsdecode(raw_path)
+ parts = pathlib.PurePosixPath(path).parts
+ if len(parts) >= 2 and parts[0] == "tests" and parts[1].endswith(".test.sh"):
+ scripts.add(f"tests/{parts[1]}")
+ return sorted(scripts)
+
+
+def make_inventory(label, requested_ref, commit, copy):
+ expected_scripts = tracked_test_scripts(copy, commit)
+ discovered_scripts = sorted(
+ str(path.relative_to(copy))
+ for path in (copy / "tests").glob("*.test.sh")
+ if path.is_file()
+ )
+ rows = []
+ for script in expected_scripts:
+ rows.append({
+ "path": script,
+ "outcome": "errored",
+ "exit_code": None,
+ "duration_ms": 0,
+ "detail": "not_run: runner interrupted before execution",
+ "log": f"{label}/logs/{pathlib.Path(script).name}.log",
+ })
+ head, detached, clean = checkout_state(copy)
+ doc = {
+ "schema_version": 2,
+ "label": label,
+ "requested_ref": requested_ref,
+ "commit": commit,
+ "timeout_seconds": timeout_seconds,
+ "checkout": {
+ "path": str(copy),
+ "initial_head": head,
+ "final_head": None,
+ "detached": detached,
+ "clean_before": clean,
+ "clean_after": None,
+ "invariant_ok": head == commit and detached and clean,
+ },
+ "expected_scripts": expected_scripts,
+ "discovered_scripts": discovered_scripts,
+ "scripts": rows,
+ }
+ write_inventory(doc)
+ return doc
+
+
+def run_inventory(doc, copy):
+ global active_proc
+ compromised = not doc["checkout"]["invariant_ok"]
+ for row in doc["scripts"]:
+ if compromised:
+ row["detail"] = "not_run: checkout invariant was already violated"
+ write_inventory(doc)
+ continue
+
+ before_head, before_detached, before_clean = checkout_state(copy)
+ if before_head != doc["commit"] or not before_detached or not before_clean:
+ row["detail"] = (
+ "not_run: checkout invariant violated before script "
+ f"(head={before_head}, detached={before_detached}, clean={before_clean})"
+ )
+ compromised = True
+ doc["checkout"]["invariant_ok"] = False
+ write_inventory(doc)
+ continue
+
+ script_path = copy / row["path"]
+ log_path = output_dir / row["log"]
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ if not script_path.is_file():
+ row["detail"] = "not_run: tracked test path is not a regular file"
+ write_inventory(doc)
+ continue
+
+ env = os.environ.copy()
+ for name in (
+ "FM_HOME", "FM_STATE_OVERRIDE", "FM_DATA_OVERRIDE", "FM_ROOT_OVERRIDE",
+ "FM_PROJECTS_OVERRIDE", "FM_CONFIG_OVERRIDE", "FM_BACKEND",
+ ):
+ env.pop(name, None)
+ script_tmp = pathlib.Path(tempfile.mkdtemp(prefix=f"fm-compare-{doc['label']}-"))
+ env["TMPDIR"] = str(script_tmp)
+ env["TMP"] = str(script_tmp)
+ started = time.monotonic()
+ try:
+ with log_path.open("wb") as log_stream:
+ proc = subprocess.Popen(
+ ["/bin/bash", row["path"]], cwd=copy, env=env,
+ stdout=log_stream, stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ active_proc = proc
+ row["outcome"] = "running"
+ row["detail"] = "in flight"
+ row["exit_code"] = None
+ write_inventory(doc)
+ try:
+ returncode = proc.wait(timeout=timeout_seconds)
+ except subprocess.TimeoutExpired:
+ stop_process_group(proc)
+ returncode = 124
+ row["outcome"] = "timed_out"
+ row["detail"] = f"per-script timeout after {timeout_seconds}s"
+ else:
+ if returncode == 0:
+ if first_meaningful_line(log_path).startswith("skip:"):
+ row["outcome"] = "skipped"
+ row["detail"] = "first meaningful output line is a gate skip"
+ else:
+ row["outcome"] = "passed"
+ row["detail"] = "exit 0"
+ elif returncode < 0:
+ row["outcome"] = "errored"
+ row["detail"] = f"terminated by signal {-returncode}"
+ else:
+ row["outcome"] = "failed"
+ row["detail"] = f"exit {returncode}"
+ row["exit_code"] = returncode
+ active_proc = None
+ except OSError as exc:
+ row["outcome"] = "errored"
+ row["detail"] = f"runner error: {exc}"
+ row["exit_code"] = None
+ finally:
+ active_proc = None
+ row["duration_ms"] = max(0, int((time.monotonic() - started) * 1000))
+ shutil.rmtree(script_tmp, ignore_errors=True)
+
+ after_head, after_detached, after_clean = checkout_state(copy)
+ if after_head != doc["commit"] or not after_detached or not after_clean:
+ row["outcome"] = "errored"
+ row["detail"] = (
+ "checkout invariant violated by script "
+ f"(head={after_head}, detached={after_detached}, clean={after_clean})"
+ )
+ compromised = True
+ doc["checkout"]["invariant_ok"] = False
+ write_inventory(doc)
+ print(
+ "FM_TEST_COMPARE_ROW "
+ f"side={doc['label']} script={row['path']} outcome={row['outcome']} "
+ f"exit={row['exit_code']} duration_ms={row['duration_ms']}",
+ flush=True,
+ )
+
+ final_head, final_detached, final_clean = checkout_state(copy)
+ doc["checkout"]["final_head"] = final_head
+ doc["checkout"]["clean_after"] = final_clean
+ doc["checkout"]["invariant_ok"] = bool(
+ doc["checkout"]["invariant_ok"]
+ and final_head == doc["commit"] and final_detached and final_clean
+ )
+ write_inventory(doc)
+
+
+def run_confirmation(doc, copy, path, attempt):
+ global active_proc
+ if path not in doc["expected_scripts"]:
+ confirmations[path][doc["label"]].append("absent")
+ return "absent"
+ script_path = copy / path
+ if not script_path.is_file():
+ confirmations[path][doc["label"]].append("errored")
+ return "errored"
+ before_head, before_detached, before_clean = checkout_state(copy)
+ if before_head != doc["commit"] or not before_detached or not before_clean:
+ doc["checkout"]["invariant_ok"] = False
+ write_inventory(doc)
+ confirmations[path][doc["label"]].append("errored")
+ return "errored"
+ log_path = output_dir / doc["label"] / "confirmations" / (
+ f"{pathlib.Path(path).name}.attempt-{attempt}.log"
+ )
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ for name in (
+ "FM_HOME", "FM_STATE_OVERRIDE", "FM_DATA_OVERRIDE", "FM_ROOT_OVERRIDE",
+ "FM_PROJECTS_OVERRIDE", "FM_CONFIG_OVERRIDE", "FM_BACKEND",
+ ):
+ env.pop(name, None)
+ script_tmp = pathlib.Path(tempfile.mkdtemp(prefix=f"fm-confirm-{doc['label']}-"))
+ env["TMPDIR"] = str(script_tmp)
+ env["TMP"] = str(script_tmp)
+ outcome = "errored"
+ confirmations[path][doc["label"]].append("running")
+ write_current_partition()
+ try:
+ with log_path.open("wb") as log_stream:
+ proc = subprocess.Popen(
+ ["/bin/bash", path], cwd=copy, env=env,
+ stdout=log_stream, stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ active_proc = proc
+ try:
+ returncode = proc.wait(timeout=timeout_seconds)
+ except subprocess.TimeoutExpired:
+ stop_process_group(proc)
+ outcome = "timed_out"
+ else:
+ if returncode == 0:
+ outcome = (
+ "skipped" if first_meaningful_line(log_path).startswith("skip:")
+ else "passed"
+ )
+ elif returncode < 0:
+ outcome = "errored"
+ else:
+ outcome = "failed"
+ except OSError:
+ outcome = "errored"
+ finally:
+ active_proc = None
+ shutil.rmtree(script_tmp, ignore_errors=True)
+ after_head, after_detached, after_clean = checkout_state(copy)
+ if after_head != doc["commit"] or not after_detached or not after_clean:
+ doc["checkout"]["invariant_ok"] = False
+ outcome = "errored"
+ write_inventory(doc)
+ confirmations[path][doc["label"]][-1] = outcome
+ write_current_partition()
+ print(
+ "FM_TEST_COMPARE_CONFIRMATION "
+ f"side={doc['label']} script={path} attempt={attempt} outcome={outcome}",
+ flush=True,
+ )
+ return outcome
+
+
+def outcome_map(doc):
+ return {row["path"]: row["outcome"] for row in doc["scripts"]}
+
+
+def transition_entry(path, base_outcome, head_outcome, confirmation=None):
+ entry = {
+ "path": path,
+ "base_outcome": base_outcome,
+ "head_outcome": head_outcome,
+ }
+ if confirmation is not None:
+ entry["base_observations"] = confirmation["base"]
+ entry["head_observations"] = confirmation["head"]
+ return entry
+
+
+def make_partition(base, head):
+ base_rows = outcome_map(base)
+ head_rows = outcome_map(head)
+ all_paths = sorted(set(base["expected_scripts"]) | set(head["expected_scripts"]))
+ inherited = []
+ inherited_timeouts = []
+ introduced = []
+ regressed = []
+ fixed = []
+ now_passing = []
+ no_longer_measured = []
+ flaky = []
+ comparable_outcomes = 0
+ for path in all_paths:
+ base_outcome = base_rows.get(path, "absent")
+ head_outcome = head_rows.get(path, "absent")
+ confirmation = confirmations.get(path)
+ entry = transition_entry(path, base_outcome, head_outcome, confirmation)
+ if base_outcome in {"passed", "failed"} and head_outcome in {"passed", "failed"}:
+ comparable_outcomes += 1
+ if base_outcome == head_outcome:
+ if base_outcome in {"failed", "errored"}:
+ inherited.append(entry)
+ elif base_outcome == "timed_out":
+ inherited_timeouts.append(entry)
+ continue
+ if head_outcome == "absent" and base_outcome != "absent":
+ no_longer_measured.append(entry)
+ continue
+ if head_outcome == "skipped" and base_outcome not in {"absent", "skipped"}:
+ no_longer_measured.append(entry)
+ continue
+ if base_outcome == "failed" and head_outcome == "passed":
+ fixed.append(entry)
+ continue
+ if head_outcome == "passed" and base_outcome in {"timed_out", "errored", "skipped"}:
+ now_passing.append(entry)
+ continue
+ candidate_bucket = None
+ if base_outcome == "absent" and head_outcome in {"failed", "timed_out", "errored"}:
+ candidate_bucket = introduced
+ elif base_outcome != "absent" and head_outcome in {"failed", "timed_out", "errored"}:
+ candidate_bucket = regressed
+ if candidate_bucket is not None:
+ retry_outcomes = [] if confirmation is None else (
+ confirmation["base"][1:] + confirmation["head"][1:]
+ )
+ if (
+ confirmation is not None
+ and confirmation["head"][-1] == "passed"
+ and not any(outcome in {"errored", "running", "skipped", "timed_out"}
+ for outcome in retry_outcomes)
+ ):
+ flaky.append(entry)
+ else:
+ candidate_bucket.append(entry)
+ comparison_total = len(all_paths)
+ return {
+ "schema_version": 2,
+ "base_commit": base["commit"],
+ "head_commit": head["commit"],
+ "inventory_reconciled": bool(
+ base["reconciliation"]["accounted"]
+ and head["reconciliation"]["accounted"]
+ ),
+ "checkout_invariants_ok": bool(
+ base["checkout"]["invariant_ok"]
+ and head["checkout"]["invariant_ok"]
+ ),
+ "inherited_failures": inherited,
+ "inherited_timeouts": inherited_timeouts,
+ "head_introduced_failures": introduced,
+ "regressed": regressed,
+ "fixed": fixed,
+ "now_passing": now_passing,
+ "no_longer_measured": no_longer_measured,
+ "flaky_transitions": flaky,
+ "summary": {
+ "inherited": len(inherited),
+ "inherited_timeouts": len(inherited_timeouts),
+ "head_introduced": len(introduced),
+ "regressed": len(regressed),
+ "fixed": len(fixed),
+ "now_passing": len(now_passing),
+ "no_longer_measured": len(no_longer_measured),
+ "flaky": len(flaky),
+ "compared_outcomes": comparable_outcomes,
+ "comparison_total": comparison_total,
+ "coverage": f"{comparable_outcomes}/{comparison_total}",
+ },
+ }
+
+
+def confirm_regression_candidates(partition, base, head, copies):
+ paths = sorted(
+ row["path"]
+ for bucket in (partition["head_introduced_failures"], partition["regressed"])
+ for row in bucket
+ )
+ base_rows = outcome_map(base)
+ head_rows = outcome_map(head)
+ for path in paths:
+ observation = {
+ "base": [base_rows.get(path, "absent")],
+ "head": [head_rows.get(path, "absent")],
+ }
+ confirmations[path] = observation
+ run_confirmation(base, copies["base"], path, 1)
+ run_confirmation(head, copies["head"], path, 1)
+
+
+def current_partition():
+ if len(inventories) == 2:
+ partition = make_partition(*inventories)
+ else:
+ partition = {
+ "schema_version": 2,
+ "base_commit": base_commit,
+ "head_commit": head_commit,
+ "inventory_reconciled": False,
+ "checkout_invariants_ok": False,
+ "incomplete": True,
+ "inventories_available": len(inventories),
+ }
+ if termination is not None:
+ partition["termination"] = termination
+ return partition
+
+
+def write_current_partition():
+ atomic_json(output_dir / "partition.json", current_partition())
+
+
+def handle_signal(signum, _frame):
+ global termination
+ termination = {
+ "complete": False,
+ "signal": signal.Signals(signum).name,
+ "exit_code": 128 + signum,
+ }
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ if active_proc is not None:
+ stop_process_group(active_proc)
+ for doc in inventories:
+ write_inventory(doc)
+ write_current_partition()
+ raise SystemExit(128 + signum)
+
+
+base_commit = None
+head_commit = None
+try:
+ if timeout_seconds < 1:
+ raise RuntimeError("--script-timeout must be a positive integer")
+ if output_dir.exists():
+ if not output_dir.is_dir():
+ raise RuntimeError(f"--output-dir must be a directory: {output_dir}")
+ if any(output_dir.iterdir()):
+ raise RuntimeError(f"--output-dir must be absent or empty: {output_dir}")
+ output_dir.mkdir(parents=True, exist_ok=True)
+ base_commit = resolve_commit(base_ref)
+ head_commit = resolve_commit(head_ref)
+ signal.signal(signal.SIGINT, handle_signal)
+ signal.signal(signal.SIGTERM, handle_signal)
+ temp_root = pathlib.Path(tempfile.mkdtemp(prefix="fm-test-compare-"))
+ copies = {}
+ for label, commit in (("base", base_commit), ("head", head_commit)):
+ copy = temp_root / label
+ clone = subprocess.run(
+ ["git", "clone", "--no-local", "--no-checkout", "--quiet", str(root), str(copy)],
+ text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ )
+ if clone.returncode != 0:
+ raise RuntimeError(f"could not create isolated {label} copy: {clone.stderr.strip()}")
+ checkout = git("checkout", "--detach", "--quiet", commit, cwd=copy, check=False)
+ if checkout.returncode != 0:
+ raise RuntimeError(f"could not detach {label} at {commit}: {checkout.stderr.strip()}")
+ copies[label] = copy
+
+ base = make_inventory("base", base_ref, base_commit, copies["base"])
+ head = make_inventory("head", head_ref, head_commit, copies["head"])
+ inventories.extend((base, head))
+ run_inventory(base, copies["base"])
+ run_inventory(head, copies["head"])
+ partition = make_partition(base, head)
+ write_current_partition()
+ confirm_regression_candidates(partition, base, head, copies)
+ partition = make_partition(base, head)
+ write_current_partition()
+ for doc in (base, head):
+ summary = doc["summary"]
+ print(
+ "FM_TEST_COMPARE_INVENTORY "
+ f"side={doc['label']} commit={doc['commit']} total={summary['total']} "
+ f"passed={summary['passed']} failed={summary['failed']} "
+ f"timed_out={summary['timed_out']} skipped={summary['skipped']} "
+ f"errored={summary['errored']} running={summary['running']} "
+ f"accounted={str(doc['reconciliation']['accounted']).lower()} "
+ f"checkout_ok={str(doc['checkout']['invariant_ok']).lower()}",
+ flush=True,
+ )
+ summary = partition["summary"]
+ print(
+ "FM_TEST_COMPARE_PARTITION "
+ f"inherited={summary['inherited']} inherited_timeouts={summary['inherited_timeouts']} "
+ f"head_introduced={summary['head_introduced']} regressed={summary['regressed']} "
+ f"fixed={summary['fixed']} now_passing={summary['now_passing']} "
+ f"no_longer_measured={summary['no_longer_measured']} flaky={summary['flaky']} "
+ f"compared_outcomes={summary['compared_outcomes']} coverage={summary['coverage']} "
+ f"output_dir={output_dir}",
+ flush=True,
+ )
+ valid = partition["inventory_reconciled"] and partition["checkout_invariants_ok"]
+ refused = bool(
+ partition["head_introduced_failures"]
+ or partition["regressed"]
+ or partition["no_longer_measured"]
+ or not valid
+ )
+ raise SystemExit(1 if refused else 0)
+except BaseException as exc:
+ for doc in inventories:
+ write_inventory(doc)
+ if output_dir.exists() and output_dir.is_dir():
+ write_current_partition()
+ if isinstance(exc, SystemExit):
+ raise
+ print(f"fm-test-run: compare failed: {exc}", file=sys.stderr)
+ raise SystemExit(2)
+finally:
+ if temp_root is not None:
+ shutil.rmtree(temp_root, ignore_errors=True)
+PY
+}
+
while [ "$#" -gt 0 ]; do
case "$1" in
--all)
@@ -1236,6 +1875,14 @@ while [ "$#" -gt 0 ]; do
MODE=changed
shift
;;
+ --compare-commits)
+ [ -z "$MODE" ] || die "only one selection mode is allowed"
+ [ "$#" -gt 2 ] || die "--compare-commits requires "
+ MODE=compare
+ COMPARE_BASE=$2
+ COMPARE_HEAD=$3
+ shift 3
+ ;;
--base)
[ "$#" -gt 1 ] || die "--base requires a git ref"
BASE_REF=$2
@@ -1254,6 +1901,24 @@ while [ "$#" -gt 0 ]; do
JSON_PATH=${1#--json=}
shift
;;
+ --script-timeout)
+ [ "$#" -gt 1 ] || die "--script-timeout requires a positive integer"
+ SCRIPT_TIMEOUT=$2
+ shift 2
+ ;;
+ --script-timeout=*)
+ SCRIPT_TIMEOUT=${1#--script-timeout=}
+ shift
+ ;;
+ --output-dir)
+ [ "$#" -gt 1 ] || die "--output-dir requires a path"
+ COMPARE_OUTPUT_DIR=$2
+ shift 2
+ ;;
+ --output-dir=*)
+ COMPARE_OUTPUT_DIR=${1#--output-dir=}
+ shift
+ ;;
--jobs)
[ "$#" -gt 1 ] || die "--jobs requires a positive integer"
JOBS=$2
@@ -1358,6 +2023,17 @@ if [ "${MODE:-}" = "aggregate" ]; then
exit 0
fi
+if [ "${MODE:-}" = "compare" ]; then
+ [ "$LIST_ONLY" -eq 0 ] || die "--list cannot be combined with --compare-commits"
+ [ -n "$COMPARE_OUTPUT_DIR" ] || die "--compare-commits requires --output-dir "
+ case "$SCRIPT_TIMEOUT" in
+ ''|*[!0-9]*) die "--script-timeout must be a positive integer" ;;
+ esac
+ [ "$SCRIPT_TIMEOUT" -ge 1 ] || die "--script-timeout must be >= 1"
+ compare_commits "$COMPARE_BASE" "$COMPARE_HEAD" "$COMPARE_OUTPUT_DIR" "$SCRIPT_TIMEOUT"
+ exit $?
+fi
+
case "$JOBS" in
''|*[!0-9]*) die "--jobs must be a positive integer" ;;
esac
diff --git a/docs/scripts.md b/docs/scripts.md
index 484911c380..1450be455c 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -31,7 +31,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks |
| `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees |
| `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane |
-| `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, proven-isolated `--jobs`, coverage guard, timing/JSON |
+| `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, proven-isolated `--jobs`, coverage guard, timing/JSON, and isolated base/head comparison |
| `fm-test-isolation-proof.sh` | Concurrent isolation proof and proven-isolated candidate set owner |
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section |
| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and unhealthy supervision |
diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh
index 21bdd69ba5..6b302fa646 100755
--- a/tests/fm-test-run.test.sh
+++ b/tests/fm-test-run.test.sh
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Contract tests for bin/fm-test-run.sh - the single owner of behavior suite
# selection, portable lane composition, proven-isolated --jobs, timing markers,
-# JSON artifacts, coverage guard, and aggregate exit status.
+# JSON artifacts, coverage guard, aggregate exit status, and bounded detached
+# base/head failure partitioning.
#
# These tests intentionally exercise the runner with fixtures, --list, and
# focused scheduler checks, not the complete Firstmate suite.
@@ -669,6 +670,675 @@ assert len(doc["scripts"])==3
pass "aggregate-json merges lane timing artifacts"
}
+init_compare_fixture_repo() {
+ local repo=$1
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-pass.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - pass"
+SH
+ cat >"$repo/tests/ab-inherited.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - inherited"
+exit 1
+SH
+ cat >"$repo/tests/ac-fixed.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - base failure"
+exit 1
+SH
+ cat >"$repo/tests/ad-introduced.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - base pass"
+SH
+ cat >"$repo/tests/ae-skip.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "skip: optional fixture unavailable"
+SH
+ cat >"$repo/tests/af-hang.test.sh" <<'SH'
+#!/usr/bin/env bash
+trap '' TERM
+sleep 30
+SH
+ chmod +x "$repo"/tests/*.test.sh
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+}
+
+test_compare_commits_partitions_and_bounds_every_script() {
+ local tmp repo base head rc out
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare.XXXXXX")
+ repo="$tmp/repo"
+ init_compare_fixture_repo "$repo"
+ base=$(git -C "$repo" rev-parse HEAD)
+ cat >"$repo/tests/ac-fixed.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - fixed"
+SH
+ cat >"$repo/tests/ad-introduced.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - introduced"
+exit 1
+SH
+ chmod +x "$repo/tests/ac-fixed.test.sh" "$repo/tests/ad-introduced.test.sh"
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ out=$(cd "$repo" && bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 1 --output-dir "$tmp/result" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "introduced failure must make compare exit 1 (got $rc): $(cat "$tmp/err")"; }
+ assert_contains "$out" "FM_TEST_COMPARE_INVENTORY side=base" "base terminal inventory marker"
+ assert_contains "$out" "FM_TEST_COMPARE_INVENTORY side=head" "head terminal inventory marker"
+ assert_contains "$out" "FM_TEST_COMPARE_PARTITION inherited=1 inherited_timeouts=1 head_introduced=0 regressed=1 fixed=1" \
+ "outcome-transition partition"
+ if ! python3 - "$tmp/result/base.json" "$tmp/result/head.json" "$tmp/result/partition.json" <<'PY'
+import json, sys
+base, head, partition = (json.load(open(path, encoding="utf-8")) for path in sys.argv[1:])
+assert base["reconciliation"]["accounted"] is True, base
+assert head["reconciliation"]["accounted"] is True, head
+assert base["checkout"]["invariant_ok"] is True, base
+assert head["checkout"]["invariant_ok"] is True, head
+assert base["summary"] == {"errored": 0, "failed": 2, "passed": 2, "running": 0, "skipped": 1, "timed_out": 1, "total": 6}, base
+assert head["summary"] == {"errored": 0, "failed": 2, "passed": 2, "running": 0, "skipped": 1, "timed_out": 1, "total": 6}, head
+base_rows = {row["path"]: row["outcome"] for row in base["scripts"]}
+head_rows = {row["path"]: row["outcome"] for row in head["scripts"]}
+assert base_rows["tests/af-hang.test.sh"] == "timed_out", base_rows
+assert head_rows["tests/af-hang.test.sh"] == "timed_out", head_rows
+assert [row["path"] for row in partition["inherited_failures"]] == [
+ "tests/ab-inherited.test.sh"
+], partition
+assert [row["path"] for row in partition["inherited_timeouts"]] == ["tests/af-hang.test.sh"], partition
+assert partition["head_introduced_failures"] == [], partition
+assert [row["path"] for row in partition["regressed"]] == ["tests/ad-introduced.test.sh"], partition
+assert [row["path"] for row in partition["fixed"]] == ["tests/ac-fixed.test.sh"], partition
+assert partition["summary"]["coverage"] == "4/6", partition
+PY
+ then
+ rm -rf "$tmp"
+ fail "compare JSON inventories or partition are wrong"
+ fi
+ rm -rf "$tmp"
+ pass "commit comparison bounds a deliberate hang and emits complete diffable inventories"
+}
+
+test_compare_commits_accounts_for_script_lost_after_discovery() {
+ local tmp repo commit rc
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-missing.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-delete-next.test.sh" <<'SH'
+#!/usr/bin/env bash
+rm tests/zz-victim.test.sh
+SH
+ cat >"$repo/tests/zz-victim.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - should never run from a corrupted checkout"
+SH
+ chmod +x "$repo"/tests/*.test.sh
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm fixture
+ commit=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$commit" "$commit" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "checkout corruption must make compare exit 1 (got $rc)"; }
+ if ! python3 - "$tmp/result/base.json" "$tmp/result/head.json" "$tmp/result/partition.json" <<'PY'
+import json, sys
+base, head, partition = (json.load(open(path, encoding="utf-8")) for path in sys.argv[1:])
+for doc in (base, head):
+ assert doc["discovered_scripts"] == ["tests/aa-delete-next.test.sh", "tests/zz-victim.test.sh"], doc
+ assert [row["path"] for row in doc["scripts"]] == doc["discovered_scripts"], doc
+ assert doc["reconciliation"] == {
+ "accounted": True, "discovered_count": 2, "duplicates": [],
+ "expected_count": 2, "extra": [], "extra_in_discovery": [],
+ "inventory_count": 2, "missing": [], "missing_from_discovery": []
+ }, doc
+ assert all(row["outcome"] == "errored" for row in doc["scripts"]), doc
+ assert doc["checkout"]["invariant_ok"] is False, doc
+assert partition["inventory_reconciled"] is True, partition
+assert partition["checkout_invariants_ok"] is False, partition
+PY
+ then
+ rm -rf "$tmp"
+ fail "lost-script accounting did not fail closed"
+ fi
+ rm -rf "$tmp"
+ pass "a script lost after discovery remains inventoried as errored and checkout drift fails loudly"
+}
+
+test_compare_commits_rechecks_a_first_only_failure() {
+ local tmp repo marker base head rc fail_detail
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-flake.XXXXXX")
+ repo="$tmp/repo"
+ marker="$tmp/head-failed-once"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-flaky.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - stable base"
+SH
+ chmod +x "$repo/tests/aa-flaky.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+ base=$(git -C "$repo" rev-parse HEAD)
+ cat >"$repo/tests/aa-flaky.test.sh" <"$marker"
+ echo "not ok - first-only environmental failure"
+ exit 1
+fi
+echo "ok - retry passes"
+SH
+ chmod +x "$repo/tests/aa-flaky.test.sh"
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 0 ] || {
+ fail_detail=$(cat "$tmp/err" 2>/dev/null || true)
+ rm -rf "$tmp"
+ fail "a first-only failure must not be a regression (got $rc): $fail_detail"
+ }
+ python3 - "$tmp/result/partition.json" <<'PY' || {
+import json, sys
+partition = json.load(open(sys.argv[1], encoding="utf-8"))
+assert partition["head_introduced_failures"] == [], partition
+assert partition["regressed"] == [], partition
+assert [row["path"] for row in partition["flaky_transitions"]] == ["tests/aa-flaky.test.sh"], partition
+row = partition["flaky_transitions"][0]
+assert row["base_observations"] == ["passed", "passed"], row
+assert row["head_observations"] == ["failed", "passed"], row
+PY
+ rm -rf "$tmp"
+ fail "first-only failure was not recorded as flaky"
+ }
+ rm -rf "$tmp"
+ pass "a first-only failure is rechecked and recorded as flaky, not introduced"
+}
+
+test_compare_commits_refuses_an_inconclusive_retry() {
+ local tmp repo marker base head rc
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-inconclusive.XXXXXX")
+ repo="$tmp/repo"
+ marker="$tmp/head-failed-once"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-inconclusive.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - stable base"
+SH
+ chmod +x "$repo/tests/aa-inconclusive.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+ base=$(git -C "$repo" rev-parse HEAD)
+ cat >"$repo/tests/aa-inconclusive.test.sh" <"$marker"
+ echo "not ok - first head failure"
+ exit 1
+fi
+trap '' TERM
+sleep 30
+SH
+ chmod +x "$repo/tests/aa-inconclusive.test.sh"
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "inconclusive retry must exit 1 (got $rc)"; }
+ python3 - "$tmp/result/partition.json" <<'PY' || {
+import json, sys
+partition = json.load(open(sys.argv[1], encoding="utf-8"))
+assert partition["head_introduced_failures"] == [], partition
+assert partition["flaky_transitions"] == [], partition
+assert partition["regressed"] == [{
+ "base_observations": ["passed", "passed"],
+ "base_outcome": "passed",
+ "head_observations": ["failed", "timed_out"],
+ "head_outcome": "failed",
+ "path": "tests/aa-inconclusive.test.sh",
+}], partition
+PY
+ rm -rf "$tmp"
+ fail "inconclusive retry was not retained as a refusing regression"
+ }
+ rm -rf "$tmp"
+ pass "an inconclusive retry remains a regression"
+}
+
+test_compare_commits_reports_timeout_to_failure_as_regressed() {
+ local tmp repo base head rc
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-regressed.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-transition.test.sh" <<'SH'
+#!/usr/bin/env bash
+trap '' TERM
+sleep 30
+SH
+ chmod +x "$repo/tests/aa-transition.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+ base=$(git -C "$repo" rev-parse HEAD)
+ cat >"$repo/tests/aa-transition.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - assertion now reaches a real failure"
+exit 1
+SH
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "timeout-to-failure regression must exit 1 (got $rc)"; }
+ assert_contains "$(cat "$tmp/out")" "regressed=1" "timeout-to-failure regression headline"
+ python3 - "$tmp/result/partition.json" <<'PY' || {
+import json, sys
+partition = json.load(open(sys.argv[1], encoding="utf-8"))
+assert partition["inherited_failures"] == [], partition
+assert partition["regressed"] == [{
+ "base_observations": ["timed_out", "timed_out"],
+ "base_outcome": "timed_out",
+ "head_observations": ["failed", "failed"],
+ "head_outcome": "failed",
+ "path": "tests/aa-transition.test.sh",
+}], partition
+PY
+ rm -rf "$tmp"
+ fail "timeout-to-failure transition was not classified as regressed"
+ }
+ rm -rf "$tmp"
+ pass "timeout-to-failure is a confirmed regression and exits non-zero"
+}
+
+test_compare_commits_never_calls_deleted_or_skipped_tests_fixed() {
+ local tmp repo base head rc
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-erosion.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ for script in aa-deleted bb-skipped cc-fixed; do
+ cat >"$repo/tests/$script.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - base failure"
+exit 1
+SH
+ done
+ chmod +x "$repo"/tests/*.test.sh
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+ base=$(git -C "$repo" rev-parse HEAD)
+ git -C "$repo" rm -q tests/aa-deleted.test.sh
+ cat >"$repo/tests/bb-skipped.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "skip: head no longer measures this test"
+SH
+ cat >"$repo/tests/cc-fixed.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - genuinely fixed"
+SH
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "deleted or skipped coverage must exit 1 (got $rc)"; }
+ python3 - "$tmp/result/partition.json" <<'PY' || {
+import json, sys
+partition = json.load(open(sys.argv[1], encoding="utf-8"))
+assert [row["path"] for row in partition["fixed"]] == ["tests/cc-fixed.test.sh"], partition
+assert [row["path"] for row in partition["no_longer_measured"]] == [
+ "tests/aa-deleted.test.sh", "tests/bb-skipped.test.sh",
+], partition
+assert partition["summary"]["fixed"] == 1, partition
+assert partition["summary"]["no_longer_measured"] == 2, partition
+PY
+ rm -rf "$tmp"
+ fail "deleted or skipped tests were confused with genuine fixes"
+ }
+ rm -rf "$tmp"
+ pass "only failure-to-pass is fixed; deletion and skip are coverage erosion"
+}
+
+test_compare_commits_refuses_an_independent_enumeration_mismatch() {
+ local tmp repo commit rc
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-enumeration.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-real.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - real test"
+SH
+ chmod +x "$repo/tests/aa-real.test.sh"
+ ln -s ./nowhere-nothing "$repo/tests/zz-ghost.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm fixture
+ commit=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$commit" "$commit" \
+ --script-timeout 1 --output-dir "$tmp/result" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 1 ] || { rm -rf "$tmp"; fail "enumeration mismatch must exit 1 (got $rc)"; }
+ assert_contains "$(cat "$tmp/out")" "accounted=false" "enumeration mismatch inventory marker"
+ python3 - "$tmp/result/base.json" "$tmp/result/head.json" "$tmp/result/partition.json" <<'PY' || {
+import json, sys
+base, head, partition = (json.load(open(path, encoding="utf-8")) for path in sys.argv[1:])
+for doc in (base, head):
+ assert doc["expected_scripts"] == ["tests/aa-real.test.sh", "tests/zz-ghost.test.sh"], doc
+ assert doc["discovered_scripts"] == ["tests/aa-real.test.sh"], doc
+ assert doc["reconciliation"]["accounted"] is False, doc
+ assert doc["reconciliation"]["missing_from_discovery"] == ["tests/zz-ghost.test.sh"], doc
+ rows = {row["path"]: row for row in doc["scripts"]}
+ assert rows["tests/zz-ghost.test.sh"]["outcome"] == "errored", rows
+ assert rows["tests/zz-ghost.test.sh"]["detail"] == "not_run: tracked test path is not a regular file", rows
+assert partition["inventory_reconciled"] is False, partition
+PY
+ rm -rf "$tmp"
+ fail "independent enumeration mismatch did not fail closed"
+ }
+ rm -rf "$tmp"
+ pass "tracked test paths missing from discovery make accounted refuse"
+}
+
+test_compare_commits_headline_exposes_measured_coverage() {
+ local tmp repo commit rc out
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-coverage.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-pass.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - measured"
+SH
+ for script in bb-timeout cc-timeout; do
+ cat >"$repo/tests/$script.test.sh" <<'SH'
+#!/usr/bin/env bash
+trap '' TERM
+sleep 30
+SH
+ done
+ chmod +x "$repo"/tests/*.test.sh
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm fixture
+ commit=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ out=$(cd "$repo" && bin/fm-test-run.sh --compare-commits "$commit" "$commit" \
+ --script-timeout 1 --output-dir "$tmp/result" 2>"$tmp/err")
+ rc=$?
+ set -e
+ [ "$rc" -eq 0 ] || { rm -rf "$tmp"; fail "inherited timeouts alone must remain inspectable (got $rc)"; }
+ assert_contains "$out" "inherited_timeouts=2" "timeout-specific inherited count"
+ assert_contains "$out" "compared_outcomes=1 coverage=1/3" "honest compared-outcome coverage"
+ rm -rf "$tmp"
+ pass "headline distinguishes timeouts from one genuinely compared outcome"
+}
+
+test_compare_commits_preflight_errors_exit_two_without_tracebacks() {
+ local tmp repo commit rc err
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-preflight.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-pass.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "ok - pass"
+SH
+ chmod +x "$repo/tests/aa-pass.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm fixture
+ commit=$(git -C "$repo" rev-parse HEAD)
+
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits definitely-missing "$commit" \
+ --script-timeout 1 --output-dir "$tmp/bad-ref" >"$tmp/out" 2>"$tmp/err")
+ rc=$?
+ set -e
+ err=$(cat "$tmp/err")
+ [ "$rc" -eq 2 ] || { rm -rf "$tmp"; fail "bad ref must exit 2 (got $rc)"; }
+ assert_contains "$err" "fm-test-run: compare failed: commit not found: definitely-missing" "bad-ref reason"
+ case "$err" in *Traceback*) rm -rf "$tmp"; fail "bad ref must not emit a traceback" ;; esac
+
+ : >"$tmp/not-a-directory"
+ set +e
+ (cd "$repo" && bin/fm-test-run.sh --compare-commits "$commit" "$commit" \
+ --script-timeout 1 --output-dir "$tmp/not-a-directory" >"$tmp/out2" 2>"$tmp/err2")
+ rc=$?
+ set -e
+ err=$(cat "$tmp/err2")
+ [ "$rc" -eq 2 ] || { rm -rf "$tmp"; fail "non-directory output path must exit 2 (got $rc)"; }
+ assert_contains "$err" "fm-test-run: compare failed: --output-dir must be a directory" "output-dir reason"
+ case "$err" in *Traceback*) rm -rf "$tmp"; fail "bad output dir must not emit a traceback" ;; esac
+ rm -rf "$tmp"
+ pass "bad refs and output paths are infrastructure errors with exact reasons"
+}
+
+test_compare_commits_signal_records_inflight_and_cleans_clones() {
+ local tmp repo commit run_dir runner_pid python_pid rc clone_path signal_name expected_rc i
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-signal.XXXXXX")
+ repo="$tmp/repo"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-slow.test.sh" <<'SH'
+#!/usr/bin/env bash
+trap '' TERM
+sleep 30
+SH
+ chmod +x "$repo/tests/aa-slow.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm fixture
+ commit=$(git -C "$repo" rev-parse HEAD)
+
+ for signal_name in TERM INT; do
+ run_dir="$tmp/result-$signal_name"
+ (cd "$repo" && exec bin/fm-test-run.sh --compare-commits "$commit" "$commit" \
+ --script-timeout 60 --output-dir "$run_dir" >"$tmp/out-$signal_name" 2>"$tmp/err-$signal_name") &
+ runner_pid=$!
+ clone_path=
+ i=0
+ while [ "$i" -lt 100 ]; do
+ i=$((i + 1))
+ if [ -f "$run_dir/base.json" ]; then
+ clone_path=$(python3 - "$run_dir/base.json" <<'PY'
+import json, sys
+doc = json.load(open(sys.argv[1], encoding="utf-8"))
+row = doc["scripts"][0]
+if row["outcome"] == "running" and row["detail"] == "in flight":
+ print(doc["checkout"]["path"])
+PY
+)
+ [ -n "$clone_path" ] && break
+ fi
+ sleep 0.05
+ done
+ [ -n "$clone_path" ] || {
+ kill -TERM "$runner_pid" 2>/dev/null || true
+ wait "$runner_pid" 2>/dev/null || true
+ rm -rf "$tmp"
+ fail "$signal_name fixture never recorded its in-flight script"
+ }
+ python_pid=$(ps -axo pid=,ppid=,command= | awk -v result="$run_dir" \
+ 'index($0, result) && $0 ~ /[Pp]ython/ { print $1; exit }')
+ # Bash may exec its final foreground command, in which case the recorded
+ # runner PID is already the Python compare worker.
+ [ -n "$python_pid" ] || python_pid=$runner_pid
+ kill -"$signal_name" "$python_pid"
+ set +e
+ wait "$runner_pid"
+ rc=$?
+ set -e
+ case "$signal_name" in
+ TERM) expected_rc=143 ;;
+ INT) expected_rc=130 ;;
+ esac
+ [ "$rc" -eq "$expected_rc" ] || { rm -rf "$tmp"; fail "$signal_name must exit $expected_rc (got $rc)"; }
+ python3 - "$run_dir/base.json" "$run_dir/partition.json" "$signal_name" "$expected_rc" <<'PY' || {
+import json, sys
+base = json.load(open(sys.argv[1], encoding="utf-8"))
+partition = json.load(open(sys.argv[2], encoding="utf-8"))
+row = base["scripts"][0]
+assert row["outcome"] == "running", row
+assert row["detail"] == "in flight", row
+assert partition["termination"] == {
+ "complete": False, "exit_code": int(sys.argv[4]), "signal": f"SIG{sys.argv[3]}"
+}, partition
+PY
+ rm -rf "$tmp"
+ fail "$signal_name artifacts did not preserve in-flight and termination evidence"
+ }
+ [ ! -e "$(dirname "$clone_path")" ] || { rm -rf "$tmp"; fail "$signal_name leaked isolated clones at $(dirname "$clone_path")"; }
+ done
+ rm -rf "$tmp"
+ pass "SIGTERM and SIGINT preserve partial evidence and remove isolated clones"
+}
+
+test_compare_commits_signal_records_inflight_confirmation() {
+ local tmp repo marker base head run_dir runner_pid python_pid rc clone_root i
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-compare-confirmation-signal.XXXXXX")
+ repo="$tmp/repo"
+ marker="$tmp/base-confirmation-started"
+ mkdir -p "$repo/bin" "$repo/tests"
+ cp "$RUNNER" "$repo/bin/fm-test-run.sh"
+ chmod +x "$repo/bin/fm-test-run.sh"
+ cat >"$repo/tests/aa-transition.test.sh" <"$marker"
+echo "ok - initial base run"
+SH
+ chmod +x "$repo/tests/aa-transition.test.sh"
+ git -C "$repo" init -q
+ git -C "$repo" add bin tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm base
+ base=$(git -C "$repo" rev-parse HEAD)
+ cat >"$repo/tests/aa-transition.test.sh" <<'SH'
+#!/usr/bin/env bash
+echo "not ok - head regression"
+exit 1
+SH
+ chmod +x "$repo/tests/aa-transition.test.sh"
+ git -C "$repo" add tests
+ git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm head
+ head=$(git -C "$repo" rev-parse HEAD)
+ run_dir="$tmp/result"
+ (cd "$repo" && exec bin/fm-test-run.sh --compare-commits "$base" "$head" \
+ --script-timeout 60 --output-dir "$run_dir" >"$tmp/out" 2>"$tmp/err") &
+ runner_pid=$!
+ clone_root=
+ i=0
+ while [ "$i" -lt 100 ]; do
+ i=$((i + 1))
+ if [ -f "$run_dir/partition.json" ]; then
+ clone_root=$(python3 - "$run_dir/base.json" "$run_dir/partition.json" <<'PY'
+import json, sys
+base = json.load(open(sys.argv[1], encoding="utf-8"))
+partition = json.load(open(sys.argv[2], encoding="utf-8"))
+rows = partition.get("regressed", [])
+if rows and rows[0].get("base_observations") == ["passed", "running"] and rows[0].get("head_observations") == ["failed"]:
+ print(__import__("pathlib").Path(base["checkout"]["path"]).parent)
+PY
+)
+ [ -n "$clone_root" ] && break
+ fi
+ sleep 0.05
+ done
+ [ -n "$clone_root" ] || {
+ kill -TERM "$runner_pid" 2>/dev/null || true
+ wait "$runner_pid" 2>/dev/null || true
+ rm -rf "$tmp"
+ fail "confirmation fixture never recorded its running observation"
+ }
+ python_pid=$(ps -axo pid=,ppid=,command= | awk -v result="$run_dir" \
+ 'index($0, result) && $0 ~ /[Pp]ython/ { print $1; exit }')
+ [ -n "$python_pid" ] || python_pid=$runner_pid
+ kill -TERM "$python_pid"
+ set +e
+ wait "$runner_pid"
+ rc=$?
+ set -e
+ [ "$rc" -eq 143 ] || { rm -rf "$tmp"; fail "SIGTERM during confirmation must exit 143 (got $rc)"; }
+ python3 - "$run_dir/partition.json" <<'PY' || {
+import json, sys
+partition = json.load(open(sys.argv[1], encoding="utf-8"))
+assert partition["termination"] == {"complete": False, "exit_code": 143, "signal": "SIGTERM"}, partition
+rows = partition["regressed"]
+assert rows == [{
+ "base_observations": ["passed", "running"],
+ "base_outcome": "passed",
+ "head_observations": ["failed"],
+ "head_outcome": "failed",
+ "path": "tests/aa-transition.test.sh",
+}], partition
+PY
+ rm -rf "$tmp"
+ fail "SIGTERM artifacts did not preserve the in-flight confirmation"
+ }
+ [ ! -e "$clone_root" ] || { rm -rf "$tmp"; fail "SIGTERM leaked isolated clones at $clone_root"; }
+ rm -rf "$tmp"
+ pass "SIGTERM during confirmation preserves a running observation"
+}
+
+if [ -n "${FM_TEST_RUN_ONLY:-}" ]; then
+ "$FM_TEST_RUN_ONLY"
+ exit $?
+fi
+
test_list_all_exact_suite_coverage
test_family_selection
test_single_script_selection
@@ -686,3 +1356,14 @@ test_portable_serial_shard_lane_refusals
test_jobs_requires_proven_isolated
test_jobs_parallel_scheduler_and_failure_propagation
test_aggregate_json
+test_compare_commits_partitions_and_bounds_every_script
+test_compare_commits_accounts_for_script_lost_after_discovery
+test_compare_commits_rechecks_a_first_only_failure
+test_compare_commits_refuses_an_inconclusive_retry
+test_compare_commits_reports_timeout_to_failure_as_regressed
+test_compare_commits_never_calls_deleted_or_skipped_tests_fixed
+test_compare_commits_refuses_an_independent_enumeration_mismatch
+test_compare_commits_headline_exposes_measured_coverage
+test_compare_commits_preflight_errors_exit_two_without_tracebacks
+test_compare_commits_signal_records_inflight_and_cleans_clones
+test_compare_commits_signal_records_inflight_confirmation