From 03d9de81d9a4b4580fd32a92b429f79cd09208fa Mon Sep 17 00:00:00 2001 From: Tri Lam Date: Mon, 1 Jun 2026 15:03:42 -0700 Subject: [PATCH] refactor(cut-criteria): unify 3 bash scripts into one Python module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `scripts/cut-criteria-{status,render,status_test}.sh` with a single `scripts/cut_criteria.py` exposing `status`, `render`, `check`, and `test` subcommands. Output is byte-identical for the same YAML + repo state (the only deliberate diff is the script-name reference line in the rendered legend). Drops: - The base64-over-TSV plumbing in cut-criteria-status.sh. It existed only to survive embedded newlines through a bash `read` loop, a macOS-3.2 compat tax. subprocess.run(snippet, shell=True) passes multi-line YAML block scalars to /bin/bash directly, no encoding hop. A regression fixture (`multiline-shell-no-base64`) locks this in: a future refactor that reintroduces a TSV/base64 round-trip without honoring newlines goes red. - The per-shell-invocation Python heredocs in render.sh (196 LOC) and status.sh (75 LOC). Three Makefile shell-outs to one module now replace three bash scripts that each shelled out to python3. - The separate `*_test.sh` test harness (160 LOC). The fixture suite is now a `cut_criteria.py test` subcommand using the same in-process status_rows() the production path uses. Adds (per issue ask): - YAML schema validation at parse time (required keys, allowed tiers, duplicate-id check) — surfaces malformed spec at `make cut-criteria-status` instead of producing a quietly-wrong markdown. - `check` subcommand that renders in-process and diffs against the on-disk markdown; no `mktemp`/`diff -u`/`rm -f` choreography. The Makefile target collapsed from 11 lines to 1. Closes #386. Signed-off-by: Tri Lam --- Makefile | 17 +- docs/cut-criteria.yaml | 2 +- docs/cut-criteria.yaml.md | 20 +- docs/v1-rc1-cut-criteria.md | 2 +- scripts/cut-criteria-render.sh | 240 ----------- scripts/cut-criteria-status.sh | 122 ------ scripts/cut-criteria-status_test.sh | 160 ------- scripts/cut_criteria.py | 642 ++++++++++++++++++++++++++++ 8 files changed, 660 insertions(+), 545 deletions(-) delete mode 100755 scripts/cut-criteria-render.sh delete mode 100755 scripts/cut-criteria-status.sh delete mode 100755 scripts/cut-criteria-status_test.sh create mode 100755 scripts/cut_criteria.py diff --git a/Makefile b/Makefile index 9bcccd2b..436aeea5 100644 --- a/Makefile +++ b/Makefile @@ -223,25 +223,16 @@ deprecation-check: ## Enforce the deprecation policy at docs/DEPRECATION.md (v1 @bash scripts/deprecation-check.sh cut-criteria-status: ## Compute the live status of every v1.0-rc1 cut criterion from docs/cut-criteria.yaml. Prints `id\tstatus\ttitle` per row; read-only, never gates. - @bash scripts/cut-criteria-status.sh + @python3 scripts/cut_criteria.py status cut-criteria-render: ## Regenerate docs/v1-rc1-cut-criteria.md from docs/cut-criteria.yaml. Source of truth is the YAML; the markdown is rendered. - @bash scripts/cut-criteria-render.sh + @python3 scripts/cut_criteria.py render cut-criteria-check: ## Drift gate: rendered docs/v1-rc1-cut-criteria.md must match what `make cut-criteria-render` would produce against the current docs/cut-criteria.yaml. Catches PRs that ship a criterion's artifact without re-rendering. - @# Render to a tempfile and diff against the on-disk copy. Both + @# Renders in-process and diffs against the on-disk copy. Both @# the YAML source and the live repo state feed the render: a PR @# that updates either without re-rendering trips this gate. - @tmp=$$(mktemp); \ - bash scripts/cut-criteria-render.sh docs/cut-criteria.yaml "$$tmp" >/dev/null; \ - if ! diff -u docs/v1-rc1-cut-criteria.md "$$tmp"; then \ - rm -f "$$tmp"; \ - echo ""; \ - echo "cut-criteria-check: docs/v1-rc1-cut-criteria.md is out of sync with docs/cut-criteria.yaml."; \ - echo "Run \`make cut-criteria-render\` and commit the result."; \ - exit 1; \ - fi; \ - rm -f "$$tmp" + @python3 scripts/cut_criteria.py check verify: check license-check generate-fixtures-check build-tags nccl-fr-rce-gate register-lint actionlint zizmor doc-check deprecation-check no-autoupdate-check ## Pre-push gate. Medium (<30s); CI handles heavy gates (test, coverage, govulncheck, fuzz, build). diff --git a/docs/cut-criteria.yaml b/docs/cut-criteria.yaml index 609b8fd6..f34e323d 100644 --- a/docs/cut-criteria.yaml +++ b/docs/cut-criteria.yaml @@ -5,7 +5,7 @@ # `docs/v1-rc1-cut-criteria.md`. CI fails any PR that lands a feature # without re-rendering, so the markdown can never drift from this spec. # -# Status (☑ / ⧗ / ☐) is COMPUTED, not stored. `scripts/cut-criteria-status.sh` +# Status (☑ / ⧗ / ☐) is COMPUTED, not stored. `scripts/cut_criteria.py status` # runs each criterion's `rubric_check.artifact_exists` (and optional # `rubric_check.gate_script`) against the live repo state and renders # the result inline. The per-PR ☐→☑ flip dance is gone: a PR that ships diff --git a/docs/cut-criteria.yaml.md b/docs/cut-criteria.yaml.md index 41df0542..a218c587 100644 --- a/docs/cut-criteria.yaml.md +++ b/docs/cut-criteria.yaml.md @@ -56,7 +56,7 @@ Inspirations: ## Status computation -`scripts/cut-criteria-status.sh` runs each criterion's `rubric_check` +`scripts/cut_criteria.py status` runs each criterion's `rubric_check` against the live repo. The decision table is: | artifact_exists | gate_script | computed status | @@ -100,8 +100,8 @@ but forgets to re-render the markdown will fail CI. |-------------------------------------------------|-----------------------------------| | Rubric, citation, narrative, ownership | `docs/cut-criteria.yaml` | | Status (☐ / ⧗ / ☑) | Don't — it's computed. | -| Static framing prose (intro, legend, out-of-scope, drift policy) | `scripts/cut-criteria-render.sh` | -| Status decision table / glyph bytes | `scripts/cut-criteria-status.sh` | +| Static framing prose (intro, legend, out-of-scope, drift policy) | `scripts/cut_criteria.py` (`render()`) | +| Status decision table / glyph bytes | `scripts/cut_criteria.py` (`status_for()`) | | Rendered markdown | Never. Run `make cut-criteria-render`. | ## Adding a new criterion @@ -114,9 +114,13 @@ but forgets to re-render the markdown will fail CI. `rubric_check.gate_script` so the status walks `☐ → ⧗ → ☑`. 3. Run `make cut-criteria-render` and commit both files. -## Why bash + YAML, not Go +## Why Python + YAML, not Go -The render path is a 200-line problem with no performance budget. Bash -+ `python3 -c yaml.safe_load` is portable, requires no build step, and -makes the rubric checks themselves first-class shell — operators can -copy a single line out of the YAML and run it locally. +The render path is a 300-line problem with no performance budget. +`python3` + `PyYAML` is portable, requires no build step, and makes the +rubric checks themselves first-class shell — operators can copy a +single block out of the YAML and run it locally. A single Python +module (`scripts/cut_criteria.py`) carries `status`, `render`, `check`, +and `test` subcommands, replacing the earlier three-script bash setup +whose base64-over-TSV plumbing existed only to survive embedded +newlines through a shell pipeline. diff --git a/docs/v1-rc1-cut-criteria.md b/docs/v1-rc1-cut-criteria.md index cad8e653..d8f1c3ef 100644 --- a/docs/v1-rc1-cut-criteria.md +++ b/docs/v1-rc1-cut-criteria.md @@ -26,7 +26,7 @@ gate matrix that mirrors the criteria below. - ⧗ in progress — actively being developed (PR open or branch landed against rubric) - ☑ shipped — merged on `main` and the falsifiable rubric is provably satisfied -Status is COMPUTED by `scripts/cut-criteria-status.sh` against the live +Status is COMPUTED by `scripts/cut_criteria.py status` against the live repo state at render time; never hand-edited in this file. --- diff --git a/scripts/cut-criteria-render.sh b/scripts/cut-criteria-render.sh deleted file mode 100755 index 96331714..00000000 --- a/scripts/cut-criteria-render.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash -# cut-criteria-render.sh — regenerate `docs/v1-rc1-cut-criteria.md` -# from `docs/cut-criteria.yaml` + the live `cut-criteria-status.sh` -# output. The rendered file is AUTO-GENERATED; never edit it by hand. -# -# Pattern overview: docs/cut-criteria.yaml.md. Inspired by Kubernetes -# KEP frontmatter `status:` + Bazel `bazel run //docs:gen-status`. -# -# Usage: -# bash scripts/cut-criteria-render.sh # default paths -# bash scripts/cut-criteria-render.sh SPEC OUT # explicit paths -# -# Exit codes: -# 0 rendered file written -# 2 spec missing / malformed - -set -euo pipefail - -spec_path="${1:-docs/cut-criteria.yaml}" -out_path="${2:-docs/v1-rc1-cut-criteria.md}" -status_script="$(cd "$(dirname "$0")" && pwd)/cut-criteria-status.sh" - -if [ ! -f "$spec_path" ]; then - echo "cut-criteria-render: spec not found: $spec_path" >&2 - exit 2 -fi -if [ ! -f "$status_script" ]; then - echo "cut-criteria-render: status script not found: $status_script" >&2 - exit 2 -fi - -# Compute the live status table once. Each line: idstatustitle. -status_tmp=$(mktemp) -trap 'rm -f "$status_tmp"' EXIT -bash "$status_script" "$spec_path" >"$status_tmp" - -# Hand the spec + status table to Python; Python emits the rendered -# markdown directly (jinja-free: pure string templating keeps the -# rendering trivially inspectable). Static framing prose (intro, legend, -# Out-of-scope, drift policy) is embedded in this script — those -# sections rarely change and live one-edit-away here rather than as -# a third file to keep in sync. -python3 - "$spec_path" "$status_tmp" >"$out_path" <<'PY' -import sys, yaml - -spec_path, status_path = sys.argv[1], sys.argv[2] - -with open(spec_path, "r", encoding="utf-8") as fh: - spec = yaml.safe_load(fh) or [] - -# id -> "