diff --git a/.github/workflows/det-sweep.yml b/.github/workflows/det-sweep.yml index 4c9d19b6fb..aacbc3193a 100644 --- a/.github/workflows/det-sweep.yml +++ b/.github/workflows/det-sweep.yml @@ -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// # 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.) @@ -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: @@ -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 @@ -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: diff --git a/.github/workflows/mainframe-ground-truth.yml b/.github/workflows/mainframe-ground-truth.yml index ed1c20381a..d1dca12edb 100644 --- a/.github/workflows/mainframe-ground-truth.yml +++ b/.github/workflows/mainframe-ground-truth.yml @@ -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"] diff --git a/.github/workflows/mypy-audit.yml b/.github/workflows/mypy-audit.yml index 30644f5935..97bb14181e 100644 --- a/.github/workflows/mypy-audit.yml +++ b/.github/workflows/mypy-audit.yml @@ -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 diff --git a/.github/workflows/ruff-audit.yml b/.github/workflows/ruff-audit.yml index 6ffa1f3c4e..e3cb206eee 100644 --- a/.github/workflows/ruff-audit.yml +++ b/.github/workflows/ruff-audit.yml @@ -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 diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 6efa08ee04..9a80faf47f 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -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] diff --git a/tests/cobol_mainframe/test_det_sweep_fast.py b/tests/cobol_mainframe/test_det_sweep_fast.py index e6bb1c3a5e..0c2847df79 100644 --- a/tests/cobol_mainframe/test_det_sweep_fast.py +++ b/tests/cobol_mainframe/test_det_sweep_fast.py @@ -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 @@ -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" @@ -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): diff --git a/tests/tools/det_sweep_plan.py b/tests/tools/det_sweep_plan.py index 39a6483163..cee2ae0697 100644 --- a/tests/tools/det_sweep_plan.py +++ b/tests/tools/det_sweep_plan.py @@ -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// 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// 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/.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 @@ -17,6 +19,7 @@ from __future__ import annotations import argparse +import fnmatch import json import subprocess import sys @@ -24,9 +27,35 @@ 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: .json, the det-sweep coverage ledger's entry of a case LEDGER_DIR = "tests/equivalence/det_sweep_coverage/" @@ -58,9 +87,14 @@ 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 @@ -68,6 +102,12 @@ def plan(files: list[str], event: str, cases: dict[str, dict]) -> dict: 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, @@ -75,8 +115,27 @@ def plan(files: list[str], event: str, cases: dict[str, dict]) -> dict: + (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: @@ -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")