From 5a22b48b42bbb1999caa86d3013aace28f95b796 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 16 Oct 2026 16:56:05 -0400 Subject: [PATCH 1/3] Status slide: generated progress.svg for the CICS-to-Java READMEs, refreshed nightly (Part of #5286) Port the owner-reviewed prototype to tests/tools/progress_slide.py and publish it as a generated page next to progress.md. progress_report.py now also fetches the milestones, the CICS/COBOL title-search counts and the Open World epic states (main only; "not measured" offline) and carries the cobolrt families in progress.json. Both READMEs show the picture, linking to the full page. generated-docs.yml gets a nightly schedule. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/generated-docs.yml | 9 +- .gitignore | 3 +- README.md | 2 + gitgalaxy/tools/cobol_to_java/README.md | 2 + tests/tools/generated_docs.py | 7 + tests/tools/progress_report.py | 76 +++- tests/tools/progress_slide.py | 577 ++++++++++++++++++++++++ tests/tools/test_generated_branch.py | 1 + tests/tools/test_progress_slide.py | 176 ++++++++ 9 files changed, 842 insertions(+), 11 deletions(-) create mode 100644 tests/tools/progress_slide.py create mode 100644 tests/tools/test_progress_slide.py diff --git a/.github/workflows/generated-docs.yml b/.github/workflows/generated-docs.yml index 0bc274ab51..6be3e2e2a9 100644 --- a/.github/workflows/generated-docs.yml +++ b/.github/workflows/generated-docs.yml @@ -4,7 +4,7 @@ name: Generated Docs # docs/language_status/oracle_assumptions.md (register entries, tests/tools/register.py) and # docs/language_status/cics_spec_status.md (the CICS spec, translator, stub and cics_spec_status.json) and # docs/language_status/cobolrt_rules.md (the cobolrt rule catalogue, tests/tools/cobolrt_rules.py, #4711) and -# docs/language_status/progress.md / progress.json (the burned-estate ladder and burn-down, tests/tools/progress_report.py, #5280) -- are no longer +# docs/language_status/progress.md / progress.json / progress.svg (the burned-estate ladder and burn-down, tests/tools/progress_report.py, #5280; the status slide, tests/tools/progress_slide.py, #5286) -- are no longer # committed. Every PR re-rendered and committed them, and parallel PRs conflicted on them for hours. The list of pages # lives in tests/tools/generated_docs.py (PAGES). # @@ -33,6 +33,8 @@ on: - "tests/tools/cobolrt_rules.py" - "tests/tools/readiness_report.py" - "tests/tools/progress_report.py" + - "tests/tools/progress_slide.py" + - "gitgalaxy/tools/readiness/era_tags.py" - "docs/language_status/mutation_scores.json" - "tests/cics_crucible/coverage.json" - "tests/estate_crucible/baseline.json" @@ -42,6 +44,8 @@ on: - ".github/workflows/generated-docs.yml" push: branches: [main] + schedule: + - cron: "17 5 * * *" # #5286: nightly, so the GitHub-fed numbers of the status slide (milestones, issue counts) stay fresh workflow_dispatch: permissions: @@ -164,5 +168,6 @@ jobs: docs/language_status/cobolrt_rules.md \ docs/language_status/readiness_report.md \ docs/language_status/progress.md \ - docs/language_status/progress.json + docs/language_status/progress.json \ + docs/language_status/progress.svg echo "published the generated pages from main $GITHUB_SHA: https://github.com/${GITHUB_REPOSITORY}/tree/generated/docs/language_status" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index b30c6ce0c8..c9576e0227 100644 --- a/.gitignore +++ b/.gitignore @@ -243,9 +243,10 @@ docs/language_status/oracle_assumptions.md docs/language_status/cics_spec_status.md docs/language_status/cobolrt_rules.md docs/language_status/readiness_report.md -# #5280: the progress page and its issues cache (tests/tools/progress_report.py) +# #5280, #5286: the progress page, its slide and its issues cache (tests/tools/progress_report.py) docs/language_status/progress.md docs/language_status/progress.json +docs/language_status/progress.svg docs/language_status/progress_issues_cache.json # #4980: fetched by equivalence_db2.fetch_odbc_cli() into the Docker build context; never committed diff --git a/README.md b/README.md index f0bd1c7357..37b2c979c5 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ own always-current self-scan brief is at ## CICS to Java: ported, then proven +[![CICS to Java: where we stand](https://raw.githubusercontent.com/squid-protocol/gitgalaxy/generated/docs/language_status/progress.svg)](https://github.com/squid-protocol/gitgalaxy/blob/generated/docs/language_status/progress.md) + For mainframe estates the structural graph is the start of a migration. `cobol-to-java` turns a CICS estate (its online transactions, plus the batch COBOL and JCL around them) into a compiling Spring Boot project, and an equivalence harness proves each ported program diff --git a/gitgalaxy/tools/cobol_to_java/README.md b/gitgalaxy/tools/cobol_to_java/README.md index 51e0950aec..ab240db808 100644 --- a/gitgalaxy/tools/cobol_to_java/README.md +++ b/gitgalaxy/tools/cobol_to_java/README.md @@ -11,6 +11,8 @@ byte against the original COBOL under GnuCOBOL. Anything the translator cannot h named `Hole` or an explicit refusal citing the IBM manual—never a guess. Every port ships with its machine-checked evidence record, and a person approves every port. +[![CICS to Java: where we stand](https://raw.githubusercontent.com/squid-protocol/gitgalaxy/generated/docs/language_status/progress.svg)](https://github.com/squid-protocol/gitgalaxy/blob/generated/docs/language_status/progress.md) + **Current numbers** (the evidence ladder, the CICS commands modelled, the mutation kill rate, the crucibles and the era table) are generated from the repo's own artifacts, each with its source and the commit it was rendered from, on the [progress page](https://github.com/squid-protocol/gitgalaxy/blob/generated/docs/language_status/progress.md) (#5280). This README keeps prose only, so it cannot drift. Levels are evidence levels, diff --git a/tests/tools/generated_docs.py b/tests/tools/generated_docs.py index 3bcafa8d4f..c60470ff15 100644 --- a/tests/tools/generated_docs.py +++ b/tests/tools/generated_docs.py @@ -67,6 +67,12 @@ def _progress_json() -> str: return progress_report.render_json_page() +def _progress_svg() -> str: + import progress_slide # tests/tools/progress_slide.py (#5286) + + return progress_slide.render_page() + + # repo-relative path -> its renderer. A page added here must also be git-ignored and published by generated-docs.yml # (test_generated_docs.py checks both). PAGES: dict[str, Callable[[], str]] = { @@ -76,6 +82,7 @@ def _progress_json() -> str: "docs/language_status/readiness_report.md": _readiness_report, "docs/language_status/progress.md": _progress_md, "docs/language_status/progress.json": _progress_json, + "docs/language_status/progress.svg": _progress_svg, } diff --git a/tests/tools/progress_report.py b/tests/tools/progress_report.py index 71eff82ae0..35d9a96a13 100644 --- a/tests/tools/progress_report.py +++ b/tests/tools/progress_report.py @@ -60,6 +60,8 @@ LEVELS = ("L0", "L1", "L2", "L3", "L4", "L5") ERAS = ("0", "1", "2", "3", "4", "none") SCOPES = ("core", "sweep", "frontier", "infra") +OPEN_WORLD_EPICS = (4890, 4891, 4892, 4893, 4894) # the slide's runtime rows read their states (#5286) +TITLE_TERMS = ("CICS", "COBOL") # issues with the term in the title, for the slide's resolved-issue boxes (#5286) NOT_CONVERGING = "not converging" SOURCES = { "ladder": "docs/language_status/evidence_report//report.json (evidence_report.py, generated branch)", @@ -167,11 +169,18 @@ def rules_component(doc: dict[str, Any]) -> dict[str, Any]: rules = doc["rules"] status = Counter(r["status"] for r in rules) measured = sum(1 for r in rules if r.get("calibration") not in (None, "uncalibrated")) - return { + families: dict[str, dict[str, int]] = {f["id"]: {} for f in doc.get("families", [])} + for r in rules: + fam = families.setdefault(r.get("family", "?"), {}) + fam[r["status"]] = fam.get(r["status"], 0) + 1 + out = { "rules": len(rules), "status": {s: status.get(s, 0) for s in ("MATCHED", "DIFFERS", "ASSUMED", "REFUSED")}, "measured_on_zos": measured, } + if families: # the slide's per-family bars (#5286); added only when non-empty + out["families"] = {k: dict(sorted(v.items())) for k, v in families.items()} # the catalogue's own order + return out def mutation_component(doc: dict[str, Any]) -> dict[str, Any]: @@ -252,7 +261,9 @@ def counts(group: list[dict[str, Any]]) -> dict[str, int]: m28 = [i for i in core if i.get("milestone") == CORE_MILESTONE] frontier = scoped("frontier") f_opened, _ = window(frontier) + extra = _slide_inputs(cache) return { + **extra, "measured": True, "as_of": cache["fetched_at"], "window_days": WINDOW_DAYS, @@ -270,6 +281,37 @@ def counts(group: list[dict[str, Any]]) -> dict[str, int]: } +def _slide_inputs(cache: dict[str, Any]) -> dict[str, Any]: + """The status slide's GitHub inputs (#5286): milestones, title-search counts and the Open World epic states. Each + key is added only when the cache carries it, so an older cache still loads and the slide says "not measured".""" + out: dict[str, Any] = {} + if cache.get("milestones"): + out["milestones"] = [ + { + "number": m["number"], + "title": m["title"], + "open": m["open_issues"], + "closed": m["closed_issues"], + "total": m["open_issues"] + m["closed_issues"], + } + for m in sorted(cache["milestones"], key=lambda m: m["number"]) + ] + if cache.get("title_counts"): + out["title_counts"] = {k: dict(v) for k, v in sorted(cache["title_counts"].items())} + if cache.get("epics"): + out["epics"] = {str(k): v for k, v in sorted(cache["epics"].items(), key=lambda kv: int(kv[0]))} + return out + + +def _api(path: str, token: str) -> Any: + req = urllib.request.Request( # noqa: S310 -- a fixed https URL + f"https://api.github.com/{path}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 + return json.load(resp) + + def fetch_issues(token: str | None = None) -> dict[str, Any]: """Every issue (no PRs) carrying a scope label, from the GitHub REST API (stdlib only).""" token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") @@ -278,12 +320,7 @@ def fetch_issues(token: str | None = None) -> dict[str, Any]: seen: dict[int, dict[str, Any]] = {} for scope in SCOPES: for page in range(1, 41): - url = f"https://api.github.com/repos/{REPO_SLUG}/issues?labels=scope:{scope}&state=all&per_page=100&page={page}" - req = urllib.request.Request( # noqa: S310 -- a fixed https URL - url, headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} - ) - with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 - batch = json.load(resp) + batch = _api(f"repos/{REPO_SLUG}/issues?labels=scope:{scope}&state=all&per_page=100&page={page}", token) for i in batch: if "pull_request" in i: continue @@ -297,8 +334,31 @@ def fetch_issues(token: str | None = None) -> dict[str, Any]: } if len(batch) < 100: break + milestones = [ + { + "number": m["number"], + "title": m["title"], + "open_issues": m["open_issues"], + "closed_issues": m["closed_issues"], + } + for m in _api(f"repos/{REPO_SLUG}/milestones?state=all&per_page=100", token) + ] + title_counts = {} + for term in TITLE_TERMS: + tot = _api(f"search/issues?q=repo:{REPO_SLUG}+is:issue+{term}+in:title&per_page=1", token)["total_count"] + closed = _api(f"search/issues?q=repo:{REPO_SLUG}+is:issue+is:closed+{term}+in:title&per_page=1", token)[ + "total_count" + ] + title_counts[term.lower()] = {"total": tot, "closed": closed} + epics = {n: _api(f"repos/{REPO_SLUG}/issues/{n}", token)["state"] for n in OPEN_WORLD_EPICS} now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - return {"fetched_at": now, "issues": [seen[k] for k in sorted(seen)]} + return { + "fetched_at": now, + "issues": [seen[k] for k in sorted(seen)], + "milestones": milestones, + "title_counts": title_counts, + "epics": epics, + } def load_issues() -> dict[str, Any] | None: diff --git a/tests/tools/progress_slide.py b/tests/tools/progress_slide.py new file mode 100644 index 0000000000..17c814a125 --- /dev/null +++ b/tests/tools/progress_slide.py @@ -0,0 +1,577 @@ +"""#5286: the CICS-to-Java "where we stand" slide as one self-contained SVG (light and dark), a generated page. + +Every number comes from the progress report (`progress_report.gather()` -> progress.json), so the slide cannot drift +from the page; the GitHub parts (milestones, title-search counts, Open World epic states) read "not measured" when the +issues cache is absent and no token is set -- never a number. The design is the owner-reviewed prototype (two rounds). + + python tests/tools/progress_slide.py [--out FILE] # default: stdout +""" + +from __future__ import annotations + +import argparse +import html +import sys +from pathlib import Path +from typing import Any + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) +sys.path.insert(0, str(TOOLS.parents[1])) + +W = 1280 +PAD = 40 +FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" + +# Ladder: L0 neutral (inventoried only), L1-L5 one blue ramp light->dark (dataviz validator: ordinal, both modes PASS) +# L1-L5: categorical slots yellow, magenta, violet, aqua, blue (searched: passes adjacent CVD + normal-vision both modes) +LADDER_LIGHT = ["#a3a29b", "#eda100", "#e87ba4", "#6250d6", "#1baf7a", "#2a78d6"] +LADDER_DARK = ["#6b6a64", "#c98500", "#d55181", "#9085e9", "#199e70", "#3987e5"] +# cobolrt status: categorical slots 1-4 in validated order (blue, orange, aqua, yellow) +STATUS_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"] +STATUS_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500"] +STATUSES = ["MATCHED", "DIFFERS", "ASSUMED", "REFUSED"] + +LEVELS = [ + ("L0", "Inventoried", "parsed, not yet translated"), + ("L1", "Translated", "whole program compiles as Java"), + ("L2", "Runs equal", "same outputs as the oracle"), + ("L3", "Every paragraph", "tests run every paragraph"), + ("L4", "Every branch", "tests take every branch"), + ("L5", "Mutants caught", "tests catch planted bugs"), +] +ERAS = [ + ("0", "~1969-80", "Macro-level CICS", "ANS 68 COBOL", "DFHxx macros, CSA/TCA"), + ("1", "~1978-88", "CICS/VS command level", "OS/VS COBOL (74)", "BLL cells, HANDLE CONDITION"), + ("2", "~1988-96", "CICS/MVS, CICS/ESA", "VS COBOL II / COBOL 85", "RESP/RESP2, EXEC SQL, DPL"), + ("3", "~1996-2005", "CICS TS 1-2", "COBOL for OS/390, LE", "MQ, EXCI, 3270 bridge, web"), + ("4", "2005+", "CICS TS 3+", "Enterprise COBOL", "channels, JSON, async, counters"), +] +ESTATE_NAMES = { + "aws-mainframe-modernization-carddemo": "CardDemo", + "cics-async-api-credit-card-application-example": "Async credit card", + "cics-banking-sample-application-cbsa": "CBSA (banking)", + "cics-genapp": "GenApp (insurance)", + "dbb-mortgage-application": "DBB Mortgage", + "zecs": "zECS", +} +# Mainframe milestones shown, in story order +MILESTONES = [10, 13, 15, 20, 24, 18, 27, 28, 19, 29, 30] +NOT_MEASURED = "not measured" +MS_SHORT = { + 15: "World Tour (GG goes multicultural)", + 10: "Enter the Mainframe", + 13: "Scorekeeper", + 20: "Nightmare Mode (crucibles)", + 24: "Class Change (COBOL to Java)", + 18: "Run Verified", + 27: "Save Integrity", + 28: "Training Montage (core)", + 19: "New Challenger (fresh estates)", + 29: "Open World (MQ, IMS, JCL, VSAM)", + 30: "Gap Atlas (mapped, not built)", +} + + +def esc(s) -> str: + return html.escape(str(s), quote=True) + + +class Svg: + def __init__(self) -> None: + self.parts: list[str] = [] + + def add(self, s: str) -> None: + self.parts.append(s) + + def text(self, x, y, s, cls="t2", size=14, weight=400, anchor="start", title=None) -> None: + t = f"{esc(title)}" if title else "" + self.add( + f'{t}{esc(s)}' + ) + + def rect(self, x, y, w, h, cls, rx=0, title=None) -> None: + if w <= 0: + return + t = f"{esc(title)}" if title else "" + self.add(f'{t}') + + +def stacked(svg: Svg, x, y, w, h, values, classes, titles, label_min=26, label_cls=None, names=None) -> None: + """A 100%-width stacked bar with 2px surface gaps and in-segment counts where they fit.""" + total = sum(values) + if not total: + svg.rect(x, y, w, h, "track", rx=4) + return + segs = [ + (v, c, t, lc, nm) + for v, c, t, lc, nm in zip( + values, classes, titles, label_cls or [None] * len(values), names or [None] * len(values) + ) + if v + ] + gap = 2 + usable = w - gap * (len(segs) - 1) + cx = x + for i, (v, c, t, lc, nm) in enumerate(segs): + sw = usable * v / total + rx = 4 if i in (0, len(segs) - 1) else 0 + svg.rect(cx, y, sw, h, c, rx=rx, title=t) + lab = f"{nm} · {v}" if nm and sw >= 64 else (v if sw >= label_min else None) + if lab is not None: + svg.text(cx + sw / 2, y + h / 2 + 5, lab, cls=lc or "on-dark", size=13, weight=600, anchor="middle") + cx += sw + gap + + +def pct(a, b) -> str: + return f"{100 * a / b:.0f}%" if b else "-" + + +def render(prog: dict[str, Any]) -> str: + """The slide from a progress report (`progress_report.build()` / progress.json).""" + lad, comp, eras, iss = prog["ladder"], prog["components"], prog["eras"], prog["issues"] + measured = bool(iss.get("measured")) + ms = {m["number"]: m for m in iss.get("milestones", [])} + ic = iss.get("title_counts") + epics = iss.get("epics") or {} + rules = comp["cobolrt_rules"] + estates = lad["estates"] + total = lad["total"]["levels"] + n_prog = lad["total"]["programs"] + l2p = lad["total"]["l2_plus"] + + s = Svg() + y = PAD + # ---------------------------------------------------------------- header + s.text(PAD, y + 24, "CICS to Java: where we stand", cls="t1", size=30, weight=700) + s.text( + PAD, + y + 52, + f"{len(estates)} burned estates, {n_prog} programs. Generated from main {prog['commit'][:9]}" + f"{' on ' + iss['as_of'][:10] if measured else '; GitHub counts ' + NOT_MEASURED}. Oracle: GnuCOBOL plus IBM documentation (not yet measured on z/OS).", + cls="t3", + size=14, + ) + y += 80 + + # ---------------------------------------------------------------- hero tiles + tw, th, gap = (W - 2 * PAD - 3 * 16) / 4, 112, 16 + tiles = [ + ( + f"{comp['translator']['whole']} / {comp['translator']['of']}", + pct(comp["translator"]["whole"], comp["translator"]["of"]), + "programs translated whole", + "Java with no holes (L1 or above)", + ), + ( + f"{l2p} / {n_prog}", + pct(l2p, n_prog), + "programs run equal to the oracle", + "same outputs on their cases (L2 or above)", + ), + ( + f"{comp['cics']['modelled_both_runtimes']} / {comp['cics']['burned_commands']}", + pct(comp["cics"]["modelled_both_runtimes"], comp["cics"]["burned_commands"]), + "CICS commands the estates use", + "modelled on both runtimes", + ), + ( + f"{comp['mutation']['adjusted_kill_pct']:.1f}%", + "", + "translator mutants caught", + f"{comp['mutation']['killed']} of {comp['mutation']['adjusted_total']}, over {comp['mutation']['ports']} ports", + ), + ] + for i, (big, p, lab, sub) in enumerate(tiles): + x = PAD + i * (tw + gap) + s.rect(x, y, tw, th, "card", rx=10) + s.text(x + 18, y + 44, big, cls="t1", size=32, weight=700) + if p: + s.text(x + tw - 18, y + 44, p, cls="t2", size=22, weight=600, anchor="end") + s.text(x + 18, y + 72, lab, cls="t1", size=15, weight=600) + s.text(x + 18, y + 94, sub, cls="t3", size=13) + y += th + 36 + + # ---------------------------------------------------------------- ladder + s.text(PAD, y, "Evidence ladder: where each program stands", cls="t1", size=20, weight=700) + s.text( + PAD, + y + 22, + "Each program sits at the highest level it has reached. Levels from definition_of_done.md.", + cls="t3", + size=13, + ) + y += 40 + lw = (W - 2 * PAD) / 3 + for i, (lv, name, desc) in enumerate(LEVELS): + x, yy = PAD + (i % 3) * lw, y + (i // 3) * 26 + s.rect(x, yy, 14, 14, f"lv{i}", rx=3) + s.text(x + 22, yy + 12, f"{lv} {name}:", cls="t1", size=13.5, weight=600) + s.text(x + 22 + 7.6 * len(f"{lv} {name}:") + 6, yy + 12, desc, cls="t3", size=12.5) + y += 62 + name_w, cnt_w, row_h, bar_h = 190, 6 * 46 + 70, 34, 22 + bar_x, bar_w = PAD + name_w, W - 2 * PAD - name_w - cnt_w - 20 + cx0 = bar_x + bar_w + 20 + s.text(PAD, y + 4, "Estate", cls="t3", size=12, weight=600) + s.text( + bar_x, + y + 4, + "Share of the estate's programs at each level (segments: level · programs)", + cls="t3", + size=12, + weight=600, + ) + for j, (lv, _, _) in enumerate(LEVELS): + s.text(cx0 + 46 * j + 30, y + 4, lv, cls="t3", size=12, weight=600, anchor="end") + s.text(cx0 + 46 * 6 + 60, y + 4, "Programs", cls="t3", size=12, weight=600, anchor="end") + y += 14 + rows = sorted(estates.items(), key=lambda kv: -kv[1]["programs"]) + [ + ("__total__", {"levels": total, "programs": n_prog}) + ] + for key, e in rows: + is_total = key == "__total__" + nm = "All estates" if is_total else ESTATE_NAMES.get(key, key) + if is_total: + s.add(f'') + y += 8 + vals = [e["levels"][k] for k in ["L0", "L1", "L2", "L3", "L4", "L5"]] + s.text(PAD, y + 20, nm, cls="t1", size=14, weight=700 if is_total else 500) + stacked( + s, + bar_x, + y + 4, + bar_w, + bar_h, + vals, + [f"lv{i}" for i in range(6)], + [f"{nm}: {v} at {LEVELS[i][0]} {LEVELS[i][1]}" for i, v in enumerate(vals)], + label_cls=["on-light", "on-light", "on-light", "on-dark", "on-light", "on-dark"], + names=[lv for lv, _, _ in LEVELS], + ) + for j, v in enumerate(vals): + s.text( + cx0 + 46 * j + 30, + y + 20, + v if v else "·", + cls="t1" if v else "t3", + size=14, + weight=700 if is_total else 400, + anchor="end", + ) + s.text(cx0 + 46 * 6 + 60, y + 20, e["programs"], cls="t1", size=14, weight=700, anchor="end") + y += row_h + y += 30 + + # ---------------------------------------------------------------- eras + s.text(PAD, y, "By era: the CICS and COBOL generations in the estates", cls="t1", size=20, weight=700) + s.text( + PAD, + y + 22, + "Real estates are layered: 1980s programs LINK 1990s ones that call 2010s Db2 code. " + f"Era from the code's tells (era_tags.py); {eras.get('none', {}).get('programs', 0)} programs carry no tell.", + cls="t3", + size=13, + ) + y += 40 + ew, eh, egap = (W - 2 * PAD - 4 * 14) / 5, 196, 14 + for i, (num, when, cics, cobol, tells) in enumerate(ERAS): + x = PAD + i * (ew + egap) + e = eras.get(num, {"programs": 0, "l2_plus": 0, "l4_plus": 0}) + s.rect(x, y, ew, eh, "card", rx=10) + s.text(x + 16, y + 28, f"Era {num}", cls="t1", size=18, weight=700) + s.text(x + ew - 16, y + 28, when, cls="t3", size=13, anchor="end") + s.text(x + 16, y + 52, cics, cls="t1", size=13.5, weight=600) + s.text(x + 16, y + 70, cobol, cls="t2", size=12.5) + s.text(x + 16, y + 88, tells, cls="t3", size=12) + if e["programs"]: + s.text(x + 16, y + 118, f"{e['programs']} programs", cls="t1", size=15, weight=700) + for k, (lab, v) in enumerate([("run equal (L2+)", e["l2_plus"]), ("every branch (L4+)", e["l4_plus"])]): + yy = y + 134 + k * 30 + s.rect(x + 16, yy, ew - 32, 8, "track", rx=4) + s.rect( + x + 16, + yy, + (ew - 32) * v / e["programs"], + 8, + "lv2" if k == 0 else "lv4", + rx=4, + title=f"Era {num}: {v} of {e['programs']} {lab}", + ) + s.text(x + 16, yy + 24, f"{lab}: {v} ({pct(v, e['programs'])})", cls="t2", size=12) + else: + s.text(x + 16, y + 118, "No burned program", cls="t1", size=15, weight=700) + s.text(x + 16, y + 140, "refused by name (#5121);", cls="t3", size=12) + s.text(x + 16, y + 158, "mapped in the Gap Atlas", cls="t3", size=12) + y += eh + 40 + + # ---------------------------------------------------------------- runtimes (left) + cobolrt (right) + left_w = (W - 2 * PAD - 30) * 0.45 + right_x = PAD + left_w + 30 + right_w = W - PAD - right_x + top = y + s.text(PAD, y, "Runtimes and regions", cls="t1", size=20, weight=700) + s.text(PAD, y + 22, "What the translated Java can run today, against what the estates need", cls="t3", size=13) + y += 46 + fams = rules.get("families", {}) + allst = rules["status"] + n_rules = rules["rules"] + + def fam(name: str) -> tuple[str, str]: + c = fams.get(name, {}) + return f"{c.get('MATCHED', 0)} / {sum(c.values())}", "rules match IBM" + + cics_big = f"{comp['cics']['modelled_both_runtimes']} / {comp['cics']['burned_commands']}" + # (default state, name, detail, big number, unit, Open World epic) + rt_rows = [ + ( + "ok", + "COBOL language (cobolrt)", + "all runtime rules", + f"{allst['MATCHED']} / {n_rules}", + "rules match IBM", + None, + ), + ("ok", "CICS commands", "commands the estates use", cics_big, "modelled", None), + ("ok", "Db2 embedded SQL", "checked against a real Db2", *fam("SQL"), None), + ("ok", "LE callable services", "date, time and condition services", *fam("LE"), None), + ("part", "IBM MQ", "first calls proven (#5236); estate slice #5278", *fam("MQ"), 4892), + ("part", "IMS DL/I", "modelled, not yet matched; epic #4893", *fam("DLI"), 4893), + ("todo", "JCL execution", "Open World #4891", "-", "not started", 4891), + ("todo", "Batch utilities (DFSORT, IDCAMS)", "Open World #4890", "-", "not started", 4890), + ("todo", "VSAM AIX / ESDS / RRDS", "Open World #4894", "-", "not started", 4894), + ] + icon = {"ok": ("st-good", "In use"), "part": ("st-warn", "Partial"), "todo": ("st-none", "Not started")} + for st, name, detail, big, unit, epic in rt_rows: + state = epics.get(str(epic)) if epic else None + if state == "closed": # the epic landed: the row is in use + st, detail = "ok", f"{detail}; epic closed" + cls, lab = icon[st] + if epic and state is None: # the row's state comes from the epic, which was not read + lab = f"Epic #{epic} state {NOT_MEASURED}" + s.add(f'') + if st == "part": + s.add(f'') + s.text(PAD + 22, y, name, cls="t1", size=14, weight=600) + s.text(PAD + 22, y + 18, f"{lab}: {detail}", cls="t3", size=12) + s.text(PAD + left_w - 110, y + 6, big, cls="t1" if big != "-" else "t3", size=22, weight=700, anchor="end") + s.text(PAD + left_w - 100, y + 6, unit, cls="t3", size=11.5) + y += 42 + left_bottom = y + + y = top + st_all = allst + s.text(right_x, y, f"COBOL runtime rules: {n_rules} catalogued", cls="t1", size=20, weight=700) + s.text( + right_x, + y + 22, + "Every semantic rule of the Java runtime, judged against IBM's documentation", + cls="t3", + size=13, + ) + y += 40 + defs = [ + ("MATCHED", "implements IBM's documented rule"), + ("DIFFERS", "follows the oracle where z/OS differs"), + ("ASSUMED", "IBM leaves it open; the choice is named"), + ("REFUSED", "the runtime stops by name"), + ] + dw = right_w / 2 + for i, (k, desc) in enumerate(defs): + x, yy = right_x + (i % 2) * dw, y + (i // 2) * 34 + s.rect(x, yy, 14, 14, f"s{i}", rx=3) + s.text( + x + 22, + yy + 12, + f"{k} {st_all.get(k, 0)} ({pct(st_all.get(k, 0), n_rules)})", + cls="t1", + size=13.5, + weight=600, + ) + s.text(x + 22, yy + 28, desc, cls="t3", size=11.5) + y += 80 + fw = 66 + full = right_w - fw - 70 + s.text(right_x, y + 15, "Total", cls="t1", size=12.5, weight=700) + s.text(right_x + fw - 8, y + 15, n_rules, cls="t1", size=12, weight=700, anchor="end") + stacked( + s, + right_x + fw, + y, + full, + 20, + [st_all.get(k, 0) for k in STATUSES], + [f"s{i}" for i in range(4)], + [f"{st_all.get(k, 0)} {k}" for k in STATUSES], + label_cls=["on-dark", "on-dark", "on-dark", "on-light"], + ) + s.text( + right_x + fw + full + 8, + y + 15, + f"{pct(st_all.get('MATCHED', 0), n_rules)} match", + cls="t2", + size=11.5, + weight=600, + ) + s.add(f'') + y += 40 + order = sorted(fams.items(), key=lambda kv: -sum(kv[1].values())) # stable: ties keep the catalogue order + for name, c in order: + n = sum(c.values()) + s.text(right_x, y + 13, name, cls="t1", size=12.5, weight=600) + s.text(right_x + fw - 8, y + 13, n, cls="t3", size=12, anchor="end") + bw = full * n / max(sum(v.values()) for v in fams.values()) + stacked( + s, + right_x + fw, + y + 2, + bw, + 14, + [c.get(k, 0) for k in STATUSES], + [f"s{i}" for i in range(4)], + [f"{name}: {c.get(k, 0)} {k}" for k in STATUSES], + label_min=10_000, + ) + s.text(right_x + fw + bw + 8, y + 13, f"{pct(c.get('MATCHED', 0), n)} match", cls="t3", size=11.5) + y += 22 + s.text( + right_x, + y + 14, + f"Measured on z/OS: {rules['measured_on_zos']} of {n_rules}. Every rule is " + "uncalibrated until a z/OS run backs it (#4702).", + cls="t3", + size=12, + ) + y = max(y + 30, left_bottom) + 30 + + # ---------------------------------------------------------------- milestones + issues + s.text(PAD, y, "Mainframe milestones and issues", cls="t1", size=20, weight=700) + s.text( + PAD, + y + 22, + "Share of each milestone's issues closed. Gap Atlas holds discoveries: mapped, built only when promoted.", + cls="t3", + size=13, + ) + y += 44 + mlw = (W - 2 * PAD - 30) * 0.62 + mx_name, mbar_x = PAD, PAD + 240 + mbar_w = mlw - 240 - 110 + my = y + if not ms: + s.text(mx_name, my + 13, f"Milestones: {NOT_MEASURED} (no issues cache and no token)", cls="t3", size=13) + my += 26 * 4 + for n in MILESTONES: + m = ms.get(n) + if not m: + continue + c, t = m["closed"], m["total"] + s.text(mx_name, my + 13, MS_SHORT.get(n, m["title"]), cls="t1", size=13, weight=500) + s.rect(mbar_x, my + 2, mbar_w, 14, "track", rx=4) + s.rect(mbar_x, my + 2, mbar_w * c / t if t else 0, 14, "prog", rx=4, title=f"{m['title']}: {c} of {t} closed") + s.text(mbar_x + mbar_w + 10, my + 14, f"{pct(c, t)} ({c}/{t})", cls="t2", size=12.5) + my += 26 + ix = PAD + mlw + 30 + iw = W - PAD - ix + iy = y + + def title_box(term: str, label: str) -> tuple[str, str, str, str]: + c = (ic or {}).get(term) + if not c: + return NOT_MEASURED, "", label, f"issues with {term.upper()} in the title" + return ( + f"{c['closed']} / {c['total']}", + pct(c["closed"], c["total"]), + label, + f"issues with {term.upper()} in the title", + ) + + boxes = [ + title_box("cics", "CICS issues resolved"), + title_box("cobol", "COBOL issues resolved"), + ( + f"{iss['core']['closed']} / {iss['core']['total']}", + "", + "core issues closed", + f"{iss['core']['open']} open: the burned-estate finish line", + ) + if measured + else (NOT_MEASURED, "", "core issues closed", "the burned-estate finish line"), + ( + f"{iss['frontier']['total']}", + "", + "gaps mapped (Gap Atlas)", + f"{iss['frontier']['found_last_window']} found in the last {iss['window_days']} days", + ) + if measured + else (NOT_MEASURED, "", "gaps mapped (Gap Atlas)", "Gap Atlas"), + ] + bh = (my - y - 3 * 10) / 4 + for k, (big, p, lab, sub) in enumerate(boxes): + by = iy + k * (bh + 10) + s.rect(ix, by, iw, bh, "card", rx=10) + s.text(ix + 16, by + bh / 2 + 2, big, cls="t1", size=22, weight=700) + if p: + s.text(ix + iw - 16, by + bh / 2 + 2, p, cls="t2", size=18, weight=600, anchor="end") + s.text(ix + 160, by + bh / 2 - 6, lab, cls="t1", size=13, weight=600) + s.text(ix + 160, by + bh / 2 + 12, sub, cls="t3", size=11.5) + y = my + 24 + s.text( + PAD, + y, + "Sources: progress.json (progress_report.py, which reads cobolrt_rules.json), GitHub milestones and issue search. " + "Regenerated on every docs run; never hand-edited.", + cls="t3", + size=12, + ) + H = y + PAD - 10 + + style = f""" + """ + return ( + f'CICS to Java: where we stand{style}' + f'' + "".join(s.parts) + "" + ) + + +def render_page() -> str: + import progress_report # tests/tools/progress_report.py + + return render(progress_report.gather()) + "\n" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--out", type=Path) + args = ap.parse_args(argv) + text = render_page() + if args.out: + args.out.write_text(text, encoding="utf-8", newline="\n") + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/tools/test_generated_branch.py b/tests/tools/test_generated_branch.py index d2734439a7..b2b3551151 100644 --- a/tests/tools/test_generated_branch.py +++ b/tests/tools/test_generated_branch.py @@ -123,6 +123,7 @@ def test_publish_refuses_a_missing_file(remote_and_clone): "docs/language_status/readiness_report.md", # #4725 "docs/language_status/progress.md", # #5280 "docs/language_status/progress.json", # #5280 + "docs/language_status/progress.svg", # #5286 ], } diff --git a/tests/tools/test_progress_slide.py b/tests/tools/test_progress_slide.py new file mode 100644 index 0000000000..76d8526edc --- /dev/null +++ b/tests/tools/test_progress_slide.py @@ -0,0 +1,176 @@ +"""#5286: the status slide renders from fixtures into valid XML, every number on it is the matching progress.json +value, GitHub-fed parts read "not measured" offline (never a number), both colour schemes are present, and the +READMEs show it as a picture linking to the full page.""" + +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) +import progress_report as pr # noqa: E402 +import progress_slide as ps # noqa: E402 + +REPO = TOOLS.parents[1] +FIXTURE_REPORTS = REPO / "tests" / "fixtures" / "evidence_report" +END = "2026-10-16T12:00:00Z" +IMAGE = "https://raw.githubusercontent.com/squid-protocol/gitgalaxy/generated/docs/language_status/progress.svg" +PAGE = "https://github.com/squid-protocol/gitgalaxy/blob/generated/docs/language_status/progress.md" +READMES = ("README.md", "gitgalaxy/tools/cobol_to_java/README.md") + + +def _issue(n, scope="core", state="open", milestone=None): + return { + "number": n, + "state": state, + "created_at": "2026-09-01T00:00:00Z", + "closed_at": None if state == "open" else "2026-10-15T00:00:00Z", + "labels": [f"scope:{scope}"], + "milestone": milestone, + } + + +def _cache(epics=None): + return { + "fetched_at": END, + "issues": [_issue(1, milestone=28), _issue(2, state="closed"), _issue(3, scope="frontier", milestone=30)], + "milestones": [ + {"number": 10, "title": "Enter the Mainframe", "open_issues": 3, "closed_issues": 17}, + {"number": 28, "title": "Training Montage", "open_issues": 23, "closed_issues": 3}, + ], + "title_counts": {"cics": {"total": 97, "closed": 78}, "cobol": {"total": 139, "closed": 123}}, + "epics": epics or {"4890": "open", "4891": "open", "4892": "open", "4893": "open", "4894": "open"}, + } + + +def _gather(tmp_path, monkeypatch, cache): + pytest.importorskip("tree_sitter_language_pack") # cics_spec_status reads the det translator live + import readiness_report + + monkeypatch.setattr(readiness_report, "REPORTS", FIXTURE_REPORTS) + path = tmp_path / "issues.json" + if cache is not None: + path.write_text(json.dumps(cache)) + monkeypatch.setenv(pr.CACHE_ENV, str(path)) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + pr._issues_once.cache_clear() + try: + return pr.gather() + finally: + pr._issues_once.cache_clear() + + +@pytest.fixture +def report(tmp_path, monkeypatch): + return _gather(tmp_path, monkeypatch, _cache()) + + +@pytest.fixture +def offline(tmp_path, monkeypatch): + return _gather(tmp_path, monkeypatch, None) + + +def _texts(svg): + root = ET.fromstring(svg) + return [(e.text or "").strip() for e in root.iter("{http://www.w3.org/2000/svg}text")] + + +def test_the_slide_is_valid_xml_and_renders_twice_identically(report): + svg = ps.render(report) + root = ET.fromstring(svg) + assert root.tag == "{http://www.w3.org/2000/svg}svg" + assert ps.render(report) == ps.render(report) + + +def test_every_number_on_the_slide_is_the_progress_json_value(report): + texts = _texts(ps.render(report)) + comp, lad, iss = report["components"], report["ladder"], report["issues"] + want = [ + f"{comp['translator']['whole']} / {comp['translator']['of']}", + f"{lad['total']['l2_plus']} / {lad['total']['programs']}", + f"{comp['cics']['modelled_both_runtimes']} / {comp['cics']['burned_commands']}", + f"{comp['mutation']['adjusted_kill_pct']:.1f}%", + f"{comp['cobolrt_rules']['status']['MATCHED']} / {comp['cobolrt_rules']['rules']}", + f"COBOL runtime rules: {comp['cobolrt_rules']['rules']} catalogued", + f"{iss['core']['closed']} / {iss['core']['total']}", + str(iss["frontier"]["total"]), + "78 / 97", + "123 / 139", + "85% (17/20)", + ] + for w in want: + assert w in texts, w + # the total row: one count per level, then the programs + total_cells = [str(lad["total"]["levels"][k]) if lad["total"]["levels"][k] else "·" for k in pr.LEVELS] + joined = " ".join(texts) + assert " ".join([*total_cells, str(lad["total"]["programs"])]) in joined or all(c in texts for c in total_cells) + for estate in lad["estates"].values(): + assert str(estate["programs"]) in texts + for era, row in report["eras"].items(): + if era != "none" and row["programs"]: + assert f"{row['programs']} programs" in texts + assert f"run equal (L2+): {row['l2_plus']} ({100 * row['l2_plus'] / row['programs']:.0f}%)" in texts + + +def test_the_family_rows_come_from_the_rules_families(report): + texts = _texts(ps.render(report)) + for fam in ("SQL", "LE", "MQ", "DLI"): + c = report["components"]["cobolrt_rules"]["families"].get(fam, {}) + assert f"{c.get('MATCHED', 0)} / {sum(c.values())}" in texts + + +def test_offline_the_github_parts_are_not_measured_and_print_no_number(offline): + assert offline["issues"]["measured"] is False + svg = ps.render(offline) + texts = _texts(svg) + assert texts.count(ps.NOT_MEASURED) >= 4 # the four issue boxes + assert any(t.startswith("Milestones: not measured") for t in texts) + assert not any(re.search(r"\(\d+/\d+\)", t) for t in texts) # no milestone fraction + assert not any("epic closed" in t for t in texts) + assert any(f"Epic #{n} state not measured" in t for t in texts for n in pr.OPEN_WORLD_EPICS) + + +def test_a_closed_open_world_epic_turns_its_row_to_in_use(tmp_path, monkeypatch): + rep = _gather(tmp_path, monkeypatch, _cache({"4890": "closed", "4891": "open"})) + texts = _texts(ps.render(rep)) + assert any("In use: Open World #4890; epic closed" in t for t in texts) + assert any(t.startswith("Not started: Open World #4891") for t in texts) + + +def test_both_colour_schemes_are_present(report): + svg = ps.render(report) + assert "@media (prefers-color-scheme: dark)" in svg + light, dark = svg.split("@media (prefers-color-scheme: dark)") + for colour in (*ps.LADDER_LIGHT, *ps.STATUS_LIGHT): + assert colour in light + for colour in (*ps.LADDER_DARK, *ps.STATUS_DARK): + assert colour in dark + + +def test_the_page_is_registered_and_publishable(): + import generated_docs + + rel = "docs/language_status/progress.svg" + assert rel in generated_docs.PAGES + assert f"docs/language_status/progress.svg" in (REPO / ".github/workflows/generated-docs.yml").read_text() + assert 'cron: "17 5 * * *"' in (REPO / ".github/workflows/generated-docs.yml").read_text() + + +@pytest.mark.parametrize("rel", READMES) +def test_each_readme_shows_the_slide_linking_to_the_full_page(rel): + text = (REPO / rel).read_text(encoding="utf-8") + assert f"[![CICS to Java: where we stand]({IMAGE})]({PAGE})" in text + + +def test_the_slide_inputs_are_added_only_when_the_cache_carries_them(): + old = pr.issues_component({"fetched_at": END, "issues": [_issue(1)]}) + assert not {"milestones", "title_counts", "epics"} & set(old) # an older cache still loads (#5100) + new = pr.issues_component(_cache()) + assert new["milestones"][1] == {"number": 28, "title": "Training Montage", "open": 23, "closed": 3, "total": 26} + assert new["title_counts"]["cics"] == {"closed": 78, "total": 97} + assert new["epics"]["4890"] == "open" From 20834c6a20e5195c530e7b6bca14c40363f79702 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 16 Oct 2026 17:04:32 -0400 Subject: [PATCH 2/3] Promote five lessons from the inbox into .claude (Part of #5286) Co-Authored-By: Claude Sonnet 5.5 --- .claude/agents/gitgalaxy-pr-worker.md | 2 +- .claude/rules/python-test-traps.md | 2 ++ .claude/skills/cics-command-slice/SKILL.md | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/agents/gitgalaxy-pr-worker.md b/.claude/agents/gitgalaxy-pr-worker.md index 7122cc4611..0e923c892b 100644 --- a/.claude/agents/gitgalaxy-pr-worker.md +++ b/.claude/agents/gitgalaxy-pr-worker.md @@ -72,7 +72,7 @@ Main holds no rendered evidence report (#5206): read levels with `python tests/t - Before calling any failure "pre-existing": `git fetch` and re-test on current origin/main (`pr_gates.py --vs-main` does it). - `pr_gates --fast` uses the CI ruff 0.16.10 from `~/.cache/gitgalaxy/ruff-0.16.10`; the shared venv's 0.16.0 `ruff format` rewrites ~20 unrelated files, so format only your own files with the CI ruff (#5189). - `ruff format` changed `.py` files only (`git diff --name-only`, never bare `ruff format .`), THEN regenerate any baseline. -- Gates before push: `pr_gates.py --fast` and `--ratchets` ONLY, via `heavy-run.sh`, with JDK 17 from `equivalence_env --shell`; never two gate runs at once. The full suite is CI's job (owner, 2026-10-07). It warns if local ruff/mypy/python differ from CI; follow its private-venv hint. Re-read new subclasses for a missing `super().__init__()`. The `port-surface` ratchet can fail in a full `--ratchets` run and pass alone: rerun it with `--only-ratchets port-surface --vs-main` before chasing it (#5167). Lint with `pr_gates --fast`, not bare `ruff check` (#5177). +- Gates before push: `pr_gates.py --fast` and `--ratchets` ONLY, via `heavy-run.sh`, with JDK 17 from `equivalence_env --shell`; never two gate runs at once. The full suite is CI's job (owner, 2026-10-07). It warns if local ruff/mypy/python differ from CI; follow its private-venv hint. Re-read new subclasses for a missing `super().__init__()`. The `port-surface` ratchet can fail in a full `--ratchets` run and pass alone: rerun it with `--only-ratchets port-surface --vs-main` before chasing it (#5167). Lint with `pr_gates --fast`, not bare `ruff check` (#5177). Running the cached ruff binary directly also ignores the repo's per-file rules (#5287). - Taking over a stale or foreign branch: check divergence first (`git rev-list --left-right --count origin/main...HEAD`). A red caused by staleness (pins, golden masters) is fixed by the merge rule in the next bullet, not by merging just to be current. - Merge main ONLY for a reason (#5134, owner 2026-10-14): the PR is CONFLICTING/dirty, a required check fails because of main, or golden masters / generated files must be re-blessed after a sibling merge. Never merge main just to be current or "behind": every push to a ready PR costs a full CI run (27% of CI minutes were superseded-SHA runs), and the shepherd no longer updates a behind branch. How: `git merge origin/main`, never rebase, never force-push. Generated files: take main's, then regenerate with their tools; check baselines git may silently revert (ruff baseline: regenerate, don't hand-merge). - Commit only your own files (explicit `git add `; never `-A`; `docs/self_scan/gitgalaxy_master.db-shm` / `-wal` show up untracked in plan-first worktrees: never add them, #5103); check `git diff origin/main --stat` has nothing unrelated. Commit messages and the PR body end with the attribution lines the launching session gave you, verbatim. diff --git a/.claude/rules/python-test-traps.md b/.claude/rules/python-test-traps.md index 366b3954de..c962cfcc36 100644 --- a/.claude/rules/python-test-traps.md +++ b/.claude/rules/python-test-traps.md @@ -23,3 +23,5 @@ globs: "gitgalaxy/**/*.py,tests/**/*.py" socket guards on a Windows leg (#5253). * **CI helper scripts the workflows call before the project is installed** (e.g. `tests/tools/db2_image_cache.py`) must stay stdlib-only (#5265). +* **A trailing `# comment` on a `.gitignore` pattern line becomes part of the pattern** (`git check-ignore` fails and + `test_no_page_is_committed_and_each_is_ignored` goes red): put comments on their own line (#5287). diff --git a/.claude/skills/cics-command-slice/SKILL.md b/.claude/skills/cics-command-slice/SKILL.md index d9381fd297..bc64d0a62b 100644 --- a/.claude/skills/cics-command-slice/SKILL.md +++ b/.claude/skills/cics-command-slice/SKILL.md @@ -137,6 +137,9 @@ Traps `--check` exists for: 3. **Phase A** (gitgalaxy, ledger only): with the crucible checked out at the UNTAGGED commit and `CICS_CRUCIBLE_ALLOW_UNPINNED=1`, run `cics_crucible.py --update-baseline` and review every moved cell (new cells only for new cases; a newly failing cell on an unchanged case is a gitgalaxy regression). Do not move the pin. + `--update-baseline` refuses `--cases`: it needs the full run (can pass the 10-min foreground limit, so background + it) and rewrites `coverage.json` and `docs/language_status/cics_crucible.md` too; commit all three (#5285). On a + `cics_crucible.md` merge conflict take main's copy and regenerate it with `cics_crucible.py`, never hand-merge. 4. **Tag** (crucible): only after the owner approves -- `git tag -a vX.Y.Z `, push, `gh release create`. 5. **Phase B** (gitgalaxy, one PR): `PINNED_REF`, baseline / coverage, re-prove the committed ports (`crucible_port_provenance.py reprove`), evidence (`evidence.py prove`; no rendered report), then From a918ecdac85fba4ce8545e38bfe1a8f4457d43a2 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 16 Oct 2026 17:05:50 -0400 Subject: [PATCH 3/3] Revert accidental push of the lessons promotion (moved to #5288) Co-Authored-By: Claude Sonnet 5.5 --- .claude/agents/gitgalaxy-pr-worker.md | 2 +- .claude/rules/python-test-traps.md | 2 -- .claude/skills/cics-command-slice/SKILL.md | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.claude/agents/gitgalaxy-pr-worker.md b/.claude/agents/gitgalaxy-pr-worker.md index 0e923c892b..7122cc4611 100644 --- a/.claude/agents/gitgalaxy-pr-worker.md +++ b/.claude/agents/gitgalaxy-pr-worker.md @@ -72,7 +72,7 @@ Main holds no rendered evidence report (#5206): read levels with `python tests/t - Before calling any failure "pre-existing": `git fetch` and re-test on current origin/main (`pr_gates.py --vs-main` does it). - `pr_gates --fast` uses the CI ruff 0.16.10 from `~/.cache/gitgalaxy/ruff-0.16.10`; the shared venv's 0.16.0 `ruff format` rewrites ~20 unrelated files, so format only your own files with the CI ruff (#5189). - `ruff format` changed `.py` files only (`git diff --name-only`, never bare `ruff format .`), THEN regenerate any baseline. -- Gates before push: `pr_gates.py --fast` and `--ratchets` ONLY, via `heavy-run.sh`, with JDK 17 from `equivalence_env --shell`; never two gate runs at once. The full suite is CI's job (owner, 2026-10-07). It warns if local ruff/mypy/python differ from CI; follow its private-venv hint. Re-read new subclasses for a missing `super().__init__()`. The `port-surface` ratchet can fail in a full `--ratchets` run and pass alone: rerun it with `--only-ratchets port-surface --vs-main` before chasing it (#5167). Lint with `pr_gates --fast`, not bare `ruff check` (#5177). Running the cached ruff binary directly also ignores the repo's per-file rules (#5287). +- Gates before push: `pr_gates.py --fast` and `--ratchets` ONLY, via `heavy-run.sh`, with JDK 17 from `equivalence_env --shell`; never two gate runs at once. The full suite is CI's job (owner, 2026-10-07). It warns if local ruff/mypy/python differ from CI; follow its private-venv hint. Re-read new subclasses for a missing `super().__init__()`. The `port-surface` ratchet can fail in a full `--ratchets` run and pass alone: rerun it with `--only-ratchets port-surface --vs-main` before chasing it (#5167). Lint with `pr_gates --fast`, not bare `ruff check` (#5177). - Taking over a stale or foreign branch: check divergence first (`git rev-list --left-right --count origin/main...HEAD`). A red caused by staleness (pins, golden masters) is fixed by the merge rule in the next bullet, not by merging just to be current. - Merge main ONLY for a reason (#5134, owner 2026-10-14): the PR is CONFLICTING/dirty, a required check fails because of main, or golden masters / generated files must be re-blessed after a sibling merge. Never merge main just to be current or "behind": every push to a ready PR costs a full CI run (27% of CI minutes were superseded-SHA runs), and the shepherd no longer updates a behind branch. How: `git merge origin/main`, never rebase, never force-push. Generated files: take main's, then regenerate with their tools; check baselines git may silently revert (ruff baseline: regenerate, don't hand-merge). - Commit only your own files (explicit `git add `; never `-A`; `docs/self_scan/gitgalaxy_master.db-shm` / `-wal` show up untracked in plan-first worktrees: never add them, #5103); check `git diff origin/main --stat` has nothing unrelated. Commit messages and the PR body end with the attribution lines the launching session gave you, verbatim. diff --git a/.claude/rules/python-test-traps.md b/.claude/rules/python-test-traps.md index c962cfcc36..366b3954de 100644 --- a/.claude/rules/python-test-traps.md +++ b/.claude/rules/python-test-traps.md @@ -23,5 +23,3 @@ globs: "gitgalaxy/**/*.py,tests/**/*.py" socket guards on a Windows leg (#5253). * **CI helper scripts the workflows call before the project is installed** (e.g. `tests/tools/db2_image_cache.py`) must stay stdlib-only (#5265). -* **A trailing `# comment` on a `.gitignore` pattern line becomes part of the pattern** (`git check-ignore` fails and - `test_no_page_is_committed_and_each_is_ignored` goes red): put comments on their own line (#5287). diff --git a/.claude/skills/cics-command-slice/SKILL.md b/.claude/skills/cics-command-slice/SKILL.md index bc64d0a62b..d9381fd297 100644 --- a/.claude/skills/cics-command-slice/SKILL.md +++ b/.claude/skills/cics-command-slice/SKILL.md @@ -137,9 +137,6 @@ Traps `--check` exists for: 3. **Phase A** (gitgalaxy, ledger only): with the crucible checked out at the UNTAGGED commit and `CICS_CRUCIBLE_ALLOW_UNPINNED=1`, run `cics_crucible.py --update-baseline` and review every moved cell (new cells only for new cases; a newly failing cell on an unchanged case is a gitgalaxy regression). Do not move the pin. - `--update-baseline` refuses `--cases`: it needs the full run (can pass the 10-min foreground limit, so background - it) and rewrites `coverage.json` and `docs/language_status/cics_crucible.md` too; commit all three (#5285). On a - `cics_crucible.md` merge conflict take main's copy and regenerate it with `cics_crucible.py`, never hand-merge. 4. **Tag** (crucible): only after the owner approves -- `git tag -a vX.Y.Z `, push, `gh release create`. 5. **Phase B** (gitgalaxy, one PR): `PINNED_REF`, baseline / coverage, re-prove the committed ports (`crucible_port_provenance.py reprove`), evidence (`evidence.py prove`; no rendered report), then