Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 62 additions & 21 deletions .github/workflows/det-sweep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ name: Det Sweep
#
# Fast (#4463 follow-up): three jobs. `plan` reads the diff: a pull request that touches only tests/equivalence/<case>/
# proves just those cases (and the cases that take their ports); anything else, and every nightly, proves all
# of them (tests/tools/det_sweep_plan.py: when in doubt, full). `shard` proves the plan on up to 6 runners
# of them (tests/tools/det_sweep_plan.py: when in doubt, full). `shard` proves the plan on up to 20 runners (#4814: one per ~3 cases; a translator change is narrowed by its port diff)
# (proof_sweep.py --shard I/N, balanced by tests/equivalence/det_sweep_durations.json), each with the GnuCOBOL image
# restored from cache. (A cache of the COBOL-side step outputs was tried and dropped: it saved no measurable time --
# the Java build and the fault runs dominate a proof, not the COBOL step.)
Expand All @@ -23,19 +23,8 @@ name: Det Sweep
# the harness and oracle, the cases, the corpus pin.

on:
pull_request:
branches: [main]
paths:
- "gitgalaxy/tools/cobol_to_java/**"
- "tests/tools/det_port.py"
- "tests/tools/det_parity.py"
- "tests/tools/proof_sweep.py"
- "tests/tools/equivalence*.py"
- "tests/tools/mainframe_corpus.py"
- "tests/equivalence/**"
- "tests/cobol_mainframe/corpora.json"
- ".github/workflows/det-sweep.yml"
- "!**/*.md"
pull_request: # #4825: no path filter -- `det` is a required check; det_sweep_plan.py proves nothing (mode none)
branches: [main] # when no changed file is a det input (DET_INPUTS), so a docs-only PR passes `det` in about a minute
schedule:
- cron: "41 4 * * *" # nightly: the scan engine moves too (#4245 broke the mortgage ports from an engine change)
workflow_dispatch:
Expand All @@ -51,12 +40,16 @@ jobs:
plan:
name: plan
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
mode: ${{ steps.plan.outputs.mode }}
cases: ${{ steps.plan.outputs.cases }}
matrix: ${{ steps.plan.outputs.matrix }}
shards: ${{ steps.plan.outputs.shards }}
timeout-minutes: 25 # a minute normally; ~10 when a translator change needs the port diff (#4814)
env:
GITGALAXY_LICENSE_KEY: COMMUNITY_FREE_TIER
PYTHONUTF8: "1"
GITGALAXY_MAINFRAME_CORPORA: ${{ github.workspace }}/.mainframe_corpora
outputs: # #4814: the port-diff plan (steps.final) replaces the first one when it ran
mode: ${{ steps.final.outputs.mode || steps.plan.outputs.mode }}
cases: ${{ steps.final.outputs.cases || steps.plan.outputs.cases }}
matrix: ${{ steps.final.outputs.matrix || steps.plan.outputs.matrix }}
shards: ${{ steps.final.outputs.shards || steps.plan.outputs.shards }}
steps:
- name: Checkout GitGalaxy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -77,12 +70,60 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

# #4814: a change confined to the det translator / runtime (mode "ports") is judged by the ports it changes:
# det_port.py check translates every case at origin/main and at HEAD (no Docker, no JDK) and lists the ports
# that differ; only those, the cases the PR touched and their dependents are proven. The rest are carried
# forward from main, where the nightly full sweep (schedule) re-proves everything with no shortcut.
- name: Setup Python (port diff)
if: steps.plan.outputs.mode == 'ports'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip"

- name: Install GitGalaxy with the translator (port diff)
if: steps.plan.outputs.mode == 'ports'
run: |
python -m pip install --upgrade pip
pip install -e ".[translator]"

- name: Cache the pinned corpora (port diff)
if: steps.plan.outputs.mode == 'ports'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .mainframe_corpora
key: mainframe-corpora-${{ hashFiles('tests/cobol_mainframe/corpora.json') }}

- name: Port diff against main
if: steps.plan.outputs.mode == 'ports'
run: |
rm -rf "$GITGALAXY_MAINFRAME_CORPORA/_scans"
PYTHONPATH="$PWD" python tests/tools/mainframe_corpus.py fetch
# exit 1 means "some port changed"; a crash leaves no check.json and the next step plans a full sweep
PYTHONPATH="$PWD" python tests/tools/det_port.py check --work "$RUNNER_TEMP/portcheck" --base-ref origin/main \
| tail -40 || true

- name: Plan from the port diff
id: final
if: steps.plan.outputs.mode == 'ports'
env:
TOUCHED: ${{ steps.plan.outputs.cases }}
run: |
python3 tests/tools/det_sweep_plan.py --ports "$RUNNER_TEMP/portcheck/check.json" --touched "$TOUCHED" \
--github-output "$GITHUB_OUTPUT" | tee plan2.json
{
echo "## Det sweep plan, from the port diff"
echo '```json'
cat plan2.json
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

shard:
name: shard ${{ matrix.shard }}
needs: plan
if: needs.plan.outputs.shards != '0'
runs-on: ubuntu-latest
timeout-minutes: 30 # a shard is ~1/6 of the old 30 min sweep; a hang should fail, not hold a runner for 6 h
timeout-minutes: 30 # a shard is ~4 cases (#4814: up to 20 shards); a hang should fail, not hold a runner for 6 h
strategy:
fail-fast: false
matrix:
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/mainframe-ground-truth.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ name: Mainframe Ground Truth
# bot-PR mechanism as call-resolution-history.yml.

on:
pull_request:
pull_request: # #4825: a required check -- runs on every PR
branches: [main]
paths-ignore: ["docs/**", "**/*.md"]
push:
branches: [main]
paths-ignore: ["docs/**", "**/*.md"]
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/mypy-audit.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
name: Mypy Audit

on:
pull_request:
pull_request: # #4825: a required check -- runs on every PR
branches:
- main
paths-ignore: ["docs/**"]

permissions:
contents: read
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/ruff-audit.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
name: Ruff Audit

on:
pull_request:
pull_request: # #4825: a required check -- runs on every PR (a docs-only PR is a quick pass)
branches:
- main
paths-ignore: ["docs/**"]

permissions:
contents: read
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
name: GitGalaxy Smoke Tests

on:
pull_request:
pull_request: # #4825: full-suite / smoke-test are required checks -- they run on every PR
branches: [main]
paths-ignore: ["docs/**"]
# #4518: the full 3 OS x 3 Python smoke grid runs here; PRs get a lean set.
push:
branches: [main]
Expand Down
58 changes: 55 additions & 3 deletions tests/cobol_mainframe/test_det_sweep_fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_dependents_of_a_changed_port_are_proven_too():
@pytest.mark.parametrize(
"path",
[
"gitgalaxy/tools/cobol_to_java/det/x.py", # the translator
"gitgalaxy/tools/cobol_to_java/proof_reach.py", # moves verdicts without moving a port byte
"tests/tools/equivalence.py", # the harness / tools
"tests/equivalence/det_sweep_baseline.json", # the ratchet itself
"tests/equivalence/gnucobol.Dockerfile", # the oracle
Expand All @@ -110,13 +110,65 @@ def test_dependents_of_a_changed_port_are_proven_too():
"tests/equivalence/det_sweep_coverage.json", # the retired single-file ledger
"tests/cobol_mainframe/corpora.json",
".github/workflows/det-sweep.yml",
"README.md",
],
)
def test_anything_but_case_files_is_a_full_sweep(path):
assert plan.plan(["tests/equivalence/delta/case.json", path], "pull_request", CASES)["mode"] == "full"


@pytest.mark.parametrize("path", ["README.md", "docs/x/y.md", "gitgalaxy/core/detector.py", "tests/tools/pr_check.py",
"gitgalaxy/tools/cobol_to_java/det/NOTES.md"]) # fmt: skip
def test_a_pr_that_changes_no_det_input_proves_nothing(path):
"""#4825: det-sweep runs on every PR (`det` is a required check); the planner proves nothing when no changed file
is something a det proof reads -- and such a file never widens a case-only plan to a full sweep."""
p = plan.plan([path], "pull_request", CASES)
assert p["mode"] == "none" and p["cases"] == [] and plan.shards_for(p) == []
assert plan.plan([path, "tests/equivalence/delta/case.json"], "pull_request", CASES)["mode"] == "narrow"


def test_a_translator_only_change_is_planned_from_its_port_diff(tmp_path):
"""#4814: det/ (translator + runtime) changes are narrowed to the ports they move; the rest is carried forward."""
p = plan.plan(
["gitgalaxy/tools/cobol_to_java/det/gen.py", "tests/equivalence/delta/case.json"], "pull_request", CASES
)
assert p["mode"] == "ports" and p["cases"] == ["delta"] and plan.shards_for(p) == []
check = tmp_path / "check.json"
check.write_text(json.dumps([{"case": "alpha", "status": "changed", "files": ["X.java"]},
{"case": "beta", "status": "unchanged", "files": []},
{"case": "dbcase", "status": "changed", "files": ["Y.java"]},
{"case": "delta", "status": "unchanged", "files": []}]), encoding="utf-8") # fmt: skip
n = plan.narrow_by_ports(check, ["delta"], CASES)
assert n["mode"] == "narrow" and n["cases"] == ["alpha", "beta", "delta", "gamma"] # alpha's port moved: dependents
assert n["db2_not_swept"] == ["dbcase"] and n["carried"] == 0
assert plan.shards_for(n) == ["1/2", "2/2"]


def test_no_usable_port_diff_is_a_full_sweep(tmp_path):
assert plan.narrow_by_ports(tmp_path / "missing.json", [], CASES)["mode"] == "full"
(tmp_path / "bad.json").write_text("{not json", encoding="utf-8")
assert plan.narrow_by_ports(tmp_path / "bad.json", [], CASES)["mode"] == "full"


def test_the_port_diff_cli_writes_the_final_outputs(tmp_path):
check, out = tmp_path / "check.json", tmp_path / "out"
rows = [
{"case": c, "status": "changed" if c == "carddemo-menu" else "unchanged", "files": []}
for c in ["carddemo-menu"]
]
check.write_text(json.dumps(rows), encoding="utf-8")
run = [
sys.executable,
str(ROOT / "tests" / "tools" / "det_sweep_plan.py"),
"--ports",
str(check),
"--github-output",
str(out),
]
proc = subprocess.run(run, capture_output=True, text=True, check=True) # noqa: S603
assert json.loads(proc.stdout)["mode"] == "narrow"
assert "mode=narrow" in out.read_text(encoding="utf-8") and "shards=1" in out.read_text(encoding="utf-8")


@pytest.mark.parametrize("event", ["schedule", "workflow_dispatch", "push"])
def test_every_event_but_a_pull_request_is_full(event):
assert plan.plan(["tests/equivalence/delta/case.json"], event, CASES)["cases"] == "all"
Expand All @@ -127,7 +179,7 @@ def test_db2_cases_are_not_planned_and_the_runner_count_follows_the_plan():
assert p["cases"] == [] and plan.shards_for(p) == []
assert plan.shards_for({"mode": "narrow", "cases": ["a"]}) == ["1/1"]
assert plan.shards_for({"mode": "narrow", "cases": list("abcdefg")}) == ["1/3", "2/3", "3/3"]
assert plan.shards_for({"mode": "full", "cases": "all"}) == [f"{i}/6" for i in range(1, 7)]
assert plan.shards_for({"mode": "full", "cases": "all"}) == [f"{i}/20" for i in range(1, 21)] # #4814


def test_the_cli_reads_the_diff_and_falls_back_to_full(tmp_path):
Expand Down
78 changes: 73 additions & 5 deletions tests/tools/det_sweep_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

python tests/tools/det_sweep_plan.py [--event pull_request] [--base origin/main] [--files FILE ...] [--github-output PATH]

A pull request that changes only files under tests/equivalence/<case>/ re-proves those cases (and the cases that
A pull request that changes no det input (DET_INPUTS: docs, other engine code ...) proves nothing (mode "none"; #4825,
so the workflow can run -- and report its required `det` check -- on every PR). One that changes only files under
tests/equivalence/<case>/ re-proves those cases (and the cases that
take that case's port: `port_from`, `uses_ports`, transitively); so does one changing a case's coverage-ledger file,
tests/equivalence/det_sweep_coverage/<case>.json (#4789: the sweep of that case is what checks the entry). Anything
else -- the translator, the harness, the tools, the corpora pin, the workflow, the baseline, a file straight under
Expand All @@ -17,16 +19,43 @@
from __future__ import annotations

import argparse
import fnmatch
import json
import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
CASES = REPO_ROOT / "tests" / "equivalence"
MAX_SHARDS = 6 # full sweep: runners
MAX_SHARDS = 20 # full sweep: runners (#4814: work-sized; a narrow plan starts one per PER_SHARD cases)
PER_SHARD = 3 # narrow: about this many cases per runner
IGNORED = {"tests/equivalence/det_sweep_durations.json"} # only balances shards, never a verdict
# #4814: a change confined to these is judged by what it does to the generated ports: `det_port.py check` translates
# every case at the base and at HEAD and names the ports that differ; only those (and their dependents) are proven.
# Only the translator + runtime -- everything a port's bytes come from. Anything else that can move a verdict without
# moving a byte (the harness, proof_reach.py, the oracle) stays a full sweep.
PORT_DIFF = "gitgalaxy/tools/cobol_to_java/det/"

# What a det proof reads (#4825: moved here from det-sweep.yml's trigger, so the workflow runs -- and reports `det` --
# on every PR). A PR that changes none of these proves nothing (mode "none"). fnmatch: `*` also crosses `/`.
DET_INPUTS = (
"gitgalaxy/tools/cobol_to_java/*",
"tests/tools/det_port.py",
"tests/tools/det_parity.py",
"tests/tools/proof_sweep.py",
"tests/tools/det_sweep_plan.py",
"tests/tools/equivalence*.py",
"tests/tools/mainframe_corpus.py",
"tests/equivalence/*",
"tests/cobol_mainframe/corpora.json",
".github/workflows/det-sweep.yml",
)


def det_input(path: str) -> bool:
return not path.endswith(".md") and any(fnmatch.fnmatch(path, p) for p in DET_INPUTS)


# #4789: <case>.json, the det-sweep coverage ledger's entry of a case
LEDGER_DIR = "tests/equivalence/det_sweep_coverage/"

Expand Down Expand Up @@ -58,25 +87,55 @@ def plan(files: list[str], event: str, cases: dict[str, dict]) -> dict:
full = {"mode": "full", "cases": "all"}
if event != "pull_request":
return {**full, "reason": f"{event}: always a full sweep"}
files = [f for f in files if f not in IGNORED]
files = [f for f in files if f not in IGNORED and det_input(f)]
if not files:
return {"mode": "none", "cases": [], "reason": "no changed file is a det-sweep input (DET_INPUTS)"}
touched: set[str] = set()
translator = sorted(f for f in files if f.startswith(PORT_DIFF))
for f in files:
if f.startswith(PORT_DIFF):
continue
if f.startswith(LEDGER_DIR) and f.endswith(".json") and f[len(LEDGER_DIR) : -5] in cases:
touched.add(f[len(LEDGER_DIR) : -5]) # #4789: a case's ledger entry is checked by sweeping that case
continue
parts = f.split("/")
if len(parts) < 4 or parts[:2] != ["tests", "equivalence"] or parts[2] not in cases:
return {**full, "reason": f"{f} is not inside one existing case directory"}
touched.add(parts[2])
if translator: # the port diff decides (the workflow runs det_port.py check, then --ports)
return {
"mode": "ports",
"cases": sorted(touched),
"reason": f"translator changed ({len(translator)} file(s)): prove the ports it changes",
}
affected = sorted(dependents(touched, cases))
runnable = [c for c in affected if not is_db2(cases[c])] # (the sweep is --skip-db2)
return {"mode": "narrow", "cases": runnable,
"reason": f"only case files changed: {', '.join(sorted(touched)) or 'none'}"
+ (f" (+ dependents {', '.join(sorted(set(affected) - touched))})" if set(affected) - touched else "")} # fmt: skip


def narrow_by_ports(check: Path, touched: list[str], cases: dict[str, dict]) -> dict:
"""#4814: the plan after `det_port.py check`: the ports that differ from the base, the touched cases, and their
dependents. A missing or unreadable check is a full sweep (when in doubt, full)."""
try:
rows = json.loads(check.read_text(encoding="utf-8"))
moved = {r["case"] for r in rows if r.get("status") != "unchanged"}
except (OSError, ValueError, KeyError, TypeError) as e:
return {"mode": "full", "cases": "all", "reason": f"no usable port diff ({e}): a full sweep"}
affected = sorted(dependents(moved | set(touched), cases) & set(cases))
runnable = [c for c in affected if not is_db2(cases[c])]
db2 = [c for c in affected if is_db2(cases[c])]
return {"mode": "narrow", "cases": runnable, "carried": len(cases) - len(affected),
"db2_not_swept": db2,
"reason": f"port diff: {len(moved)} port(s) changed of {len(rows)}; "
f"{len(cases) - len(affected)} case(s) carried forward unchanged"} # fmt: skip


def shards_for(p: dict) -> list[str]:
"""The runner labels for a plan: MAX_SHARDS for a full sweep, one per PER_SHARD cases when narrow, none for no cases."""
if p["mode"] == "ports":
return [] # not final: --ports turns it into a narrow (or full) plan
if p["mode"] == "full":
n = MAX_SHARDS
else:
Expand All @@ -98,19 +157,28 @@ def main() -> int:
ap.add_argument("--base", default="origin/main")
ap.add_argument("--files", nargs="*", help="the changed files, instead of git diff --name-only BASE...HEAD")
ap.add_argument("--github-output", type=Path, help="append mode / cases / matrix / shards for later jobs")
ap.add_argument("--ports", type=Path, help="#4814: det_port.py check's check.json -- plan from the port diff")
ap.add_argument("--touched", default="", help="with --ports: case directories the PR changed (comma-separated)")
args = ap.parse_args()
cases = case_dirs()
if args.ports:
p = narrow_by_ports(args.ports, [c for c in args.touched.split(",") if c], cases)
return emit(p, args.github_output)
try:
files = args.files if args.files is not None else changed_files(args.base)
p = plan(files, args.event, cases) if files or args.event != "pull_request" else {
"mode": "full", "cases": "all", "reason": "no changed files found (the diff is unreadable or empty)"} # fmt: skip
except RuntimeError as e: # cannot tell what changed: everything
p = {"mode": "full", "cases": "all", "reason": f"cannot diff ({e})"}
return emit(p, args.github_output)


def emit(p: dict, github_output: Path | None) -> int:
p["matrix"] = shards_for(p)
p["shards"] = len(p["matrix"])
print(json.dumps(p, indent=1))
if args.github_output:
with args.github_output.open("a", encoding="utf-8") as fh:
if github_output:
with github_output.open("a", encoding="utf-8") as fh:
fh.write(f"mode={p['mode']}\n")
fh.write(f"cases={'all' if p['cases'] == 'all' else ','.join(p['cases'])}\n")
fh.write(f"matrix={json.dumps(p['matrix'])}\n")
Expand Down
Loading